diff --git a/stellar-payment-platform/server.js b/stellar-payment-platform/server.js index c55c098e..d1565f29 100644 --- a/stellar-payment-platform/server.js +++ b/stellar-payment-platform/server.js @@ -32,6 +32,7 @@ const { validateSchema } = require('./src/middleware/validateSchema'); const { buildErrorHandler, notFoundHandler } = require('./src/middleware/errorHandler'); const { ApiError, errorBody } = require('./src/errors'); const { requireJson } = require('./src/middleware/requireJson'); +const { bodySizeLimit } = require('./src/middleware/bodyLimit'); const { apiVersion } = require('./src/middleware/apiVersion'); const { registerBodySchema, @@ -203,10 +204,13 @@ const limiter = rateLimit({ }); app.use(cors(corsOptions)); -app.use(express.json()); + +// #588 — Per-route request body size limits. A single JSON parser enforces a +// cap that depends on the endpoint type (auth 1kb / standard 10kb / bulk 100kb) +// instead of the previous uniform 10kb, and answers oversized payloads with 413. +app.use(bodySizeLimit); app.use(limiter); -app.use(express.json({ limit: '10kb' })); const isPrimitive = (v) => v === null || v === undefined || typeof v !== 'object'; const rejectNestedObjects = (req, res, next) => { diff --git a/stellar-payment-platform/src/middleware/bodyLimit.js b/stellar-payment-platform/src/middleware/bodyLimit.js new file mode 100644 index 00000000..2d2738be --- /dev/null +++ b/stellar-payment-platform/src/middleware/bodyLimit.js @@ -0,0 +1,91 @@ +'use strict'; + +const express = require('express'); + +/** + * #588 — Per-route request body size limits. + * + * A single global JSON parser enforces a cap that depends on the endpoint + * type, so the cap travels with the request regardless of mount point: + * + * - Auth / register endpoints: 1kb (tighter, to blunt abuse) + * - Bulk endpoints (/payments, /webhooks): 100kb (legitimate large payloads) + * - Everything else: 10kb (standard) + * + * body-parser already answers an oversized payload with a 413 + * (`entity.too.large`), which the error handler turns into the standard + * envelope. The resolved byte limit is stashed on `req.bodySizeLimit` so the + * 413 message can name the exact cap that was exceeded. + * + * NOTE: the installed raw-body only honours numeric / string `limit` values + * (not a per-request function), so we dispatch to one of three pre-built + * parsers based on the request path instead of using limit-as-a-function. + */ + +const KB = 1024; + +const AUTH_LIMIT = 1 * KB; +const STANDARD_LIMIT = 10 * KB; +const BULK_LIMIT = 100 * KB; + +// Per-route tiers are intentionally explicit so the mapping is easy to audit +// and extend. Matched against the path portion of the request URL (no query). +const AUTH_PATTERNS = [ + /^\/register(\/|$)/i, + /^\/auth(\/|$)/i, +]; + +const BULK_PATTERNS = [ + /\/payments(\/|$)/i, + /\/webhooks(\/|$)/i, +]; + +/** + * Resolve the byte limit for a request based on its path. + * + * @param {string} url full request URL (req.originalUrl) + * @returns {number} maximum body size in bytes + */ +const limitForPath = (url) => { + const path = (url || '').split('?')[0]; + + if (AUTH_PATTERNS.some((re) => re.test(path))) { + return AUTH_LIMIT; + } + if (BULK_PATTERNS.some((re) => re.test(path))) { + return BULK_LIMIT; + } + return STANDARD_LIMIT; +}; + +// Pre-built parsers — each carries an explicit numeric limit that raw-body can +// enforce. The dispatcher below picks the right one per request. +const authParser = express.json({ limit: AUTH_LIMIT }); +const standardParser = express.json({ limit: STANDARD_LIMIT }); +const bulkParser = express.json({ limit: BULK_LIMIT }); + +/** + * Express middleware that applies the per-route body size limit. It forwards to + * the appropriate pre-built JSON parser for the request path, recording the + * resolved cap on `req.bodySizeLimit` for downstream error reporting. + */ +const bodySizeLimit = (req, res, next) => { + const bytes = limitForPath(req.originalUrl); + req.bodySizeLimit = bytes; + + const parser = + bytes === AUTH_LIMIT ? authParser + : bytes === BULK_LIMIT ? bulkParser + : standardParser; + + return parser(req, res, next); +}; + +module.exports = { + KB, + AUTH_LIMIT, + STANDARD_LIMIT, + BULK_LIMIT, + limitForPath, + bodySizeLimit, +}; diff --git a/stellar-payment-platform/src/middleware/errorHandler.js b/stellar-payment-platform/src/middleware/errorHandler.js index 500b5fe4..cd6cd8f6 100644 --- a/stellar-payment-platform/src/middleware/errorHandler.js +++ b/stellar-payment-platform/src/middleware/errorHandler.js @@ -8,7 +8,7 @@ const { ApiError, codeForStatus, errorBody, DEFAULT_MESSAGES } = require('../err * Maps errors thrown by libraries, which carry their own conventions rather * than a code, onto the platform's codes. */ -const classify = (err, isPrismaConnectionError) => { +const classify = (err, req, isPrismaConnectionError) => { if (err instanceof ApiError) { return { code: err.code, @@ -30,10 +30,12 @@ const classify = (err, isPrismaConnectionError) => { // body-parser rejects oversized payloads with its own type tag. if (err.type === 'entity.too.large') { + const bytes = req && req.bodySizeLimit; + const maxKb = bytes ? Math.round(bytes / 1024) : 10; return { code: 'PAYLOAD_TOO_LARGE', statusCode: 413, - message: 'Payload too large. Maximum allowed size is 10kb.', + message: `Payload too large. Maximum allowed size for this endpoint is ${maxKb}kb.`, }; } @@ -67,7 +69,7 @@ const classify = (err, isPrismaConnectionError) => { const buildErrorHandler = (isPrismaConnectionError) => // eslint-disable-next-line no-unused-vars (err, req, res, _next) => { - const { code, statusCode, message, details, expected } = classify(err, isPrismaConnectionError); + const { code, statusCode, message, details, expected } = classify(err, req, isPrismaConnectionError); if (res.headersSent) { return; diff --git a/stellar-payment-platform/tests/middleware/bodyLimit.test.js b/stellar-payment-platform/tests/middleware/bodyLimit.test.js new file mode 100644 index 00000000..9126d6e4 --- /dev/null +++ b/stellar-payment-platform/tests/middleware/bodyLimit.test.js @@ -0,0 +1,105 @@ +'use strict'; + +const express = require('express'); +const request = require('supertest'); + +const mockLogger = { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }; +jest.mock('../../src/logger', () => ({ logger: mockLogger })); + +const { bodySizeLimit } = require('../../src/middleware/bodyLimit'); +const { buildErrorHandler } = require('../../src/middleware/errorHandler'); + +const buildApp = (path, handler) => { + const app = express(); + app.use((req, res, next) => { + req.correlationId = 'test-correlation-id'; + next(); + }); + // #588 — same wiring server.js uses: per-route limit, then the terminal 413 + // handler that turns body-parser's entity.too.large into the envelope. + app.use(bodySizeLimit); + app.post(path, handler || ((req, res) => res.json({ ok: true, bytes: JSON.stringify(req.body).length }))); + app.use(buildErrorHandler(() => false)); + return app; +}; + +// A payload just over a given kb ceiling. +const over = (kb) => 'x'.repeat(kb * 1024 + 1); +const under = (kb) => 'x'.repeat(kb * 1024); + +describe('body size limits (#588)', () => { + test('auth endpoint rejects payloads over 1kb with 413', async () => { + const res = await request(buildApp('/register')) + .post('/register') + .set('Content-Type', 'application/json') + .send(over(1)); + expect(res.status).toBe(413); + expect(res.body.error.code).toBe('PAYLOAD_TOO_LARGE'); + expect(res.body.error.message).toMatch(/1kb/); + }); + + test('auth endpoint accepts payloads within 1kb', async () => { + const res = await request(buildApp('/register')) + .post('/register') + .set('Content-Type', 'application/json') + .send({ username: 'alice', address: 'GABCDEFGHIJKLMNOP' }); + expect(res.status).toBeLessThan(400); + }); + + test('bulk /payments endpoint accepts payloads up to 100kb', async () => { + const res = await request(buildApp('/api/v1/payments/bulk')) + .post('/api/v1/payments/bulk') + .set('Content-Type', 'application/json') + .send({ intents: under(50) }); + expect(res.status).toBeLessThan(400); + }); + + test('bulk /payments endpoint rejects payloads over 100kb', async () => { + const res = await request(buildApp('/api/v1/payments/bulk')) + .post('/api/v1/payments/bulk') + .set('Content-Type', 'application/json') + .send({ intents: over(100) }); + expect(res.status).toBe(413); + expect(res.body.error.message).toMatch(/100kb/); + }); + + test('bulk /webhooks endpoint accepts up to 100kb and rejects over', async () => { + const ok = await request(buildApp('/api/v1/webhooks')) + .post('/api/v1/webhooks') + .set('Content-Type', 'application/json') + .send({ url: 'https://example.com', username: under(50) }); + expect(ok.status).toBeLessThan(400); + + const res = await request(buildApp('/api/v1/webhooks')) + .post('/api/v1/webhooks') + .set('Content-Type', 'application/json') + .send({ url: 'https://example.com', username: over(100) }); + expect(res.status).toBe(413); + expect(res.body.error.message).toMatch(/100kb/); + }); + + test('standard endpoints reject payloads over 10kb', async () => { + const res = await request(buildApp('/api/v1/users')) + .post('/api/v1/users') + .set('Content-Type', 'application/json') + .send({ data: over(10) }); + expect(res.status).toBe(413); + expect(res.body.error.message).toMatch(/10kb/); + }); + + test('standard endpoints accept payloads within 10kb', async () => { + const res = await request(buildApp('/api/v1/users')) + .post('/api/v1/users') + .set('Content-Type', 'application/json') + .send({ data: under(5) }); + expect(res.status).toBeLessThan(400); + }); + + test('non-JSON bodies are not parsed or limited', async () => { + const res = await request(buildApp('/api/v1/users')) + .post('/api/v1/users') + .set('Content-Type', 'text/plain') + .send(over(1000)); + expect(res.status).not.toBe(413); + }); +});