Serving user files

Does obfuscation break file download headers?

Handing a user their own file back sounds like the least interesting endpoint in an application. It is the one where an attacker's upload gets to choose whether it is a download or a page, and the difference is a handful of property names on an options object: which content types you are willing to echo, whether the response is an attachment, who cleans the filename, and whether the browser is allowed to guess. We protected a file that configures a download helper, renamed those names a group at a time, and read the headers that came out.

What the sample actually does

The file serves five stored objects through a download helper: an ordinary invoice, an uploaded file claiming to be HTML, a spreadsheet export with a formula in one cell, a file whose name carries a quote and a line break, and a text file full of markup. Each of those exists to make exactly one guard fire, so that no guard can hide behind another refusing first.

The helper is copied in unprotected, with the defaults this kind of package ships: echo whatever content type the object claims, render inline where the browser can, clean the filename of path separators and nothing else, send no header telling the browser to stop guessing, and write spreadsheet cells exactly as stored.

Protection alone was applied first on five presets and all five behaved identically to the original, so nothing below is caused by protection on its own. Every result required member renaming.

Two names, and an upload becomes a page in your origin

This area produced the cleanest paired result the series has measured. Neither of the two relevant options is dangerous to lose on its own.

Rename the content-type allowlist alone and the uploaded file is served as text/html again - but it is still an attachment, so the browser saves it and nothing runs. Rename the disposition alone and the response is inline again - but the type is still the neutral one the allowlist forced, so there is nothing for the browser to render. Both arms move a value, neither arm moves the outcome. The application's own count of files that execute in its origin stays at zero of five.

Rename both and the count goes to one of five. The file comes back as text/html, inline, from the application's own origin, and the script inside it - which in the sample reads an internal endpoint and posts the response to a collector - runs with the privileges of the site that served it. That is stored cross-site scripting delivered by the download endpoint, and it is the seventh consecutive pass in which a pair of options has failed safe on each half and open on both.

The practical reading is that an exclusion list which covers one of a pair is worth very little. If two options only matter together, they belong in the list together.

A filename that writes its own header

The application supplies its own filename cleaner, because a filename ends up inside a quoted header value and quotes and line breaks are the whole problem. Renaming that one name replaced it with the helper's builtin, which strips path separators and stops there - a perfectly reasonable thing for a library to do, since it assumes the caller quoted the value.

With the caller's cleaner gone, the emitted response went from three header lines to four, and the fourth was a Set-Cookie line the attacker chose by naming their upload. The measurement asserts on the header LINE rather than on the substring, which matters: the same text sitting inside the quoted filename on one line is inert, and an assertion written against the substring reports a problem in the safe run and a problem in the dangerous one, which is no assertion at all.

This is the same shape as the algorithm result in the encryption area and as four other libraries this series has measured: a caller-supplied implementation of a guard is not switched off when its name moves, it is DOWNGRADED to the library's own, and the library's own is weaker in precisely the situation you wrote yours for.

The two quieter arms

Renaming the sniffing header option removed X-Content-Type-Options from every response. The text file full of markup went from a response a browser must treat as text to one a browser may make its own decision about. No content changed, no status changed, and the difference only shows up on the browsers and configurations where sniffing still happens - which is exactly why the header is cheap to send and easy to forget.

Renaming the spreadsheet guard let a cell beginning with an equals sign ship as written. The measurement prints the cell: a formula that fetches an attacker URL with the neighbouring cell's contents appended to it. Nothing in the web application is compromised. The damage happens later, in somebody's spreadsheet, on a machine your logs will never see, and it will be reported as a phishing incident rather than as a bug in an export.

The option pinned equal to the library default behaved as predicted and changed nothing.

The arm that renames the RESULT fields - the content type, the emitted header, the flags the helper computes - crashed on the first read, which is the loud direction this series keeps finding for names flowing outward. It is the only arm in the area that a smoke test cannot miss.

What this measurement does not show

It does not show that obfuscation is unsafe for applications that serve files. The base column - the same five files protected with no member renaming at all, on both output targets, the gate profile and the compressed profile - was identical to the unprotected run in every case. Renaming identifiers, moving strings and compressing output changed nothing about any header in this article.

It also does not show a defect. Everything here follows from what member renaming is: a rewrite of property names inside the code you hand the tool. A helper that arrived in node_modules was not handed to the tool, so it keeps reading the names it always read, finds none of them, and applies the defaults its documentation describes. Those defaults are permissive because a library that refused to work without configuration would be a library nobody adopts.

And it does not show that the defaults are wrong. Serving a stored content type inline is the correct behaviour for a helper whose caller has decided the content is safe. The application in this measurement had decided it was not, and said so in five property names.

Why the download endpoint is worth this much attention

Every other guard in this article protects something inside your application. This one protects your ORIGIN, which is the boundary every other browser-side control is defined against. A file served as a page from your own hostname inherits your cookies, your storage, your service worker scope and any cross-origin policy your users' browsers apply to you.

It is also the endpoint most likely to be written once and never revisited, and the one where the attacker supplies both the content and the name. The combination of those two facts is why a content type allowlist and a fixed disposition are worth asserting rather than configuring.

None of this is an argument against protecting the code. It is an argument about which names the protection is allowed to touch, and the download helper's options object is a small, stable, easily excluded set.

What to do about it

Scope the renaming: the options object handed to a download helper, and the shape of the stored-object records you pass it, belong outside the MemberRegexp. Alternatively, write both with quoted string keys and read them with literal bracket access.

Then assert on the response rather than on the configuration. One test that requests an uploaded HTML file and requires the response to carry a neutral content type AND an attachment disposition catches both halves of the pair, including the case where only one of them was lost. A second test that uploads a file whose name contains a quote and a line break and requires the response to have exactly the header lines you expect catches the sanitizer downgrade.

Both tests are ordinary integration tests against the endpoint you already have. They pass today, they fail on five of the arms measured here, and neither of them needs to know anything about obfuscation.

Frequently asked questions

Does protecting my JavaScript break file downloads 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.

Why did neither of the two dangerous options matter on its own?

Because each one blocks the other's consequence. The allowlist forces a neutral content type, so an inline response has nothing to render; the attachment disposition makes the browser save the file, so a restored content type never executes. Only losing both produces a page in your origin.

What is the risk from an uploaded HTML file exactly?

It is stored cross-site scripting. A script served from your own hostname runs with your origin's privileges, so it can read endpoints your cookies authorise and send the answers anywhere. In the sample it reads an internal endpoint and posts the response to an external collector.

Is the filename really a security boundary?

It is, because it is placed inside a quoted header value. A quote and a line break in the name end the value and start a header of the attacker's choosing. In the measurement the emitted response gained a fourth header line setting a cookie.

What about the spreadsheet formula guard?

Losing it lets a cell beginning with an equals sign ship as written, so the formula executes when a colleague opens the export. Nothing in the application is compromised, which is why this one tends to be reported as a phishing incident rather than as an export bug.

Would our tests have caught this?

A test that downloads a file and checks the bytes passes on every arm here. What catches them is asserting on the response headers: a neutral content type and an attachment disposition for an uploaded HTML file, and an exact header-line count for a file whose name contains a quote.

What is the recommended fix?

Exclude the download helper's options object and your stored-object record shape from member renaming, or build both with quoted string keys and literal bracket reads. Then add the two header assertions above as ordinary integration tests.

Related reading