Skip to content

Commit dc361ea

Browse files
committed
feat(security): add per-route-group request body size limits (#146)
Adds a configurable request body size ceiling per route group instead of one hardcoded 10mb limit applied globally. auth/admin/creators routes can each override BODY_SIZE_LIMIT_DEFAULT via their own env var (BODY_SIZE_LIMIT_AUTH/_ADMIN/_CREATORS); every other group falls back to the default. Oversized requests get a structured 413 response (no raw body logged) via bodyParseErrorMiddleware. JSON parsing moves from a single global app.use() before the router to per-group instances mounted inside modules/index.ts, so bodyParseErrorMiddleware — which only catches errors from middleware registered after it — is relocated to just after the router mount. Documented in docs/body-size-limits.md alongside the existing rate-limiting docs.
1 parent 94cbd4b commit dc361ea

8 files changed

Lines changed: 337 additions & 15 deletions

File tree

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,9 @@ OWNERSHIP_SNAPSHOT_CLEANUP_DRY_RUN=true
5353
OWNERSHIP_SNAPSHOT_RETENTION_DAYS=30
5454
OWNERSHIP_SNAPSHOT_CLEANUP_ENABLED=false
5555
OWNERSHIP_SNAPSHOT_CLEANUP_INTERVAL_MINUTES=60
56+
57+
# Request body size limits (see docs/body-size-limits.md)
58+
BODY_SIZE_LIMIT_DEFAULT=10mb
59+
# BODY_SIZE_LIMIT_AUTH=100kb
60+
# BODY_SIZE_LIMIT_ADMIN=10mb
61+
# BODY_SIZE_LIMIT_CREATORS=10mb

docs/body-size-limits.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Request Body Size Limits
2+
3+
This document describes how JSON request body size limits are configured per route group, their defaults, and how a client is notified when a request exceeds its limit.
4+
5+
## Overview
6+
7+
Every route group mounted in [modules/index.ts](../src/modules/index.ts) gets its own `express.json()` parser via `routeBodySizeLimit(group)`, instead of a single limit applied globally to every endpoint. This lets a group with a legitimate need for a larger (or smaller) payload be tuned independently, without changing the ceiling for the rest of the API.
8+
9+
- **Middleware:** [body-size-limit.middleware.ts](../src/middlewares/body-size-limit.middleware.ts)
10+
- **Applied in:** [modules/index.ts](../src/modules/index.ts) — one `routeBodySizeLimit(group)` call per router mount
11+
- **Error handling:** [body-parse-error.middleware.ts](../src/middlewares/body-parse-error.middleware.ts), mounted after the router in [app.ts](../src/app.ts)
12+
13+
## Default and Overrides
14+
15+
| Group | Env Var | Default (if unset) |
16+
| :----------- | :------------------------- | :------------------------ |
17+
| _(fallback)_ | `BODY_SIZE_LIMIT_DEFAULT` | `10mb` |
18+
| `auth` | `BODY_SIZE_LIMIT_AUTH` | `BODY_SIZE_LIMIT_DEFAULT` |
19+
| `admin` | `BODY_SIZE_LIMIT_ADMIN` | `BODY_SIZE_LIMIT_DEFAULT` |
20+
| `creators` | `BODY_SIZE_LIMIT_CREATORS` | `BODY_SIZE_LIMIT_DEFAULT` |
21+
22+
All other route groups (`health`, `config`, `metrics`, `ledger`, `activity`, `ownership`, `wallets`, `alerts`) always use `BODY_SIZE_LIMIT_DEFAULT` — they don't currently have a dedicated override, since none of their payloads differ meaningfully from the default ceiling.
23+
24+
Limit values accept any size string understood by the [`bytes`](https://www.npmjs.com/package/bytes) package (used internally by `body-parser`), e.g. `'100kb'`, `'1mb'`, `'10mb'`.
25+
26+
`BODY_SIZE_LIMIT_DEFAULT` itself defaults to `10mb`, matching the single global limit this replaced — existing deployments see no behavior change unless they explicitly set new overrides.
27+
28+
## Adding an Override for a New Group
29+
30+
1. Add the env var to `envSchema` in [config.schema.ts](../src/config.schema.ts):
31+
```typescript
32+
BODY_SIZE_LIMIT_METRICS: optionalNonEmptyString,
33+
```
34+
2. Add the group to `BodySizeLimitGroup` and `GROUP_OVERRIDES` in [body-size-limit.middleware.ts](../src/middlewares/body-size-limit.middleware.ts):
35+
36+
```typescript
37+
export type BodySizeLimitGroup =
38+
| 'auth'
39+
| 'admin'
40+
| 'creators'
41+
| 'metrics'
42+
| 'default';
43+
44+
const GROUP_OVERRIDES: Record<
45+
Exclude<BodySizeLimitGroup, 'default'>,
46+
string | undefined
47+
> = {
48+
auth: envConfig.BODY_SIZE_LIMIT_AUTH,
49+
admin: envConfig.BODY_SIZE_LIMIT_ADMIN,
50+
creators: envConfig.BODY_SIZE_LIMIT_CREATORS,
51+
metrics: envConfig.BODY_SIZE_LIMIT_METRICS,
52+
};
53+
```
54+
55+
3. Pass the group name at the mount point in `modules/index.ts`:
56+
```typescript
57+
router.use('/metrics', routeBodySizeLimit('metrics'), metricsRouter);
58+
```
59+
4. Document the new var's default in the table above and in `.env.example`.
60+
61+
## Fail-Fast Behavior
62+
63+
When a request body exceeds its group's limit, `express.json()` never calls the route handler — it raises a body-parser error (`type: 'entity.too.large'`, `status: 413`) before any controller or database code runs. `bodyParseErrorMiddleware` catches this (for mutation methods — `POST`/`PUT`/`PATCH`/`DELETE`) and returns:
64+
65+
```json
66+
{
67+
"success": false,
68+
"code": "BAD_REQUEST",
69+
"message": "Request payload too large"
70+
}
71+
```
72+
73+
with HTTP status `413`. The same error path also logs a structured `body_parse_failure` entry (method, path, request ID, client IP — never the raw body) for observability. This behavior is identical across every route group regardless of its configured limit — only the threshold that triggers it differs.
74+
75+
## Related Documentation
76+
77+
- [Configuration Guide](./configuration.md) — loading environment configuration.
78+
- [Error Code Registry](./ERROR_CODE_REGISTRY.md) — standard API error shapes.
79+
- [Rate Limiting](./rate-limiting.md) — the sibling per-route-group mechanism for request rate, following the same override pattern.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import supertest from 'supertest';
2+
3+
/**
4+
* Configures a deliberately tiny limit for the 'auth' group before the app
5+
* (and its config) is loaded, so the test can send a real oversized payload
6+
* and observe the actual 413 response — not just call the middleware
7+
* function directly. jest.resetModules() + a fresh require() is necessary
8+
* here because config.ts parses process.env once, at import time.
9+
*/
10+
function loadAppWithAuthLimit(limit: string) {
11+
jest.resetModules();
12+
process.env.BODY_SIZE_LIMIT_AUTH = limit;
13+
14+
return require('../../app').default;
15+
}
16+
17+
describe('request body size limits (route-group scoped)', () => {
18+
const ORIGINAL_AUTH_LIMIT = process.env.BODY_SIZE_LIMIT_AUTH;
19+
20+
afterEach(() => {
21+
if (ORIGINAL_AUTH_LIMIT === undefined) {
22+
delete process.env.BODY_SIZE_LIMIT_AUTH;
23+
} else {
24+
process.env.BODY_SIZE_LIMIT_AUTH = ORIGINAL_AUTH_LIMIT;
25+
}
26+
});
27+
28+
it('rejects a request exceeding the auth group\'s configured limit with a clean 413', async () => {
29+
const app = loadAppWithAuthLimit('1kb');
30+
31+
// A payload comfortably over 1kb.
32+
const oversizedPayload = { data: 'x'.repeat(5000) };
33+
34+
const res = await supertest(app)
35+
.post('/api/v1/auth/login')
36+
.send(oversizedPayload);
37+
38+
expect(res.status).toBe(413);
39+
expect(res.body).toEqual({
40+
success: false,
41+
code: 'BAD_REQUEST',
42+
message: 'Request payload too large',
43+
});
44+
});
45+
46+
it('accepts a request within the auth group\'s configured limit (does not reject on size)', async () => {
47+
const app = loadAppWithAuthLimit('1kb');
48+
49+
const smallPayload = { email: 'user@example.com', password: 'x' };
50+
51+
const res = await supertest(app)
52+
.post('/api/v1/auth/login')
53+
.send(smallPayload);
54+
55+
// Whatever the auth handler does with these credentials (likely a 400
56+
// or 401 for a nonexistent user) is out of scope here — the only thing
57+
// this test asserts is that the request was NOT rejected for size.
58+
expect(res.status).not.toBe(413);
59+
});
60+
61+
it('does not reject a differently-sized payload on an unrelated group sharing the default limit', async () => {
62+
const app = loadAppWithAuthLimit('1kb');
63+
64+
// /api/v1/health is in the 'default' group, unaffected by the 'auth'
65+
// group's tiny override.
66+
const res = await supertest(app).get('/api/v1/health');
67+
68+
expect(res.status).not.toBe(413);
69+
});
70+
});

src/app.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,12 @@ app.use(requestCompletionLoggerMiddleware);
3434
app.use(corsMiddleware());
3535
app.use(helmet());
3636

37-
app.use(express.json({ limit: '10mb' }));
38-
app.use(bodyParseErrorMiddleware);
37+
// Request body parsing is applied per route group (see modules/index.ts)
38+
// via routeBodySizeLimit, so each group can have its own configured size
39+
// limit instead of one global express.json() call. bodyParseErrorMiddleware
40+
// is mounted after the router (below) since that's where those parsers
41+
// actually live now — Express only walks forward to later error handlers,
42+
// so it has to come after the point where the parse error can occur.
3943

4044
if (!envConfig.ENABLE_REQUEST_LOGGING) {
4145
app.use(morgan('combined'));
@@ -87,6 +91,11 @@ app.get('/', (_, res: Response) => {
8791
// Routes
8892
app.use('/api/v1', router);
8993

94+
// Catches body-parse errors (including entity.too.large from the per-group
95+
// JSON parsers mounted inside router) — must come after the router since
96+
// that's where those parsers run.
97+
app.use(bodyParseErrorMiddleware);
98+
9099
// 404 handler - MUST come after all routes
91100
app.use(notFoundHandler);
92101

src/config.schema.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,14 @@ export const envSchema = z
166166
.int()
167167
.positive()
168168
.default(60),
169+
170+
// Request body size limits (see docs/body-size-limits.md).
171+
// Accepts any size string understood by the `bytes` package used
172+
// internally by body-parser (e.g. '100kb', '1mb', '10mb').
173+
BODY_SIZE_LIMIT_DEFAULT: z.string().min(1).default('10mb'),
174+
BODY_SIZE_LIMIT_AUTH: optionalNonEmptyString,
175+
BODY_SIZE_LIMIT_ADMIN: optionalNonEmptyString,
176+
BODY_SIZE_LIMIT_CREATORS: optionalNonEmptyString,
169177
})
170178
.superRefine((data, ctx) => {
171179
if (data.MODE === 'production' && data.STELLAR_NETWORK === 'testnet') {
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* body-size-limit.middleware resolves its per-group overrides from envConfig
3+
* once, at module load — matching how envConfig itself is a one-time
4+
* envSchema.parse(process.env) snapshot. Each test that needs a different
5+
* envConfig shape therefore mocks '../config' and re-imports the module
6+
* under test fresh via jest.resetModules(), rather than mutating envConfig
7+
* after the fact (which the real module never observes, by design).
8+
*/
9+
function loadWithEnvConfig(envConfig: {
10+
BODY_SIZE_LIMIT_DEFAULT: string;
11+
BODY_SIZE_LIMIT_AUTH?: string;
12+
BODY_SIZE_LIMIT_ADMIN?: string;
13+
BODY_SIZE_LIMIT_CREATORS?: string;
14+
}) {
15+
jest.resetModules();
16+
jest.doMock('../config', () => ({ envConfig }));
17+
18+
return require('./body-size-limit.middleware') as typeof import('./body-size-limit.middleware');
19+
}
20+
21+
describe('getBodySizeLimit', () => {
22+
afterEach(() => {
23+
jest.dontMock('../config');
24+
});
25+
26+
it('returns BODY_SIZE_LIMIT_DEFAULT for the "default" group', () => {
27+
const { getBodySizeLimit } = loadWithEnvConfig({
28+
BODY_SIZE_LIMIT_DEFAULT: '10mb',
29+
});
30+
expect(getBodySizeLimit('default')).toBe('10mb');
31+
});
32+
33+
it('falls back to the default for a group with no override configured', () => {
34+
const { getBodySizeLimit } = loadWithEnvConfig({
35+
BODY_SIZE_LIMIT_DEFAULT: '10mb',
36+
});
37+
expect(getBodySizeLimit('auth')).toBe('10mb');
38+
expect(getBodySizeLimit('admin')).toBe('10mb');
39+
expect(getBodySizeLimit('creators')).toBe('10mb');
40+
});
41+
42+
it('uses the group-specific override when configured', () => {
43+
const { getBodySizeLimit } = loadWithEnvConfig({
44+
BODY_SIZE_LIMIT_DEFAULT: '10mb',
45+
BODY_SIZE_LIMIT_AUTH: '100kb',
46+
});
47+
48+
expect(getBodySizeLimit('auth')).toBe('100kb');
49+
// Unrelated groups are unaffected.
50+
expect(getBodySizeLimit('admin')).toBe('10mb');
51+
});
52+
53+
it('supports a distinct override per group simultaneously', () => {
54+
const { getBodySizeLimit } = loadWithEnvConfig({
55+
BODY_SIZE_LIMIT_DEFAULT: '10mb',
56+
BODY_SIZE_LIMIT_AUTH: '100kb',
57+
BODY_SIZE_LIMIT_ADMIN: '20mb',
58+
});
59+
60+
expect(getBodySizeLimit('auth')).toBe('100kb');
61+
expect(getBodySizeLimit('admin')).toBe('20mb');
62+
// creators has no override in this config, still falls back.
63+
expect(getBodySizeLimit('creators')).toBe('10mb');
64+
});
65+
66+
it('reflects a non-default BODY_SIZE_LIMIT_DEFAULT for groups with no override', () => {
67+
const { getBodySizeLimit } = loadWithEnvConfig({
68+
BODY_SIZE_LIMIT_DEFAULT: '5mb',
69+
});
70+
71+
expect(getBodySizeLimit('default')).toBe('5mb');
72+
expect(getBodySizeLimit('admin')).toBe('5mb');
73+
});
74+
});
75+
76+
describe('routeBodySizeLimit', () => {
77+
afterEach(() => {
78+
jest.dontMock('../config');
79+
});
80+
81+
it('returns an express.json middleware function', () => {
82+
const { routeBodySizeLimit } = loadWithEnvConfig({
83+
BODY_SIZE_LIMIT_DEFAULT: '10mb',
84+
});
85+
const middleware = routeBodySizeLimit('default');
86+
expect(typeof middleware).toBe('function');
87+
// express.json() returns a function with this arity: (req, res, next)
88+
expect(middleware.length).toBe(3);
89+
});
90+
91+
it('produces a distinct middleware instance per call (no shared limit state)', () => {
92+
const { routeBodySizeLimit } = loadWithEnvConfig({
93+
BODY_SIZE_LIMIT_DEFAULT: '10mb',
94+
BODY_SIZE_LIMIT_AUTH: '100kb',
95+
BODY_SIZE_LIMIT_ADMIN: '20mb',
96+
});
97+
const first = routeBodySizeLimit('auth');
98+
const second = routeBodySizeLimit('admin');
99+
expect(first).not.toBe(second);
100+
});
101+
});
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import express, { RequestHandler } from 'express';
2+
import { envConfig } from '../config';
3+
4+
/**
5+
* Route groups that can be given their own request body size limit.
6+
* Add a group here (and its optional `BODY_SIZE_LIMIT_<GROUP>` override in
7+
* config.schema.ts) when a mount point in modules/index.ts needs a distinct
8+
* limit from BODY_SIZE_LIMIT_DEFAULT.
9+
*/
10+
export type BodySizeLimitGroup = 'auth' | 'admin' | 'creators' | 'default';
11+
12+
const GROUP_OVERRIDES: Record<Exclude<BodySizeLimitGroup, 'default'>, string | undefined> = {
13+
auth: envConfig.BODY_SIZE_LIMIT_AUTH,
14+
admin: envConfig.BODY_SIZE_LIMIT_ADMIN,
15+
creators: envConfig.BODY_SIZE_LIMIT_CREATORS,
16+
};
17+
18+
/**
19+
* Resolves the configured body size limit for a route group, falling back
20+
* to BODY_SIZE_LIMIT_DEFAULT when the group has no override configured.
21+
*/
22+
export function getBodySizeLimit(group: BodySizeLimitGroup): string {
23+
if (group === 'default') {
24+
return envConfig.BODY_SIZE_LIMIT_DEFAULT;
25+
}
26+
27+
return GROUP_OVERRIDES[group] ?? envConfig.BODY_SIZE_LIMIT_DEFAULT;
28+
}
29+
30+
/**
31+
* Returns a JSON body parser scoped to the given route group's configured
32+
* size limit. Mount this in place of a global `express.json()` at the top
33+
* of each route group in modules/index.ts.
34+
*
35+
* A request exceeding the limit is not rejected here directly — express.json
36+
* hands control to `next(err)` with a body-parser `entity.too.large` error,
37+
* which bodyParseErrorMiddleware (mounted after all route groups in app.ts)
38+
* turns into the actual 413 response. This keeps the "fail fast with a
39+
* clear error" behavior identical across every group regardless of its
40+
* configured limit.
41+
*/
42+
export function routeBodySizeLimit(group: BodySizeLimitGroup): RequestHandler {
43+
return express.json({ limit: getBodySizeLimit(group) });
44+
}

src/modules/index.ts

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,26 @@ import webhookRouter from './webhooks/webhook.router';
1313
import walletsRouter from './wallets/wallets.routes';
1414
import alertsRouter from './alerts/alert.router';
1515
import { BASE as CREATORS_BASE } from '../constants/creator.constants';
16+
import { routeBodySizeLimit } from '../middlewares/body-size-limit.middleware';
1617

1718
const router = Router();
1819

19-
router.use('/health', healthRouter);
20-
router.use('/auth', authRouter);
21-
router.use('/config', configRouter);
22-
router.use(CREATORS_BASE, creatorsRouter);
23-
router.use(CREATORS_BASE, creatorRouter);
24-
router.use('/metrics', metricsRouter);
25-
router.use('/ledger', ledgerRouter);
26-
router.use('/admin', adminRouter);
27-
router.use('/activity', activityRouter);
28-
router.use('/ownership', ownershipRouter);
29-
router.use(CREATORS_BASE, webhookRouter);
30-
router.use('/wallets', walletsRouter);
31-
router.use('/alerts', alertsRouter);
20+
// Each group gets its own JSON body parser so its size limit can be tuned
21+
// independently via BODY_SIZE_LIMIT_<GROUP> env vars (see
22+
// docs/body-size-limits.md). Groups without a dedicated override share
23+
// BODY_SIZE_LIMIT_DEFAULT.
24+
router.use('/health', routeBodySizeLimit('default'), healthRouter);
25+
router.use('/auth', routeBodySizeLimit('auth'), authRouter);
26+
router.use('/config', routeBodySizeLimit('default'), configRouter);
27+
router.use(CREATORS_BASE, routeBodySizeLimit('creators'), creatorsRouter);
28+
router.use(CREATORS_BASE, routeBodySizeLimit('creators'), creatorRouter);
29+
router.use('/metrics', routeBodySizeLimit('default'), metricsRouter);
30+
router.use('/ledger', routeBodySizeLimit('default'), ledgerRouter);
31+
router.use('/admin', routeBodySizeLimit('admin'), adminRouter);
32+
router.use('/activity', routeBodySizeLimit('default'), activityRouter);
33+
router.use('/ownership', routeBodySizeLimit('default'), ownershipRouter);
34+
router.use(CREATORS_BASE, routeBodySizeLimit('creators'), webhookRouter);
35+
router.use('/wallets', routeBodySizeLimit('default'), walletsRouter);
36+
router.use('/alerts', routeBodySizeLimit('default'), alertsRouter);
3237

3338
export default router;

0 commit comments

Comments
 (0)