mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(api): reject unparsable bodies and screen non-form ones (#2503)
This commit is contained in:
1 parent
f8a04b8985
commit
f06d65db54
5 files changed
+72
-3
No files matched your search
@@ -10,7 +10,7 @@ import type {ValidationError} from '@fluxer/errors/src/domains/core/ValidationEr
|
||||
import type {Context, Env, Input, MiddlewareHandler, TypedResponse, ValidationTargets} from 'hono';
|
||||
import {getCookie} from 'hono/cookie';
|
||||
import type {ZodError, ZodTypeAny} from 'zod';
|
||||
import {readRequestJsonBody} from './utils/RequestJsonBody';
|
||||
import {requireRequestJsonBody} from './utils/RequestJsonBody';
|
||||
import {initializeFluxerErrorMap} from './ZodErrorMap';
|
||||
|
||||
initializeFluxerErrorMap();
|
||||
@@ -200,7 +200,7 @@ export const Validator = <
|
||||
let value: unknown;
|
||||
switch (target) {
|
||||
case 'json':
|
||||
value = (await readRequestJsonBody(c.req)).value;
|
||||
value = await requireRequestJsonBody(c.req);
|
||||
break;
|
||||
case 'form': {
|
||||
const formData = await c.req.formData();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import type {GuildBanResponse, GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
|
||||
@@ -195,6 +196,30 @@ describe('Guild Member Management', () => {
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
test('should reject a ban body that is not valid JSON', async () => {
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0];
|
||||
const {json} = await createBuilder<{
|
||||
code: string;
|
||||
errors: Array<{
|
||||
path: string;
|
||||
code: string;
|
||||
}>;
|
||||
}>(harness, owner.token)
|
||||
.put(`/guilds/${guild.id}/bans/${member.userId}`)
|
||||
.body('{not json')
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.executeWithResponse();
|
||||
expect(json.code).toBe(APIErrorCodes.INVALID_FORM_BODY);
|
||||
const bodyError = json.errors.find((entry) => entry.path === 'body');
|
||||
expect(bodyError).toBeDefined();
|
||||
expect(bodyError?.code).toBe(ValidationErrorCodes.INVALID_FORMAT);
|
||||
const bans = await createBuilder<Array<GuildBanResponse>>(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/bans`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(bans.find((entry) => entry.user.id === member.userId)).toBeUndefined();
|
||||
});
|
||||
test('should disallow banning nonexistent user from guild', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const nonExistentId = '1234567890123456789';
|
||||
|
||||
@@ -134,7 +134,7 @@ const ContentFilterMiddleware = createMiddleware(async (ctx, next) => {
|
||||
return next();
|
||||
}
|
||||
const contentType = ctx.req.header('content-type') ?? '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
if (contentType.includes('multipart/form-data') || contentType.includes('application/x-www-form-urlencoded')) {
|
||||
return next();
|
||||
}
|
||||
const body = await readRequestJsonBody(ctx.req);
|
||||
|
||||
@@ -4,7 +4,9 @@ import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
import {createTestAccount, createUniqueEmail} from '../../auth/tests/AuthTestUtils';
|
||||
import {sendMessageWithAttachments, setupTestGuildAndChannel} from '../../channel/tests/AttachmentTestUtils';
|
||||
import {ensureSessionStarted} from '../../message/tests/MessageTestUtils';
|
||||
import {phraseBlocklistCache} from '../../middleware/PhraseBlocklistCache';
|
||||
import {profileSubstringBlocklistCache} from '../../middleware/ProfileSubstringBlocklistCache';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {HTTP_STATUS, TEST_CREDENTIALS, TEST_USER_DATA} from '../../test/TestConstants';
|
||||
@@ -32,6 +34,7 @@ describe('User profile text validation', () => {
|
||||
for (const scope of ['username', 'global_name', 'bio', 'pronouns'] as const) {
|
||||
profileSubstringBlocklistCache.remove(scope, 'blockedslug');
|
||||
}
|
||||
phraseBlocklistCache.remove('blockedphrase');
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
@@ -76,6 +79,37 @@ describe('User profile text validation', () => {
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.CONTENT_BLOCKED)
|
||||
.execute();
|
||||
});
|
||||
it('screens a JSON body declared under a non-JSON content type', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const target = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, account.token);
|
||||
phraseBlocklistCache.add('blockedphrase');
|
||||
await createBuilder(harness, account.token)
|
||||
.put(`/users/@me/notes/${target.userId}`)
|
||||
.header('content-type', 'text/plain')
|
||||
.body({note: 'note carrying a blockedphrase value'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.CONTENT_BLOCKED)
|
||||
.execute();
|
||||
await createBuilder(harness, account.token)
|
||||
.post('/users/@me/themes')
|
||||
.header('content-type', 'text/plain')
|
||||
.body({css: 'body { content: "blockedphrase"; }'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, APIErrorCodes.CONTENT_BLOCKED)
|
||||
.execute();
|
||||
});
|
||||
it('leaves a multipart body for the route to parse', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const {guild, channel} = await setupTestGuildAndChannel(harness, account);
|
||||
const channelId = guild.system_channel_id ?? channel.id;
|
||||
const {response} = await sendMessageWithAttachments(
|
||||
harness,
|
||||
account.token,
|
||||
channelId,
|
||||
{content: 'multipart message body', attachments: [{id: 0, filename: 'upload.txt'}]},
|
||||
[{index: 0, filename: 'upload.txt', data: Buffer.from('uploaded through multipart')}],
|
||||
);
|
||||
expect(response.status).toBe(HTTP_STATUS.OK);
|
||||
});
|
||||
it('blocks banned substrings during account registration', async () => {
|
||||
profileSubstringBlocklistCache.add('username', 'blockedslug');
|
||||
profileSubstringBlocklistCache.add('global_name', 'blockedslug');
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import type {HonoRequest} from 'hono';
|
||||
import {parseJsonPreservingLargeIntegers} from './LosslessJsonParser';
|
||||
|
||||
@@ -24,3 +26,11 @@ export async function readRequestJsonBody(req: HonoRequest): Promise<RequestJson
|
||||
bodyCache.set(raw, body);
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function requireRequestJsonBody(req: HonoRequest): Promise<unknown> {
|
||||
const body = await readRequestJsonBody(req);
|
||||
if (!body.parsed) {
|
||||
throw InputValidationError.fromCode('body', ValidationErrorCodes.INVALID_FORMAT);
|
||||
}
|
||||
return body.value;
|
||||
}
|
||||
Reference in new issue
Block a user