From f8a04b89858adcbbb30585e1f4137492a47c180b Mon Sep 17 00:00:00 2001 From: Hampus Date: Sun, 6 Sep 2026 15:28:32 +0200 Subject: [PATCH] fix(api)!: enforce declared rate limits and correct route auth (#2500) --- fluxer_api/src/api/auth/AuthController.ts | 10 +- fluxer_api/src/api/auth/AuthRequestService.ts | 8 +- .../api/auth/tests/LoginAndSessions.test.ts | 12 ++ .../src/api/bluesky/BlueskyOAuthController.ts | 122 +++++++------- .../guild/controllers/GuildBaseController.ts | 2 +- .../controllers/GuildDiscoveryController.ts | 4 +- .../tests/PublicRouteRateLimits.test.ts | 153 ++++++++++++++++++ fluxer_api/src/api/oauth/OAuth2Controller.ts | 2 + fluxer_api/src/api/openapi/openapi.json | 20 ++- .../ConnectionRateLimitConfig.ts | 8 + .../search/controllers/SearchController.ts | 2 +- fluxer_api/src/api/stripe/StripeController.ts | 2 +- .../tests/GiftCodeRedeemCaptcha.test.ts | 49 ++++++ 13 files changed, 317 insertions(+), 77 deletions(-) create mode 100644 fluxer_api/src/api/middleware/tests/PublicRouteRateLimits.test.ts create mode 100644 fluxer_api/src/api/stripe/tests/GiftCodeRedeemCaptcha.test.ts diff --git a/fluxer_api/src/api/auth/AuthController.ts b/fluxer_api/src/api/auth/AuthController.ts index 98e95cecb..65f094d71 100644 --- a/fluxer_api/src/api/auth/AuthController.ts +++ b/fluxer_api/src/api/auth/AuthController.ts @@ -181,21 +181,19 @@ export function AuthController(app: HonoApp) { app.post( '/auth/logout', RateLimitMiddleware(RateLimitConfigs.AUTH_LOGOUT), + LoginRequiredAllowSuspicious, OpenAPI({ operationId: 'logout_user', summary: 'Logout account', responseSchema: null, statusCode: 204, - security: ['bearerToken', 'sessionToken'], + security: ['botToken', 'bearerToken', 'sessionToken'], tags: ['Auth'], description: - 'Invalidate the current authentication token and end the session. The auth token in the Authorization header will no longer be valid.', + 'Invalidate the current authentication token and end the session. The auth token in the Authorization header will no longer be valid. A bot token has no session to end, so the call answers 204 and the token stays valid.', }), async (ctx) => { - await ctx.get('authRequestService').logout({ - authorizationHeader: ctx.req.header('Authorization') ?? undefined, - authToken: ctx.get('authToken') ?? undefined, - }); + await ctx.get('authRequestService').logout({authToken: ctx.get('authToken')}); return ctx.body(null, 204); }, ); diff --git a/fluxer_api/src/api/auth/AuthRequestService.ts b/fluxer_api/src/api/auth/AuthRequestService.ts index 882098bce..34f2446d3 100644 --- a/fluxer_api/src/api/auth/AuthRequestService.ts +++ b/fluxer_api/src/api/auth/AuthRequestService.ts @@ -84,7 +84,6 @@ interface AuthLoginMfaRequest { } interface AuthLogoutRequest { - authorizationHeader?: string; authToken?: string; } @@ -187,10 +186,9 @@ export class AuthRequestService { return await this.toAuthTokenResponse(result); } - async logout({authorizationHeader, authToken}: AuthLogoutRequest): Promise { - const token = authToken ?? authorizationHeader; - if (token) { - await AuthSession.revokeToken(this.apiContext, token); + async logout({authToken}: AuthLogoutRequest): Promise { + if (authToken) { + await AuthSession.revokeToken(this.apiContext, authToken); } } diff --git a/fluxer_api/src/api/auth/tests/LoginAndSessions.test.ts b/fluxer_api/src/api/auth/tests/LoginAndSessions.test.ts index a81f1a8d8..5bfa4f1d3 100644 --- a/fluxer_api/src/api/auth/tests/LoginAndSessions.test.ts +++ b/fluxer_api/src/api/auth/tests/LoginAndSessions.test.ts @@ -101,6 +101,18 @@ describe('Auth login and sessions', () => { await createBuilder(harness, account.token).post('/auth/logout').expect(204).execute(); await createBuilder(harness, account.token).get('/users/@me').expect(401).execute(); }); + it('logout requires a live session', async () => { + await createBuilderWithoutAuth(harness).post('/auth/logout').expect(401).execute(); + await createBuilder(harness, createFakeAuthToken()).post('/auth/logout').expect(401).execute(); + const account = await createTestAccount(harness); + await createBuilder(harness, account.token).post('/auth/logout').expect(204).execute(); + await createBuilder(harness, account.token).post('/auth/logout').expect(401).execute(); + }); + it('logout with a Bearer-prefixed session token revokes it', async () => { + const account = await createTestAccount(harness); + await createBuilder(harness, `Bearer ${account.token}`).post('/auth/logout').expect(204).execute(); + await createBuilder(harness, account.token).get('/users/@me').expect(401).execute(); + }); it('treats /auth/sessions/logout as idempotent and removes targeted sessions', async () => { let account: TestAccount = await createTestAccount(harness); await createBuilder(harness, account.token) diff --git a/fluxer_api/src/api/bluesky/BlueskyOAuthController.ts b/fluxer_api/src/api/bluesky/BlueskyOAuthController.ts index e8e1494ec..107cdb94b 100644 --- a/fluxer_api/src/api/bluesky/BlueskyOAuthController.ts +++ b/fluxer_api/src/api/bluesky/BlueskyOAuthController.ts @@ -35,20 +35,28 @@ function isBlueskyOAuthEnabled(service: unknown): boolean { } export function BlueskyOAuthController(app: HonoApp) { - app.get('/connections/bluesky/client-metadata.json', async (ctx) => { - const service = ctx.get('blueskyOAuthService'); - if (!isBlueskyOAuthEnabled(service)) { - return ctx.json({error: 'Bluesky OAuth is not enabled'}, 404); - } - return ctx.json(service.clientMetadata); - }); - app.get('/connections/bluesky/jwks.json', async (ctx) => { - const service = ctx.get('blueskyOAuthService'); - if (!isBlueskyOAuthEnabled(service)) { - return ctx.json({error: 'Bluesky OAuth is not enabled'}, 404); - } - return ctx.json(service.jwks); - }); + app.get( + '/connections/bluesky/client-metadata.json', + RateLimitMiddleware(ConnectionRateLimitConfigs.BLUESKY_CLIENT_DOCUMENT), + async (ctx) => { + const service = ctx.get('blueskyOAuthService'); + if (!isBlueskyOAuthEnabled(service)) { + return ctx.json({error: 'Bluesky OAuth is not enabled'}, 404); + } + return ctx.json(service.clientMetadata); + }, + ); + app.get( + '/connections/bluesky/jwks.json', + RateLimitMiddleware(ConnectionRateLimitConfigs.BLUESKY_CLIENT_DOCUMENT), + async (ctx) => { + const service = ctx.get('blueskyOAuthService'); + if (!isBlueskyOAuthEnabled(service)) { + return ctx.json({error: 'Bluesky OAuth is not enabled'}, 404); + } + return ctx.json(service.jwks); + }, + ); app.post( '/users/@me/connections/bluesky/authorize', RateLimitMiddleware(ConnectionRateLimitConfigs.CONNECTION_CREATE), @@ -98,47 +106,51 @@ export function BlueskyOAuthController(app: HonoApp) { return ctx.json({authorize_url: result.authorizeUrl}); }, ); - app.get('/connections/bluesky/callback', async (ctx) => { - const appUrl = Config.endpoints.webApp; - const callbackUrl = `${appUrl}/connection-callback`; - const service = ctx.get('blueskyOAuthService'); - if (!isBlueskyOAuthEnabled(service)) { - return ctx.redirect(`${callbackUrl}?status=error&reason=not_enabled`); - } - try { - const params = new URLSearchParams(ctx.req.url.split('?')[1] ?? ''); - let result: BlueskyCallbackResult; - try { - result = await service.callback(params); - } catch (callbackError) { - if (callbackError instanceof BlueskyOAuthNotEnabledError) { - throw callbackError; - } - Logger.error({error: callbackError}, 'Bluesky OAuth callback error from upstream'); - if ( - callbackError instanceof Error && - (callbackError.message.toLowerCase().includes('state') || - callbackError.message.toLowerCase().includes('expired')) - ) { - throw new BlueskyOAuthStateInvalidError(); - } - throw new BlueskyOAuthCallbackFailedError(); - } - const connectionService = ctx.get('connectionService'); - await connectionService.createOrUpdateBlueskyConnection(result.userId, result.did, result.handle); - return ctx.redirect(`${callbackUrl}?status=connected`); - } catch (error) { - Logger.error({error}, 'Bluesky OAuth callback failed'); - if (error instanceof BlueskyOAuthStateInvalidError) { - return ctx.redirect(`${callbackUrl}?status=error&reason=state_invalid`); - } - if (error instanceof BlueskyOAuthCallbackFailedError) { - return ctx.redirect(`${callbackUrl}?status=error&reason=callback_failed`); - } - if (error instanceof BlueskyOAuthNotEnabledError) { + app.get( + '/connections/bluesky/callback', + RateLimitMiddleware(ConnectionRateLimitConfigs.BLUESKY_CALLBACK), + async (ctx) => { + const appUrl = Config.endpoints.webApp; + const callbackUrl = `${appUrl}/connection-callback`; + const service = ctx.get('blueskyOAuthService'); + if (!isBlueskyOAuthEnabled(service)) { return ctx.redirect(`${callbackUrl}?status=error&reason=not_enabled`); } - return ctx.redirect(`${callbackUrl}?status=error&reason=unknown`); - } - }); + try { + const params = new URLSearchParams(ctx.req.url.split('?')[1] ?? ''); + let result: BlueskyCallbackResult; + try { + result = await service.callback(params); + } catch (callbackError) { + if (callbackError instanceof BlueskyOAuthNotEnabledError) { + throw callbackError; + } + Logger.error({error: callbackError}, 'Bluesky OAuth callback error from upstream'); + if ( + callbackError instanceof Error && + (callbackError.message.toLowerCase().includes('state') || + callbackError.message.toLowerCase().includes('expired')) + ) { + throw new BlueskyOAuthStateInvalidError(); + } + throw new BlueskyOAuthCallbackFailedError(); + } + const connectionService = ctx.get('connectionService'); + await connectionService.createOrUpdateBlueskyConnection(result.userId, result.did, result.handle); + return ctx.redirect(`${callbackUrl}?status=connected`); + } catch (error) { + Logger.error({error}, 'Bluesky OAuth callback failed'); + if (error instanceof BlueskyOAuthStateInvalidError) { + return ctx.redirect(`${callbackUrl}?status=error&reason=state_invalid`); + } + if (error instanceof BlueskyOAuthCallbackFailedError) { + return ctx.redirect(`${callbackUrl}?status=error&reason=callback_failed`); + } + if (error instanceof BlueskyOAuthNotEnabledError) { + return ctx.redirect(`${callbackUrl}?status=error&reason=not_enabled`); + } + return ctx.redirect(`${callbackUrl}?status=error&reason=unknown`); + } + }, + ); } diff --git a/fluxer_api/src/api/guild/controllers/GuildBaseController.ts b/fluxer_api/src/api/guild/controllers/GuildBaseController.ts index b40059cb4..cf7d85be1 100644 --- a/fluxer_api/src/api/guild/controllers/GuildBaseController.ts +++ b/fluxer_api/src/api/guild/controllers/GuildBaseController.ts @@ -40,7 +40,7 @@ export function GuildBaseController(app: HonoApp) { description: 'Only claimed, email-verified non-bot users can create guilds.', responseSchema: GuildResponse, statusCode: 200, - security: ['botToken', 'bearerToken', 'sessionToken'], + security: ['bearerToken', 'sessionToken'], tags: ['Guilds'], }), async (ctx) => { diff --git a/fluxer_api/src/api/guild/controllers/GuildDiscoveryController.ts b/fluxer_api/src/api/guild/controllers/GuildDiscoveryController.ts index 09bcee403..1dfecf535 100644 --- a/fluxer_api/src/api/guild/controllers/GuildDiscoveryController.ts +++ b/fluxer_api/src/api/guild/controllers/GuildDiscoveryController.ts @@ -60,7 +60,7 @@ export function GuildDiscoveryController(app: HonoApp) { description: 'Search for guilds listed in the discovery directory.', responseSchema: DiscoveryGuildListResponse, statusCode: 200, - security: ['sessionToken', 'bearerToken'], + security: ['botToken', 'bearerToken', 'sessionToken'], tags: ['Discovery'], }), async (ctx) => { @@ -89,7 +89,7 @@ export function GuildDiscoveryController(app: HonoApp) { description: 'Returns the list of available discovery categories.', responseSchema: DiscoveryCategoryListResponse, statusCode: 200, - security: ['sessionToken', 'bearerToken'], + security: ['botToken', 'bearerToken', 'sessionToken'], tags: ['Discovery'], }), async (ctx) => { diff --git a/fluxer_api/src/api/middleware/tests/PublicRouteRateLimits.test.ts b/fluxer_api/src/api/middleware/tests/PublicRouteRateLimits.test.ts new file mode 100644 index 000000000..0c937ec39 --- /dev/null +++ b/fluxer_api/src/api/middleware/tests/PublicRouteRateLimits.test.ts @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createHash} from 'node:crypto'; +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import type { + BucketConfig, + IRateLimitService, + RateLimitConfig, + RateLimitResult, +} from '@pkgs/rate_limit/src/IRateLimitService'; +import {Hono} from 'hono'; +import {describe, expect, test} from 'vitest'; +import {BlueskyOAuthController} from '../../bluesky/BlueskyOAuthController'; +import type {IBlueskyOAuthService} from '../../bluesky/IBlueskyOAuthService'; +import {ConnectionRateLimitConfigs} from '../../rate_limit_configs/ConnectionRateLimitConfig'; +import type {HonoApp, HonoEnv} from '../../types/HonoEnv'; +import {RateLimitMiddleware} from '../RateLimitMiddleware'; + +const CLIENT_IP = '203.0.113.10'; +const HTTP_TOO_MANY_REQUESTS = 429; +const RATE_LIMIT_MIDDLEWARE_SOURCE = String(RateLimitMiddleware(ConnectionRateLimitConfigs.CONNECTION_LIST)); + +interface BucketCheck { + bucket: string; + limit: number; + windowMs: number; +} + +function allowedResult(limit: number): RateLimitResult { + return { + allowed: true, + limit, + remaining: limit - 1, + resetTime: new Date(Date.now() + 60000), + resetAfterDecimal: 60, + }; +} + +class RecordingRateLimitService implements IRateLimitService { + readonly bucketChecks: Array = []; + readonly globalIdentifiers: Array = []; + denyBuckets = false; + + async checkLimit(config: RateLimitConfig): Promise { + return allowedResult(config.maxAttempts); + } + + async peekLimit(config: RateLimitConfig): Promise { + return allowedResult(config.maxAttempts); + } + + async checkBucketLimit(bucket: string, config: BucketConfig): Promise { + this.bucketChecks.push({bucket, limit: config.limit, windowMs: config.windowMs}); + return {...allowedResult(config.limit), allowed: !this.denyBuckets, remaining: this.denyBuckets ? 0 : 1}; + } + + async checkGlobalLimit(identifier: string, limit: number): Promise { + this.globalIdentifiers.push(identifier); + return allowedResult(limit); + } + + async resetLimit(_identifier: string): Promise {} + + async clearLimitsByIdentifierPrefix(_identifierPrefix: string): Promise { + return 0; + } +} + +const blueskyOAuthService = { + clientMetadata: {client_id: 'https://fluxer.test/v1/connections/bluesky/client-metadata.json'}, + jwks: {keys: []}, +} as unknown as IBlueskyOAuthService; + +interface Harness { + app: HonoApp; + service: RecordingRateLimitService; +} + +function buildHarness(register: (app: HonoApp) => void): Harness { + const service = new RecordingRateLimitService(); + const app = new Hono(); + app.use('*', async (ctx, next) => { + ctx.set('rateLimitService', service); + ctx.set('blueskyOAuthService', blueskyOAuthService); + await next(); + }); + register(app); + return {app, service}; +} + +async function callRoute(harness: Harness, path: string): Promise { + return await harness.app.request(`http://localhost${path}`, { + headers: { + 'x-forwarded-for': CLIENT_IP, + 'x-fluxer-test-enable-rate-limits': 'true', + }, + }); +} + +function routesWithoutRateLimit(register: (app: HonoApp) => void): Array { + const app = new Hono(); + register(app); + const registered = new Set(); + const throttled = new Set(); + for (const route of app.routes) { + const shape = `${route.method} ${route.path}`; + registered.add(shape); + if (String(route.handler) === RATE_LIMIT_MIDDLEWARE_SOURCE) { + throttled.add(shape); + } + } + return [...registered].filter((shape) => !throttled.has(shape)).sort(); +} + +function bucketHash(bucket: string): string { + return createHash('sha256').update(bucket).digest('hex').slice(0, 16); +} + +describe('public route rate limits', () => { + test('every Bluesky OAuth route declares a rate limit bucket', () => { + expect(routesWithoutRateLimit(BlueskyOAuthController)).toEqual([]); + }); + + test('the Bluesky client documents share one bucket keyed by client IP', async () => { + const harness = buildHarness(BlueskyOAuthController); + + const metadata = await callRoute(harness, '/connections/bluesky/client-metadata.json'); + const jwks = await callRoute(harness, '/connections/bluesky/jwks.json'); + + expect(metadata.status).toBe(200); + expect(jwks.status).toBe(200); + expect(harness.service.bucketChecks).toEqual([ + {bucket: `ip:${CLIENT_IP}:connection:bluesky:client_document`, limit: 60, windowMs: 60000}, + {bucket: `ip:${CLIENT_IP}:connection:bluesky:client_document`, limit: 60, windowMs: 60000}, + ]); + expect(harness.service.globalIdentifiers).toEqual([`ip:${CLIENT_IP}`, `ip:${CLIENT_IP}`]); + }); + + test('a denied bucket answers a Bluesky client document with 429 and the bucket hash', async () => { + const harness = buildHarness(BlueskyOAuthController); + harness.service.denyBuckets = true; + + const response = await callRoute(harness, '/connections/bluesky/client-metadata.json'); + const body = (await response.json()) as {code?: string}; + + expect(response.status).toBe(HTTP_TOO_MANY_REQUESTS); + expect(body.code).toBe(APIErrorCodes.RATE_LIMITED); + expect(response.headers.get('X-RateLimit-Bucket')).toBe( + bucketHash(ConnectionRateLimitConfigs.BLUESKY_CLIENT_DOCUMENT.bucket), + ); + expect(response.headers.get('X-RateLimit-Scope')).toBe('user'); + }); +}); diff --git a/fluxer_api/src/api/oauth/OAuth2Controller.ts b/fluxer_api/src/api/oauth/OAuth2Controller.ts index 005e31b91..84cb24c77 100644 --- a/fluxer_api/src/api/oauth/OAuth2Controller.ts +++ b/fluxer_api/src/api/oauth/OAuth2Controller.ts @@ -218,6 +218,7 @@ export function OAuth2Controller(app: HonoApp) { ); app.get( '/oauth2/@me', + RateLimitMiddleware(RateLimitConfigs.OAUTH_INTROSPECT), requireOAuth2BearerToken(), LoginRequiredAllowSuspicious, DefaultUserOnly, @@ -259,6 +260,7 @@ export function OAuth2Controller(app: HonoApp) { ); app.get( '/applications/@me', + RateLimitMiddleware(RateLimitConfigs.OAUTH_DEV_CLIENTS_LIST), OpenAPI({ operationId: 'get_current_user_applications', summary: 'List current user applications', diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index af7a2fcc8..f030f7fb6 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -1064,6 +1064,14 @@ "description": "Bad Request - The request was malformed or contained invalid data", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, "429": { "description": "Too Many Requests - You are being rate limited", "content": { @@ -1105,8 +1113,8 @@ } }, "x-mint": {"metadata": {"title": "Logout account"}}, - "description": "Invalidate the current authentication token and end the session. The auth token in the Authorization header will no longer be valid.", - "security": [{"sessionToken": []}] + "description": "Invalidate the current authentication token and end the session. The auth token in the Authorization header will no longer be valid. A bot token has no session to end, so the call answers 204 and the token stays valid.", + "security": [{"botToken": []}, {"sessionToken": []}] } }, "/auth/register": { @@ -5674,7 +5682,7 @@ }, "x-mint": {"metadata": {"title": "List discovery categories"}}, "description": "Returns the list of available discovery categories.", - "security": [{"sessionToken": []}] + "security": [{"botToken": []}, {"sessionToken": []}] } }, "/discovery/guilds": { @@ -5741,7 +5749,7 @@ }, "x-mint": {"metadata": {"title": "Search discoverable guilds"}}, "description": "Search for guilds listed in the discovery directory.", - "security": [{"sessionToken": []}], + "security": [{"botToken": []}, {"sessionToken": []}], "parameters": [ { "name": "query", @@ -7110,7 +7118,7 @@ }, "x-mint": {"metadata": {"title": "Create guild"}}, "description": "Only claimed, email-verified non-bot users can create guilds.", - "security": [{"botToken": []}, {"sessionToken": []}], + "security": [{"sessionToken": []}], "requestBody": { "required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GuildCreateRequest"}}} @@ -14748,7 +14756,7 @@ }, "x-mint": {"metadata": {"title": "Search messages"}}, "description": "Searches for messages across guilds and channels accessible to the authenticated user. Bots must supply a context guild or channel and may only use the current scope.", - "security": [{"sessionToken": []}], + "security": [{"botToken": []}, {"sessionToken": []}], "requestBody": { "required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GlobalSearchMessagesRequest"}}} diff --git a/fluxer_api/src/api/rate_limit_configs/ConnectionRateLimitConfig.ts b/fluxer_api/src/api/rate_limit_configs/ConnectionRateLimitConfig.ts index b4d31f55d..e708ba586 100644 --- a/fluxer_api/src/api/rate_limit_configs/ConnectionRateLimitConfig.ts +++ b/fluxer_api/src/api/rate_limit_configs/ConnectionRateLimitConfig.ts @@ -28,4 +28,12 @@ export const ConnectionRateLimitConfigs = { bucket: 'connection:verify_and_create', config: {limit: 5, windowMs: ms('1 minute')}, } as RouteRateLimitConfig, + BLUESKY_CLIENT_DOCUMENT: { + bucket: 'connection:bluesky:client_document', + config: {limit: 60, windowMs: ms('1 minute')}, + } as RouteRateLimitConfig, + BLUESKY_CALLBACK: { + bucket: 'connection:bluesky:callback', + config: {limit: 10, windowMs: ms('1 minute')}, + } as RouteRateLimitConfig, } as const; diff --git a/fluxer_api/src/api/search/controllers/SearchController.ts b/fluxer_api/src/api/search/controllers/SearchController.ts index cd892b5f1..2f8c8b48c 100644 --- a/fluxer_api/src/api/search/controllers/SearchController.ts +++ b/fluxer_api/src/api/search/controllers/SearchController.ts @@ -23,7 +23,7 @@ export function SearchController(app: HonoApp) { 'Searches for messages across guilds and channels accessible to the authenticated user. Bots must supply a context guild or channel and may only use the current scope.', responseSchema: MessageSearchResponse, statusCode: 200, - security: ['bearerToken', 'sessionToken'], + security: ['botToken', 'bearerToken', 'sessionToken'], tags: 'Search', }), Validator('json', GlobalSearchMessagesRequest), diff --git a/fluxer_api/src/api/stripe/StripeController.ts b/fluxer_api/src/api/stripe/StripeController.ts index f5099fba7..8fcf8ae85 100644 --- a/fluxer_api/src/api/stripe/StripeController.ts +++ b/fluxer_api/src/api/stripe/StripeController.ts @@ -237,9 +237,9 @@ export function StripeController(app: HonoApp) { app.post( '/gifts/:code/redeem', RateLimitMiddleware(RateLimitConfigs.GIFT_CODE_REDEEM), - CaptchaMiddleware, LoginRequired, DefaultUserOnly, + CaptchaMiddleware, OpenAPI({ operationId: 'redeem_gift_code', summary: 'Redeem gift code', diff --git a/fluxer_api/src/api/stripe/tests/GiftCodeRedeemCaptcha.test.ts b/fluxer_api/src/api/stripe/tests/GiftCodeRedeemCaptcha.test.ts new file mode 100644 index 000000000..74c5df0cb --- /dev/null +++ b/fluxer_api/src/api/stripe/tests/GiftCodeRedeemCaptcha.test.ts @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {afterEach, beforeEach, describe, test} from 'vitest'; +import {createTestAccount} from '../../auth/tests/AuthTestUtils'; +import {Config} from '../../Config'; +import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; +import {HTTP_STATUS} from '../../test/TestConstants'; +import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder'; + +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; + } +} + +describe('Gift Code Redeem Captcha', () => { + let harness: ApiTestHarness; + beforeEach(async () => { + harness = await createApiTestHarness(); + }); + 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(), + ); + }); + 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(), + ); + }); +});