Request handling

Does obfuscation break request body limits?

A body parser is configured almost entirely with numbers that are smaller than the library's own. That is worth saying out loud, because it fixes the direction every failure has to take: an option the parser cannot find is an option you never set, and what it uses instead is bigger. We protected a file that hands a tight parser config to an installed body parser, renamed the option names one group at a time, and read the HTTP status codes back out of a parser that was never rebuilt.

What the sample actually does

The file builds a four-key parser config and pushes six representative requests through it: a normal 2 kB JSON body, a 64 kB body, a 6 kB gzip body that expands fifteen times, a 6 kB gzip body that expands twelve hundred times, a form post carrying 400 parameters, and a JSON body that is a bare string rather than an object or array. The config pins the payload limit to 8kb, the parameter count to 20, strict JSON on, and compressed bodies off.

The parser is copied into the measurement directory unprotected, which is the whole design. An installed parser is a dependency. It is not rebuilt when you protect your own bundle, so it keeps looking up the property names it has always looked up. Its defaults are the ones the real library documents: a 100 kB limit, a thousand form parameters, strict JSON on, and inflation of compressed bodies on.

Only two of those four defaults are stricter than nothing, and the one that matters most - accepting compressed bodies - is on by default. Turning it off is a decision a caller makes deliberately. That asymmetry is what the measurements below keep landing on.

Protection alone was applied first, on five presets covering both output targets and the compressed profile. All five produced output byte-identical in behaviour to the unprotected file. Nothing in this article is caused by protection on its own; every result below required member renaming pointed at the option names.

The limit that moves twelve-fold, silently

Renaming limit alone took the 64 kB body from 413 entity-too-large to 200 accepted. The parser did not fail to find a limit and give up; it found no limit supplied and used its own, so the number it enforced went from 8192 to 102400.

The line worth staring at is the pair we printed from opposite sides of the boundary on purpose. The application's own configuration dump still reports config-dump-says-limit-bytes=8192, because that value is read out of the object the application still holds. The parser reports parser-enforced-limit-bytes=102400. Both numbers are true. They disagree because they are being read by two different programs, only one of which was rebuilt.

That is the shape to remember when you are trying to reproduce a report like this. Every dashboard, every health endpoint and every startup log that prints your configuration back at you is reading the object you wrote, not the behaviour the dependency implements. There is no place in the system where the two are compared.

Renaming parameterLimit behaves the same way and is easier to overlook, because form parameter counts rarely appear in monitoring at all. The 400-parameter flood went from 413 too-many-parameters to 200 accepted with all 400 parameters parsed, because the default is 1000 and the pinned value was 20. HTTP parameter pollution needs exactly that much room.

Half the pair is a nuisance; both halves is a gzip bomb

Renaming inflate on its own is the reassuring arm. The 6 kB compressed body stopped being refused with 415 content-encoding-unsupported and started being refused with 413 entity-too-large instead, after expanding to 90 kB. The status changed and a caller might file a bug about it, but the request was still rejected - the size limit caught what the encoding rule used to catch first.

Rename inflate and limit together and the same request comes back 200 accepted, with 90 kB buffered out of a 6 kB body. Neither loss is sufficient alone. The encoding rule was the outer gate and the size limit was the inner one, and only losing both opens the path.

This matters because a member pattern scoped to an options block is exactly the thing that takes both. Nobody writes a rename pattern that carefully picks one key out of a config object; they write one that matches the block. The safe-looking arm and the dangerous arm are the same name group at different widths.

The twelve-hundred-times body was still refused at 413 even with both renamed, because 7.2 MB is larger than the 100 kB default too. That is the honest boundary of the result: the default limit is not nothing. It is simply twelve times what this application asked for, applied to a request the application intended to refuse at the door.

The arm that makes every numeric guard pass

The sharpest result of the five was not on the configuration side at all. Renaming the fields of the request object - the declared byte count, the content encoding, the expansion ratio, the parameter count and the parsed body - took every single request to 200 accepted, including the bare-string JSON that strict mode exists to refuse.

The mechanism is worth spelling out because it generalises well beyond body parsing. Every guard in the parser is a comparison of the form value > max. When the field the comparison reads is renamed, value is undefined, and undefined > max is false. Not an error, not NaN propagating into a visible mess: simply false, which is the answer that means "this is within the limit".

So losing the field names does not merely lose the values. It makes every numeric guard in the file pass, all at once, in the direction of acceptance. The printed diagnostics went to expanded=undefined params=undefined, which is the only visible trace, and nothing in that trace looks like a refusal that failed to happen.

The lesson is not specific to this library. Anywhere you guard with a greater-than comparison against a value read off an object, the absent-property case is the permissive case. A guard written as if (!(value <= max)) reject() would fail closed on the same input, which is a genuinely defensive way to write a size check.

What the caller sees when the result object is renamed

The other direction - renaming the fields on what the parser hands back - is the one most likely to be caught, but it is not loud. The status field read as undefined, so every === 413 check in the calling code became false and the application reported oversized-rejected=false for a request the parser had in fact rejected.

Nothing threw. The parser did its job correctly, refused what it was supposed to refuse, and the calling code could not read the answer. If the calling code's next step is "if it was not rejected, process it", the rejection has been converted into an acceptance one layer up.

This is the same pattern we have now measured in several unrelated areas: renaming names on the way out of a dependency tends to crash, renaming names on the way in tends to degrade quietly. Body parsing sits in between, because the result object is read with equality checks rather than with method calls, and an equality check against undefined is a perfectly valid expression.

The control arm, and why it is not reassuring

One option was pinned to a value identical to the library default on purpose: strict JSON parsing, which is on either way. Renaming it produced no change at all, on both output targets. That is the expected result and it confirms the rule that an arm is only as informative as the difference between your value and the runtime's.

The caveat is the useful part. An audit that asks "which of my options would change behaviour if they disappeared" is really asking "which of my options carry a non-default value", and that list is your exclusion list. Strict JSON is safe to lose only because this application agreed with the default. An application that had deliberately turned strict mode off - to accept a bare string or number as a JSON body from a legacy client - would find the same rename turning working requests into 400s.

So the exclusion list is not a property of the library. It is a property of your configuration, and it changes the day someone edits a value to disagree with the default.

What to do about it

Member renaming is off unless you turn it on, and when it is on it only touches names your expression matches. The fix is a scoped pattern, not avoiding protection. Exclude the option names your parser reads, and the field names on anything it hands back.

Keeping parser configuration in a JSON file rather than an object literal removes the exposure entirely, because keys loaded from JSON are string data and string literals are not renamed. That is a genuine architectural argument, not a workaround, and it has the side benefit of making the limits reviewable without reading code.

Whatever you choose, test the decision rather than the presence. A test that posts a body one byte over your limit and asserts a 413 catches every arm in this article. A test that asserts the parser is installed catches none of them.

Frequently asked questions

Does obfuscation change how a request body parser behaves?

Not by itself. Protection alone, on all five presets we tested including both output targets and the compressed profile, produced behaviour identical to the unprotected file. Everything in this article required member renaming pointed at the option names the parser reads.

Why does a lost limit get bigger rather than smaller?

Because a renamed key is indistinguishable from a key you never supplied, and the library then applies its own default. A body parser's defaults are deliberately generous so that it works out of the box, so almost every value you set is smaller than the one you fall back to.

Which body parser option is the most dangerous to rename?

In our measurement, no single one. The compression switch alone was still caught by the size limit, and the size limit alone still refused very large bodies. It took losing both together for a compressed body that expands fifteen times to come back with a 200, which is exactly what a pattern scoped to the whole options block does.

Would our monitoring notice?

Only if it measures the parser rather than the config. Our sample printed the application's own configuration dump and the limit the parser actually enforced side by side; they read 8192 and 102400 at the same moment. Nothing in the system compares those two numbers.

What happens if the request object's own fields get renamed?

Every size and count comparison becomes undefined against a maximum, which evaluates to false, so every request is accepted. That was the single widest result we measured in this area, and it produced no error of any kind.

What should I exclude from member renaming?

The option names your parser reads, the field names on the request shape you pass it, and the property names on the result it hands back. In our sample that was limit, parameterLimit, strict and inflate, plus the status and reason fields.

Is a JSON config file safer than an object literal?

For this failure, yes. Keys loaded from JSON are string data, and member renaming does not touch string literals. It is a reasonable place to keep size limits for review reasons anyway.

Related reading