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
31 changes: 22 additions & 9 deletions src/Formidable.js
Original file line number Diff line number Diff line change
Expand Up @@ -372,15 +372,7 @@ class IncomingForm extends EventEmitter {
);

part.on("data", (buffer) => {
this._fieldsSize += buffer.length;
if (this._fieldsSize > this.options.maxFieldsSize) {
this._error(
new FormidableError(
`options.maxFieldsSize (${this.options.maxFieldsSize} bytes) exceeded, received ${this._fieldsSize} bytes of field data`,
errors.maxFieldsSizeExceeded,
413 // Payload Too Large
)
);
if (!this._addFieldSize(buffer.length)) {
return;
}
value += decoder.write(buffer);
Expand Down Expand Up @@ -684,6 +676,27 @@ class IncomingForm extends EventEmitter {
}
}

/**
* Account `size` bytes of field data against `options.maxFieldsSize`.
* Shared by the multipart field parser and the buffering JSON/urlencoded
* plugins so the option applies to every built-in parser, not just multipart.
* Returns false (after emitting the error) when the limit is exceeded.
*/
_addFieldSize(size) {
this._fieldsSize += size;
if (this._fieldsSize > this.options.maxFieldsSize) {
this._error(
new FormidableError(
`options.maxFieldsSize (${this.options.maxFieldsSize} bytes) exceeded, received ${this._fieldsSize} bytes of field data`,
errors.maxFieldsSizeExceeded,
413 // Payload Too Large
)
);
return false;
}
return true;
}

_setUpMaxFields() {
if (this.options.maxFields !== Infinity) {
let fieldsCount = 0;
Expand Down
60 changes: 59 additions & 1 deletion src/plugins/json.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,51 @@ export default function plugin(formidable, options) {
return self;
}

/**
* Byte length of a JSON value's encoding, counted with an explicit stack.
* `JSON.stringify` recurses per nesting level and overflows the call stack on a
* deeply nested value that `JSON.parse` (and so this parser) accepted; a value
* that reached this handler is plain JSON data, so the walk only has to cover
* the types `JSON.parse` can produce.
*/
function jsonByteLength(value) {
let bytes = 0;
const stack = [value];

while (stack.length > 0) {
const current = stack.pop();

if (current === null) {
bytes += 4; // "null"
} else if (typeof current === "string") {
bytes += Buffer.byteLength(JSON.stringify(current));
} else if (typeof current === "number") {
// JSON.stringify encodes a non-finite number as "null"
bytes += Number.isFinite(current)
? Buffer.byteLength(String(current))
: 4;
} else if (typeof current === "boolean") {
bytes += current ? 4 : 5;
} else if (Array.isArray(current)) {
bytes += 2 + Math.max(current.length - 1, 0); // brackets and commas
for (let index = 0; index < current.length; index += 1) {
stack.push(current[index]);
}
} else if (typeof current === "object") {
const keys = Object.keys(current);
bytes += 2 + Math.max(keys.length - 1, 0); // braces and commas
for (let index = 0; index < keys.length; index += 1) {
const key = keys[index];
bytes += Buffer.byteLength(JSON.stringify(key)) + 1; // key and colon
stack.push(current[key]);
}
}
// undefined, functions and symbols cannot come out of JSON.parse
}

return bytes;
}

// Note that it's a good practice (but it's up to you) to use the `this.options` instead
// of the passed `options` (second) param, because when you decide
// to test the plugin you can pass custom `this` context to it (and so `this.options`)
Expand All @@ -27,7 +72,20 @@ function init(_self, _opts) {
const parser = new JSONParser(this.options);

parser.on("data", (fields) => {
this.fields = Object.assign(Object.create(null), fields);
// Emit each field through the regular `field` path instead of assigning
// `this.fields` wholesale, so the shared field accounting (maxFieldsSize)
// and the maxFields listener apply to the JSON parser as well.
Object.entries(fields).every(([name, value]) => {
const size =
typeof value === "string"
? Buffer.byteLength(value)
: jsonByteLength(value);
if (!this._addFieldSize(size)) {
return false;
}
this.emit("field", name, value);
return !this.error;
});
});

parser.once("end", () => {
Expand Down
3 changes: 3 additions & 0 deletions src/plugins/querystring.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ function init(_self, _opts) {
const parser = new QuerystringParser(this.options);

parser.on("data", ({ key, value }) => {
if (!this._addFieldSize(Buffer.byteLength(value))) {
return;
}
this.emit("field", key, value);
});

Expand Down
232 changes: 232 additions & 0 deletions test-node/standalone/field-limits.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import { deepStrictEqual, strictEqual } from 'node:assert';
import { createServer } from 'node:http';
import test from 'node:test';
import formidable, { errors } from '../../src/index.js';

const MAX_FIELDS_SIZE = 1024;
const MAX_FIELDS = 2;
const BIG = 2 * 1024 * 1024;

// Parse one body with the given options and return the parse callback outcome.
async function parseBody(options, contentType, body) {
let outcome;
const server = createServer((req, res) => {
const form = formidable(options);
form.parse(req, (err, fields) => {
outcome = { err, fields };
res.writeHead(204);
res.end();
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
try {
const res = await fetch(`http://127.0.0.1:${port}/`, {
method: 'POST',
headers: {
'content-type': contentType,
'content-length': String(body.length),
},
body,
});
await res.arrayBuffer();
// The parse callback runs before the response is written; give the
// microtask queue a tick so `outcome` is visible here.
await new Promise((resolve) => setImmediate(resolve));
} finally {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
}
return outcome;
}

const limits = { maxFieldsSize: MAX_FIELDS_SIZE, maxFields: MAX_FIELDS };
const json = (obj) => Buffer.from(JSON.stringify(obj));
const urlenc = (pairs) =>
Buffer.from(pairs.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&'));

function multipartField(name, data) {
const boundary = `----field-limits-${Math.random().toString(16).slice(2)}`;
const head = Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n`,
);
const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
return {
type: `multipart/form-data; boundary=${boundary}`,
body: Buffer.concat([head, data, tail]),
};
}

function assertRejected(outcome, code) {
strictEqual(outcome.err?.code, code);
strictEqual(outcome.err?.httpCode, 413);
}

test('maxFieldsSize is enforced for application/json', async () => {
const outcome = await parseBody(
limits,
'application/json',
json({ blob: 'a'.repeat(BIG) }),
);
assertRejected(outcome, errors.maxFieldsSizeExceeded);
});

test('maxFieldsSize is enforced for application/x-www-form-urlencoded', async () => {
const outcome = await parseBody(
limits,
'application/x-www-form-urlencoded',
urlenc([['blob', 'a'.repeat(BIG)]]),
);
assertRejected(outcome, errors.maxFieldsSizeExceeded);
});

test('maxFieldsSize is enforced for multipart fields (control)', async () => {
const part = multipartField('blob', Buffer.alloc(BIG, 0x61));
const outcome = await parseBody(limits, part.type, part.body);
assertRejected(outcome, errors.maxFieldsSizeExceeded);
});

test('maxFields is enforced for application/json', async () => {
const outcome = await parseBody(limits, 'application/json', json({ a: 1, b: 2, c: 3 }));
assertRejected(outcome, errors.maxFieldsExceeded);
});

test('maxFields is enforced for application/x-www-form-urlencoded (control)', async () => {
const outcome = await parseBody(
limits,
'application/x-www-form-urlencoded',
urlenc([
['a', '1'],
['b', '2'],
['c', '3'],
]),
);
assertRejected(outcome, errors.maxFieldsExceeded);
});

test('maxFieldsSize boundary: a field equal to the limit is accepted', async () => {
const jsonOutcome = await parseBody(
limits,
'application/json',
json({ blob: 'a'.repeat(MAX_FIELDS_SIZE) }),
Comment thread
superbot-helper marked this conversation as resolved.
Resolved
);
strictEqual(jsonOutcome.err, null);
strictEqual(jsonOutcome.fields.blob.length, MAX_FIELDS_SIZE);

const urlencodedOutcome = await parseBody(
limits,
'application/x-www-form-urlencoded',
urlenc([['blob', 'a'.repeat(MAX_FIELDS_SIZE)]]),
);
strictEqual(urlencodedOutcome.err, null);
strictEqual(urlencodedOutcome.fields.blob[0].length, MAX_FIELDS_SIZE);
});

test('maxFieldsSize boundary: a field one byte over the limit is rejected', async () => {
const jsonOutcome = await parseBody(
limits,
'application/json',
json({ blob: 'a'.repeat(MAX_FIELDS_SIZE + 1) }),
);
assertRejected(jsonOutcome, errors.maxFieldsSizeExceeded);

const urlencodedOutcome = await parseBody(
limits,
'application/x-www-form-urlencoded',
urlenc([['blob', 'a'.repeat(MAX_FIELDS_SIZE + 1)]]),
);
assertRejected(urlencodedOutcome, errors.maxFieldsSizeExceeded);
});

test('JSON fields are still parsed and collected when under the limits', async () => {
const outcome = await parseBody(
{ ...limits, maxFields: 10 },
'application/json',
json({ a: 'b', nested: { k: 'v' }, list: [1, 2] }),
);
strictEqual(outcome.err, null);
deepStrictEqual({ ...outcome.fields }, {
a: 'b',
nested: { k: 'v' },
list: [1, 2],
});
strictEqual(Object.getPrototypeOf(outcome.fields), null);
});

test('urlencoded fields are still parsed and collected when under the limits', async () => {
const outcome = await parseBody(
limits,
'application/x-www-form-urlencoded',
urlenc([
['a', 'b'],
['c', 'd'],
]),
);
strictEqual(outcome.err, null);
deepStrictEqual({ ...outcome.fields }, { a: ['b'], c: ['d'] });
});

test('maxFieldsSize counts JSON fields together, not one at a time', async () => {
const half = MAX_FIELDS_SIZE / 2;
const atLimit = await parseBody(
limits,
'application/json',
json({ a: 'a'.repeat(half), b: 'b'.repeat(half) }),
);
strictEqual(atLimit.err, null);

const overLimit = await parseBody(
limits,
'application/json',
json({ a: 'a'.repeat(half), b: 'b'.repeat(half + 1) }),
);
assertRejected(overLimit, errors.maxFieldsSizeExceeded);
});

test('maxFieldsSize counts urlencoded fields together, not one at a time', async () => {
const half = MAX_FIELDS_SIZE / 2;
const atLimit = await parseBody(
limits,
'application/x-www-form-urlencoded',
urlenc([
['a', 'a'.repeat(half)],
['b', 'b'.repeat(half)],
]),
);
strictEqual(atLimit.err, null);

const overLimit = await parseBody(
limits,
'application/x-www-form-urlencoded',
urlenc([
['a', 'a'.repeat(half)],
['b', 'b'.repeat(half + 1)],
]),
);
assertRejected(overLimit, errors.maxFieldsSizeExceeded);
});

// 10,000 nested arrays: JSON.parse accepts this depth, but JSON.stringify
// recurses per level and throws RangeError on Node 18/20/22 at this size.
const DEEP = 10000;
const deeplyNested = (depth) => `${'['.repeat(depth)}${']'.repeat(depth)}`;
const deepBody = (depth) => Buffer.from(`{"deep":${deeplyNested(depth)}}`);

test('a deeply nested JSON value is measured without overflowing the stack', async () => {
const outcome = await parseBody(
{ maxFieldsSize: 4 * DEEP, maxFields: 10 },
'application/json',
deepBody(DEEP),
);
strictEqual(outcome.err, null);
strictEqual(Array.isArray(outcome.fields.deep), true);
});

test('a deeply nested JSON value over the limit is rejected, not a RangeError', async () => {
const outcome = await parseBody(
{ maxFieldsSize: MAX_FIELDS_SIZE, maxFields: 10 },
'application/json',
deepBody(DEEP),
);
assertRejected(outcome, errors.maxFieldsSizeExceeded);
});