From d297dc58058fc6f6c5165c7ea0db6a3e9f0161e6 Mon Sep 17 00:00:00 2001 From: Hampus Date: Sat, 29 Aug 2026 18:00:52 +0200 Subject: [PATCH] feat(api): exempt APP_STORE_REVIEWER accounts from captcha (#2090) --- .../api/auth/tests/CaptchaBypassFlags.test.ts | 79 +++++++++++++++++++ .../src/api/middleware/CaptchaMiddleware.ts | 20 +++++ 2 files changed, 99 insertions(+) create mode 100644 fluxer_api/src/api/auth/tests/CaptchaBypassFlags.test.ts diff --git a/fluxer_api/src/api/auth/tests/CaptchaBypassFlags.test.ts b/fluxer_api/src/api/auth/tests/CaptchaBypassFlags.test.ts new file mode 100644 index 000000000..db84dcb19 --- /dev/null +++ b/fluxer_api/src/api/auth/tests/CaptchaBypassFlags.test.ts @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; +import {Config} from '../../Config'; +import type {ApiTestHarness} from '../../test/ApiTestHarness'; +import {HTTP_STATUS} from '../../test/TestConstants'; +import {createBuilderWithoutAuth} from '../../test/TestRequestBuilder'; +import {createAuthHarness, createUniqueEmail, createUniqueUsername, registerUser} from './AuthTestUtils'; + +async function withCaptchaEnabled(run: () => Promise): Promise { + 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, +): Promise<{email: string; password: string; userId: string}> { + const email = createUniqueEmail('captcha-flags'); + const password = 'a-strong-password'; + const reg = await registerUser(harness, { + email, + username: createUniqueUsername('captchaflags'), + global_name: 'Captcha Flags User', + password, + date_of_birth: '2000-01-01', + consent: true, + }); + if (flags.length > 0) { + await createBuilderWithoutAuth(harness) + .post(`/test/users/${reg.user_id}/security-flags`) + .body({set_flags: flags}) + .execute(); + } + return {email, password, userId: reg.user_id}; +} + +describe('Auth Captcha Bypass Flags', () => { + let harness: ApiTestHarness; + beforeAll(async () => { + harness = await createAuthHarness(); + }); + beforeEach(async () => { + await harness.reset(); + }); + 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); + }); + }); + 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(); + }); + }); +}); diff --git a/fluxer_api/src/api/middleware/CaptchaMiddleware.ts b/fluxer_api/src/api/middleware/CaptchaMiddleware.ts index 7a10b2cb3..109a5c6ab 100644 --- a/fluxer_api/src/api/middleware/CaptchaMiddleware.ts +++ b/fluxer_api/src/api/middleware/CaptchaMiddleware.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {UserFlags} from '@fluxer/constants/src/UserConstants'; import {CaptchaRequiredError, InvalidCaptchaError} from '@fluxer/errors/src/CaptchaErrors'; import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp'; import {createCaptchaProvider} from '@pkgs/captcha/src/CaptchaProviderFactory'; @@ -53,7 +54,9 @@ export async function verifyCaptchaToken(ctx: Context): Promise { if (!captchaConfig.enabled && !(Config.dev.testModeEnabled && Config.captcha.enabled)) return; const user = ctx.get('user') as User | undefined; if (accountPolicyContactHasCapability(user?.email, 'captcha_exempt')) return; + if (userHasCaptchaExemptFlag(user)) return; if (await requestContactHasCaptchaExemption(ctx.req.raw)) return; + if (await requestUserHasCaptchaExemptFlag(ctx)) return; const token = ctx.req.header('x-captcha-token'); if (!token) { throw new CaptchaRequiredError(); @@ -83,6 +86,23 @@ async function requestContactHasCaptchaExemption(request: Request): Promise): Promise { + try { + const body = (await ctx.req.raw.clone().json()) as unknown; + if (!body || typeof body !== 'object' || Array.isArray(body)) return false; + const email = (body as Record).email; + if (typeof email !== 'string') return false; + const user = await ctx.get('userRepository').findByEmail(email); + return userHasCaptchaExemptFlag(user); + } catch { + return false; + } +} + export const CaptchaMiddleware = createMiddleware(async (ctx, next) => { await verifyCaptchaToken(ctx); await next();