diff --git a/src/middlewares.js b/src/middlewares.js index 255098e..8cdf1eb 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -23,6 +23,11 @@ const querystring = require('fast-querystring'); const { AsyncResource } = require('async_hooks'); const { fastQueryParse, NullObject } = require('./utils.js'); +// largest content-length we will allocate a body buffer for up front. above this the body is +// collected chunk by chunk instead, so a declared-but-unsent body cannot pin more memory than a +// real one of the same size would +const MAX_PREALLOCATED_BODY = 1024 * 1024; + function static(root, options) { if(!options) options = new NullObject(); if(typeof options.index === 'undefined') options.index = 'index.html'; @@ -232,6 +237,19 @@ function createBodyParser(defaultType, beforeReturn) { } } + // uWS neuters its ArrayBuffer after the callback, so every chunk has to be copied out of + // it - and then Buffer.concat copied the whole body a second time. when content-length is + // known and we aren't inflating, the final size is known up front, so chunks can go + // straight into one buffer and the body is copied once. + // the cap means a client that declares a body and never sends it costs no more than one + // that actually sends a body that size, and content-length above options.limit was + // already rejected above + const declaredLength = inflate ? -1 : Number(length); + let target = declaredLength > 0 && declaredLength <= MAX_PREALLOCATED_BODY + ? Buffer.allocUnsafe(declaredLength) + : null; + let targetOffset = 0; + req.bodyRead = true; // uWS keeps delivering chunks after we reject an oversized body, and the @@ -250,15 +268,27 @@ function createBodyParser(defaultType, beforeReturn) { buf = inflate.process(buf); } - // shallow copy, to avoid shared references for large bodies. - abs.push(Buffer.from(buf)); - totalSize += buf.length; if(totalSize > options.limit) { finished = true; abs.length = 0; + target = null; return next(new Error('Request entity too large')); } + + if(target) { + if(targetOffset + buf.length <= target.length) { + buf.copy(target, targetOffset); + targetOffset += buf.length; + return; + } + // more body than content-length promised: keep what we have and fall back + abs.push(Buffer.from(target.subarray(0, targetOffset))); + target = null; + } + + // shallow copy, to avoid shared references for large bodies. + abs.push(Buffer.from(buf)); } function onEnd() { @@ -266,7 +296,11 @@ function createBodyParser(defaultType, beforeReturn) { return; } finished = true; - const buf = Buffer.concat(abs); + // target holds the whole body already; otherwise a single chunk is the body, and + // only a genuinely chunked body needs the concat + const buf = target + ? (targetOffset === target.length ? target : target.subarray(0, targetOffset)) + : (abs.length === 1 ? abs[0] : Buffer.concat(abs)); if(options.verify) { try { options.verify(req, res, buf); diff --git a/tests/tests/middlewares/body-collection-paths.js b/tests/tests/middlewares/body-collection-paths.js new file mode 100644 index 0000000..dfa43d1 --- /dev/null +++ b/tests/tests/middlewares/body-collection-paths.js @@ -0,0 +1,78 @@ +// must parse bodies identically whether they arrive in one chunk, many chunks, or compressed + +const express = require("express"); +const zlib = require("zlib"); +const crypto = require("crypto"); + +const app = express(); + +app.use(express.json({ limit: '8mb' })); +app.use(express.raw({ limit: '8mb', type: 'application/octet-stream' })); + +app.post('/json', (req, res) => { + res.json({ len: req.body.pad.length, n: req.body.n }); +}); + +app.post('/raw', (req, res) => { + res.json({ + len: req.body.length, + sha: crypto.createHash('sha256').update(req.body).digest('hex').slice(0, 16) + }); +}); + +function post(path, body, headers = {}) { + return fetch(`http://localhost:13333${path}`, { method: 'POST', body, headers }); +} + +app.listen(13333, async () => { + console.log('Server is running on port 13333'); + + // small enough to arrive in a single chunk, with a content-length + const small = JSON.stringify({ n: 1, pad: 'x'.repeat(4 * 1024) }); + console.log(await (await post('/json', small, { 'Content-Type': 'application/json' })).text()); + + // large enough to arrive in several chunks, still with a content-length + const big = JSON.stringify({ n: 2, pad: 'y'.repeat(512 * 1024) }); + console.log(await (await post('/json', big, { 'Content-Type': 'application/json' })).text()); + + // past the point where the body is buffered up front, so it is collected chunk by chunk + const huge = JSON.stringify({ n: 3, pad: 'z'.repeat(2 * 1024 * 1024) }); + console.log(await (await post('/json', huge, { 'Content-Type': 'application/json' })).text()); + + // no content-length at all: the size is only known once the stream ends + const chunked = new ReadableStream({ + start(controller) { + const enc = new TextEncoder(); + controller.enqueue(enc.encode('{"n":4,"pad":"')); + for (let i = 0; i < 8; i++) { + controller.enqueue(enc.encode('w'.repeat(32 * 1024))); + } + controller.enqueue(enc.encode('"}')); + controller.close(); + } + }); + const chunkedResponse = await fetch('http://localhost:13333/json', { + method: 'POST', + body: chunked, + duplex: 'half', + headers: { 'Content-Type': 'application/json' } + }); + console.log(await chunkedResponse.text()); + + // gzipped: content-length describes the compressed size, not the parsed one + const gzipped = zlib.gzipSync(Buffer.from(JSON.stringify({ n: 5, pad: 'q'.repeat(256 * 1024) }))); + console.log(await (await post('/json', gzipped, { + 'Content-Type': 'application/json', + 'Content-Encoding': 'gzip' + })).text()); + + // binary round trip, to catch a body that is assembled at the wrong offset + const binary = Buffer.alloc(300 * 1024); + for (let i = 0; i < binary.length; i++) { + binary[i] = i % 251; + } + console.log(await (await post('/raw', binary, { 'Content-Type': 'application/octet-stream' })).text()); + + await new Promise(resolve => setTimeout(resolve, 100)); + process.exit(0); +});