diff --git a/fluxer_api/src/api/Validator.ts b/fluxer_api/src/api/Validator.ts index 276677a52..fe69180fe 100644 --- a/fluxer_api/src/api/Validator.ts +++ b/fluxer_api/src/api/Validator.ts @@ -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(); diff --git a/fluxer_api/src/api/guild/tests/GuildMemberManagement.test.ts b/fluxer_api/src/api/guild/tests/GuildMemberManagement.test.ts index 2a29b6982..cd09836ba 100644 --- a/fluxer_api/src/api/guild/tests/GuildMemberManagement.test.ts +++ b/fluxer_api/src/api/guild/tests/GuildMemberManagement.test.ts @@ -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>(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'; diff --git a/fluxer_api/src/api/middleware/ContentFilterMiddleware.ts b/fluxer_api/src/api/middleware/ContentFilterMiddleware.ts index edbb59667..84b229dc5 100644 --- a/fluxer_api/src/api/middleware/ContentFilterMiddleware.ts +++ b/fluxer_api/src/api/middleware/ContentFilterMiddleware.ts @@ -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); diff --git a/fluxer_api/src/api/user/tests/UserProfileTextValidation.test.ts b/fluxer_api/src/api/user/tests/UserProfileTextValidation.test.ts index c19f60322..e1dc79819 100644 --- a/fluxer_api/src/api/user/tests/UserProfileTextValidation.test.ts +++ b/fluxer_api/src/api/user/tests/UserProfileTextValidation.test.ts @@ -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'); diff --git a/fluxer_api/src/api/utils/RequestJsonBody.ts b/fluxer_api/src/api/utils/RequestJsonBody.ts index e9b03774a..6096b15c3 100644 --- a/fluxer_api/src/api/utils/RequestJsonBody.ts +++ b/fluxer_api/src/api/utils/RequestJsonBody.ts @@ -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 { + const body = await readRequestJsonBody(req); + if (!body.parsed) { + throw InputValidationError.fromCode('body', ValidationErrorCodes.INVALID_FORMAT); + } + return body.value; +}