From 22fe8f5b5f01a334a21b17f63941f401cb931a6e Mon Sep 17 00:00:00 2001 From: Vinayak Mishra Date: Thu, 16 Apr 2026 08:13:23 +0545 Subject: [PATCH] fix: limit decompressed request body size to prevent memory exhaustion JSON and urlencoded parsers buffer the entire decompressed body with no size check. A gzip-compressed request can force unbounded memory allocation (50KB compressed, 200MB+ RSS with default settings). Add bytesReceived check in write() against maxTotalFileSize. Multipart was already protected via _handlePart; this closes the gap for JSON and urlencoded paths. Refs #1063 --- src/Formidable.js | 12 +++ .../standalone/decompression_limit.test.js | 102 ++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 test-node/standalone/decompression_limit.test.js diff --git a/src/Formidable.js b/src/Formidable.js index 733ff673..cd0a4da6 100644 --- a/src/Formidable.js +++ b/src/Formidable.js @@ -332,6 +332,18 @@ class IncomingForm extends EventEmitter { } this.bytesReceived += buffer.length; + + if (this.bytesReceived > this.options.maxTotalFileSize) { + this._error( + new FormidableError( + `options.maxTotalFileSize (${this.options.maxTotalFileSize} bytes) exceeded, received ${this.bytesReceived} bytes of data`, + errors.biggerThanTotalMaxFileSize, + 413 + ) + ); + return null; + } + this.emit("progress", this.bytesReceived, this.bytesExpected); this._parser.write(buffer); diff --git a/test-node/standalone/decompression_limit.test.js b/test-node/standalone/decompression_limit.test.js new file mode 100644 index 00000000..e320c0d6 --- /dev/null +++ b/test-node/standalone/decompression_limit.test.js @@ -0,0 +1,102 @@ +import { strictEqual } from "node:assert"; +import { createServer } from "node:http"; +import test from "node:test"; +import { gzipSync } from "node:zlib"; +import formidable, { errors } from "../../src/index.js"; + +let server; +let port = 13100; + +test.beforeEach(() => { + port += 1; + server = createServer(); +}); + +test("gzip-compressed JSON body exceeding maxTotalFileSize triggers 413", async () => { + const maxSize = 1024 * 1024; // 1MB + + server.on("request", async (req, res) => { + const form = formidable({ maxTotalFileSize: maxSize }); + + try { + await form.parse(req); + res.writeHead(200); + res.end("ok"); + } catch (err) { + res.writeHead(err.httpCode || 500); + res.end( + JSON.stringify({ code: err.code, httpCode: err.httpCode }), + ); + } + }); + + await new Promise((resolve) => server.listen(port, resolve)); + + // 2MB JSON body compressed to ~2KB + const body = JSON.stringify({ data: "a".repeat(2 * 1024 * 1024) }); + const compressed = gzipSync(Buffer.from(body)); + + const res = await fetch(`http://localhost:${port}/`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Encoding": "gzip", + "Content-Length": compressed.length, + }, + body: compressed, + }); + + strictEqual(res.status, 413); + + const result = JSON.parse(await res.text()); + strictEqual(result.code, errors.biggerThanTotalMaxFileSize); + strictEqual(result.httpCode, 413); +}); + +test("gzip-compressed JSON body within limit succeeds", async () => { + const maxSize = 5 * 1024 * 1024; // 5MB + + server.on("request", async (req, res) => { + const form = formidable({ maxTotalFileSize: maxSize }); + + try { + const [fields] = await form.parse(req); + res.writeHead(200); + res.end(JSON.stringify({ fieldCount: Object.keys(fields).length })); + } catch (err) { + res.writeHead(err.httpCode || 500); + res.end(err.message); + } + }); + + await new Promise((resolve) => server.listen(port, resolve)); + + // 1MB JSON body, under the 5MB limit + const body = JSON.stringify({ data: "a".repeat(1024 * 1024) }); + const compressed = gzipSync(Buffer.from(body)); + + const res = await fetch(`http://localhost:${port}/`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Encoding": "gzip", + "Content-Length": compressed.length, + }, + body: compressed, + }); + + strictEqual(res.status, 200); + + const result = JSON.parse(await res.text()); + strictEqual(result.fieldCount, 1); +}); + +test.afterEach(async () => { + await new Promise((resolve) => { + if (server.listening) { + server.close(() => resolve()); + } else { + resolve(); + } + }); +});