feat(captcha): make ALTCHA the only captcha (#3035)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-29 17:00:15 +02:00
1 parent d433a039b5
commit 4f968bbc47
209 files changed
+4097 -8692

No files matched your search

@@ -1,178 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {Config} from '@app/api/Config';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth, type TestRequestBuilder} from '@app/api/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {
type AltchaCaptchaConfig,
DEFAULT_ALTCHA_CAPTCHA_CONFIG,
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {solveChallenge} from 'altcha-lib';
import {deriveKey} from 'altcha-lib/algorithms/pbkdf2';
import type {Challenge} from 'altcha-lib/types';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface CaptchaErrorBody {
code: string;
captcha_provider?: string;
altcha_challenge?: Challenge;
}
const FORGOT_PATH = '/auth/forgot';
const FORGOT_BODY = {email: '[email protected]'};
async function setAltchaConfig(overrides: Partial<AltchaCaptchaConfig>): Promise<void> {
await getInstanceConfigRepository().setAltchaCaptchaConfig({
...DEFAULT_ALTCHA_CAPTCHA_CONFIG,
enabled: true,
cost: 1000,
max_counter: 100,
...overrides,
});
}
async function solve(challenge: Challenge): Promise<string> {
const solution = await solveChallenge({challenge, deriveKey, timeout: 0});
if (!solution) throw new Error('ALTCHA challenge was not solved');
return Buffer.from(JSON.stringify({challenge, solution}), 'utf8').toString('base64');
}
async function rejectWith(builder: TestRequestBuilder<CaptchaErrorBody>, code: string): Promise<CaptchaErrorBody> {
const {json} = await builder.expect(HTTP_STATUS.BAD_REQUEST, code).executeWithResponse();
expect(json.code).toBe(code);
return json;
}
function forgot(harness: ApiTestHarness): TestRequestBuilder<CaptchaErrorBody> {
return createBuilderWithoutAuth<CaptchaErrorBody>(harness).post(FORGOT_PATH).body(FORGOT_BODY);
}
describe('ALTCHA captcha experiment', () => {
let harness: ApiTestHarness;
let previousCaptchaEnabled: boolean;
let previousTestModeEnabled: boolean;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
previousCaptchaEnabled = Config.captcha.enabled;
previousTestModeEnabled = Config.dev.testModeEnabled;
Config.captcha.enabled = true;
Config.dev.testModeEnabled = true;
});
afterEach(() => {
Config.captcha.enabled = previousCaptchaEnabled;
Config.dev.testModeEnabled = previousTestModeEnabled;
});
afterAll(async () => {
await harness.shutdown();
});
it('keeps the configured provider while the experiment is off', async () => {
const body = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
expect(body).not.toHaveProperty('captcha_provider');
expect(body).not.toHaveProperty('altcha_challenge');
});
it('leaves anonymous requests on the configured provider unless anonymous_enabled is set', async () => {
await setAltchaConfig({rollout_basis_points: 10000});
const body = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
expect(body).not.toHaveProperty('altcha_challenge');
});
it('serves anonymous requests a challenge and accepts the solved payload once', async () => {
await setAltchaConfig({anonymous_enabled: true});
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
expect(required.captcha_provider).toBe('altcha');
expect(required.altcha_challenge?.parameters).toMatchObject({algorithm: 'PBKDF2/SHA-256', cost: 1000});
const token = await solve(required.altcha_challenge as Challenge);
await forgot(harness)
.header('X-Captcha-Token', token)
.header('X-Captcha-Type', 'altcha')
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
const replayed = await rejectWith(
forgot(harness).header('X-Captcha-Token', token).header('X-Captcha-Type', 'altcha'),
APIErrorCodes.INVALID_CAPTCHA,
);
expect(replayed.captcha_provider).toBe('altcha');
expect(replayed.altcha_challenge?.signature).not.toBe(required.altcha_challenge?.signature);
});
it('rejects a payload whose derived key does not match the challenge', async () => {
await setAltchaConfig({anonymous_enabled: true});
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
const challenge = required.altcha_challenge as Challenge;
const forged = Buffer.from(
JSON.stringify({challenge, solution: {counter: 1, derivedKey: '00'.repeat(32)}}),
'utf8',
).toString('base64');
await rejectWith(
forgot(harness).header('X-Captcha-Token', forged).header('X-Captcha-Type', 'altcha'),
APIErrorCodes.INVALID_CAPTCHA,
);
});
it('rejects an ALTCHA payload from a requester outside the experiment', async () => {
await setAltchaConfig({anonymous_enabled: true});
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
const token = await solve(required.altcha_challenge as Challenge);
await setAltchaConfig({anonymous_enabled: false});
const rejected = await rejectWith(
forgot(harness).header('X-Captcha-Token', token).header('X-Captcha-Type', 'altcha'),
APIErrorCodes.INVALID_CAPTCHA,
);
expect(rejected).not.toHaveProperty('altcha_challenge');
});
it('buckets signed-in users by their own rollout and still accepts the configured provider', async () => {
Config.captcha.enabled = false;
const included = await createTestAccount(harness);
const excluded = await createTestAccount(harness);
Config.captcha.enabled = true;
await setAltchaConfig({
anonymous_enabled: true,
included_user_ids: [included.userId],
excluded_user_ids: [excluded.userId],
});
const redeemPath = '/gifts/altcha-gift-code/redeem';
const excludedBody = await rejectWith(
createBuilder<CaptchaErrorBody>(harness, excluded.token).post(redeemPath),
APIErrorCodes.CAPTCHA_REQUIRED,
);
expect(excludedBody).not.toHaveProperty('altcha_challenge');
const includedBody = await rejectWith(
createBuilder<CaptchaErrorBody>(harness, included.token).post(redeemPath),
APIErrorCodes.CAPTCHA_REQUIRED,
);
const token = await solve(includedBody.altcha_challenge as Challenge);
const solved = await createBuilder<CaptchaErrorBody>(harness, included.token)
.post(redeemPath)
.header('X-Captcha-Token', token)
.header('X-Captcha-Type', 'altcha')
.executeRaw();
expect([APIErrorCodes.CAPTCHA_REQUIRED, APIErrorCodes.INVALID_CAPTCHA]).not.toContain(solved.json?.code);
const classic = await createBuilder<CaptchaErrorBody>(harness, included.token)
.post(redeemPath)
.header('X-Captcha-Token', 'hcaptcha-token')
.header('X-Captcha-Type', 'hcaptcha')
.executeRaw();
expect([APIErrorCodes.CAPTCHA_REQUIRED, APIErrorCodes.INVALID_CAPTCHA]).not.toContain(classic.json?.code);
});
});
@@ -6,26 +6,13 @@ import {
createUniqueUsername,
registerUser,
} from '@app/api/auth/tests/AuthTestUtils';
import {Config} from '@app/api/Config';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {CAPTCHA_TEST_HEADER, useCheapCaptcha} from '@app/api/test/CaptchaTestUtils';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
async function withCaptchaEnabled<T>(run: () => Promise<T>): Promise<T> {
const previousEnabled = Config.captcha.enabled;
const previousTestModeEnabled = Config.dev.testModeEnabled;
Config.captcha.enabled = true;
Config.dev.testModeEnabled = true;
try {
return await run();
} finally {
Config.captcha.enabled = previousEnabled;
Config.dev.testModeEnabled = previousTestModeEnabled;
}
}
async function registerAndFlag(
harness: ApiTestHarness,
flags: Array<string>,
@@ -56,29 +43,28 @@ describe('Auth Captcha Bypass Flags', () => {
});
beforeEach(async () => {
await harness.reset();
await useCheapCaptcha();
});
afterAll(async () => {
await harness?.shutdown();
});
it('lets APP_STORE_REVIEWER accounts log in without solving a captcha', async () => {
const account = await registerAndFlag(harness, ['APP_STORE_REVIEWER']);
await withCaptchaEnabled(async () => {
const resp = await createBuilderWithoutAuth<{token?: string; user_id?: string}>(harness)
.post('/auth/login')
.body({email: account.email, password: account.password})
.execute();
expect(resp.token).toBeTruthy();
expect(resp.user_id).toBe(account.userId);
});
const resp = await createBuilderWithoutAuth<{token?: string; user_id?: string}>(harness)
.post('/auth/login')
.header(CAPTCHA_TEST_HEADER, 'true')
.body({email: account.email, password: account.password})
.execute();
expect(resp.token).toBeTruthy();
expect(resp.user_id).toBe(account.userId);
});
it('still requires a captcha for accounts without the APP_STORE_REVIEWER flag', async () => {
const account = await registerAndFlag(harness, []);
await withCaptchaEnabled(async () => {
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({email: account.email, password: account.password})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
.execute();
});
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.header(CAPTCHA_TEST_HEADER, 'true')
.body({email: account.email, password: account.password})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
.execute();
});
});
@@ -0,0 +1,190 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {setPhoneRpcConnection} from '@app/api/auth/PhoneVerificationClient';
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {
CAPTCHA_TEST_HEADER,
type CaptchaErrorBody,
solveCaptchaChallenge,
useCheapCaptcha,
} from '@app/api/test/CaptchaTestUtils';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth, type TestRequestBuilder} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
import type {CaptchaConfigResponse} from '@fluxer/schema/src/domains/admin/CaptchaSchemas';
import type {NatsConnection} from '@nats-io/transport-node';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
async function rejectWith(builder: TestRequestBuilder<CaptchaErrorBody>, code: string): Promise<CaptchaErrorBody> {
const {json} = await builder.expect(HTTP_STATUS.BAD_REQUEST, code).executeWithResponse();
expect(json.code).toBe(code);
return json;
}
function forgot(harness: ApiTestHarness): TestRequestBuilder<CaptchaErrorBody> {
return createBuilderWithoutAuth<CaptchaErrorBody>(harness)
.post('/auth/forgot')
.header(CAPTCHA_TEST_HEADER, 'true')
.body({email: '[email protected]'});
}
async function createPhoneRequiredAccount(harness: ApiTestHarness): Promise<TestAccount> {
const account = await createTestAccount(harness);
await createBuilder(harness, '')
.post(`/test/users/${account.userId}/security-flags`)
.body({email_verified: true, suspicious_activity_flags: SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE})
.expect(HTTP_STATUS.OK)
.execute();
return account;
}
function usePhoneServiceThatAsksForCaptcha(): Array<boolean> {
const captchaPassed: Array<boolean> = [];
const connection = {
async request(_subject: string, payload: string) {
const passed = (JSON.parse(payload) as {captcha_passed: boolean}).captcha_passed;
captchaPassed.push(passed);
const reply = passed
? {result: 'sms_sent'}
: {
result: 'error',
code: 'captcha_required',
retry_after_s: null,
rate_limit_scope: null,
limit: null,
message: null,
};
return {string: () => JSON.stringify(reply)};
},
} as unknown as NatsConnection;
setPhoneRpcConnection(() => connection);
return captchaPassed;
}
function sendPhoneCode(harness: ApiTestHarness, account: TestAccount): TestRequestBuilder<CaptchaErrorBody> {
return createBuilder<CaptchaErrorBody>(harness, account.token)
.post('/users/@me/phone/send-verification')
.header(CAPTCHA_TEST_HEADER, 'true')
.body({phone: '+15551230001'});
}
async function turnCaptchaOff(harness: ApiTestHarness): Promise<void> {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
await createBuilder(harness, admin.token)
.patch('/admin/instance/config')
.body({captcha: {enabled: false}})
.execute();
}
describe('Captcha challenge', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
await useCheapCaptcha();
});
afterEach(() => {
setPhoneRpcConnection(null);
});
afterAll(async () => {
await harness.shutdown();
});
it('issues an ALTCHA challenge to a request without a token', async () => {
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
expect(required.captcha_provider).toBe('altcha');
expect(required.altcha_challenge?.parameters).toMatchObject({
algorithm: 'PBKDF2/SHA-256',
cost: 1000,
keyLength: 32,
});
expect(required.altcha_challenge?.signature).toBeTruthy();
});
it('accepts a solved challenge once and answers a replay with a fresh challenge', async () => {
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
const token = await solveCaptchaChallenge(required);
await forgot(harness).header('X-Captcha-Token', token).expect(HTTP_STATUS.NO_CONTENT).execute();
const replayed = await rejectWith(forgot(harness).header('X-Captcha-Token', token), APIErrorCodes.INVALID_CAPTCHA);
expect(replayed.captcha_provider).toBe('altcha');
expect(replayed.altcha_challenge?.signature).toBeTruthy();
expect(replayed.altcha_challenge?.signature).not.toBe(required.altcha_challenge?.signature);
});
it('rejects a payload whose derived key does not match the challenge', async () => {
const required = await rejectWith(forgot(harness), APIErrorCodes.CAPTCHA_REQUIRED);
const challenge = required.altcha_challenge;
const forged = Buffer.from(
JSON.stringify({
challenge: {parameters: challenge?.parameters, signature: challenge?.signature},
solution: {counter: 1, derivedKey: '00'.repeat(32)},
}),
'utf8',
).toString('base64');
const rejected = await rejectWith(forgot(harness).header('X-Captcha-Token', forged), APIErrorCodes.INVALID_CAPTCHA);
expect(rejected.altcha_challenge?.signature).toBeTruthy();
});
it('skips the check once an admin turns it off', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const updated = await createBuilder<{captcha: CaptchaConfigResponse}>(harness, admin.token)
.patch('/admin/instance/config')
.body({captcha: {enabled: false}})
.execute();
expect(updated.captcha).toEqual({enabled: false, cost: 1000, max_counter: 100});
await forgot(harness).expect(HTTP_STATUS.NO_CONTENT).execute();
});
it('challenges a phone send the phone service flags and lets it through once solved', async () => {
const captchaPassed = usePhoneServiceThatAsksForCaptcha();
const account = await createPhoneRequiredAccount(harness);
const required = await rejectWith(sendPhoneCode(harness, account), APIErrorCodes.CAPTCHA_REQUIRED);
expect(required.altcha_challenge?.signature).toBeTruthy();
expect(captchaPassed).toEqual([false]);
const token = await solveCaptchaChallenge(required);
await sendPhoneCode(harness, account).header('X-Captcha-Token', token).expect(HTTP_STATUS.OK).execute();
expect(captchaPassed).toEqual([false, true]);
});
it('refuses a flagged phone send while the check is off', async () => {
await turnCaptchaOff(harness);
const captchaPassed = usePhoneServiceThatAsksForCaptcha();
const account = await createPhoneRequiredAccount(harness);
const {response, json} = await sendPhoneCode(harness, account).executeRaw();
expect(response.status).toBe(429);
expect(json).toMatchObject({code: APIErrorCodes.PHONE_RATE_LIMIT_EXCEEDED});
expect(captchaPassed).toEqual([false]);
});
it('skips the check in test mode unless the request opts in', async () => {
await createBuilderWithoutAuth(harness)
.post('/auth/forgot')
.body({email: '[email protected]'})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
});
@@ -146,6 +146,9 @@ describe('phone verification client', () => {
for (const code of ['unavailable', 'deadline_exceeded', 'unsupported_contract'] as const) {
expect(errorForPhoneReply({code, ...blank})).toBeInstanceOf(SmsVerificationUnavailableError);
}
const captchaRequired = errorForPhoneReply({code: 'captcha_required', ...blank});
expect(captchaRequired).toBeInstanceOf(RateLimitError);
expect((captchaRequired as RateLimitError).code).toBe(APIErrorCodes.PHONE_RATE_LIMIT_EXCEEDED);
});
});
@@ -87,6 +87,7 @@ describe('Auth registration', () => {
expect(reg.user_id.length).toBeGreaterThan(0);
});
it('grants wildcard admin ACL to first accepted local dev registration', async () => {
await getInstanceConfigRepository().updateCaptchaConfig({enabled: false});
await withBootstrapAdminConfig({selfHosted: false, testModeEnabled: false}, async () => {
const first = await registerUser(harness, bootstrapRegistrationBodyWithDnsEmail('localdevadminone'));
const second = await registerUser(harness, bootstrapRegistrationBodyWithDnsEmail('localdevadmintwo'));