From 8cc2a5b174f9442a8f34748e0da34312ac534035 Mon Sep 17 00:00:00 2001 From: rd-code-23 Date: Mon, 24 Aug 2026 21:05:16 -0700 Subject: [PATCH] fix(api): limit JSON request size --- apps/api/src/app.test.ts | 34 +++++++++++++++++++ apps/api/src/app.ts | 24 +++++++++++-- .../src/exercises/exercise.contract.test.ts | 18 +++++++--- .../src/exercises/exercise.contract.ts | 8 +++-- 4 files changed, 74 insertions(+), 10 deletions(-) diff --git a/apps/api/src/app.test.ts b/apps/api/src/app.test.ts index 22360f4..183146f 100644 --- a/apps/api/src/app.test.ts +++ b/apps/api/src/app.test.ts @@ -98,3 +98,37 @@ describe('GET /health', () => { expect(errorLog).toHaveBeenCalledWith('API request failed'); }); }); + +describe('JSON request parsing', () => { + function createTestApp() { + return createApp({ + authRouter: Router(), + exerciseRouter: Router(), + csrfErrorHandler: noOpErrorMiddleware, + csrfProtection: noOpMiddleware, + webOrigin: 'http://localhost:5173', + trustProxyHops: 0, + sessionMiddleware: noOpMiddleware, + checkDatabaseConnection: vi.fn().mockResolvedValue(undefined), + }); + } + + it('returns a client error for malformed JSON', async () => { + const response = await request(createTestApp()) + .post('/exercises') + .set('Content-Type', 'application/json') + .send('{"name":'); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'invalid_json' }); + }); + + it('rejects JSON bodies larger than the configured limit', async () => { + const response = await request(createTestApp()) + .post('/exercises') + .send({ description: 'a'.repeat(101 * 1024) }); + + expect(response.status).toBe(413); + expect(response.body).toEqual({ error: 'request_too_large' }); + }); +}); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index d0240f0..e152b7b 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -3,6 +3,16 @@ import { healthResponseSchema } from '@goforlift/contracts'; import cors from 'cors'; import express, { type RequestHandler, type Router } from 'express'; +const JSON_BODY_LIMIT = '100kb'; + +type ExpressBodyError = Error & { + type?: string; +}; + +function isExpressBodyError(error: unknown, type: string) { + return error instanceof Error && (error as ExpressBodyError).type === type; +} + type AppDependencies = { authRouter: Router; checkDatabaseConnection: () => Promise; @@ -32,7 +42,7 @@ export function createApp({ } app.use(cors({ origin: webOrigin, credentials: true })); app.use(sessionMiddleware); - app.use(express.json()); + app.use(express.json({ limit: JSON_BODY_LIMIT })); app.use(csrfProtection); app.use('/auth', authRouter); app.use('/exercises', exerciseRouter); @@ -57,8 +67,18 @@ export function createApp({ app.use(csrfErrorHandler); app.use(((error, _request, response, next) => { - void error; void next; + + if (isExpressBodyError(error, 'entity.parse.failed')) { + response.status(400).json({ error: 'invalid_json' }); + return; + } + + if (isExpressBodyError(error, 'entity.too.large')) { + response.status(413).json({ error: 'request_too_large' }); + return; + } + console.error('API request failed'); response.status(500).json({ error: 'internal_server_error' }); }) satisfies express.ErrorRequestHandler); diff --git a/packages/contracts/src/exercises/exercise.contract.test.ts b/packages/contracts/src/exercises/exercise.contract.test.ts index ef0d94b..eefc8b7 100644 --- a/packages/contracts/src/exercises/exercise.contract.test.ts +++ b/packages/contracts/src/exercises/exercise.contract.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { createExerciseInputSchema, + EXERCISE_NAME_MAX_LENGTH, exerciseListResponseSchema, exerciseSummarySchema, } from './exercise.contract.js'; @@ -22,13 +23,9 @@ describe('createExerciseInputSchema', () => { it('rejects empty, oversized, and client-controlled fields', () => { expect(() => createExerciseInputSchema.parse({ name: ' ' })).toThrow(); - expect(() => - createExerciseInputSchema.parse({ name: 'a'.repeat(101) }), - ).toThrow(); expect(() => createExerciseInputSchema.parse({ - name: 'Cable Fly', - description: 'a'.repeat(1001), + name: 'a'.repeat(EXERCISE_NAME_MAX_LENGTH + 1), }), ).toThrow(); expect(() => @@ -72,4 +69,15 @@ describe('exercise contracts', () => { expect(result.success).toBe(false); }); + + it('rejects exercise names that exceed the product limit', () => { + expect(() => + exerciseSummarySchema.parse({ + id: 'bde77251-e433-4f39-b1cb-41a2f2ad5462', + name: 'a'.repeat(EXERCISE_NAME_MAX_LENGTH + 1), + description: null, + isCustom: false, + }), + ).toThrow(); + }); }); diff --git a/packages/contracts/src/exercises/exercise.contract.ts b/packages/contracts/src/exercises/exercise.contract.ts index 84c4b92..b8a634a 100644 --- a/packages/contracts/src/exercises/exercise.contract.ts +++ b/packages/contracts/src/exercises/exercise.contract.ts @@ -1,16 +1,18 @@ /** Defines browser-safe exercise-list contracts shared across applications. */ import { z } from 'zod'; +export const EXERCISE_NAME_MAX_LENGTH = 100; + export const createExerciseInputSchema = z .object({ - name: z.string().trim().min(1).max(100), - description: z.string().trim().max(1000).nullable().optional(), + name: z.string().trim().min(1).max(EXERCISE_NAME_MAX_LENGTH), + description: z.string().trim().nullable().optional(), }) .strict(); export const exerciseSummarySchema = z.object({ id: z.uuid(), - name: z.string().min(1), + name: z.string().min(1).max(EXERCISE_NAME_MAX_LENGTH), description: z.string().nullable(), isCustom: z.boolean(), });