mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
feat(api): protect group dm changes (#1244)
This commit is contained in:
1 parent
6fd7c027e3
commit
33d05f9763
7 files changed
+210
-5
No files matched your search
@@ -21,6 +21,7 @@ import {z} from 'zod';
|
|||||||
import {requireSudoMode} from '../../auth/services/SudoVerificationService';
|
import {requireSudoMode} from '../../auth/services/SudoVerificationService';
|
||||||
import {createChannelID, createUserID} from '../../BrandedTypes';
|
import {createChannelID, createUserID} from '../../BrandedTypes';
|
||||||
import {DefaultUserOnly, LoginRequired} from '../../middleware/AuthMiddleware';
|
import {DefaultUserOnly, LoginRequired} from '../../middleware/AuthMiddleware';
|
||||||
|
import {GroupDmRecipientAddProtectionMiddleware} from '../../middleware/GroupDmProtectionMiddleware';
|
||||||
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
|
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
|
||||||
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
|
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
|
||||||
import {SudoModeMiddleware} from '../../middleware/SudoModeMiddleware';
|
import {SudoModeMiddleware} from '../../middleware/SudoModeMiddleware';
|
||||||
@@ -208,11 +209,12 @@ export function ChannelController(app: HonoApp) {
|
|||||||
RateLimitMiddleware(RateLimitConfigs.CHANNEL_UPDATE),
|
RateLimitMiddleware(RateLimitConfigs.CHANNEL_UPDATE),
|
||||||
LoginRequired,
|
LoginRequired,
|
||||||
Validator('param', ChannelIdUserIdParam),
|
Validator('param', ChannelIdUserIdParam),
|
||||||
|
GroupDmRecipientAddProtectionMiddleware,
|
||||||
OpenAPI({
|
OpenAPI({
|
||||||
operationId: 'add_group_dm_recipient',
|
operationId: 'add_group_dm_recipient',
|
||||||
summary: 'Add recipient to group DM',
|
summary: 'Add recipient to group DM',
|
||||||
description:
|
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,
|
responseSchema: null,
|
||||||
statusCode: 204,
|
statusCode: 204,
|
||||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||||
|
|||||||
@@ -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<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,
|
||||||
|
recipientIds: Array<string>,
|
||||||
|
): Promise<GroupDmChannelResponse> {
|
||||||
|
return await createBuilder<GroupDmChannelResponse>(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<TestAccount> = [];
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -48,7 +48,7 @@ function resolveCaptchaProvider(
|
|||||||
return createCaptchaProvider({mode: requestedProvider, secretKey});
|
return createCaptchaProvider({mode: requestedProvider, secretKey});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verifyCaptchaToken(ctx: Context<HonoEnv>): Promise<void> {
|
export async function verifyCaptchaToken(ctx: Context<HonoEnv>): Promise<void> {
|
||||||
const captchaConfig = await ctx.get('instanceConfigRepository').getEffectiveCaptchaConfig();
|
const captchaConfig = await ctx.get('instanceConfigRepository').getEffectiveCaptchaConfig();
|
||||||
if (!captchaConfig.enabled && !(Config.dev.testModeEnabled && Config.captcha.enabled)) return;
|
if (!captchaConfig.enabled && !(Config.dev.testModeEnabled && Config.captcha.enabled)) return;
|
||||||
const user = ctx.get('user') as User | undefined;
|
const user = ctx.get('user') as User | undefined;
|
||||||
|
|||||||
@@ -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<HonoEnv>): CreatePrivateChannelRequest {
|
||||||
|
const req = ctx.req as unknown as {
|
||||||
|
valid(target: 'json'): CreatePrivateChannelRequest;
|
||||||
|
};
|
||||||
|
return req.valid('json');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enforceRateLimitAndCaptcha(ctx: Context<HonoEnv>, next: Next, rateLimit: MiddlewareHandler<HonoEnv>) {
|
||||||
|
return await rateLimit(ctx, async () => {
|
||||||
|
await verifyCaptchaToken(ctx);
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GroupDmCreateProtectionMiddleware = createMiddleware<HonoEnv>(async (ctx, next) => {
|
||||||
|
const data = getCreatePrivateChannelRequest(ctx);
|
||||||
|
if (data.recipients === undefined) {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return await enforceRateLimitAndCaptcha(ctx, next, groupDmCreateRateLimit);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GroupDmRecipientAddProtectionMiddleware = createMiddleware<HonoEnv>(async (ctx, next) => {
|
||||||
|
return await enforceRateLimitAndCaptcha(ctx, next, groupDmRecipientAddRateLimit);
|
||||||
|
});
|
||||||
@@ -5119,7 +5119,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"x-mint": {"metadata": {"title": "Add recipient to group DM"}},
|
"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": []}],
|
"security": [{"botToken": []}, {"sessionToken": []}],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -16329,7 +16329,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"x-mint": {"metadata": {"title": "Create private channel"}},
|
"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": []}],
|
"security": [{"botToken": []}, {"sessionToken": []}],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
|
|||||||
@@ -116,6 +116,14 @@ export const UserRateLimitConfigs = {
|
|||||||
bucket: 'user:channels',
|
bucket: 'user:channels',
|
||||||
config: {limit: 40, windowMs: ms('10 seconds')},
|
config: {limit: 40, windowMs: ms('10 seconds')},
|
||||||
} as RouteRateLimitConfig,
|
} 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: {
|
USER_RELATIONSHIPS_LIST: {
|
||||||
bucket: 'user:relationships:list',
|
bucket: 'user:relationships:list',
|
||||||
config: {limit: 40, windowMs: ms('10 seconds')},
|
config: {limit: 40, windowMs: ms('10 seconds')},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {CreatePrivateChannelRequest} from '@fluxer/schema/src/domains/user/UserR
|
|||||||
import {z} from 'zod';
|
import {z} from 'zod';
|
||||||
import {createChannelID} from '../../BrandedTypes';
|
import {createChannelID} from '../../BrandedTypes';
|
||||||
import {LoginRequired} from '../../middleware/AuthMiddleware';
|
import {LoginRequired} from '../../middleware/AuthMiddleware';
|
||||||
|
import {GroupDmCreateProtectionMiddleware} from '../../middleware/GroupDmProtectionMiddleware';
|
||||||
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
|
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
|
||||||
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
|
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
|
||||||
import {RateLimitConfigs} from '../../RateLimitConfig';
|
import {RateLimitConfigs} from '../../RateLimitConfig';
|
||||||
@@ -41,6 +42,7 @@ export function UserChannelController(app: HonoApp) {
|
|||||||
RateLimitMiddleware(RateLimitConfigs.USER_CHANNELS),
|
RateLimitMiddleware(RateLimitConfigs.USER_CHANNELS),
|
||||||
LoginRequired,
|
LoginRequired,
|
||||||
Validator('json', CreatePrivateChannelRequest),
|
Validator('json', CreatePrivateChannelRequest),
|
||||||
|
GroupDmCreateProtectionMiddleware,
|
||||||
OpenAPI({
|
OpenAPI({
|
||||||
operationId: 'create_private_channel',
|
operationId: 'create_private_channel',
|
||||||
summary: 'Create private channel',
|
summary: 'Create private channel',
|
||||||
@@ -49,7 +51,7 @@ export function UserChannelController(app: HonoApp) {
|
|||||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||||
tags: ['Users'],
|
tags: ['Users'],
|
||||||
description:
|
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) => {
|
async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
|
|||||||
Reference in new issue
Block a user