Skip to content
Closed
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
42 changes: 38 additions & 4 deletions src/middlewares.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -250,23 +268,39 @@ 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() {
if(finished) {
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);
Expand Down
78 changes: 78 additions & 0 deletions tests/tests/middlewares/body-collection-paths.js
Original file line number Diff line number Diff line change
@@ -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);
});
Loading