Uploads and media

Does obfuscation break image processing limits?

An image upload is a small file that asks your server to allocate a large amount of memory, and sometimes to publish more than the person uploading it realised. The options that keep that in hand are a pixel ceiling, a format allowlist, a metadata policy and a refusal to work with a truncated file. They are property names on an options object read by a package you installed. We protected a file that configures one, renamed the option names a group at a time, and recorded what got stored.

What the sample actually does

The file configures an image pipeline with five narrowings and one control, then puts five uploads through it. One is an ordinary photograph from a phone, complete with the metadata a phone writes. The others each trip exactly one guard: a 40,000 by 40,000 pixel PNG that is 61 KB on disk, an SVG, a truncated JPEG, and a poster-sized PNG whose output would exceed the size ceiling.

No pixels are decoded. Each file is a small descriptor and the pipeline is a simulation of the wrappers in wide use, so the measurement is reproducible and costs nothing. What it models faithfully is the default set: no ceiling on decoded pixels, any format the decoder recognises, metadata carried into the output, and a truncated file processed as far as it goes.

Protection alone was applied first, on five presets covering both output targets, the gate profile and the compressed profile. All five behaved identically to the unprotected file. Every result below required member renaming aimed at the option names.

Sixty-one kilobytes became six gigabytes

Renaming the pixel ceiling restored the default of no ceiling, and the decompression bomb was accepted. The pipeline's own accounting moved from a refusal to bomb-decoded=6103.5 MB status=stored. The file on disk is 61 KB; the decoded surface is 1.6 billion pixels.

This is the oldest trick in image handling and it survives because the ratio is so extreme. A single request that costs an attacker almost nothing costs the server several gigabytes of resident memory, and the request that follows it does the same. In a container with a memory limit the process is killed; without one, the machine is. Either way the failure surfaces as an availability incident rather than as an upload problem, which is what makes the cause hard to find.

The pixel ceiling is not a performance setting. It is the only thing in the pipeline that relates the cost of decoding to the size of the file that arrived, and it is one property name away from not existing.

A photograph published with its GPS coordinates

The application asks the pipeline to strip metadata. Renaming that option restored the default, which carries metadata into the output, and the published file's metadata went from (stripped) to the phone's original string: latitude and longitude, the camera's serial number, and the capture timestamp.

The coordinates in the sample are a London address, which is the point. A profile picture, a photograph attached to a support ticket, a picture of a product on a marketplace listing - all of them routinely carry where they were taken. Publishing them is not a breach of your systems at all. It is a breach of your users' privacy, committed by your application, and discoverable by anyone who downloads the file and runs a standard tool over it.

Nothing about the image looks different. It renders identically, it is the right size, and the pipeline reports success. The only visible difference is in a metadata block almost nobody inspects.

One arm in this area produced the inverse failure, and it is instructive. Rename the metadata option AND the descriptor field that holds the metadata, and the pipeline reports its policy as carrying metadata into the output while the published file still has none - because the field it reads is now called something else. The policy says the guard is off; the observable says nothing leaked. A report that is wrong in the safe direction is still a report you cannot trust, and it is the mirror image of the case where the guard is off and the alarm stays quiet.

An SVG in the image store, and a partial file presented as a picture

Renaming the format allowlist restored the default of anything the decoder recognises, and the SVG upload was stored. An SVG is not a picture in the sense the rest of the pipeline means; it is a document that can carry script, and it is served from your origin with your cookies available to it. The allowlist is what keeps active markup out of a directory of images, and it is the same one property name as everything else here.

Renaming the truncation policy stored the half-uploaded JPEG as a partial image, with the pipeline's own reason string moving to partial-image. That is an integrity problem rather than a security one: the user sees a grey band where the rest of their photograph should be, the system believes the upload succeeded, and nothing retries. Renaming the output ceiling stored the poster-sized render that the application had refused.

The whole-block arm is the one to picture if you are wondering how these combine: every upload in the run was stored. The bomb, the SVG, the truncated file and the oversized render, with the photograph's coordinates published alongside them.

The arm that failed closed

One arm broke the feature outright, and it is worth knowing which. Renaming the descriptor's own field names - format, width, height and the rest - meant the pipeline could not read any of them, so every upload including the ordinary photograph was refused as format-not-allowed. Stored files went from one in five to zero in five.

That is the loud direction, and it is loud for a structural reason: the allowlist check runs first and an unknown value is not on an allowlist. The same rename against a pipeline that checked formats with a denylist would have passed everything instead, because an unknown value is not on a denylist either. The safety came from the shape of the check, not from the code being careful.

It is the same argument the whole series keeps arriving at from different directions. An allowlist that fails closed, a denylist that fails open, and a rename that makes a value unrecognisable will find out which one you wrote. The option pinned to a value identical to the library default changed nothing at all, exactly as predicted.

What to do about it

Scope member renaming so the pipeline's options object is excluded, along with the descriptor objects you build for it. If you prefer not to maintain an exclusion list, express the options with quoted string keys and read them with literal bracket access, both of which survive renaming.

Then assert the limits rather than the outcome. Ask the pipeline what pixel ceiling it will apply, which formats it will accept and whether it will carry metadata, and refuse to start if any answer is not what you configured. An upload test that posts a valid JPEG and checks for a thumbnail passes on every arm in this article except the one that broke everything.

Two of these controls also belong outside the application. A body-size limit at the reverse proxy bounds what can arrive, and a memory limit on the container bounds what a decode can consume; neither depends on a property name surviving your build. And for the metadata question, the safest arrangement is to re-encode every published image from decoded pixels rather than to ask a pipeline to remove a block, because a re-encode has nowhere to carry the original metadata even if the option is lost.

Frequently asked questions

Does protecting my JavaScript break image processing on its own?

Not in this measurement. The sample was protected on five profiles covering both output targets, the gate profile and the compressed profile, and all five behaved identically to the unprotected file. Every result required member renaming pointed at the option names.

How can a 61 KB file consume six gigabytes?

Compressed formats store a description of an image, not its pixels. A 40,000 by 40,000 pixel image of flat colour compresses to almost nothing and decodes to 1.6 billion pixels. The pixel ceiling is the only option that relates decode cost to the size of the file that arrived.

Why is an SVG upload a problem?

Because it is a document rather than a raster image, it can carry script, and it is served from your own origin. The format allowlist is what keeps it out; with the option name renamed the pipeline accepted anything its decoder recognised.

Does stripping metadata matter if the image looks the same?

It matters precisely because the image looks the same. The published file in this measurement carried GPS coordinates, a camera serial number and a capture time, none of which are visible when the image renders and all of which are readable with a standard tool.

One arm refused every upload. Why is that the good outcome?

Because it is discovered immediately. It happens because the format check is an allowlist and an unrecognisable value is not on it. The identical rename against a denylist would have accepted everything instead, which is the same failure with no symptom.

Do proxy limits make these options unnecessary?

No, but they are a useful second layer. A request body limit bounds what can arrive and a container memory limit bounds what a decode can consume, and neither depends on a property name surviving your build. The pixel ceiling is still what refuses a small file with a huge decode.

What is the recommended fix?

Exclude the pipeline's options and descriptor objects from member renaming or express them with quoted string keys and literal bracket reads, assert the effective limits at start-up, and re-encode published images from decoded pixels so that metadata has nowhere to survive.

Related reading