diff --git a/fluxer_api/src/api/channel/controllers/ChannelController.ts b/fluxer_api/src/api/channel/controllers/ChannelController.ts index dc833b264..fb8bcc373 100644 --- a/fluxer_api/src/api/channel/controllers/ChannelController.ts +++ b/fluxer_api/src/api/channel/controllers/ChannelController.ts @@ -21,6 +21,7 @@ import {z} from 'zod'; import {requireSudoMode} from '../../auth/services/SudoVerificationService'; import {createChannelID, createUserID} from '../../BrandedTypes'; import {DefaultUserOnly, LoginRequired} from '../../middleware/AuthMiddleware'; +import {GroupDmRecipientAddProtectionMiddleware} from '../../middleware/GroupDmProtectionMiddleware'; import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware'; import {OpenAPI} from '../../middleware/ResponseTypeMiddleware'; import {SudoModeMiddleware} from '../../middleware/SudoModeMiddleware'; @@ -208,11 +209,12 @@ export function ChannelController(app: HonoApp) { RateLimitMiddleware(RateLimitConfigs.CHANNEL_UPDATE), LoginRequired, Validator('param', ChannelIdUserIdParam), + GroupDmRecipientAddProtectionMiddleware, OpenAPI({ 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.', + 'Adds a user to a group direct message channel. The requesting user must be a member of the group DM. Requires CAPTCHA verification.', responseSchema: null, statusCode: 204, security: ['botToken', 'bearerToken', 'sessionToken'], diff --git a/fluxer_api/src/api/channel/tests/GroupDmCaptchaRateLimit.test.ts b/fluxer_api/src/api/channel/tests/GroupDmCaptchaRateLimit.test.ts new file mode 100644 index 000000000..79cda15d2 --- /dev/null +++ b/fluxer_api/src/api/channel/tests/GroupDmCaptchaRateLimit.test.ts @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; +import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants'; +import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; +import {createTestAccount, type TestAccount} from '../../auth/tests/AuthTestUtils'; +import {Config} from '../../Config'; +import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness'; +import {HTTP_STATUS} from '../../test/TestConstants'; +import {createBuilder} from '../../test/TestRequestBuilder'; +import {createFriendship, createGroupDmChannel, type GroupDmChannelResponse} from './ChannelTestUtils'; + +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(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 createGroupDmWithCaptcha( + harness: ApiTestHarness, + owner: TestAccount, + recipientIds: Array, +): Promise { + return await createBuilder(harness, owner.token) + .post('/users/@me/channels') + .header('x-captcha-token', TEST_CAPTCHA_TOKEN) + .body({recipients: recipientIds}) + .expect(HTTP_STATUS.OK) + .execute(); +} + +describe('Group DM captcha and rate limits', () => { + let harness: ApiTestHarness; + beforeAll(async () => { + harness = await createApiTestHarness(); + }); + beforeEach(async () => { + await harness.reset(); + }); + afterAll(async () => { + await harness?.shutdown(); + }); + + it('requires captcha for group DM creation but not opening a 1:1 DM', async () => { + const owner = await createTestAccount(harness); + const dmRecipient = await createTestAccount(harness); + 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); + }); + }); + + it('requires captcha when adding a recipient to an existing group DM', async () => { + const owner = await createTestAccount(harness); + const member = await createTestAccount(harness); + const newMember = await createTestAccount(harness); + 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(); + }); + }); + + 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(); + } + 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_TOO_MANY_REQUESTS, APIErrorCodes.RATE_LIMITED) + .execute(); + }); + }); + + it('limits group DM recipient additions to 10 per user per hour', async () => { + const owner = await createTestAccount(harness); + const initialMember = await createTestAccount(harness); + const newMembers: Array = []; + await createFriendship(harness, owner, initialMember); + for (let index = 0; index < 11; index++) { + const member = await createTestAccount(harness); + await createFriendship(harness, owner, member); + 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(); + } + await createBuilder(harness, owner.token) + .put(`/channels/${groupDm.id}/recipients/${newMembers[10].userId}`) + .header(RATE_LIMIT_TEST_HEADER, 'true') + .header('x-captcha-token', TEST_CAPTCHA_TOKEN) + .body(null) + .expect(HTTP_TOO_MANY_REQUESTS, APIErrorCodes.RATE_LIMITED) + .execute(); + }); + }); +}); diff --git a/fluxer_api/src/api/middleware/CaptchaMiddleware.ts b/fluxer_api/src/api/middleware/CaptchaMiddleware.ts index 7c4c1c2b8..7a10b2cb3 100644 --- a/fluxer_api/src/api/middleware/CaptchaMiddleware.ts +++ b/fluxer_api/src/api/middleware/CaptchaMiddleware.ts @@ -48,7 +48,7 @@ function resolveCaptchaProvider( return createCaptchaProvider({mode: requestedProvider, secretKey}); } -async function verifyCaptchaToken(ctx: Context): Promise { +export async function verifyCaptchaToken(ctx: Context): Promise { const captchaConfig = await ctx.get('instanceConfigRepository').getEffectiveCaptchaConfig(); if (!captchaConfig.enabled && !(Config.dev.testModeEnabled && Config.captcha.enabled)) return; const user = ctx.get('user') as User | undefined; diff --git a/fluxer_api/src/api/middleware/GroupDmProtectionMiddleware.ts b/fluxer_api/src/api/middleware/GroupDmProtectionMiddleware.ts new file mode 100644 index 000000000..b7cc83e59 --- /dev/null +++ b/fluxer_api/src/api/middleware/GroupDmProtectionMiddleware.ts @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {CreatePrivateChannelRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas'; +import type {Context, MiddlewareHandler, Next} from 'hono'; +import {createMiddleware} from 'hono/factory'; +import {RateLimitConfigs} from '../RateLimitConfig'; +import type {HonoEnv} from '../types/HonoEnv'; +import {verifyCaptchaToken} from './CaptchaMiddleware'; +import {RateLimitMiddleware} from './RateLimitMiddleware'; + +const groupDmCreateRateLimit = RateLimitMiddleware(RateLimitConfigs.USER_GROUP_DM_CREATE); +const groupDmRecipientAddRateLimit = RateLimitMiddleware(RateLimitConfigs.USER_GROUP_DM_RECIPIENT_ADD); + +function getCreatePrivateChannelRequest(ctx: Context): CreatePrivateChannelRequest { + const req = ctx.req as unknown as { + valid(target: 'json'): CreatePrivateChannelRequest; + }; + return req.valid('json'); +} + +async function enforceRateLimitAndCaptcha(ctx: Context, next: Next, rateLimit: MiddlewareHandler) { + return await rateLimit(ctx, async () => { + await verifyCaptchaToken(ctx); + await next(); + }); +} + +export const GroupDmCreateProtectionMiddleware = createMiddleware(async (ctx, next) => { + const data = getCreatePrivateChannelRequest(ctx); + if (data.recipients === undefined) { + await next(); + return; + } + return await enforceRateLimitAndCaptcha(ctx, next, groupDmCreateRateLimit); +}); + +export const GroupDmRecipientAddProtectionMiddleware = createMiddleware(async (ctx, next) => { + return await enforceRateLimitAndCaptcha(ctx, next, groupDmRecipientAddRateLimit); +}); diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index e9b8dcc13..a366ffbbf 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -5119,7 +5119,7 @@ } }, "x-mint": {"metadata": {"title": "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.", + "description": "Adds a user to a group direct message channel. The requesting user must be a member of the group DM. Requires CAPTCHA verification.", "security": [{"botToken": []}, {"sessionToken": []}], "parameters": [ { @@ -16329,7 +16329,7 @@ } }, "x-mint": {"metadata": {"title": "Create private channel"}}, - "description": "Creates a new private channel (direct message) between the current user and one or more recipients. 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 CAPTCHA verification. Returns the newly created channel object.", "security": [{"botToken": []}, {"sessionToken": []}], "requestBody": { "required": true, diff --git a/fluxer_api/src/api/rate_limit_configs/UserRateLimitConfig.ts b/fluxer_api/src/api/rate_limit_configs/UserRateLimitConfig.ts index b1f05198a..5cdbf1479 100644 --- a/fluxer_api/src/api/rate_limit_configs/UserRateLimitConfig.ts +++ b/fluxer_api/src/api/rate_limit_configs/UserRateLimitConfig.ts @@ -116,6 +116,14 @@ export const UserRateLimitConfigs = { bucket: 'user:channels', config: {limit: 40, windowMs: ms('10 seconds')}, } as RouteRateLimitConfig, + USER_GROUP_DM_CREATE: { + bucket: 'user:group_dm:create', + config: {limit: 10, windowMs: ms('1 hour'), exemptFromGlobal: true}, + } as RouteRateLimitConfig, + USER_GROUP_DM_RECIPIENT_ADD: { + bucket: 'user:group_dm:recipient:add', + config: {limit: 10, windowMs: ms('1 hour'), exemptFromGlobal: true}, + } as RouteRateLimitConfig, USER_RELATIONSHIPS_LIST: { bucket: 'user:relationships:list', config: {limit: 40, windowMs: ms('10 seconds')}, diff --git a/fluxer_api/src/api/user/controllers/UserChannelController.ts b/fluxer_api/src/api/user/controllers/UserChannelController.ts index 1d95e5314..bd2ff536b 100644 --- a/fluxer_api/src/api/user/controllers/UserChannelController.ts +++ b/fluxer_api/src/api/user/controllers/UserChannelController.ts @@ -7,6 +7,7 @@ import {CreatePrivateChannelRequest} from '@fluxer/schema/src/domains/user/UserR import {z} from 'zod'; import {createChannelID} from '../../BrandedTypes'; import {LoginRequired} from '../../middleware/AuthMiddleware'; +import {GroupDmCreateProtectionMiddleware} from '../../middleware/GroupDmProtectionMiddleware'; import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware'; import {OpenAPI} from '../../middleware/ResponseTypeMiddleware'; import {RateLimitConfigs} from '../../RateLimitConfig'; @@ -41,6 +42,7 @@ export function UserChannelController(app: HonoApp) { RateLimitMiddleware(RateLimitConfigs.USER_CHANNELS), LoginRequired, Validator('json', CreatePrivateChannelRequest), + GroupDmCreateProtectionMiddleware, OpenAPI({ operationId: 'create_private_channel', summary: 'Create private channel', @@ -49,7 +51,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. 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 CAPTCHA verification. Returns the newly created channel object.', }), async (ctx) => { const user = ctx.get('user');