Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions stellar-payment-platform/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down
91 changes: 91 additions & 0 deletions stellar-payment-platform/src/middleware/bodyLimit.js
Original file line number Diff line number Diff line change
@@ -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,
};
8 changes: 5 additions & 3 deletions stellar-payment-platform/src/middleware/errorHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.`,
};
}

Expand Down Expand Up @@ -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;
Expand Down
105 changes: 105 additions & 0 deletions stellar-payment-platform/tests/middleware/bodyLimit.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading