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

This commit is contained in:
Hampus
2026-09-29 17:00:15 +02:00
committed by GitHub
parent d433a039b5
commit 4f968bbc47
209 changed files with 4097 additions and 8692 deletions
@@ -1,87 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import type {ICaptchaProvider} from '@pkgs/captcha/src/ICaptchaProvider';
import {HcaptchaProvider} from '@pkgs/captcha/src/providers/HcaptchaProvider';
import type {HttpCaptchaProviderOptions} from '@pkgs/captcha/src/providers/HttpCaptchaProvider';
import type {RecaptchaProviderOptions} from '@pkgs/captcha/src/providers/RecaptchaProvider';
import {RecaptchaProvider} from '@pkgs/captcha/src/providers/RecaptchaProvider';
import {TestCaptchaProvider} from '@pkgs/captcha/src/providers/TestProvider';
import {TurnstileProvider} from '@pkgs/captcha/src/providers/TurnstileProvider';
import {UnavailableCaptchaProvider} from '@pkgs/captcha/src/providers/UnavailableCaptchaProvider';
interface BaseCaptchaProviderFactoryParams {
logger?: LoggerInterface;
}
interface CreateUnavailableCaptchaProviderParams extends BaseCaptchaProviderFactoryParams {
mode: 'unavailable';
}
interface CreateTestCaptchaProviderParams extends BaseCaptchaProviderFactoryParams {
mode: 'test';
}
interface CreateHcaptchaProviderParams extends BaseCaptchaProviderFactoryParams {
mode: 'hcaptcha';
secretKey: string;
timeoutMs?: number;
userAgent?: string;
fetchFn?: typeof fetch;
}
interface CreateTurnstileProviderParams extends BaseCaptchaProviderFactoryParams {
mode: 'turnstile';
secretKey: string;
timeoutMs?: number;
userAgent?: string;
fetchFn?: typeof fetch;
}
interface CreateRecaptchaProviderParams extends BaseCaptchaProviderFactoryParams {
mode: 'recaptcha';
secretKey: string;
minimumScore?: number;
timeoutMs?: number;
userAgent?: string;
fetchFn?: typeof fetch;
}
type CreateCaptchaProviderParams =
| CreateUnavailableCaptchaProviderParams
| CreateTestCaptchaProviderParams
| CreateHcaptchaProviderParams
| CreateTurnstileProviderParams
| CreateRecaptchaProviderParams;
function buildHttpOptions(
params: CreateHcaptchaProviderParams | CreateTurnstileProviderParams | CreateRecaptchaProviderParams,
): HttpCaptchaProviderOptions {
return {
secretKey: params.secretKey,
logger: params.logger,
timeoutMs: params.timeoutMs,
userAgent: params.userAgent,
fetchFn: params.fetchFn,
};
}
export function createCaptchaProvider(params: CreateCaptchaProviderParams): ICaptchaProvider {
if (params.mode === 'test') {
return new TestCaptchaProvider();
}
if (params.mode === 'hcaptcha') {
return new HcaptchaProvider(buildHttpOptions(params));
}
if (params.mode === 'turnstile') {
return new TurnstileProvider(buildHttpOptions(params));
}
if (params.mode === 'recaptcha') {
const options: RecaptchaProviderOptions = {
...buildHttpOptions(params),
minimumScore: params.minimumScore,
};
return new RecaptchaProvider(options);
}
return new UnavailableCaptchaProvider();
}
@@ -1,13 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export interface VerifyCaptchaParams {
token: string;
remoteIp?: string;
}
export type CaptchaProviderType = 'hcaptcha' | 'recaptcha' | 'turnstile' | 'altcha' | 'test' | 'unavailable';
export interface ICaptchaProvider {
readonly type: CaptchaProviderType;
verify(params: VerifyCaptchaParams): Promise<boolean>;
}
@@ -1,14 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
import {createChallenge, randomInt, verifySolution} from 'altcha-lib';
import {deriveKey} from 'altcha-lib/algorithms/pbkdf2';
import type {Challenge} from 'altcha-lib/types';
import {ms} from 'itty-time';
import {z} from 'zod';
export const ALTCHA_ALGORITHM = 'PBKDF2/SHA-256';
const ALTCHA_ALGORITHM = 'PBKDF2/SHA-256';
const ALTCHA_CHALLENGE_TTL_MS = ms('10 minutes');
const ALTCHA_MAX_TOKEN_LENGTH = 4096;
const HEX_PATTERN = /^[0-9a-f]+$/u;
@@ -56,8 +55,7 @@ function decodePayload(token: string): AltchaPayload | null {
}
}
export class AltchaProvider implements ICaptchaProvider {
readonly type: CaptchaProviderType = 'altcha';
export class AltchaProvider {
private readonly options: AltchaProviderOptions;
private readonly now: () => number;
@@ -79,7 +77,7 @@ export class AltchaProvider implements ICaptchaProvider {
});
}
async verify({token}: VerifyCaptchaParams): Promise<boolean> {
async verify({token}: {token: string}): Promise<boolean> {
const payload = decodePayload(token);
if (!payload) return false;
try {
@@ -1,10 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {CaptchaProviderType} from '@pkgs/captcha/src/ICaptchaProvider';
import {HttpCaptchaProvider} from '@pkgs/captcha/src/providers/HttpCaptchaProvider';
export class HcaptchaProvider extends HttpCaptchaProvider {
readonly type: CaptchaProviderType = 'hcaptcha';
protected readonly verifyUrl = 'https://api.hcaptcha.com/siteverify';
protected readonly providerName = 'hCaptcha';
}
@@ -1,105 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
import {ms} from 'itty-time';
const DEFAULT_USER_AGENT = 'Mozilla/5.0 (compatible; Fluxerbot/1.0; +https://fluxer.app)';
const DEFAULT_TIMEOUT = ms('10 seconds');
export interface HttpCaptchaProviderOptions {
secretKey: string;
logger?: LoggerInterface;
timeoutMs?: number;
userAgent?: string;
fetchFn?: typeof fetch;
}
interface CaptchaVerifyResponse {
success: boolean;
'error-codes'?: Array<string>;
hostname?: string;
challenge_ts?: string;
score?: number;
}
function isCaptchaVerifyResponse(value: unknown): value is CaptchaVerifyResponse {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const data = value as Record<string, unknown>;
const errorCodes = data['error-codes'];
return (
typeof data.success === 'boolean' &&
(errorCodes === undefined || (Array.isArray(errorCodes) && errorCodes.every((code) => typeof code === 'string'))) &&
(data.hostname === undefined || typeof data.hostname === 'string') &&
(data.challenge_ts === undefined || typeof data.challenge_ts === 'string') &&
(data.score === undefined ||
(typeof data.score === 'number' && Number.isFinite(data.score) && data.score >= 0 && data.score <= 1))
);
}
export abstract class HttpCaptchaProvider implements ICaptchaProvider {
abstract readonly type: CaptchaProviderType;
protected readonly secretKey: string;
protected readonly logger: LoggerInterface | undefined;
protected readonly timeoutMs: number;
protected readonly userAgent: string;
protected readonly fetchFn: typeof fetch;
protected abstract readonly verifyUrl: string;
protected abstract readonly providerName: string;
constructor(options: HttpCaptchaProviderOptions) {
this.secretKey = options.secretKey;
this.logger = options.logger;
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT;
this.userAgent = options.userAgent ?? DEFAULT_USER_AGENT;
this.fetchFn = options.fetchFn ?? fetch;
}
async verify({token, remoteIp}: VerifyCaptchaParams): Promise<boolean> {
try {
const body = new URLSearchParams();
body.append('secret', this.secretKey);
body.append('response', token);
if (remoteIp) {
body.append('remoteip', remoteIp);
}
const response = await this.fetchFn(this.verifyUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': this.userAgent,
},
body: body.toString(),
signal: AbortSignal.timeout(this.timeoutMs),
});
if (!response.ok) {
await response.body?.cancel().catch(() => {
this.logger?.warn({status: response.status}, `${this.providerName} failed to cancel discarded response body`);
});
this.logger?.error({status: response.status}, `${this.providerName} verify request failed`);
return false;
}
const data: unknown = await response.json();
if (!isCaptchaVerifyResponse(data)) {
this.logger?.error({}, `${this.providerName} returned an invalid verification response`);
return false;
}
if (!data.success) {
this.logger?.warn({errorCodes: data['error-codes']}, `${this.providerName} verification failed`);
return false;
}
return this.validateResponse(data);
} catch (error) {
if (error instanceof Error && error.name === 'TimeoutError') {
this.logger?.error({}, `${this.providerName} verification timed out after ${this.timeoutMs}ms`);
} else {
this.logger?.error({error}, `Error verifying ${this.providerName} token`);
}
return false;
}
}
protected validateResponse(_data: CaptchaVerifyResponse): boolean {
return true;
}
}
@@ -1,40 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {CaptchaProviderType} from '@pkgs/captcha/src/ICaptchaProvider';
import type {HttpCaptchaProviderOptions} from '@pkgs/captcha/src/providers/HttpCaptchaProvider';
import {HttpCaptchaProvider} from '@pkgs/captcha/src/providers/HttpCaptchaProvider';
const DEFAULT_MINIMUM_SCORE = 0.5;
interface RecaptchaVerifyResponse {
success: boolean;
'error-codes'?: Array<string>;
score?: number;
}
export interface RecaptchaProviderOptions extends HttpCaptchaProviderOptions {
minimumScore?: number;
}
export class RecaptchaProvider extends HttpCaptchaProvider {
readonly type: CaptchaProviderType = 'recaptcha';
protected readonly verifyUrl = 'https://www.google.com/recaptcha/api/siteverify';
protected readonly providerName = 'reCAPTCHA';
private readonly minimumScore: number;
constructor(options: RecaptchaProviderOptions) {
super(options);
this.minimumScore = options.minimumScore ?? DEFAULT_MINIMUM_SCORE;
}
protected override validateResponse(data: RecaptchaVerifyResponse): boolean {
if (data.score !== undefined && data.score < this.minimumScore) {
this.logger?.warn(
{score: data.score, minimumScore: this.minimumScore},
'reCAPTCHA score below minimum threshold',
);
return false;
}
return true;
}
}
@@ -1,11 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
export class TestCaptchaProvider implements ICaptchaProvider {
readonly type: CaptchaProviderType = 'test';
async verify(_params: VerifyCaptchaParams): Promise<boolean> {
return true;
}
}
@@ -1,10 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {CaptchaProviderType} from '@pkgs/captcha/src/ICaptchaProvider';
import {HttpCaptchaProvider} from '@pkgs/captcha/src/providers/HttpCaptchaProvider';
export class TurnstileProvider extends HttpCaptchaProvider {
readonly type: CaptchaProviderType = 'turnstile';
protected readonly verifyUrl = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
protected readonly providerName = 'Turnstile';
}
@@ -1,11 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {CaptchaProviderType, ICaptchaProvider, VerifyCaptchaParams} from '@pkgs/captcha/src/ICaptchaProvider';
export class UnavailableCaptchaProvider implements ICaptchaProvider {
readonly type: CaptchaProviderType = 'unavailable';
async verify(_params: VerifyCaptchaParams): Promise<boolean> {
return true;
}
}
-16
View File
@@ -329,22 +329,6 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
breachedPasswordCheck: {
enabled: master.integrations.breached_password_check.enabled ?? !master.instance.self_hosted,
},
captcha: {
enabled: master.integrations.captcha.enabled,
provider: master.integrations.captcha.provider,
hcaptcha: master.integrations.captcha.hcaptcha
? {
siteKey: master.integrations.captcha.hcaptcha.site_key,
secretKey: master.integrations.captcha.hcaptcha.secret_key,
}
: undefined,
turnstile: master.integrations.captcha.turnstile
? {
siteKey: master.integrations.captcha.turnstile.site_key,
secretKey: master.integrations.captcha.turnstile.secret_key,
}
: undefined,
},
contentModeration: {
nsfwThreshold: master.services.api.content_moderation?.nsfw_threshold ?? 0.7,
},
@@ -35,7 +35,6 @@ import {
PendingRegistrationActionRequest,
RegistrationUrlIdParam,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {AltchaCaptchaConfigSchema} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import type {PushRelayConfig, PushRelayConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
@@ -67,7 +66,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
gatewayRollout,
pushRelay,
domainMigration,
altchaCaptcha,
captcha,
experimentDelivery,
registrationConfig,
registrationUrls,
@@ -77,7 +76,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
instanceConfigRepository.getGatewayRolloutConfig(),
instanceConfigRepository.getPushRelayConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getAltchaCaptchaConfig(),
instanceConfigRepository.getCaptchaConfig(),
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getRegistrationConfig(),
instanceConfigRepository.getRegistrationUrlsForAdmin(),
@@ -111,7 +110,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
gateway_rollout: gatewayRollout,
push_relay: pushRelay,
domain_migration: domainMigration,
altcha_captcha: altchaCaptcha,
captcha,
experiment_delivery: experimentDelivery,
registration: {
...registrationConfig,
@@ -388,16 +387,10 @@ export function InstanceConfigAdminController(app: HonoApp) {
);
}
}
if (data.altcha_captcha) {
const patch = omitUndefinedFields(data.altcha_captcha);
if (data.captcha) {
const patch = omitUndefinedFields(data.captcha);
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.updateAltchaCaptchaConfig((current) =>
AltchaCaptchaConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
await instanceConfigRepository.updateCaptchaConfig(patch);
}
}
if (data.experiment_delivery) {
@@ -498,15 +491,6 @@ export function InstanceConfigAdminController(app: HonoApp) {
api_key: readOptionalField(data.integrations.youtube, 'api_key'),
})
: undefined,
captcha: data.integrations.captcha
? omitUndefinedFields({
provider: readOptionalField(data.integrations.captcha, 'provider'),
hcaptcha_site_key: readOptionalField(data.integrations.captcha, 'hcaptcha_site_key'),
hcaptcha_secret_key: readOptionalField(data.integrations.captcha, 'hcaptcha_secret_key'),
turnstile_site_key: readOptionalField(data.integrations.captcha, 'turnstile_site_key'),
turnstile_secret_key: readOptionalField(data.integrations.captcha, 'turnstile_secret_key'),
})
: undefined,
email: data.integrations.email
? {
...omitUndefinedFields({
+6 -6
View File
@@ -109,8 +109,8 @@ export function AuthController(app: HonoApp) {
app.post(
'/auth/register',
LocalAuthMiddleware,
CaptchaMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_REGISTER),
CaptchaMiddleware,
Validator('json', RegisterRequest),
OpenAPI({
operationId: 'register_account',
@@ -120,7 +120,7 @@ export function AuthController(app: HonoApp) {
security: [],
tags: ['Auth'],
description:
'Create a new user account with email and password. Requires CAPTCHA verification. User account is created but must verify email before logging in.',
'Create a new user account with email and password. Requires a solved captcha challenge (X-Captcha-Token). User account is created but must verify email before logging in.',
}),
async (ctx) => {
const result = await ctx.get('authRequestService').register({
@@ -134,8 +134,8 @@ export function AuthController(app: HonoApp) {
app.post(
'/auth/login',
LocalAuthMiddleware,
CaptchaMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_LOGIN),
CaptchaMiddleware,
Validator('json', LoginRequest),
OpenAPI({
operationId: 'login_user',
@@ -145,7 +145,7 @@ export function AuthController(app: HonoApp) {
security: [],
tags: ['Auth'],
description:
'Authenticate with email and password. Returns authentication token if credentials are valid and MFA is not required. If MFA is enabled, returns a ticket for MFA verification.',
'Authenticate with email and password. Returns authentication token if credentials are valid and MFA is not required. If MFA is enabled, returns a ticket for MFA verification. Requires a solved captcha challenge (X-Captcha-Token).',
}),
async (ctx) => {
const result = await ctx.get('authRequestService').login({
@@ -240,8 +240,8 @@ export function AuthController(app: HonoApp) {
app.post(
'/auth/forgot',
LocalAuthMiddleware,
CaptchaMiddleware,
RateLimitMiddleware(RateLimitConfigs.AUTH_FORGOT_PASSWORD),
CaptchaMiddleware,
Validator('json', ForgotPasswordRequest),
OpenAPI({
operationId: 'forgot_password',
@@ -251,7 +251,7 @@ export function AuthController(app: HonoApp) {
security: [],
tags: ['Auth'],
description:
"Initiate password reset process by email. A password reset link will be sent to the user's email address. Requires CAPTCHA verification.",
"Initiate password reset process by email. A password reset link will be sent to the user's email address. Requires a solved captcha challenge (X-Captcha-Token).",
}),
async (ctx) => {
await ctx.get('authRequestService').forgotPassword({
+21 -12
View File
@@ -39,6 +39,8 @@ interface IssuedChallenge {
interface SendPhoneVerificationOptions {
clientIp: string;
channel?: PhoneChannel;
hasCaptchaToken: boolean;
verifyCaptcha: () => Promise<boolean>;
}
function assertPhoneFormat(phone: string): void {
@@ -74,18 +76,25 @@ export async function sendPhoneVerificationCode(
): Promise<PhoneVerificationStartResult> {
assertPhoneFormat(phone);
const user = await loadRequestingUser(ctx, userId);
const reply = await getPhoneVerificationClient().start(
{
user_id: user.id.toString(),
user_flags: user.flags.toString(),
has_verified_phone: user.hasVerifiedPhone,
phone,
requested_channel: options.channel ?? null,
client_ip: options.clientIp,
captcha_passed: false,
},
requestInfo(ctx, userId, phone),
);
const start = (captchaPassed: boolean) =>
getPhoneVerificationClient().start(
{
user_id: user.id.toString(),
user_flags: user.flags.toString(),
has_verified_phone: user.hasVerifiedPhone,
phone,
requested_channel: options.channel ?? null,
client_ip: options.clientIp,
captcha_passed: captchaPassed,
},
requestInfo(ctx, userId, phone),
);
const captchaPassed = options.hasCaptchaToken && (await options.verifyCaptcha());
const reply = await start(captchaPassed);
if (reply.result === 'error' && reply.code === 'captcha_required' && !captchaPassed) {
await options.verifyCaptcha();
Logger.warn({userId: userId.toString()}, 'Phone verification asked for a captcha without a captcha check');
}
switch (reply.result) {
case 'sms_sent':
return {channel: 'sms'};
@@ -32,7 +32,6 @@ import {PhoneNumberNotInServiceError} from '@fluxer/errors/src/domains/auth/Phon
import {PhoneNumberNotMobileError} from '@fluxer/errors/src/domains/auth/PhoneNumberNotMobileError';
import {PhoneVerificationNeedsReviewError} from '@fluxer/errors/src/domains/auth/PhoneVerificationNeedsReviewError';
import {SmsVerificationUnavailableError} from '@fluxer/errors/src/domains/auth/SmsVerificationUnavailableError';
import {CaptchaVerificationRequiredError} from '@fluxer/errors/src/domains/core/CaptchaVerificationRequiredError';
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
import type {FluxerError} from '@fluxer/errors/src/FluxerError';
import {type NatsConnection, headers as natsHeaders, RequestError, TimeoutError} from '@nats-io/transport-node';
@@ -204,8 +203,18 @@ export function errorForPhoneReply(error: PhoneError): FluxerError {
return new PhoneInboundVerificationRequiredError();
case 'already_used':
return new PhoneAlreadyUsedError();
case 'captcha_required':
return new CaptchaVerificationRequiredError();
case 'captcha_required': {
const retryAfter = 24 * 60 * 60;
return new RateLimitError({
code: APIErrorCodes.PHONE_RATE_LIMIT_EXCEEDED,
retryAfter,
retryAfterDecimal: retryAfter,
limit: 1,
resetTime: new Date(Date.now() + retryAfter * 1000),
resetAfterDecimal: retryAfter,
scope: 'user',
});
}
case 'invalid_code':
return new InvalidPhoneVerificationCodeError();
case 'rate_limited': {
@@ -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'));
@@ -220,7 +220,7 @@ export function ChannelController(app: HonoApp) {
operationId: 'add_group_dm_recipient',
summary: 'Add recipient to group DM',
description:
'Adds a user to a group direct message channel. The requesting user must be a member of the group DM. Requires CAPTCHA verification.',
'Adds a user to a group direct message channel. The requesting user must be a member of the group DM. Requires a solved captcha challenge (X-Captcha-Token).',
responseSchema: null,
statusCode: 204,
security: ['botToken', 'bearerToken', 'sessionToken'],
@@ -1,13 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {Config} from '@app/api/Config';
import {
createFriendship,
createGroupDmChannel,
type GroupDmChannelResponse,
} from '@app/api/channel/tests/ChannelTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {CAPTCHA_TEST_HEADER, issueSolvedCaptchaToken, useCheapCaptcha} from '@app/api/test/CaptchaTestUtils';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
@@ -15,22 +15,8 @@ import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const HTTP_TOO_MANY_REQUESTS = 429;
const TEST_CAPTCHA_TOKEN = 'test-captcha-token';
const RATE_LIMIT_TEST_HEADER = 'x-fluxer-test-enable-rate-limits';
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 createGroupDmWithCaptcha(
harness: ApiTestHarness,
owner: TestAccount,
@@ -38,7 +24,8 @@ async function createGroupDmWithCaptcha(
): Promise<GroupDmChannelResponse> {
return await createBuilder<GroupDmChannelResponse>(harness, owner.token)
.post('/users/@me/channels')
.header('x-captcha-token', TEST_CAPTCHA_TOKEN)
.header(CAPTCHA_TEST_HEADER, 'true')
.header('x-captcha-token', await issueSolvedCaptchaToken(harness))
.body({recipients: recipientIds})
.expect(HTTP_STATUS.OK)
.execute();
@@ -51,6 +38,7 @@ describe('Group DM captcha and rate limits', () => {
});
beforeEach(async () => {
await harness.reset();
await useCheapCaptcha();
});
afterAll(async () => {
await harness?.shutdown();
@@ -62,21 +50,20 @@ describe('Group DM captcha and rate limits', () => {
const groupDmRecipient = await createTestAccount(harness);
await createFriendship(harness, owner, dmRecipient);
await createFriendship(harness, owner, groupDmRecipient);
await withCaptchaEnabled(async () => {
const dm = await createBuilder<{type: number}>(harness, owner.token)
.post('/users/@me/channels')
.body({recipient_id: dmRecipient.userId})
.expect(HTTP_STATUS.OK)
.execute();
expect(dm.type).toBe(ChannelTypes.DM);
await createBuilder(harness, owner.token)
.post('/users/@me/channels')
.body({recipients: [groupDmRecipient.userId]})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
.execute();
const groupDm = await createGroupDmWithCaptcha(harness, owner, [groupDmRecipient.userId]);
expect(groupDm.type).toBe(ChannelTypes.GROUP_DM);
});
const dm = await createBuilder<{type: number}>(harness, owner.token)
.post('/users/@me/channels')
.body({recipient_id: dmRecipient.userId})
.expect(HTTP_STATUS.OK)
.execute();
expect(dm.type).toBe(ChannelTypes.DM);
await createBuilder(harness, owner.token)
.post('/users/@me/channels')
.header(CAPTCHA_TEST_HEADER, 'true')
.body({recipients: [groupDmRecipient.userId]})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
.execute();
const groupDm = await createGroupDmWithCaptcha(harness, owner, [groupDmRecipient.userId]);
expect(groupDm.type).toBe(ChannelTypes.GROUP_DM);
});
it('requires captcha when adding a recipient to an existing group DM', async () => {
@@ -86,43 +73,43 @@ describe('Group DM captcha and rate limits', () => {
await createFriendship(harness, owner, member);
await createFriendship(harness, owner, newMember);
const groupDm = await createGroupDmChannel(harness, owner.token, [member.userId]);
await withCaptchaEnabled(async () => {
await createBuilder(harness, owner.token)
.put(`/channels/${groupDm.id}/recipients/${newMember.userId}`)
.body(null)
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
.execute();
await createBuilder(harness, owner.token)
.put(`/channels/${groupDm.id}/recipients/${newMember.userId}`)
.header('x-captcha-token', TEST_CAPTCHA_TOKEN)
.body(null)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
await createBuilder(harness, owner.token)
.put(`/channels/${groupDm.id}/recipients/${newMember.userId}`)
.header(CAPTCHA_TEST_HEADER, 'true')
.body(null)
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
.execute();
await createBuilder(harness, owner.token)
.put(`/channels/${groupDm.id}/recipients/${newMember.userId}`)
.header(CAPTCHA_TEST_HEADER, 'true')
.header('x-captcha-token', await issueSolvedCaptchaToken(harness))
.body(null)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
it('limits group DM creation to 10 per user per hour', async () => {
const owner = await createTestAccount(harness);
const recipient = await createTestAccount(harness);
await createFriendship(harness, owner, recipient);
await withCaptchaEnabled(async () => {
for (let index = 0; index < 10; index++) {
await createBuilder(harness, owner.token)
.post('/users/@me/channels')
.header(RATE_LIMIT_TEST_HEADER, 'true')
.header('x-captcha-token', TEST_CAPTCHA_TOKEN)
.body({recipients: [recipient.userId]})
.expect(HTTP_STATUS.OK)
.execute();
}
for (let index = 0; index < 10; index++) {
await createBuilder(harness, owner.token)
.post('/users/@me/channels')
.header(RATE_LIMIT_TEST_HEADER, 'true')
.header('x-captcha-token', TEST_CAPTCHA_TOKEN)
.header(CAPTCHA_TEST_HEADER, 'true')
.header('x-captcha-token', await issueSolvedCaptchaToken(harness))
.body({recipients: [recipient.userId]})
.expect(HTTP_TOO_MANY_REQUESTS, APIErrorCodes.RATE_LIMITED)
.expect(HTTP_STATUS.OK)
.execute();
});
}
await createBuilder(harness, owner.token)
.post('/users/@me/channels')
.header(RATE_LIMIT_TEST_HEADER, 'true')
.header(CAPTCHA_TEST_HEADER, 'true')
.header('x-captcha-token', await issueSolvedCaptchaToken(harness))
.body({recipients: [recipient.userId]})
.expect(HTTP_TOO_MANY_REQUESTS, APIErrorCodes.RATE_LIMITED)
.execute();
});
it('limits group DM recipient additions to 10 per user per hour', async () => {
@@ -136,23 +123,23 @@ describe('Group DM captcha and rate limits', () => {
newMembers.push(member);
}
const groupDm = await createGroupDmChannel(harness, owner.token, [initialMember.userId]);
await withCaptchaEnabled(async () => {
for (let index = 0; index < 10; index++) {
await createBuilder(harness, owner.token)
.put(`/channels/${groupDm.id}/recipients/${newMembers[index].userId}`)
.header(RATE_LIMIT_TEST_HEADER, 'true')
.header('x-captcha-token', TEST_CAPTCHA_TOKEN)
.body(null)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
}
for (let index = 0; index < 10; index++) {
await createBuilder(harness, owner.token)
.put(`/channels/${groupDm.id}/recipients/${newMembers[10].userId}`)
.put(`/channels/${groupDm.id}/recipients/${newMembers[index].userId}`)
.header(RATE_LIMIT_TEST_HEADER, 'true')
.header('x-captcha-token', TEST_CAPTCHA_TOKEN)
.header(CAPTCHA_TEST_HEADER, 'true')
.header('x-captcha-token', await issueSolvedCaptchaToken(harness))
.body(null)
.expect(HTTP_TOO_MANY_REQUESTS, APIErrorCodes.RATE_LIMITED)
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
}
await createBuilder(harness, owner.token)
.put(`/channels/${groupDm.id}/recipients/${newMembers[10].userId}`)
.header(RATE_LIMIT_TEST_HEADER, 'true')
.header(CAPTCHA_TEST_HEADER, 'true')
.header('x-captcha-token', await issueSolvedCaptchaToken(harness))
.body(null)
.expect(HTTP_TOO_MANY_REQUESTS, APIErrorCodes.RATE_LIMITED)
.execute();
});
});
-12
View File
@@ -191,18 +191,6 @@ export interface APIConfig {
breachedPasswordCheck: {
enabled: boolean;
};
captcha: {
enabled: boolean;
provider: 'hcaptcha' | 'turnstile' | 'none';
hcaptcha?: {
siteKey: string;
secretKey: string;
};
turnstile?: {
siteKey: string;
secretKey: string;
};
};
contentModeration: {
nsfwThreshold: number;
};
@@ -9,7 +9,6 @@ import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {entityTagMatches} from '@app/api/utils/EntityTag';
import {Headers as HttpHeaders} from '@fluxer/constants/src/Headers';
import {resolveAltchaCaptchaAssignment} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {resolveDomainMigrationAssignment} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
@@ -30,20 +29,18 @@ export function ExperimentController(app: HonoApp) {
}),
async (ctx) => {
const instanceConfigRepository = ctx.get('instanceConfigRepository');
const [delivery, domainMigrationConfig, altchaCaptchaConfig] = await Promise.all([
const [delivery, domainMigrationConfig] = await Promise.all([
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getAltchaCaptchaConfig(),
]);
const user = ctx.get('user');
const userId = user.id.toString();
const targeting = await resolveExperimentTargeting(user, [domainMigrationConfig, altchaCaptchaConfig]);
const targeting = await resolveExperimentTargeting(user, [domainMigrationConfig]);
const body: ExperimentAssignmentsResponse = {
poll_interval_seconds: delivery.poll_interval_seconds,
poll_jitter_percent: delivery.poll_jitter_percent,
assignments: {
domain_migration: resolveDomainMigrationAssignment(domainMigrationConfig, userId, targeting),
altcha_captcha: resolveAltchaCaptchaAssignment(altchaCaptchaConfig, userId, targeting),
},
};
const etag = `"${createHash('sha256').update(JSON.stringify(body)).digest('hex')}"`;
@@ -11,11 +11,6 @@ interface TargetableExperimentConfig {
const NO_GUILDS: ReadonlySet<string> = new Set();
export const ANONYMOUS_EXPERIMENT_TARGETING: ExperimentTargeting = {
memberGuildIds: NO_GUILDS,
premium: false,
};
export async function resolveExperimentTargeting(
user: User,
configs: ReadonlyArray<TargetableExperimentConfig>,
@@ -9,10 +9,6 @@ import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequest
import {grantPremium} from '@app/api/user/tests/UserTestUtils';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {
DEFAULT_ALTCHA_CAPTCHA_CONFIG,
INERT_ALTCHA_CAPTCHA_ASSIGNMENT,
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {
DEFAULT_DOMAIN_MIGRATION_CONFIG,
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
@@ -58,7 +54,6 @@ describe('GET /experiments', () => {
poll_jitter_percent: DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
assignments: {
domain_migration: INERT_DOMAIN_MIGRATION_ASSIGNMENT,
altcha_captcha: INERT_ALTCHA_CAPTCHA_ASSIGNMENT,
},
});
});
@@ -109,62 +104,6 @@ describe('GET /experiments', () => {
expect(body.assignments.domain_migration).toEqual({enabled: false});
});
it('resolves the altcha captcha caller through the allowlist and the exclusion list', async () => {
const targeted = await createTestAccount(harness);
const excluded = await createTestAccount(harness);
await getInstanceConfigRepository().setAltchaCaptchaConfig({
...DEFAULT_ALTCHA_CAPTCHA_CONFIG,
enabled: true,
rollout_basis_points: 10000,
anonymous_enabled: true,
included_user_ids: [targeted.userId],
excluded_user_ids: [excluded.userId],
});
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
.get(ENDPOINT)
.execute();
expect(targetedBody.assignments.altcha_captcha).toEqual({enabled: true});
const excludedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, excluded.token)
.get(ENDPOINT)
.execute();
expect(excludedBody.assignments.altcha_captcha).toEqual({enabled: false});
});
it('bumps the altcha captcha config version on every admin update without the client sending one', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
const afterFirst = await createBuilder<{altcha_captcha: {config_version: number; enabled: boolean}}>(
harness,
admin.token,
)
.patch('/admin/instance/config')
.body({altcha_captcha: {enabled: true, included_user_ids: [admin.userId]}})
.execute();
expect(afterFirst.altcha_captcha).toMatchObject({config_version: 1, enabled: true});
const afterSecond = await createBuilder<{
altcha_captcha: {config_version: number; anonymous_enabled: boolean; cost: number; max_counter: number};
}>(harness, admin.token)
.patch('/admin/instance/config')
.body({altcha_captcha: {anonymous_enabled: true, cost: 2000, max_counter: 400}})
.execute();
expect(afterSecond.altcha_captcha).toMatchObject({
config_version: 2,
anonymous_enabled: true,
cost: 2000,
max_counter: 400,
});
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
expect(body.assignments.altcha_captcha).toEqual({enabled: true});
});
it('enrols members of an included guild in every experiment and leaves everyone else out', async () => {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
@@ -179,23 +118,16 @@ describe('GET /experiments', () => {
enabled: true,
included_guild_ids: [guild.id],
});
await repository.setAltchaCaptchaConfig({
...DEFAULT_ALTCHA_CAPTCHA_CONFIG,
enabled: true,
included_guild_ids: [guild.id],
});
const memberBody = await createBuilder<ExperimentAssignmentsResponse>(harness, member.token)
.get(ENDPOINT)
.execute();
expect(memberBody.assignments.domain_migration).toEqual({enabled: true});
expect(memberBody.assignments.altcha_captcha).toEqual({enabled: true});
const outsiderBody = await createBuilder<ExperimentAssignmentsResponse>(harness, outsider.token)
.get(ENDPOINT)
.execute();
expect(outsiderBody.assignments.domain_migration).toEqual({enabled: false});
expect(outsiderBody.assignments.altcha_captcha).toEqual({enabled: false});
});
it('enrols premium users, subscription and lifetime alike, when the switch is on', async () => {
@@ -227,19 +159,15 @@ describe('GET /experiments', () => {
]);
const guildIds = ['1500000000000000001', '1500000000000000002'];
const body = await createBuilder<
Record<'domain_migration' | 'altcha_captcha', {included_guild_ids: Array<string>; include_premium_users: boolean}>
Record<'domain_migration', {included_guild_ids: Array<string>; include_premium_users: boolean}>
>(harness, admin.token)
.patch('/admin/instance/config')
.body({
domain_migration: {included_guild_ids: guildIds, include_premium_users: true},
altcha_captcha: {included_guild_ids: guildIds, include_premium_users: true},
})
.execute();
expect(body.domain_migration.included_guild_ids).toEqual(guildIds);
expect(body.altcha_captcha.included_guild_ids).toEqual(guildIds);
for (const section of [body.domain_migration, body.altcha_captcha]) {
expect(section.include_premium_users).toBe(true);
}
expect(body.domain_migration.include_premium_users).toBe(true);
});
it('revalidates with a strong etag and answers 304 when nothing changed', async () => {
@@ -39,6 +39,7 @@ const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
const APP_PUBLIC_CONFIG_KEY = 'app_public_config';
const INSTANCE_POLICY_CONFIG_KEY = 'instance_policy_config';
const INSTANCE_INTEGRATIONS_CONFIG_KEY = 'instance_integrations_config';
const LEGACY_ALTCHA_CAPTCHA_CONFIG_KEY = 'altcha_captcha_config';
const REGISTRATION_CONFIG_KEY = 'registration_config';
const REGISTRATION_URLS_KEY = 'registration_urls';
const REGISTRATION_PENDING_APPROVALS_KEY = 'registration_pending_approvals';
@@ -150,36 +151,54 @@ describe('InstanceConfigRepository', () => {
});
});
it('reports the effective captcha provider as none while the selected pair is incomplete', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
const kvProvider = new MockKVProvider();
const repository = createRepository(kvProvider);
it('turns the captcha on at the default difficulty when no row is stored', async () => {
setCassandraQueryExecutorForTesting(new CountingInMemoryCassandraQueryExecutor());
const repository = createRepository(new MockKVProvider());
await repository.setInstanceIntegrationsConfig({
captcha: {
provider: 'turnstile',
hcaptcha_site_key: 'hcaptcha-site-key',
hcaptcha_secret_key: 'hcaptcha-secret-key',
},
});
await expect(repository.getCaptchaConfig()).resolves.toEqual({enabled: true, cost: 5000, max_counter: 1000});
});
await expect(repository.getEffectiveCaptchaConfig()).resolves.toMatchObject({
enabled: false,
provider: 'none',
});
it('ignores a legacy altcha captcha row that turned the experiment off', async () => {
setCassandraQueryExecutorForTesting(new CountingInMemoryCassandraQueryExecutor());
const repository = createRepository(new MockKVProvider());
await repository.setInstanceIntegrationsConfig({
captcha: {
turnstile_site_key: 'turnstile-site-key',
turnstile_secret_key: 'turnstile-secret-key',
},
});
await repository.setConfig(
LEGACY_ALTCHA_CAPTCHA_CONFIG_KEY,
JSON.stringify({enabled: false, config_version: 4, cost: 5000, max_counter: 10000}),
);
await expect(repository.getEffectiveCaptchaConfig()).resolves.toMatchObject({
enabled: true,
provider: 'turnstile',
});
await expect(repository.getCaptchaConfig()).resolves.toEqual({enabled: true, cost: 5000, max_counter: 1000});
});
it('merges a partial captcha update onto the stored config', async () => {
setCassandraQueryExecutorForTesting(new CountingInMemoryCassandraQueryExecutor());
const repository = createRepository(new MockKVProvider());
await repository.updateCaptchaConfig({cost: 2000});
await repository.updateCaptchaConfig({enabled: false});
await expect(repository.getCaptchaConfig()).resolves.toEqual({enabled: false, cost: 2000, max_counter: 1000});
});
it('drops a legacy captcha integration, secrets included, on the next integrations write', async () => {
setCassandraQueryExecutorForTesting(new CountingInMemoryCassandraQueryExecutor());
const repository = createRepository(new MockKVProvider());
await repository.setConfig(
INSTANCE_INTEGRATIONS_CONFIG_KEY,
JSON.stringify({
captcha: {provider: 'legacy-provider', site_key: 'legacy-site-key', secret_key: 'legacy-secret-key'},
youtube: {api_key: 'youtube-key'},
}),
);
expect(await repository.getInstanceIntegrationsConfig()).not.toHaveProperty('captcha');
await repository.setInstanceIntegrationsConfig({gif: {klipy_api_key: 'klipy-key'}});
const stored = JSON.parse((await repository.getConfig(INSTANCE_INTEGRATIONS_CONFIG_KEY)) ?? '{}');
expect(stored).not.toHaveProperty('captcha');
expect(stored.youtube.api_key).toBe('youtube-key');
expect(stored.gif.klipy_api_key).toBe('klipy-key');
});
it('keeps the stored setup state when a branding field is invalid', async () => {
@@ -34,9 +34,10 @@ import {
type RegistrationUrlResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {
type AltchaCaptchaConfig,
AltchaCaptchaConfigSchema,
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
type CaptchaConfig,
CaptchaConfigSchema,
type CaptchaConfigUpdateRequest,
} from '@fluxer/schema/src/domains/admin/CaptchaSchemas';
import {
type DomainMigrationConfig,
DomainMigrationConfigSchema,
@@ -64,8 +65,6 @@ import {
type InstanceAppPublic,
InstanceAppPublicSchema,
type InstanceBranding,
type InstanceCaptchaProvider,
InstanceCaptchaProviderSchema,
type InstanceCommunity,
type InstanceRegistration,
InstanceRegistrationSchema,
@@ -79,7 +78,7 @@ import {z} from 'zod';
const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config';
const PUSH_RELAY_CONFIG_KEY = 'push_service_delivery_config';
const DOMAIN_MIGRATION_CONFIG_KEY = 'domain_migration_config';
const ALTCHA_CAPTCHA_CONFIG_KEY = 'altcha_captcha_config';
const CAPTCHA_CONFIG_KEY = 'captcha_config';
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
const REGISTRATION_CONFIG_KEY = 'registration_config';
const REGISTRATION_URLS_KEY = 'registration_urls';
@@ -189,14 +188,6 @@ interface InstanceYoutubeIntegrationConfig {
api_key: string | null;
}
interface InstanceCaptchaIntegrationConfig {
provider: InstanceCaptchaProvider | null;
hcaptcha_site_key: string | null;
hcaptcha_secret_key: string | null;
turnstile_site_key: string | null;
turnstile_secret_key: string | null;
}
interface InstanceEmailSmtpIntegrationConfig {
host: string | null;
port: number | null;
@@ -232,7 +223,6 @@ interface InstanceBlueskyIntegrationConfig {
interface InstanceIntegrationsConfig {
gif: InstanceGifIntegrationConfig;
youtube: InstanceYoutubeIntegrationConfig;
captcha: InstanceCaptchaIntegrationConfig;
email: InstanceEmailIntegrationConfig;
bluesky: InstanceBlueskyIntegrationConfig;
}
@@ -243,15 +233,6 @@ interface InstanceGifEffectiveConfig {
available: boolean;
}
export interface InstanceCaptchaEffectiveConfig {
enabled: boolean;
provider: InstanceCaptchaProvider;
hcaptcha_site_key: string | null;
hcaptcha_secret_key: string | null;
turnstile_site_key: string | null;
turnstile_secret_key: string | null;
}
interface InstanceIntegrationsAdminConfig {
gif: {
klipy_api_key_set: boolean;
@@ -261,15 +242,6 @@ interface InstanceIntegrationsAdminConfig {
api_key_set: boolean;
effective_available: boolean;
};
captcha: {
provider: InstanceCaptchaProvider | null;
effective_provider: InstanceCaptchaProvider;
hcaptcha_site_key: string | null;
hcaptcha_secret_key_set: boolean;
turnstile_site_key: string | null;
turnstile_secret_key_set: boolean;
effective_enabled: boolean;
};
email: {
enabled: boolean | null;
effective_enabled: boolean;
@@ -336,7 +308,6 @@ interface InstanceMediaAdminConfig {
interface InstanceIntegrationsConfigPatch {
gif?: Partial<InstanceGifIntegrationConfig>;
youtube?: Partial<InstanceYoutubeIntegrationConfig>;
captcha?: Partial<InstanceCaptchaIntegrationConfig>;
email?: Partial<Omit<InstanceEmailIntegrationConfig, 'smtp'>> & {
smtp?: Partial<InstanceEmailSmtpIntegrationConfig>;
};
@@ -435,7 +406,7 @@ type StoredConfigSection =
| 'gateway rollout'
| 'push relay'
| 'domain migration'
| 'altcha captcha'
| 'captcha'
| 'experiment delivery'
| 'instance policy'
| 'integrations'
@@ -596,8 +567,8 @@ function parseStoredDomainMigrationConfig(raw: string | null): DomainMigrationCo
return parseStoredConfigOrDefault(DomainMigrationConfigSchema, raw, 'domain migration');
}
function parseStoredAltchaCaptchaConfig(raw: string | null): AltchaCaptchaConfig {
return parseStoredConfigOrDefault(AltchaCaptchaConfigSchema, raw, 'altcha captcha');
function parseStoredCaptchaConfig(raw: string | null): CaptchaConfig {
return parseStoredConfigOrDefault(CaptchaConfigSchema, raw, 'captcha');
}
function parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeliveryConfig {
@@ -718,15 +689,6 @@ const StoredBlueskyKeysSchema = z
const StoredInstanceIntegrationsSchema = z.object({
gif: z.object({klipy_api_key: StoredIntegrationStringSchema}).prefault({}),
youtube: z.object({api_key: StoredIntegrationStringSchema}).prefault({}),
captcha: z
.object({
provider: InstanceCaptchaProviderSchema.nullable().default(null),
hcaptcha_site_key: StoredIntegrationStringSchema,
hcaptcha_secret_key: StoredIntegrationStringSchema,
turnstile_site_key: StoredIntegrationStringSchema,
turnstile_secret_key: StoredIntegrationStringSchema,
})
.prefault({}),
email: z
.object({
enabled: StoredNullableBooleanSchema,
@@ -1270,7 +1232,7 @@ export class InstanceConfigRepository {
);
parseStoredPushRelayConfig(snapshot.get(PUSH_RELAY_CONFIG_KEY) ?? null);
parseStoredDomainMigrationConfig(snapshot.get(DOMAIN_MIGRATION_CONFIG_KEY) ?? null);
parseStoredAltchaCaptchaConfig(snapshot.get(ALTCHA_CAPTCHA_CONFIG_KEY) ?? null);
parseStoredCaptchaConfig(snapshot.get(CAPTCHA_CONFIG_KEY) ?? null);
parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null);
parseStoredInstancePolicyConfig(snapshot.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
checkStoredConfig('registration', () =>
@@ -1384,20 +1346,14 @@ export class InstanceConfigRepository {
);
}
async getAltchaCaptchaConfig(): Promise<AltchaCaptchaConfig> {
const raw = await this.getConfig(ALTCHA_CAPTCHA_CONFIG_KEY);
return parseStoredAltchaCaptchaConfig(raw);
async getCaptchaConfig(): Promise<CaptchaConfig> {
const raw = await this.getConfig(CAPTCHA_CONFIG_KEY);
return parseStoredCaptchaConfig(raw);
}
async setAltchaCaptchaConfig(config: AltchaCaptchaConfig): Promise<void> {
await this.updateAltchaCaptchaConfig(() => config);
}
updateAltchaCaptchaConfig(
update: (current: AltchaCaptchaConfig) => AltchaCaptchaConfig,
): Promise<AltchaCaptchaConfig> {
return this.updateStoredConfig(ALTCHA_CAPTCHA_CONFIG_KEY, (raw) =>
validateStoredConfig(AltchaCaptchaConfigSchema, update(parseStoredAltchaCaptchaConfig(raw)), 'altcha captcha'),
updateCaptchaConfig(patch: CaptchaConfigUpdateRequest): Promise<CaptchaConfig> {
return this.updateStoredConfig(CAPTCHA_CONFIG_KEY, (raw) =>
validateStoredConfig(CaptchaConfigSchema, {...parseStoredCaptchaConfig(raw), ...patch}, 'captcha'),
);
}
@@ -1545,10 +1501,6 @@ export class InstanceConfigRepository {
...current.youtube,
...(config.youtube ?? {}),
},
captcha: {
...current.captcha,
...(config.captcha ?? {}),
},
email: {
...current.email,
...(config.email ?? {}),
@@ -1642,33 +1594,6 @@ export class InstanceConfigRepository {
return integrations.youtube.api_key ?? normalizeOptionalString(Config.youtube.apiKey);
}
async getEffectiveCaptchaConfig(): Promise<InstanceCaptchaEffectiveConfig> {
const integrations = await this.getInstanceIntegrationsConfig();
const provider = integrations.captcha.provider ?? (Config.captcha.enabled ? Config.captcha.provider : 'none');
const hcaptchaSiteKey =
integrations.captcha.hcaptcha_site_key ?? normalizeOptionalString(Config.captcha.hcaptcha?.siteKey);
const hcaptchaSecretKey =
integrations.captcha.hcaptcha_secret_key ?? normalizeOptionalString(Config.captcha.hcaptcha?.secretKey);
const turnstileSiteKey =
integrations.captcha.turnstile_site_key ?? normalizeOptionalString(Config.captcha.turnstile?.siteKey);
const turnstileSecretKey =
integrations.captcha.turnstile_secret_key ?? normalizeOptionalString(Config.captcha.turnstile?.secretKey);
const providerReady =
provider === 'hcaptcha'
? Boolean(hcaptchaSiteKey && hcaptchaSecretKey)
: provider === 'turnstile'
? Boolean(turnstileSiteKey && turnstileSecretKey)
: false;
return {
enabled: providerReady,
provider: providerReady ? provider : 'none',
hcaptcha_site_key: hcaptchaSiteKey,
hcaptcha_secret_key: hcaptchaSecretKey,
turnstile_site_key: turnstileSiteKey,
turnstile_secret_key: turnstileSecretKey,
};
}
async getEffectiveEmailConfig(): Promise<APIConfig['email']> {
const integrations = await this.getInstanceIntegrationsConfig();
const provider = integrations.email.provider ?? Config.email.provider;
@@ -1731,11 +1656,10 @@ export class InstanceConfigRepository {
}
async getInstanceIntegrationsAdminConfig(): Promise<InstanceIntegrationsAdminConfig> {
const [integrations, gif, youtubeApiKey, captcha, email, bluesky] = await Promise.all([
const [integrations, gif, youtubeApiKey, email, bluesky] = await Promise.all([
this.getInstanceIntegrationsConfig(),
this.getEffectiveGifConfig(),
this.getEffectiveYoutubeApiKey(),
this.getEffectiveCaptchaConfig(),
this.getEffectiveEmailConfig(),
this.getEffectiveBlueskyConfig(),
]);
@@ -1748,17 +1672,6 @@ export class InstanceConfigRepository {
api_key_set: secretIsSet(integrations.youtube.api_key) || secretIsSet(Config.youtube.apiKey),
effective_available: Boolean(youtubeApiKey),
},
captcha: {
provider: integrations.captcha.provider,
effective_provider: captcha.provider,
hcaptcha_site_key: captcha.hcaptcha_site_key,
hcaptcha_secret_key_set:
secretIsSet(integrations.captcha.hcaptcha_secret_key) || secretIsSet(Config.captcha.hcaptcha?.secretKey),
turnstile_site_key: captcha.turnstile_site_key,
turnstile_secret_key_set:
secretIsSet(integrations.captcha.turnstile_secret_key) || secretIsSet(Config.captcha.turnstile?.secretKey),
effective_enabled: captcha.enabled,
},
email: {
enabled: integrations.email.enabled,
effective_enabled: email.enabled,
@@ -14,8 +14,6 @@ import {afterEach, describe, expect, it} from 'vitest';
interface DiscoveryCaptcha {
provider: string;
hcaptcha_site_key: string | null;
turnstile_site_key: string | null;
}
describe('InstanceController discovery captcha', () => {
@@ -62,40 +60,15 @@ describe('InstanceController discovery captcha', () => {
return ((await response.json()) as {captcha: DiscoveryCaptcha}).captcha;
}
it('advertises no provider and no site key while the selected pair is incomplete', async () => {
const repository = createRepository();
await repository.setInstanceIntegrationsConfig({
captcha: {
provider: 'turnstile',
hcaptcha_site_key: 'hcaptcha-site-key',
hcaptcha_secret_key: 'hcaptcha-secret-key',
},
});
await expect(readCaptcha(repository)).resolves.toEqual({
provider: 'none',
hcaptcha_site_key: null,
turnstile_site_key: null,
});
it('advertises altcha by default', async () => {
await expect(readCaptcha(createRepository())).resolves.toEqual({provider: 'altcha'});
});
it('advertises only the site key that matches the named provider', async () => {
it('advertises no provider once an admin turns the captcha off', async () => {
const repository = createRepository();
await repository.setInstanceIntegrationsConfig({
captcha: {
provider: 'turnstile',
hcaptcha_site_key: 'hcaptcha-site-key',
hcaptcha_secret_key: 'hcaptcha-secret-key',
turnstile_site_key: 'turnstile-site-key',
turnstile_secret_key: 'turnstile-secret-key',
},
});
await repository.updateCaptchaConfig({enabled: false});
await expect(readCaptcha(repository)).resolves.toEqual({
provider: 'turnstile',
hcaptcha_site_key: null,
turnstile_site_key: 'turnstile-site-key',
});
await expect(readCaptcha(repository)).resolves.toEqual({provider: 'none'});
});
it('publishes the domain migration kill switch and anonymous rollout without the targeting lists', async () => {
@@ -8,7 +8,6 @@ import {
isDiscoveryNotModified,
nextDiscoveryValidators,
} from '@app/api/instance/DiscoveryValidators';
import type {InstanceCaptchaEffectiveConfig} from '@app/api/instance/InstanceConfigRepository';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
@@ -17,6 +16,7 @@ import type {HonoEnv} from '@app/api/types/HonoEnv';
import {API_CODE_VERSION} from '@fluxer/constants/src/AppConstants';
import {buildDiscoveryResponse, type DiscoveryStaticInput} from '@fluxer/instance_bootstrap/src/BuildDiscovery';
import type {InstanceAppPublic} from '@fluxer/instance_bootstrap/src/Types';
import type {CaptchaConfig} from '@fluxer/schema/src/domains/admin/CaptchaSchemas';
import {toDomainMigrationDiscovery} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {WellKnownFluxerResponse} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
import type {Hono} from 'hono';
@@ -27,7 +27,7 @@ function buildDiscoveryStaticInput(
gifService: GifService | undefined,
appPublic: InstanceAppPublic,
runtime: {
captcha: InstanceCaptchaEffectiveConfig;
captcha: CaptchaConfig;
emailEnabled: boolean;
},
): DiscoveryStaticInput {
@@ -61,9 +61,7 @@ function buildDiscoveryStaticInput(
webapp: Config.endpoints.webApp,
},
captcha: {
provider: runtime.captcha.provider,
hcaptcha_site_key: runtime.captcha.provider === 'hcaptcha' ? runtime.captcha.hcaptcha_site_key : null,
turnstile_site_key: runtime.captcha.provider === 'turnstile' ? runtime.captcha.turnstile_site_key : null,
provider: runtime.captcha.enabled ? 'altcha' : 'none',
},
features: {
voice_enabled: Config.voice.enabled,
@@ -111,7 +109,7 @@ export function InstanceController(app: Hono<HonoEnv>) {
instanceConfigRepository.getInstanceCommunityPublicConfig(),
instanceConfigRepository.getResolvedServicesConfig(),
instanceConfigRepository.getAppPublicConfig(),
instanceConfigRepository.getEffectiveCaptchaConfig(),
instanceConfigRepository.getCaptchaConfig(),
instanceConfigRepository.getEffectiveEmailConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
]);
@@ -2,9 +2,7 @@
import {createHmac} from 'node:crypto';
import {Config} from '@app/api/Config';
import {ANONYMOUS_EXPERIMENT_TARGETING, resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
import {sharedListHas} from '@app/api/infrastructure/activity/SharedLists';
import type {InstanceCaptchaEffectiveConfig} from '@app/api/instance/InstanceConfigRepository';
import {Logger} from '@app/api/Logger';
import {getKVClient} from '@app/api/middleware/ServiceRegistry';
import type {User} from '@app/api/models/User';
@@ -13,22 +11,19 @@ import {extractEmailDomain} from '@app/api/utils/EmailDomainUtils';
import {Headers} from '@fluxer/constants/src/Headers';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {CaptchaRequiredError, InvalidCaptchaError} from '@fluxer/errors/src/CaptchaErrors';
import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp';
import {type AltchaCaptchaConfig, altchaCaptchaAppliesTo} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import type {InstanceCaptchaProvider} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
import {createCaptchaProvider} from '@pkgs/captcha/src/CaptchaProviderFactory';
import type {ICaptchaProvider} from '@pkgs/captcha/src/ICaptchaProvider';
import type {CaptchaConfig} from '@fluxer/schema/src/domains/admin/CaptchaSchemas';
import {AltchaProvider} from '@pkgs/captcha/src/providers/AltchaProvider';
import type {Context} from 'hono';
import {createMiddleware} from 'hono/factory';
const ALTCHA_SPENT_CHALLENGE_KEY_PREFIX = 'captcha:altcha:spent:';
const TEST_ENABLE_CAPTCHA_HEADER = 'x-fluxer-test-enable-captcha';
function deriveAltchaSecret(label: string): string {
return createHmac('sha256', Config.auth.sudoModeSecret).update(label).digest('hex');
}
function createAltchaProvider(config: AltchaCaptchaConfig): AltchaProvider {
function createAltchaProvider(config: CaptchaConfig): AltchaProvider {
return new AltchaProvider({
hmacSignatureSecret: deriveAltchaSecret('fluxer-altcha-challenge-signature-v1'),
hmacKeySignatureSecret: deriveAltchaSecret('fluxer-altcha-key-signature-v1'),
@@ -40,85 +35,27 @@ function createAltchaProvider(config: AltchaCaptchaConfig): AltchaProvider {
});
}
async function altchaChallengeData(altcha: AltchaProvider | null): Promise<Record<string, unknown> | undefined> {
if (!altcha) return undefined;
async function altchaChallengeData(altcha: AltchaProvider): Promise<Record<string, unknown>> {
return {captcha_provider: 'altcha', altcha_challenge: await altcha.createChallenge()};
}
async function resolveAltchaProvider(ctx: Context<HonoEnv>, user: User | undefined): Promise<AltchaProvider | null> {
const config = await ctx.get('instanceConfigRepository').getAltchaCaptchaConfig();
const targeting = user ? await resolveExperimentTargeting(user, [config]) : ANONYMOUS_EXPERIMENT_TARGETING;
if (!altchaCaptchaAppliesTo(config, user ? user.id.toString() : null, targeting)) return null;
return createAltchaProvider(config);
}
function resolveProviderSecret(
config: InstanceCaptchaEffectiveConfig,
provider: InstanceCaptchaProvider,
): string | null {
if (provider === 'hcaptcha') {
return config.hcaptcha_secret_key;
}
if (provider === 'turnstile') {
return config.turnstile_secret_key;
}
return null;
}
function resolveCaptchaProvider(
config: InstanceCaptchaEffectiveConfig,
requestedType: string | undefined,
): ICaptchaProvider {
if (Config.dev.testModeEnabled) {
return createCaptchaProvider({mode: 'test'});
}
const requestedProvider =
requestedType === 'hcaptcha' || requestedType === 'turnstile'
? requestedType
: config.provider === 'hcaptcha' || config.provider === 'turnstile'
? config.provider
: null;
if (!requestedProvider) {
throw new Error('Captcha is enabled but no provider is configured');
}
const secretKey = resolveProviderSecret(config, requestedProvider);
if (!secretKey) {
throw new InvalidCaptchaError();
}
return createCaptchaProvider({mode: requestedProvider, secretKey});
}
export async function verifyCaptchaToken(ctx: Context<HonoEnv>): Promise<void> {
const captchaConfig = await ctx.get('instanceConfigRepository').getEffectiveCaptchaConfig();
if (!captchaConfig.enabled && !(Config.dev.testModeEnabled && Config.captcha.enabled)) return;
export async function verifyCaptchaToken(ctx: Context<HonoEnv>): Promise<boolean> {
if (Config.dev.testModeEnabled && ctx.req.header(TEST_ENABLE_CAPTCHA_HEADER) !== 'true') return false;
const config = await ctx.get('instanceConfigRepository').getCaptchaConfig();
if (!config.enabled) return false;
const user = ctx.get('user') as User | undefined;
if (sharedListHas('email_domain_exempt', extractEmailDomain(user?.email))) return;
if (userHasCaptchaExemptFlag(user)) return;
if (await requestUserHasCaptchaExemptFlag(ctx)) return;
const altcha = await resolveAltchaProvider(ctx, user);
if (sharedListHas('email_domain_exempt', extractEmailDomain(user?.email))) return false;
if (userHasCaptchaExemptFlag(user)) return false;
if (await requestUserHasCaptchaExemptFlag(ctx)) return false;
const altcha = createAltchaProvider(config);
const token = ctx.req.header(Headers.X_CAPTCHA_TOKEN);
if (!token) {
throw new CaptchaRequiredError(await altchaChallengeData(altcha));
}
const requestedType = ctx.req.header(Headers.X_CAPTCHA_TYPE);
if (requestedType === 'altcha') {
if (!altcha || !(await altcha.verify({token}))) {
throw new InvalidCaptchaError(await altchaChallengeData(altcha));
}
return;
}
const provider = resolveCaptchaProvider(captchaConfig, requestedType);
const isValid = await provider.verify({
token,
remoteIp:
extractClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
}) ?? undefined,
});
if (!isValid) {
if (!(await altcha.verify({token}))) {
throw new InvalidCaptchaError(await altchaChallengeData(altcha));
}
return true;
}
function userHasCaptchaExemptFlag(user: User | null | undefined): boolean {
@@ -0,0 +1,73 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import type {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository';
import {CaptchaMiddleware} from '@app/api/middleware/CaptchaMiddleware';
import {CAPTCHA_TEST_HEADER} from '@app/api/test/CaptchaTestUtils';
import type {HonoEnv} from '@app/api/types/HonoEnv';
import {AppErrorHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers';
import {type CaptchaConfig, DEFAULT_CAPTCHA_CONFIG} from '@fluxer/schema/src/domains/admin/CaptchaSchemas';
import {Hono} from 'hono';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
const CHEAP_CAPTCHA_CONFIG: CaptchaConfig = {...DEFAULT_CAPTCHA_CONFIG, cost: 1000, max_counter: 100};
function createHarness(captcha: CaptchaConfig): (headers: Record<string, string>) => Promise<Response> {
const repository = {
getCaptchaConfig: async () => captcha,
} as unknown as InstanceConfigRepository;
const app = new Hono<HonoEnv>();
app.use(async (ctx, next) => {
ctx.set('instanceConfigRepository', repository);
await next();
});
app.use(CaptchaMiddleware);
app.post('/auth/register', (ctx) => ctx.text('ok'));
app.onError(AppErrorHandler);
return async (headers) => app.request('http://localhost/auth/register', {method: 'POST', headers});
}
describe('CaptchaMiddleware', () => {
let previousTestModeEnabled: boolean;
beforeEach(() => {
previousTestModeEnabled = Config.dev.testModeEnabled;
Config.dev.testModeEnabled = false;
});
afterEach(() => {
Config.dev.testModeEnabled = previousTestModeEnabled;
});
it('passes every request while the captcha is disabled', async () => {
const response = await createHarness({...CHEAP_CAPTCHA_CONFIG, enabled: false})({});
expect(response.status).toBe(200);
});
it('passes a test-mode request that does not opt in', async () => {
Config.dev.testModeEnabled = true;
const request = createHarness(CHEAP_CAPTCHA_CONFIG);
expect((await request({})).status).toBe(200);
expect((await request({[CAPTCHA_TEST_HEADER]: 'true'})).status).toBe(400);
});
it('answers a request with no token with CAPTCHA_REQUIRED and an ALTCHA challenge', async () => {
const response = await createHarness(CHEAP_CAPTCHA_CONFIG)({});
expect(response.status).toBe(400);
expect(await response.json()).toMatchObject({
code: 'CAPTCHA_REQUIRED',
captcha_provider: 'altcha',
altcha_challenge: {parameters: {algorithm: 'PBKDF2/SHA-256', cost: 1000}},
});
});
it('answers a token that is not an ALTCHA payload with INVALID_CAPTCHA and a fresh challenge', async () => {
const response = await createHarness(CHEAP_CAPTCHA_CONFIG)({'x-captcha-token': 'legacy-provider-token'});
expect(response.status).toBe(400);
expect(await response.json()).toMatchObject({
code: 'INVALID_CAPTCHA',
captcha_provider: 'altcha',
altcha_challenge: {parameters: {algorithm: 'PBKDF2/SHA-256'}},
});
});
});
@@ -1,67 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import type {
InstanceCaptchaEffectiveConfig,
InstanceConfigRepository,
} from '@app/api/instance/InstanceConfigRepository';
import {CaptchaMiddleware} from '@app/api/middleware/CaptchaMiddleware';
import type {HonoEnv} from '@app/api/types/HonoEnv';
import {AppErrorHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers';
import {DEFAULT_ALTCHA_CAPTCHA_CONFIG} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {Hono} from 'hono';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
const HCAPTCHA_ONLY: InstanceCaptchaEffectiveConfig = {
enabled: true,
provider: 'hcaptcha',
hcaptcha_site_key: 'hcaptcha-site-key',
hcaptcha_secret_key: 'hcaptcha-secret-key',
turnstile_site_key: null,
turnstile_secret_key: null,
};
function createHarness(
captcha: InstanceCaptchaEffectiveConfig,
): (headers: Record<string, string>) => Promise<Response> {
const repository = {
getEffectiveCaptchaConfig: async () => captcha,
getAltchaCaptchaConfig: async () => DEFAULT_ALTCHA_CAPTCHA_CONFIG,
} as unknown as InstanceConfigRepository;
const app = new Hono<HonoEnv>();
app.use(async (ctx, next) => {
ctx.set('instanceConfigRepository', repository);
await next();
});
app.use(CaptchaMiddleware);
app.post('/auth/register', (ctx) => ctx.text('ok'));
app.onError(AppErrorHandler);
return async (headers) => app.request('http://localhost/auth/register', {method: 'POST', headers});
}
describe('CaptchaMiddleware provider header', () => {
let previousTestModeEnabled: boolean;
beforeEach(() => {
previousTestModeEnabled = Config.dev.testModeEnabled;
Config.dev.testModeEnabled = false;
});
afterEach(() => {
Config.dev.testModeEnabled = previousTestModeEnabled;
});
it('rejects a header naming a provider the instance holds no secret key for with 400 INVALID_CAPTCHA', async () => {
const request = createHarness(HCAPTCHA_ONLY);
const response = await request({'x-captcha-token': 'solution', 'x-captcha-type': 'turnstile'});
expect(response.status).toBe(400);
expect(await response.json()).toMatchObject({code: 'INVALID_CAPTCHA'});
});
it('rejects a request with no proof with 400 CAPTCHA_REQUIRED', async () => {
const request = createHarness(HCAPTCHA_ONLY);
const response = await request({});
expect(response.status).toBe(400);
expect(await response.json()).toMatchObject({code: 'CAPTCHA_REQUIRED'});
});
});
@@ -84,7 +84,7 @@ export function OAuth2ApplicationsController(app: HonoApp) {
security: ['bearerToken', 'sessionToken'],
tags: ['OAuth2'],
description:
'Creates a new bot-backed OAuth2 application (client). Requires CAPTCHA verification. Returns client credentials including ID and secret. Application can be used for authorization flows and API access.',
'Creates a new bot-backed OAuth2 application (client). Requires a solved captcha challenge (X-Captcha-Token). Returns client credentials including ID and secret. Application can be used for authorization flows and API access.',
}),
async (ctx) => {
const userId = ctx.get('user').id;
@@ -1,9 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {Config} from '@app/api/Config';
import {createOAuth2Application, createUniqueApplicationName} from '@app/api/oauth/tests/OAuth2TestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {CAPTCHA_TEST_HEADER, issueSolvedCaptchaToken, useCheapCaptcha} from '@app/api/test/CaptchaTestUtils';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
@@ -18,19 +18,6 @@ interface ValidationErrorResponse {
}>;
}
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;
}
}
describe('OAuth2 Application Create', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
@@ -94,24 +81,24 @@ describe('OAuth2 Application Create', () => {
});
test('requires captcha when creating a bot application', async () => {
const account = await createTestAccount(harness);
await withCaptchaEnabled(async () =>
createBuilder(harness, account.token)
.post('/oauth2/applications')
.body({name: createUniqueApplicationName()})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
.execute(),
);
await useCheapCaptcha();
await createBuilder(harness, account.token)
.post('/oauth2/applications')
.header(CAPTCHA_TEST_HEADER, 'true')
.body({name: createUniqueApplicationName()})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
.execute();
});
test('creates a bot application with a valid captcha token', async () => {
const account = await createTestAccount(harness);
await withCaptchaEnabled(async () =>
createBuilder(harness, account.token)
.post('/oauth2/applications')
.header('x-captcha-token', 'test-captcha-token')
.body({name: createUniqueApplicationName()})
.expect(HTTP_STATUS.OK)
.execute(),
);
await useCheapCaptcha();
await createBuilder(harness, account.token)
.post('/oauth2/applications')
.header(CAPTCHA_TEST_HEADER, 'true')
.header('x-captcha-token', await issueSolvedCaptchaToken(harness))
.body({name: createUniqueApplicationName()})
.expect(HTTP_STATUS.OK)
.execute();
});
test('rejects missing name', async () => {
const account = await createTestAccount(harness);
+11 -25
View File
@@ -295,7 +295,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Initiate password reset process by email. A password reset link will be sent to the user's email address. Requires CAPTCHA verification.",
"description": "Initiate password reset process by email. A password reset link will be sent to the user's email address. Requires a solved captcha challenge (X-Captcha-Token).",
"requestBody": {
"required": true,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ForgotPasswordRequest"}}}
@@ -745,7 +745,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Authenticate with email and password. Returns authentication token if credentials are valid and MFA is not required. If MFA is enabled, returns a ticket for MFA verification.",
"description": "Authenticate with email and password. Returns authentication token if credentials are valid and MFA is not required. If MFA is enabled, returns a ticket for MFA verification. Requires a solved captcha challenge (X-Captcha-Token).",
"requestBody": {
"required": true,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/LoginRequest"}}}
@@ -1380,7 +1380,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Create a new user account with email and password. Requires CAPTCHA verification. User account is created but must verify email before logging in.",
"description": "Create a new user account with email and password. Requires a solved captcha challenge (X-Captcha-Token). User account is created but must verify email before logging in.",
"requestBody": {
"required": false,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/RegisterRequest"}}}
@@ -4683,7 +4683,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Adds a user to a group direct message channel. The requesting user must be a member of the group DM. Requires CAPTCHA verification.",
"description": "Adds a user to a group direct message channel. The requesting user must be a member of the group DM. Requires a solved captcha challenge (X-Captcha-Token).",
"security": [{"botToken": []}, {"sessionToken": []}],
"parameters": [
{
@@ -10238,7 +10238,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Creates a new bot-backed OAuth2 application (client). Requires CAPTCHA verification. Returns client credentials including ID and secret. Application can be used for authorization flows and API access.",
"description": "Creates a new bot-backed OAuth2 application (client). Requires a solved captcha challenge (X-Captcha-Token). Returns client credentials including ID and secret. Application can be used for authorization flows and API access.",
"security": [{"sessionToken": []}],
"requestBody": {
"required": true,
@@ -13674,7 +13674,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Creates a new private channel (direct message) between the current user and one or more recipients. Group DM creation requires CAPTCHA verification. Returns the newly created channel object.",
"description": "Creates a new private channel (direct message) between the current user and one or more recipients. Group DM creation requires a solved captcha challenge (X-Captcha-Token). Returns the newly created channel object.",
"security": [{"botToken": []}, {"sessionToken": []}],
"requestBody": {
"required": true,
@@ -18392,7 +18392,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Send a one-time code on the requested channel. Defaults to the first available channel from server policy. Pass channel=\"sms\" to request SMS (only honoured for SMS-allowlisted destinations) or channel=\"inbound_challenge\" to receive challenge details to text in. Expensive outbound destinations always downgrade to an inbound challenge.",
"description": "Send a one-time code on the requested channel. Defaults to the first available channel from server policy. Pass channel=\"sms\" to request SMS (only honoured for SMS-allowlisted destinations) or channel=\"inbound_challenge\" to receive challenge details to text in. Expensive outbound destinations always downgrade to an inbound challenge. Requires a solved captcha challenge (X-Captcha-Token) when the phone verification service asks for one.",
"security": [{"sessionToken": []}],
"requestBody": {
"required": true,
@@ -27939,10 +27939,7 @@
"poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50},
"assignments": {
"type": "object",
"properties": {
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationAssignmentResponse"},
"altcha_captcha": {"$ref": "#/components/schemas/AltchaCaptchaAssignmentResponse"}
},
"properties": {"domain_migration": {"$ref": "#/components/schemas/DomainMigrationAssignmentResponse"}},
"additionalProperties": false
}
},
@@ -29905,16 +29902,11 @@
"type": "object",
"properties": {
"provider": {
"description": "Captcha provider name (hcaptcha, turnstile, none)",
"description": "Captcha provider (altcha or none)",
"$ref": "#/components/schemas/InstanceCaptchaProviderSchema"
},
"hcaptcha_site_key": {"description": "hCaptcha site key if using hCaptcha", "type": ["string", "null"]},
"turnstile_site_key": {
"description": "Cloudflare Turnstile site key if using Turnstile",
"type": ["string", "null"]
}
},
"required": ["provider", "hcaptcha_site_key", "turnstile_site_key"],
"required": ["provider"],
"additionalProperties": false,
"description": "Captcha configuration"
},
@@ -29949,7 +29941,7 @@
"additionalProperties": false,
"description": "Endpoint URLs for various services"
},
"InstanceCaptchaProviderSchema": {"type": "string", "enum": ["hcaptcha", "turnstile", "none"]},
"InstanceCaptchaProviderSchema": {"type": "string", "enum": ["altcha", "none"]},
"InstanceRegistrationModeSchema": {
"description": "Registration mode",
"x-enumNames": ["open", "approval", "closed"],
@@ -31640,12 +31632,6 @@
"additionalProperties": false
},
"DonationCurrency": {"type": "string", "enum": ["usd", "eur", "brl", "inr", "pln", "try", "sek", "dkk", "nok"]},
"AltchaCaptchaAssignmentResponse": {
"type": "object",
"properties": {"enabled": {"type": "boolean"}},
"required": ["enabled"],
"additionalProperties": false
},
"DomainMigrationAssignmentResponse": {
"type": "object",
"properties": {"enabled": {"type": "boolean"}},
@@ -1,49 +1,35 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {Config} from '@app/api/Config';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {CAPTCHA_TEST_HEADER, useCheapCaptcha} from '@app/api/test/CaptchaTestUtils';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {afterEach, beforeEach, describe, test} 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;
}
}
describe('Gift Code Redeem Captcha', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
await useCheapCaptcha();
});
afterEach(async () => {
await harness?.shutdown();
});
test('rejects an unauthenticated redeem before reading the captcha', async () => {
await withCaptchaEnabled(async () =>
createBuilderWithoutAuth(harness)
.post('/gifts/test-gift-code/redeem')
.expect(HTTP_STATUS.UNAUTHORIZED, APIErrorCodes.UNAUTHORIZED)
.execute(),
);
await createBuilderWithoutAuth(harness)
.post('/gifts/test-gift-code/redeem')
.header(CAPTCHA_TEST_HEADER, 'true')
.expect(HTTP_STATUS.UNAUTHORIZED, APIErrorCodes.UNAUTHORIZED)
.execute();
});
test('requires captcha when redeeming with a credential', async () => {
const account = await createTestAccount(harness);
await withCaptchaEnabled(async () =>
createBuilder(harness, account.token)
.post('/gifts/test-gift-code/redeem')
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
.execute(),
);
await createBuilder(harness, account.token)
.post('/gifts/test-gift-code/redeem')
.header(CAPTCHA_TEST_HEADER, 'true')
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
.execute();
});
});
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {solveChallenge} from 'altcha-lib';
import {deriveKey} from 'altcha-lib/algorithms/pbkdf2';
import type {Challenge} from 'altcha-lib/types';
export const CAPTCHA_TEST_HEADER = 'x-fluxer-test-enable-captcha';
export interface CaptchaErrorBody {
code: string;
captcha_provider?: string;
altcha_challenge?: Challenge;
}
export async function useCheapCaptcha(): Promise<void> {
await getInstanceConfigRepository().updateCaptchaConfig({enabled: true, cost: 1000, max_counter: 100});
}
export async function solveCaptchaChallenge(body: CaptchaErrorBody): Promise<string> {
const challenge = body.altcha_challenge;
if (!challenge) throw new Error('The response carried no ALTCHA challenge');
const solution = await solveChallenge({challenge, deriveKey, timeout: 0});
if (!solution) throw new Error('The ALTCHA challenge was not solved');
const payload = {challenge: {parameters: challenge.parameters, signature: challenge.signature}, solution};
return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64');
}
export async function issueSolvedCaptchaToken(harness: ApiTestHarness): Promise<string> {
const {json} = await createBuilderWithoutAuth<CaptchaErrorBody>(harness)
.post('/auth/forgot')
.header(CAPTCHA_TEST_HEADER, 'true')
.body({email: '[email protected]'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
.executeWithResponse();
return await solveCaptchaChallenge(json);
}
-2
View File
@@ -89,8 +89,6 @@ function setDefaultTestEnv(): void {
FLUXER_SEARCH_ENGINE: 'elasticsearch',
FLUXER_SEARCH_URL: 'http://127.0.0.1:9200',
FLUXER_SEARCH_API_KEY: 'test',
FLUXER_CAPTCHA_ENABLED: 'false',
FLUXER_CAPTCHA_PROVIDER: 'none',
FLUXER_DISCOVERY_ENABLED: 'true',
FLUXER_RELAX_REGISTRATION_RATE_LIMITS: 'true',
FLUXER_DISABLE_RATE_LIMITS: 'true',
@@ -8,12 +8,14 @@ import {
import {requireSudoMode} from '@app/api/auth/services/SudoVerificationService';
import {Config} from '@app/api/Config';
import {DefaultUserOnly, LoginRequired, LoginRequiredAllowSuspicious} from '@app/api/middleware/AuthMiddleware';
import {verifyCaptchaToken} from '@app/api/middleware/CaptchaMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {SudoModeMiddleware} from '@app/api/middleware/SudoModeMiddleware';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {Validator} from '@app/api/Validator';
import {Headers} from '@fluxer/constants/src/Headers';
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
import {
DisableTotpRequest,
@@ -234,7 +236,7 @@ export function UserAuthController(app: HonoApp) {
security: ['bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Send a one-time code on the requested channel. Defaults to the first available channel from server policy. Pass channel="sms" to request SMS (only honoured for SMS-allowlisted destinations) or channel="inbound_challenge" to receive challenge details to text in. Expensive outbound destinations always downgrade to an inbound challenge.',
'Send a one-time code on the requested channel. Defaults to the first available channel from server policy. Pass channel="sms" to request SMS (only honoured for SMS-allowlisted destinations) or channel="inbound_challenge" to receive challenge details to text in. Expensive outbound destinations always downgrade to an inbound challenge. Requires a solved captcha challenge (X-Captcha-Token) when the phone verification service asks for one.',
}),
async (ctx) => {
return ctx.json(
@@ -245,6 +247,8 @@ export function UserAuthController(app: HonoApp) {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
}),
hasCaptchaToken: ctx.req.header(Headers.X_CAPTCHA_TOKEN) !== undefined,
verifyCaptcha: () => verifyCaptchaToken(ctx),
}),
);
},
@@ -50,7 +50,7 @@ export function UserChannelController(app: HonoApp) {
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Creates a new private channel (direct message) between the current user and one or more recipients. Group DM creation requires CAPTCHA verification. Returns the newly created channel object.',
'Creates a new private channel (direct message) between the current user and one or more recipients. Group DM creation requires a solved captcha challenge (X-Captcha-Token). Returns the newly created channel object.',
}),
async (ctx) => {
const user = ctx.get('user');
@@ -126,13 +126,19 @@ export class UserAuthRequestService {
user,
data,
clientIp,
hasCaptchaToken,
verifyCaptcha,
}: UserAuthRequest<PhoneSendVerificationRequest> & {
clientIp: string;
hasCaptchaToken: boolean;
verifyCaptcha: () => Promise<boolean>;
}): Promise<PhoneSendVerificationResponse> {
await this.assertPhoneEligible(user);
const result = await AuthPhone.sendPhoneVerificationCode(this.apiContext, data.phone, user.id, {
clientIp,
channel: data.channel,
hasCaptchaToken,
verifyCaptcha,
});
if (result.channel === 'inbound_challenge') {
return {