Upload handling

Does obfuscation break file upload limits?

Multipart upload middleware has the most permissive defaults of anything we have measured in this series. Every numeric cap defaults to Infinity, the type filter defaults to yes, and the storage engine defaults to holding the whole file in memory. That means an upload route is configured almost entirely by subtraction, and every option you wrote is the only thing standing between a request and those defaults.

What the sample actually does

The file configures an upload route with a disk storage engine, a filter that accepts only PNG and JPEG, and four caps: half a megabyte per file, four files, three text fields and nine parts in total. It then pushes one multipart request through containing ten parts, arranged so that every single guard fires exactly once in the unprotected run - the filter rejects a PHP file, the size cap rejects a 40 MB PNG, the field cap rejects the fourth text field, the file cap rejects the fifth file and the part cap rejects the tenth part.

That arrangement is deliberate and it is worth copying if you write a measurement like this. An arm whose guard never runs is blind, not clean. Our first version of this fixture had the part cap firing before the filter and the size cap ever got a chance, and two of the most important arms reported no change because the request never reached them.

The middleware is copied unprotected, so it keeps reading the property names it has always read. Its defaults mirror the real library: fileSize, files, fields and parts all default to Infinity, the file filter defaults to accepting everything, and with no storage engine named the file is buffered in memory rather than streamed to disk.

Protection alone, on all five presets, produced behaviour identical to the unprotected file. Everything below required member renaming.

Every cap becomes Infinity

Renaming fileSize alone took the enforced maximum from 524288 to Infinity, and the 40 MB PNG that had been rejected was accepted and written. Bytes to disk went from 480 kB to 41,440 kB from a single request. Nothing was logged, nothing threw, and the route returned exactly what it returns on a successful upload.

Renaming files admitted a fifth file. Renaming fields admitted a fourth text field. Renaming the limits container - one key, holding all four caps - did all of it at once: five files accepted instead of two, 41,540 kB written, and the only remaining rejection was the PHP file, which the filter still caught.

That last detail is the useful one for anyone writing a test. Losing the entire limits block still leaves the type filter working, so a test that uploads a disallowed file type and asserts a rejection passes with every numeric cap gone. The guards are independent, and so must be the tests.

The filter, and the file that gets through it

Renaming fileFilter is the arm with the clearest security shape. The filter function is not called, because the middleware cannot find it and falls back to a default that accepts everything. The PHP file went from FILTERED to accepted and stored, and the reported php-upload-accepted flipped from false to true.

There is no error, no warning and no partial behaviour. A function-valued option does not revert to a weaker function; it removes a decision from the program. We saw the same shape in an archive extractor in an earlier pass, and it is the reason function-valued options deserve their own line in any exclusion list: they are the options most likely to encode a policy nobody wrote down anywhere else.

The filename is worth a note too. The stored name stayed basenamed in every arm, because the option that controls whether a client-supplied path is preserved was pinned to the same value as the library default. That arm reported no change, correctly - and if your application had pinned it the other way, to preserve paths for a legitimate reason, the same rename would have changed the names of stored files.

Disk to memory, and then the pair

Renaming storage moved the upload from disk to memory. The same 480 kB that had been streamed to disk was held in RAM instead. On its own that is a resource decision rather than a vulnerability, and a route handling small avatars would very likely never notice.

Rename storage and fileSize together and the two combine into something neither is alone: 41,440 kB buffered in RAM, from one request, with no cap on how many such requests arrive. The size limit was the only thing keeping memory storage survivable, and the storage engine was the only thing keeping a missing size limit cheap.

The full options block is worse again. Renaming every option key in the config - which is precisely what a member pattern scoped to that block does - produced rejected=(none): six files accepted including the PHP one, 41,544 kB buffered in memory, zero rejections of any kind. Every guard in the route was gone at once and the route still returned success.

A name collision that turned into a crash

One arm behaved differently from every other and it is instructive. Renaming parts threw a TypeError immediately, because parts is both the name of the count cap in the options object and the name the sample itself uses for the list of multipart sections. A rename pattern matches names, not roles, so it took both.

This is not a failure of the measurement; it is a real property of member renaming and it happens often enough to plan for. Generic option names - parts, size, limit, filter, open, key - are the names most likely to also be doing something else in your own code, and a pattern that names them is a pattern with a wider blast radius than the options block you were aiming at.

The practical consequence: when an arm crashes rather than degrading, do not read that as "protection would have caught this". Re-run the same group without the colliding name. We did, and the result without parts was the six-file, nothing-rejected, 41 MB-in-memory outcome described above. The crash was hiding the quiet result, not preventing it.

The direction that fails closed

Renaming the property names on the file objects the middleware hands to your filter - the original name, the MIME type and the size - failed closed rather than open. The filter could not read a MIME type, so it matched nothing, so every file was rejected. Accepted count went from two to zero.

An upload route that accepts nothing is an outage, and outages get fixed. This is the third area in a row where the outbound direction of the same boundary is loud while the inbound direction is quiet, and it is a reasonable heuristic to carry into an exclusion list review: the names you read off a dependency will announce themselves, and the names you hand to a dependency will not.

Renaming the fields on the summary object the middleware returns crashed outright with a TypeError, for the same reason - the calling code tried to map over a list that was no longer there.

What to do about it

Scope the pattern. Member renaming is optional and only touches names your expression matches, so exclude the option names your upload middleware reads, the property names on the file objects it hands your filter, and the fields on its result.

Then test the decisions rather than the installation. Upload a file one byte over your size cap and assert the rejection. Upload a disallowed content type and assert the rejection. Upload one more file than your file cap allows and assert the rejection. Those three tests are cheap, they fail on every arm in this article, and a single test that uploads one valid avatar passes on all of them.

Finally, look at where memory goes. A route that streams to disk under a size cap and a route that buffers in memory without one behave identically on the happy path, produce identical logs, and differ only in what a single large request costs you.

Frequently asked questions

Does obfuscation change how an upload route 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. Every result in this article required member renaming pointed at the option names.

Why do upload limits fail open rather than closed?

Because the library's defaults for size, file count, field count and part count are all Infinity, and its default file filter accepts everything. A renamed key is indistinguishable from a key you never set, so what you fall back to is the absence of a limit rather than a smaller one.

What was the widest result you measured?

Renaming the whole options block. Six files were accepted including one the filter had been rejecting, nothing was rejected at all, and 41 megabytes were buffered in memory from a single request. The route still returned success.

Why did one of the arms crash instead?

The count cap for multipart sections shares its name with the sample's own list of sections, so the pattern took both and the code threw. That is a name collision rather than a safety property - re-running the same group without the colliding name produced the quiet result instead.

Does the file filter still run if its option is renamed?

No. A function-valued option that the middleware cannot find is not replaced by a weaker function; the decision simply stops existing, and the library's accept-everything default applies. That is why function-valued options deserve their own line in an exclusion list.

Would our tests catch this?

Only if they test each guard separately. Every numeric cap can be gone while the type filter still rejects, and the type filter can be gone while the caps still reject, so a single test that uploads one bad file proves very little.

What should I exclude from member renaming?

The option names the middleware reads, including the container that holds the caps, the property names on the file objects passed to your filter, and the fields on the result object your handler reads.

Related reading