Compatibility

Does Obfuscation Break Compression Options?

Compression code looks like plumbing, so it gets read as plumbing. It is not. The options object beside a gunzip call carries the only limit standing between an untrusted upload and your heap, and the options object beside a gzip call decides whether a hot path costs a microsecond or a millisecond. Both are dictionaries of names that node's bindings read and your code merely writes.

What was measured

One file against node's zlib bindings, driven through five protection profiles for the base column and both member-renaming profiles for the rest. It does five things a real service does: inflate an untrusted upload behind an output cap, compress a response body cheaply on purpose, compress an asset with brotli through a parameter dictionary keyed by numeric constants, read a stream that was cut short, and read a raw deflate frame.

The untrusted upload is a genuine decompression bomb: 24 megabytes of one repeated byte, which arrives on the wire as roughly 24 kilobytes. Every option value in the file differs from node's default, because an option whose value is already the default proves nothing when its name is removed. That rule earned its keep twice on this page.

The base column is clean. All five profiles rejected the bomb, produced byte-identical compressed output, recovered the same truncated stream and read the same raw frame. Protecting a service that compresses things does not change what it produces.

The bomb guard disappears

The first arm renamed the output cap on the untrusted inflate. Before, the upload was rejected with a buffer-too-large error and the decompressed size was recorded as zero megabytes. After, the verdict reads accepted and the size reads 24 megabytes.

There is no default output cap in node's zlib. As with the sandbox timeout in the neighbouring measurement, renaming the key does not loosen the limit, it deletes the concept, and the only thing bounding the allocation is the attacker's willingness to keep sending. A 24 kilobyte request became a 24 megabyte allocation, and the request succeeded, so nothing in the application had any reason to complain.

This is the single most consequential result in this pass. It is one property name, in a file most reviewers would classify as plumbing, and the failure mode is a memory exhaustion vector that returns 200.

The economics of that are worth spelling out, because they are what makes decompression bombs a going concern rather than a curiosity. The attacker sends 24 kilobytes. The server allocates 24 megabytes. That is a thousandfold amplification per request, from a client that needs no credentials, no session and no particular timing. A handful of concurrent requests is enough to take a container to its memory limit, and the container restarts, which looks like an operational blip rather than an attack.

There is also nothing in the code review that would flag it. The cap is still written in the source, one line above the call, with a comment explaining why. The pull request that introduced the rename pattern does not touch this file at all.

A fast path that silently becomes the slow one

The compression-level arm inverts the usual direction of these findings, and it is worth reading carefully because the metric moves the wrong way.

The file compresses a response body at level 1 on purpose: it is a hot path, and the corpus is one where the expensive levels buy nothing. With the level key renamed, node applied its default level 6, and the emitted body went from 7283 bytes to 3912 bytes.

The output got smaller. Every dashboard that watches response size will show an improvement. What actually happened is that a deliberately cheap operation on a hot path became several times more expensive in CPU, on every request, and the only signal is a latency change that looks like ordinary drift.

The brotli arm did the same thing through a different door. The file asks for quality 4; renaming the parameter dictionary's key reverted it to brotli's default quality 11, and the asset went from 1345 bytes to 1145 bytes. Smaller again, and dramatically slower again. A performance decision expressed as an option is not enforced by anything once the option's name is gone.

Brotli quality 11 is the extreme case of this, and it is why the arm is included. The gap between quality 4 and quality 11 is not a few percent of CPU; it is a different order of magnitude of work for a low single-digit percentage of extra compression. That trade is correct for an asset compressed once at build time and wrong for anything compressed per request, and the option name is the entire mechanism by which the distinction is expressed.

Both arms belong to a category worth naming: options whose loss makes a metric look better. Everything in this series is easier to catch when the number moves in the direction of alarm. Here the size graph improves, the compression ratio improves, and the only thing that degrades is CPU time, which on a busy service is indistinguishable from ordinary growth in traffic.

The same option name, inert in one file and load-bearing in another

The window-size arm measured no change at all, and chasing that down produced the most transferable lesson of the pass.

The reason it measured no change is the value, not the name. The file passes a window size of 15 on the read side, and 15 is exactly what node uses for a raw inflate when you say nothing. Removing the option therefore could not change anything, and reading that as evidence the option is safe would have been wrong in a way no amount of re-running would expose.

So the option was measured again in a purpose-built file, on the side where it is load-bearing: a sender bound by a window a peer negotiated, which is how permessage-deflate works on a WebSocket. Pinned at the negotiated 9 bits, the sender emits a 1047 byte frame. With the name renamed, node used its default 15 bit window and emitted 860 bytes. The frame is smaller, well formed, and no longer inside the window the peer advertised.

The honest limit of that experiment is part of the result. Node's own raw inflate read the larger-window frame without complaint, so a node-to-node test does not fail. Whether it matters is decided by the peer, and the peer is the party that is not rebuilt with you.

The paired result sits in the same measurement: the compression level was inert in the purpose-built file, where the value passed was node's default of 6, and load-bearing in the main file, where it was 1. One option name, two files, opposite verdicts, decided entirely by the value. Any statement of the form we measured that option and it was fine is only ever a statement about the value you happened to pass.

That has a direct consequence for anyone auditing their own code against this series. The useful question is not which options do we pass, it is which options do we pass a non-default value to. Every option set to the value the runtime would have chosen anyway is genuinely safe against a rename, and every option set to something else is a live exposure whose size is the gap between your value and the default. It is a short list in most codebases, and it is exactly the list worth excluding from a rename pattern.

It is also a reminder about how these measurements should be read. An unchanged result is the weakest evidence available. Across this pass the unchanged arms came back that way for three distinct reasons: a name the file owns on both sides of the read, so renaming moved both together; a value that already equalled what the runtime would have used; and a measurement that never ran the operation the name governs. Only the first of those means the option is genuinely unaffected, and the three are indistinguishable from the result alone.

The arms that fail loudly

Three arms stop the process, and all three are worth knowing because they are the shape of failure you want.

The brotli parameter dictionary is keyed by numeric constants read off node's own constants object. Renaming those constant names produced an immediate range error saying that undefined is not a valid brotli parameter. Renaming the constants object itself produced a type error on the first line that touches it. Both fire before any request is served.

The remaining arm is the file's one relaxation, and it fails closed exactly as the pattern predicts. The file reads a truncated stream with a tolerant flush mode, deliberately, to recover what arrived. Renaming that key restored the strict default and the read threw a buffer error instead of recovering the text. Restrictive options fail open and permissive options fail closed; this file contains one of each and both behaved that way.

What to do about it

Protection alone changed nothing on any of the five profiles measured, so none of this argues against protecting a service that compresses data. The exposure is member renaming with a pattern that reaches the names the zlib bindings read.

Keep maxOutputLength, level, memLevel, strategy, windowBits, chunkSize, finishFlush, flush, dictionary and params out of that pattern, along with the constant names inside a brotli parameter dictionary. Anchoring to your own naming convention remains stronger than a list.

Then write the one test that matters: hand your inflate path a bomb and require it to reject. A payload of a few kilobytes that expands past your cap is trivial to build, the assertion is a single error code, and it is the only check in this measurement that noticed the guard had gone. If you compress on a hot path, assert the emitted byte count too, since that is where a reverted level shows up.

Frequently asked questions

Does obfuscation break gzip or brotli compression?

Not in the default configuration. A file that inflates an untrusted upload behind a cap, compresses a body at a chosen level, compresses an asset with brotli and reads a truncated stream produced identical results on all five protection profiles measured. Compression options become a surface only when member renaming reaches the names the zlib bindings read.

Can member renaming disable a decompression bomb guard?

Yes, and it was measured. With the output cap key renamed, a 24 kilobyte payload that expands to 24 megabytes went from rejected with a buffer-too-large error to accepted, with the full 24 megabytes allocated. Node's zlib has no default cap, so the name carries the whole limit.

What happens to the compression level after renaming?

It reverts to node's default of 6. In the measurement a body deliberately compressed at level 1 dropped from 7283 bytes to 3912 bytes. The output gets smaller, so size dashboards show an improvement, while a hot path silently becomes several times more expensive in CPU.

Why did the window size option appear unaffected?

Because the value passed was already node's default. The file used a 15 bit window on the read side, which is what node uses when the option is absent, so removing it could not change anything. Measured again on the sending side at a peer-negotiated 9 bits, renaming the key changed the emitted frame from 1047 bytes to 860 bytes.

Does that mean an option that measures unchanged is safe?

No. The same option name was inert in one file and load-bearing in another, decided entirely by the value passed. The compression level behaved the same way in reverse. Before reading an unchanged result as evidence, check whether the value you passed is what the runtime would have used anyway.

Which compression options fail loudly?

The ones read off node's constants object. Renaming the brotli parameter constants produced a range error saying undefined is not a valid brotli parameter, and renaming the constants object itself produced a type error on first use. The tolerant flush mode used to recover a truncated stream failed closed, throwing instead of recovering.

How do we test compression limits on a protected build?

Send a bomb through your own inflate path and require it to be rejected. A few kilobytes that expand past your cap is enough, and the assertion is one error code. If compression sits on a hot path, assert the emitted byte count as well, because a reverted level shows up there first.

Related reading