Supply chain

Does obfuscation break archive extraction guards?

Most options in this series tighten something a library was already doing. The guards on an archive extraction are different: three of them are the only limit that exists at all, because the library has no default cap on entries or bytes and no opinion about which files you want. Losing one of those names does not weaken a check, it deletes it.

What the sample actually does

The file extracts a customer-supplied plugin bundle. It strips one leading path component, applies a filter function that admits only files under dist/ and refuses anything matching an environment file or a shell script, caps the extraction at four entries, and caps it at sixty-four kilobytes. A second, fatter archive is extracted with the same options so that the byte cap is exercised by something other than the entry cap.

The archive contains what a publisher meant to ship and what an archive can carry without anyone noticing: an environment file, a post-install shell script, a path that climbs out of the target directory, an absolute path, and a forty-megabyte blob.

The extractor is copied in unprotected and models a widely used library's defaults: paths are sanitised by default, but there is no entry cap, no byte cap and no filter unless the caller supplies one. Protection alone, on five presets, produced identical results to the unprotected run.

One option in the set is pinned to a value equal to the library default on purpose. Renaming it changed nothing, exactly as predicted, and it is in the sample as a control rather than as a finding.

Losing a function-valued option removes a decision, not a number

This series has measured booleans, numbers, strings and nested objects. A filter is a function, and it fails differently: the extractor does not fall back to a stricter filter or a looser one, it stops filtering.

Renaming filter moved the written list from four files under dist/ to the environment file, the post-install shell script and a path resolving outside the intended directory. Our three verdict lines all moved from false to true at once: an environment file was extracted, a shell script was extracted, and content landed outside dist/. The skipped list went from four entries to none.

Nothing threw, and the extraction reported success. The extractor even respected the entry cap, so it wrote precisely four files - just not the four you asked for. A test asserting that four files were written passes.

The caps that are the only cap

Renaming maxEntries took the written count from four to six and removed the abort. Renaming maxBytes is the one to look at: on the fat archive, the extraction went from writing a single small file and aborting on the byte cap to writing forty megabytes and reporting success. Our megabytes-written line moved from zero to forty.

There is no default here to fall back to. The library caps nothing unless told, so the renamed name did not restore a weaker limit, it restored no limit. On a service that accepts uploads, that is a disk-exhaustion primitive handed over by an option name.

With every guard name renamed at once, the extraction wrote ten entries including the environment file, the shell script, the traversal path and the absolute path, and wrote the forty-megabyte blob as well. The unprotected run wrote four files and fifteen kilobytes.

The guard that fails closed, and why that is still a bug

Renaming strip reverted the stripped component count to zero, so every path kept its pkg/ prefix. The filter then rejected everything, because nothing began with dist/. The written list went from four files to none, and the extraction reported success with zero bytes written.

This is the safe direction in the sense that nothing dangerous landed on disk. It is still a silent failure: the install produced an empty directory, exited zero, and reported no error. Whatever consumes the extracted files discovers the problem later and somewhere else.

It is also a good illustration that the direction of a lost option is not predictable from the option's purpose. strip is a convenience setting about path shape, and losing it disabled the extraction entirely, while losing the security-critical filter left the extraction running and merely wrong.

The entry shape and the result shape both crash

Two arms failed immediately. Renaming the field names on the archive entries themselves - the path and the size - produced a TypeError on the first entry, because the extractor reads names the unprotected library expects. Renaming the fields on the result object did the same when the caller tried to read the written list.

Both are loud, both stop any build that runs the code once, and both are considerably better outcomes than everything above. The pattern is now familiar across this whole series: the interface into a library degrades quietly, the interface out of it crashes.

It is worth deliberately noticing which one you would rather have. If you must choose an arm to be hit by, choose the one that throws.

What to do about it

Exclude the extractor's option names from your member pattern - filter, strip, maxEntries, maxBytes, preservePaths and whatever else your library reads - along with the entry field names and the fields on the result.

Then assert on outcomes rather than on success. After extraction, check the count of files written, check that every written path is inside the intended directory, and check the total bytes. Those three assertions catch every silent arm in this article, and they are worth having even if you never obfuscate anything, because a malicious archive is the threat the guards were for in the first place.

One more habit is worth borrowing from this measurement. If a guard is the only thing standing between you and a behaviour the library will otherwise perform without limit, treat its name as part of your security boundary and write it down. Options that tighten a default are a nuisance to lose; options that are the entire limit are not.

Frequently asked questions

Does obfuscation change how an archive is extracted?

Not by itself. Protection alone produced identical extraction results on all five presets we tested. Behaviour changed only when member renaming matched the option names the extractor reads.

Why is a filter option worse to lose than a numeric cap?

Because there is nothing to revert to. A renamed numeric cap restores the library's default, and for entries and bytes that default is no cap at all. A renamed filter removes the decision from the pipeline entirely, so every entry in the archive is eligible.

What actually got written when the filter was renamed?

An environment file, a post-install shell script and a path that resolved outside the intended directory. The extraction reported success and still respected the entry cap, so it wrote exactly four files - just not the four the filter allowed.

How large was the decompression exposure?

With the byte cap renamed, our second archive wrote forty megabytes where the configured run had written none and aborted. The library caps nothing unless told, so the rename removed the limit rather than loosening it.

Did anything fail in the safe direction?

Yes. Renaming the path-strip count made every path keep its top-level prefix, so the filter rejected everything and nothing was extracted at all. That is safe but still silent: the install produced an empty directory and exited zero.

Which arms failed loudly?

Renaming the field names on the archive entries, and renaming the fields on the result object. Both produced a TypeError on the first use, because those names are read by code that was not rebuilt with the bundle.

What should I assert after an extraction?

The number of files written, that every written path is inside the intended directory, and the total bytes. Those three checks catch every silent result in this article and are worth having regardless of whether you obfuscate.

Related reading