Skip to content
Open
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
29 changes: 29 additions & 0 deletions docs/REQUEST_SIZE_PROTECTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# 🛡️ API Request Body Size Protection

This document details the request payload size protection middleware for NotifyChain HTTP services (Issue #688).

---

## 1. Motivation & Threat Mitigation

To protect API servers against memory exhaustion attacks, slowloris buffer flooding, and DoS from arbitrarily large JSON payloads, strict size limits are enforced before requests reach downstream controllers.

---

## 2. Protection Mechanics

1. **Header Inspection**: If `Content-Length` exceeds `maxSizeBytes` (default: 1MB / 1,048,576 bytes), the request is rejected immediately with HTTP `413 Payload Too Large` without reading the body.
2. **Streaming Termination**: For chunked transfers without pre-declared lengths, incoming stream data chunks are counted in real-time. If the threshold is passed, the stream is paused, event listeners are cleaned up, and HTTP `413` is returned immediately.

---

## 3. Response Format

```json
{
"error": "PAYLOAD_TOO_LARGE",
"message": "Request body exceeds maximum allowed size of 1048576 bytes.",
"maxSizeBytes": 1048576,
"declaredSizeBytes": 5242880
}
```
72 changes: 72 additions & 0 deletions listener/src/api/request-size-limiter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { EventEmitter } from 'events';
import { Request, Response, NextFunction } from 'express';
import {
createRequestSizeLimiter,
DEFAULT_MAX_REQUEST_SIZE_BYTES,
} from './request-size-limiter';

describe('API Request Size Protection (Issue #688)', () => {
const createMockReqRes = (contentLength?: string) => {
const req = Object.assign(new EventEmitter(), {
headers: contentLength ? { 'content-length': contentLength } : {},
pause: jest.fn(),
}) as unknown as Request;

const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
} as unknown as Response;

const next = jest.fn() as NextFunction;

return { req, res, next };
};

test('allows requests within size limits', () => {
const middleware = createRequestSizeLimiter({ maxSizeBytes: 1024 });
const { req, res, next } = createMockReqRes('500');

middleware(req, res, next);

expect(next).toHaveBeenCalledTimes(1);
expect(res.status).not.toHaveBeenCalled();
});

test('rejects oversized Content-Length header with 413 Payload Too Large', () => {
const middleware = createRequestSizeLimiter({ maxSizeBytes: 1024 });
const { req, res, next } = createMockReqRes('2048');

middleware(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(413);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
error: 'PAYLOAD_TOO_LARGE',
maxSizeBytes: 1024,
declaredSizeBytes: 2048,
})
);
});

test('aborts stream when streaming chunked data exceeds limit', () => {
const middleware = createRequestSizeLimiter({ maxSizeBytes: 100 });
const { req, res, next } = createMockReqRes();

middleware(req, res, next);
expect(next).toHaveBeenCalled();

// Emit chunks exceeding 100 bytes
req.emit('data', Buffer.alloc(60));
expect(res.status).not.toHaveBeenCalled();

req.emit('data', Buffer.alloc(60)); // total = 120 > 100
expect(res.status).toHaveBeenCalledWith(413);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
error: 'PAYLOAD_TOO_LARGE',
receivedBytes: 120,
})
);
});
});
69 changes: 69 additions & 0 deletions listener/src/api/request-size-limiter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* API Request Size Protection Middleware (Issue #688)
*
* Enforces strict byte-size limits on incoming HTTP request payloads,
* rejecting oversized bodies with HTTP 413 Payload Too Large and aborting streams.
*/

import { Request, Response, NextFunction } from 'express';

export interface RequestSizeLimiterOptions {
/** Maximum allowable request body size in bytes (default: 1MB = 1,048,576 bytes). */
maxSizeBytes?: number;
}

export const DEFAULT_MAX_REQUEST_SIZE_BYTES = 1024 * 1024; // 1 MB

/**
* Creates Express middleware enforcing request body size limits.
*/
export function createRequestSizeLimiter(options: RequestSizeLimiterOptions = {}) {
const maxBytes = options.maxSizeBytes || DEFAULT_MAX_REQUEST_SIZE_BYTES;

return (req: Request, res: Response, next: NextFunction): void => {
// 1. Fast-path check Content-Length header if present
const contentLength = req.headers['content-length'];
if (contentLength) {
const declaredSize = parseInt(contentLength, 10);
if (!isNaN(declaredSize) && declaredSize > maxBytes) {
res.status(413).json({
error: 'PAYLOAD_TOO_LARGE',
message: `Request body exceeds maximum allowed size of ${maxBytes} bytes.`,
maxSizeBytes: maxBytes,
declaredSizeBytes: declaredSize,
});
return;
}
}

// 2. Stream chunk inspection for chunked transfer-encoding
let receivedBytes = 0;
let limitExceeded = false;

const onData = (chunk: Buffer | string): void => {
if (limitExceeded) return;

receivedBytes += typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length;

if (receivedBytes > maxBytes) {
limitExceeded = true;

// Clean up listeners and destroy incoming stream to prevent memory buffering
req.removeListener('data', onData);
req.pause();

res.status(413).json({
error: 'PAYLOAD_TOO_LARGE',
message: `Streaming payload exceeded maximum size limit of ${maxBytes} bytes.`,
maxSizeBytes: maxBytes,
receivedBytes,
});
}
};

req.on('data', onData);

// Continue to next handler if size check passes
next();
};
}