fix(api): reject unparsable bodies and screen non-form ones (#2503)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-06 15:29:04 +02:00
1 parent f8a04b8985
commit f06d65db54
5 files changed
+72 -3

No files matched your search

+2 -2
View File
@@ -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;
}