diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index fa5af6a..2ce71ab 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -43,6 +43,7 @@ Admin requests are authenticated with a signed timestamp header. The timestamp * | `LOG_RETENTION_DAYS` | No | `90` | Age in days before notification logs and resolved indexer dead letters are pruned daily. Unresolved dead letters are retained. | | `NODE_ENV` | No | Unset (treated as development) | Set to `production` to switch logging to structured JSON instead of `pino-pretty`. Set automatically inside the Docker image. | | `HEALTH_CHECK_TIMEOUT_MS` | No | `3000` | Maximum duration in milliseconds allowed for each health-check dependency call before it is reported as degraded. | +| `BODY_LIMIT_BYTES` | No | `1048576` | Maximum request body size in bytes (1 MiB). Must be a positive integer. Requests above the limit receive `413 { "error": "payload_too_large" }`. | | `RESEND_API_KEY` | No | — | Resend API key used to send NGO contact-email verification messages. Required, together with `NOTIFY_EMAIL_FROM` and `API_PUBLIC_URL`, for verification emails to be sent; other notification events remain in log-only stub mode. | | `NOTIFY_EMAIL_FROM` | No | — | Sender address used by Resend for contact-email verification. Required when `RESEND_API_KEY` is configured. | | `NOTIFY_EMAIL_TO` | No (planned) | — | Planned recipient address for notification emails. Not yet read by runtime code. | diff --git a/src/server.ts b/src/server.ts index f1ebfd6..9fc283e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -87,9 +87,24 @@ export interface BuildServerOptions { trustProxy?: TrustProxySetting; } +const DEFAULT_BODY_LIMIT_BYTES = 1_048_576; + +function configuredBodyLimit(): number { + const value = process.env.BODY_LIMIT_BYTES; + if (value === undefined || value.trim() === '') return DEFAULT_BODY_LIMIT_BYTES; + if (!/^\d+$/.test(value)) { + throw new Error('BODY_LIMIT_BYTES must be a positive integer.'); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1) { + throw new Error('BODY_LIMIT_BYTES must be a positive integer.'); + } + return parsed; +} + export function buildServer(options?: BuildServerOptions) { const requestStartedAt = new WeakMap(); - + const bodyLimit = configuredBodyLimit(); const trustProxySetting = options?.trustProxy !== undefined ? options.trustProxy @@ -97,6 +112,7 @@ export function buildServer(options?: BuildServerOptions) { const app = Fastify({ ...requestIdOptions, + bodyLimit, logger: { level: process.env.LOG_LEVEL ?? 'info', transport: USE_PRETTY_LOGS @@ -119,11 +135,16 @@ export function buildServer(options?: BuildServerOptions) { } const chunks: Buffer[] = []; - + let receivedBytes = 0; for await (const chunk of payload) { - chunks.push( - Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string), - ); + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string); + receivedBytes += bytes.length; + if (receivedBytes > bodyLimit) { + const error = new Error('Request body is too large.') as FastifyError; + error.statusCode = 413; + throw error; + } + chunks.push(bytes); } const raw = Buffer.concat(chunks); @@ -498,4 +519,4 @@ export function buildServer(options?: BuildServerOptions) { registerLogRetention(app); return app; -} \ No newline at end of file +} diff --git a/test/bodyLimit.test.ts b/test/bodyLimit.test.ts new file mode 100644 index 0000000..c71e7c0 --- /dev/null +++ b/test/bodyLimit.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { buildServer } from '../src/server.js'; + +describe('request body limit', () => { + const originalLimit = process.env.BODY_LIMIT_BYTES; + + afterEach(() => { + if (originalLimit === undefined) delete process.env.BODY_LIMIT_BYTES; + else process.env.BODY_LIMIT_BYTES = originalLimit; + }); + + it('rejects oversized bodies using the standard 413 error response', async () => { + process.env.BODY_LIMIT_BYTES = '64'; + const app = buildServer(); + app.post('/test-body-limit', async (request) => request.body); + + try { + const response = await app.inject({ + method: 'POST', + url: '/test-body-limit', + headers: { 'content-type': 'application/json' }, + payload: JSON.stringify({ value: 'x'.repeat(100) }), + }); + + expect(response.statusCode).toBe(413); + expect(response.json()).toEqual({ error: 'payload_too_large' }); + } finally { + await app.close(); + } + }); + + it('rejects invalid body-limit configuration', () => { + process.env.BODY_LIMIT_BYTES = 'not-a-number'; + expect(() => buildServer()).toThrow('BODY_LIMIT_BYTES must be a positive integer.'); + }); +});