diff --git a/docs/REQUEST_SIZE_PROTECTION.md b/docs/REQUEST_SIZE_PROTECTION.md new file mode 100644 index 00000000..8a2c9a0f --- /dev/null +++ b/docs/REQUEST_SIZE_PROTECTION.md @@ -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 +} +``` diff --git a/listener/src/api/request-size-limiter.test.ts b/listener/src/api/request-size-limiter.test.ts new file mode 100644 index 00000000..bc89ece0 --- /dev/null +++ b/listener/src/api/request-size-limiter.test.ts @@ -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, + }) + ); + }); +}); diff --git a/listener/src/api/request-size-limiter.ts b/listener/src/api/request-size-limiter.ts new file mode 100644 index 00000000..da8d7853 --- /dev/null +++ b/listener/src/api/request-size-limiter.ts @@ -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(); + }; +}