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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,9 @@ OWNERSHIP_SNAPSHOT_CLEANUP_DRY_RUN=true
OWNERSHIP_SNAPSHOT_RETENTION_DAYS=30
OWNERSHIP_SNAPSHOT_CLEANUP_ENABLED=false
OWNERSHIP_SNAPSHOT_CLEANUP_INTERVAL_MINUTES=60

# Request body size limits (see docs/body-size-limits.md)
BODY_SIZE_LIMIT_DEFAULT=10mb
# BODY_SIZE_LIMIT_AUTH=100kb
# BODY_SIZE_LIMIT_ADMIN=10mb
# BODY_SIZE_LIMIT_CREATORS=10mb
79 changes: 79 additions & 0 deletions docs/body-size-limits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Request Body Size Limits

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.

## Overview

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.

- **Middleware:** [body-size-limit.middleware.ts](../src/middlewares/body-size-limit.middleware.ts)
- **Applied in:** [modules/index.ts](../src/modules/index.ts) — one `routeBodySizeLimit(group)` call per router mount
- **Error handling:** [body-parse-error.middleware.ts](../src/middlewares/body-parse-error.middleware.ts), mounted after the router in [app.ts](../src/app.ts)

## Default and Overrides

| Group | Env Var | Default (if unset) |
| :----------- | :------------------------- | :------------------------ |
| _(fallback)_ | `BODY_SIZE_LIMIT_DEFAULT` | `10mb` |
| `auth` | `BODY_SIZE_LIMIT_AUTH` | `BODY_SIZE_LIMIT_DEFAULT` |
| `admin` | `BODY_SIZE_LIMIT_ADMIN` | `BODY_SIZE_LIMIT_DEFAULT` |
| `creators` | `BODY_SIZE_LIMIT_CREATORS` | `BODY_SIZE_LIMIT_DEFAULT` |

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.

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'`.

`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.

## Adding an Override for a New Group

1. Add the env var to `envSchema` in [config.schema.ts](../src/config.schema.ts):
```typescript
BODY_SIZE_LIMIT_METRICS: optionalNonEmptyString,
```
2. Add the group to `BodySizeLimitGroup` and `GROUP_OVERRIDES` in [body-size-limit.middleware.ts](../src/middlewares/body-size-limit.middleware.ts):

```typescript
export type BodySizeLimitGroup =
| 'auth'
| 'admin'
| 'creators'
| 'metrics'
| 'default';

const GROUP_OVERRIDES: Record<
Exclude<BodySizeLimitGroup, 'default'>,
string | undefined
> = {
auth: envConfig.BODY_SIZE_LIMIT_AUTH,
admin: envConfig.BODY_SIZE_LIMIT_ADMIN,
creators: envConfig.BODY_SIZE_LIMIT_CREATORS,
metrics: envConfig.BODY_SIZE_LIMIT_METRICS,
};
```

3. Pass the group name at the mount point in `modules/index.ts`:
```typescript
router.use('/metrics', routeBodySizeLimit('metrics'), metricsRouter);
```
4. Document the new var's default in the table above and in `.env.example`.

## Fail-Fast Behavior

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:

```json
{
"success": false,
"code": "BAD_REQUEST",
"message": "Request payload too large"
}
```

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.

## Related Documentation

- [Configuration Guide](./configuration.md) — loading environment configuration.
- [Error Code Registry](./ERROR_CODE_REGISTRY.md) — standard API error shapes.
- [Rate Limiting](./rate-limiting.md) — the sibling per-route-group mechanism for request rate, following the same override pattern.
70 changes: 70 additions & 0 deletions src/__tests__/integration/body-size-limit.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import supertest from 'supertest';

/**
* Configures a deliberately tiny limit for the 'auth' group before the app
* (and its config) is loaded, so the test can send a real oversized payload
* and observe the actual 413 response — not just call the middleware
* function directly. jest.resetModules() + a fresh require() is necessary
* here because config.ts parses process.env once, at import time.
*/
function loadAppWithAuthLimit(limit: string) {
jest.resetModules();
process.env.BODY_SIZE_LIMIT_AUTH = limit;

return require('../../app').default;
}

describe('request body size limits (route-group scoped)', () => {
const ORIGINAL_AUTH_LIMIT = process.env.BODY_SIZE_LIMIT_AUTH;

afterEach(() => {
if (ORIGINAL_AUTH_LIMIT === undefined) {
delete process.env.BODY_SIZE_LIMIT_AUTH;
} else {
process.env.BODY_SIZE_LIMIT_AUTH = ORIGINAL_AUTH_LIMIT;
}
});

it('rejects a request exceeding the auth group\'s configured limit with a clean 413', async () => {
const app = loadAppWithAuthLimit('1kb');

// A payload comfortably over 1kb.
const oversizedPayload = { data: 'x'.repeat(5000) };

const res = await supertest(app)
.post('/api/v1/auth/login')
.send(oversizedPayload);

expect(res.status).toBe(413);
expect(res.body).toEqual({
success: false,
code: 'BAD_REQUEST',
message: 'Request payload too large',
});
});

it('accepts a request within the auth group\'s configured limit (does not reject on size)', async () => {
const app = loadAppWithAuthLimit('1kb');

const smallPayload = { email: 'user@example.com', password: 'x' };

const res = await supertest(app)
.post('/api/v1/auth/login')
.send(smallPayload);

// Whatever the auth handler does with these credentials (likely a 400
// or 401 for a nonexistent user) is out of scope here — the only thing
// this test asserts is that the request was NOT rejected for size.
expect(res.status).not.toBe(413);
});

it('does not reject a differently-sized payload on an unrelated group sharing the default limit', async () => {
const app = loadAppWithAuthLimit('1kb');

// /api/v1/health is in the 'default' group, unaffected by the 'auth'
// group's tiny override.
const res = await supertest(app).get('/api/v1/health');

expect(res.status).not.toBe(413);
});
});
13 changes: 11 additions & 2 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,12 @@ app.use(requestCompletionLoggerMiddleware);
app.use(corsMiddleware());
app.use(helmet());

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

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

// Catches body-parse errors (including entity.too.large from the per-group
// JSON parsers mounted inside router) — must come after the router since
// that's where those parsers run.
app.use(bodyParseErrorMiddleware);

// 404 handler - MUST come after all routes
app.use(notFoundHandler);

Expand Down
8 changes: 8 additions & 0 deletions src/config.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,14 @@ export const envSchema = z
.int()
.positive()
.default(60),

// Request body size limits (see docs/body-size-limits.md).
// Accepts any size string understood by the `bytes` package used
// internally by body-parser (e.g. '100kb', '1mb', '10mb').
BODY_SIZE_LIMIT_DEFAULT: z.string().min(1).default('10mb'),
BODY_SIZE_LIMIT_AUTH: optionalNonEmptyString,
BODY_SIZE_LIMIT_ADMIN: optionalNonEmptyString,
BODY_SIZE_LIMIT_CREATORS: optionalNonEmptyString,
})
.superRefine((data, ctx) => {
if (data.MODE === 'production' && data.STELLAR_NETWORK === 'testnet') {
Expand Down
101 changes: 101 additions & 0 deletions src/middlewares/body-size-limit.middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* body-size-limit.middleware resolves its per-group overrides from envConfig
* once, at module load — matching how envConfig itself is a one-time
* envSchema.parse(process.env) snapshot. Each test that needs a different
* envConfig shape therefore mocks '../config' and re-imports the module
* under test fresh via jest.resetModules(), rather than mutating envConfig
* after the fact (which the real module never observes, by design).
*/
function loadWithEnvConfig(envConfig: {
BODY_SIZE_LIMIT_DEFAULT: string;
BODY_SIZE_LIMIT_AUTH?: string;
BODY_SIZE_LIMIT_ADMIN?: string;
BODY_SIZE_LIMIT_CREATORS?: string;
}) {
jest.resetModules();
jest.doMock('../config', () => ({ envConfig }));

return require('./body-size-limit.middleware') as typeof import('./body-size-limit.middleware');
}

describe('getBodySizeLimit', () => {
afterEach(() => {
jest.dontMock('../config');
});

it('returns BODY_SIZE_LIMIT_DEFAULT for the "default" group', () => {
const { getBodySizeLimit } = loadWithEnvConfig({
BODY_SIZE_LIMIT_DEFAULT: '10mb',
});
expect(getBodySizeLimit('default')).toBe('10mb');
});

it('falls back to the default for a group with no override configured', () => {
const { getBodySizeLimit } = loadWithEnvConfig({
BODY_SIZE_LIMIT_DEFAULT: '10mb',
});
expect(getBodySizeLimit('auth')).toBe('10mb');
expect(getBodySizeLimit('admin')).toBe('10mb');
expect(getBodySizeLimit('creators')).toBe('10mb');
});

it('uses the group-specific override when configured', () => {
const { getBodySizeLimit } = loadWithEnvConfig({
BODY_SIZE_LIMIT_DEFAULT: '10mb',
BODY_SIZE_LIMIT_AUTH: '100kb',
});

expect(getBodySizeLimit('auth')).toBe('100kb');
// Unrelated groups are unaffected.
expect(getBodySizeLimit('admin')).toBe('10mb');
});

it('supports a distinct override per group simultaneously', () => {
const { getBodySizeLimit } = loadWithEnvConfig({
BODY_SIZE_LIMIT_DEFAULT: '10mb',
BODY_SIZE_LIMIT_AUTH: '100kb',
BODY_SIZE_LIMIT_ADMIN: '20mb',
});

expect(getBodySizeLimit('auth')).toBe('100kb');
expect(getBodySizeLimit('admin')).toBe('20mb');
// creators has no override in this config, still falls back.
expect(getBodySizeLimit('creators')).toBe('10mb');
});

it('reflects a non-default BODY_SIZE_LIMIT_DEFAULT for groups with no override', () => {
const { getBodySizeLimit } = loadWithEnvConfig({
BODY_SIZE_LIMIT_DEFAULT: '5mb',
});

expect(getBodySizeLimit('default')).toBe('5mb');
expect(getBodySizeLimit('admin')).toBe('5mb');
});
});

describe('routeBodySizeLimit', () => {
afterEach(() => {
jest.dontMock('../config');
});

it('returns an express.json middleware function', () => {
const { routeBodySizeLimit } = loadWithEnvConfig({
BODY_SIZE_LIMIT_DEFAULT: '10mb',
});
const middleware = routeBodySizeLimit('default');
expect(typeof middleware).toBe('function');
// express.json() returns a function with this arity: (req, res, next)
expect(middleware.length).toBe(3);
});

it('produces a distinct middleware instance per call (no shared limit state)', () => {
const { routeBodySizeLimit } = loadWithEnvConfig({
BODY_SIZE_LIMIT_DEFAULT: '10mb',
BODY_SIZE_LIMIT_AUTH: '100kb',
BODY_SIZE_LIMIT_ADMIN: '20mb',
});
const first = routeBodySizeLimit('auth');
const second = routeBodySizeLimit('admin');
expect(first).not.toBe(second);
});
});
44 changes: 44 additions & 0 deletions src/middlewares/body-size-limit.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import express, { RequestHandler } from 'express';
import { envConfig } from '../config';

/**
* Route groups that can be given their own request body size limit.
* Add a group here (and its optional `BODY_SIZE_LIMIT_<GROUP>` override in
* config.schema.ts) when a mount point in modules/index.ts needs a distinct
* limit from BODY_SIZE_LIMIT_DEFAULT.
*/
export type BodySizeLimitGroup = 'auth' | 'admin' | 'creators' | 'default';

const GROUP_OVERRIDES: Record<Exclude<BodySizeLimitGroup, 'default'>, string | undefined> = {
auth: envConfig.BODY_SIZE_LIMIT_AUTH,
admin: envConfig.BODY_SIZE_LIMIT_ADMIN,
creators: envConfig.BODY_SIZE_LIMIT_CREATORS,
};

/**
* Resolves the configured body size limit for a route group, falling back
* to BODY_SIZE_LIMIT_DEFAULT when the group has no override configured.
*/
export function getBodySizeLimit(group: BodySizeLimitGroup): string {
if (group === 'default') {
return envConfig.BODY_SIZE_LIMIT_DEFAULT;
}

return GROUP_OVERRIDES[group] ?? envConfig.BODY_SIZE_LIMIT_DEFAULT;
}

/**
* Returns a JSON body parser scoped to the given route group's configured
* size limit. Mount this in place of a global `express.json()` at the top
* of each route group in modules/index.ts.
*
* A request exceeding the limit is not rejected here directly — express.json
* hands control to `next(err)` with a body-parser `entity.too.large` error,
* which bodyParseErrorMiddleware (mounted after all route groups in app.ts)
* turns into the actual 413 response. This keeps the "fail fast with a
* clear error" behavior identical across every group regardless of its
* configured limit.
*/
export function routeBodySizeLimit(group: BodySizeLimitGroup): RequestHandler {
return express.json({ limit: getBodySizeLimit(group) });
}
31 changes: 18 additions & 13 deletions src/modules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,26 @@ import webhookRouter from './webhooks/webhook.router';
import walletsRouter from './wallets/wallets.routes';
import alertsRouter from './alerts/alert.router';
import { BASE as CREATORS_BASE } from '../constants/creator.constants';
import { routeBodySizeLimit } from '../middlewares/body-size-limit.middleware';

const router = Router();

router.use('/health', healthRouter);
router.use('/auth', authRouter);
router.use('/config', configRouter);
router.use(CREATORS_BASE, creatorsRouter);
router.use(CREATORS_BASE, creatorRouter);
router.use('/metrics', metricsRouter);
router.use('/ledger', ledgerRouter);
router.use('/admin', adminRouter);
router.use('/activity', activityRouter);
router.use('/ownership', ownershipRouter);
router.use(CREATORS_BASE, webhookRouter);
router.use('/wallets', walletsRouter);
router.use('/alerts', alertsRouter);
// Each group gets its own JSON body parser so its size limit can be tuned
// independently via BODY_SIZE_LIMIT_<GROUP> env vars (see
// docs/body-size-limits.md). Groups without a dedicated override share
// BODY_SIZE_LIMIT_DEFAULT.
router.use('/health', routeBodySizeLimit('default'), healthRouter);
router.use('/auth', routeBodySizeLimit('auth'), authRouter);
router.use('/config', routeBodySizeLimit('default'), configRouter);
router.use(CREATORS_BASE, routeBodySizeLimit('creators'), creatorsRouter);
router.use(CREATORS_BASE, routeBodySizeLimit('creators'), creatorRouter);
router.use('/metrics', routeBodySizeLimit('default'), metricsRouter);
router.use('/ledger', routeBodySizeLimit('default'), ledgerRouter);
router.use('/admin', routeBodySizeLimit('admin'), adminRouter);
router.use('/activity', routeBodySizeLimit('default'), activityRouter);
router.use('/ownership', routeBodySizeLimit('default'), ownershipRouter);
router.use(CREATORS_BASE, routeBodySizeLimit('creators'), webhookRouter);
router.use('/wallets', routeBodySizeLimit('default'), walletsRouter);
router.use('/alerts', routeBodySizeLimit('default'), alertsRouter);

export default router;
Loading