Serving files

Does obfuscation break static file serving options?

Static-file middleware is configured by naming a directory and then subtracting everything you did not mean to expose. The directory itself is the load-bearing part: it is the only thing that decides whether a URL path is interpreted relative to your public folder or relative to somewhere else. We protected a file that hands a hardened configuration to an installed static handler, renamed the option names one group at a time, and read back the response bodies.

What the sample actually does

The file configures a static handler over a public directory with seven settings: dotfiles denied, no directory index, no directory redirect, no fallthrough to the next handler, a header hook that adds a nosniff header everywhere and an attachment disposition under the uploads path, a long cache lifetime, and one option pinned to a value the library already uses.

Eight requests run through it, arranged so that every guard fires exactly once: an ordinary asset, two traversal attempts, a direct dotfile request, a directory request, a request for an uploaded HTML file, a request for an API route the static handler does not own, and one more that matters more than it looks.

That last one is a request for /config/keys.pem. It contains no traversal segments and no leading dot, so neither the traversal check nor the dotfile policy has anything to say about it. It is an ordinary-looking path that happens to name a real file one level above the public directory, and the only thing keeping it out of reach is the configured root. Our first version of this fixture omitted it, and the root arm therefore measured that the confinement was gone without ever showing what the confinement was for.

The handler is copied unprotected. Its defaults follow the conventional ones: no root, so paths resolve against the working directory; dotfiles treated as nonexistent rather than forbidden; a directory index; directory redirects on; fallthrough on; no header hook; no cache lifetime. Protection alone, on all five presets, behaved identically to the unprotected file.

The root option serves a private key

Renaming root alone moved the resolution base from the public directory to the working directory one level above it. The ordinary asset request started returning 404, because the file is no longer where the handler now looks - which means the visible symptom is a broken site, and you would notice within minutes.

Underneath that obvious breakage, the request for /config/keys.pem went from 404 to 200, resolved to the real file, and returned a body beginning -----BEGIN PRIVATE KEY-----. Our secret detector went from false to true. The two traversal requests also stopped being confined - the escape flag moved from false to true - though in this layout they happened to land on paths that do not exist.

That combination is worth stating precisely rather than dramatically. Losing this option does not quietly open a hole in a working site; it relocates the entire document root, which breaks the site and exposes its neighbours at the same time. The danger is not that nobody notices, it is what an attacker can reach during the window between the deploy and the rollback, and a private key retrieved in that window stays retrieved after the rollback.

The header hook disappeared in this arm too, as a side effect: with no root the responses carried no headers at all, because the code path that adds them is the one that successfully reads a file from the configured location.

The same URL, the same status code, a completely different meaning

Renaming fallthrough produced 200 for that same /config/keys.pem request. The body is the application shell - a small HTML document - because a miss in the static handler now continues to the catch-all route behind it instead of ending the response.

Two arms, one URL, the same status code, and one of them hands over a private key while the other hands over a page of HTML. Any monitoring that watches status codes sees the same thing in both cases. Any test that asserts "requesting a config path does not return 200" catches both, which is the argument for writing the assertion that way rather than checking for an error page.

The fallthrough arm on its own is not a security failure so much as a correctness one, and it has a well-known shape: every API route that misses now answers 200 with HTML instead of 404 with JSON. Clients that check the status code before parsing will try to parse the app shell as JSON. In our run the API request went from 404 with an empty body to 200 with a doctype.

Renaming root and fallthrough together produced the private key again, with every other request also answering 200. That is the widest arm in this area, and it is the one a pattern scoped to the whole options block would produce.

The uploads path, where two smaller arms combine

Renaming index took the request for the uploads directory from 404 to 200, serving a file called index.html that a user had uploaded there. Renaming setHeaders removed the nosniff header and, more importantly, the attachment disposition that the sample adds to everything under the uploads path - and our inline-rendering detector went from false to true for the uploaded HTML file.

Separately each of those is a modest problem. Together they are the classic stored cross-site scripting path through a file upload feature: a user uploads HTML, the directory serves it as an index, and it renders inline on your own origin with your own cookies rather than downloading as an attachment. Neither option is named anything that sounds security-relevant, and the header hook in particular reads like presentation.

The cache-lifetime option is the one arm in this area with no security dimension at all: the long lifetime reverted to zero, which costs bandwidth and latency and nothing else. It is included because an audit that cannot tell the expensive arms from the dangerous ones will spend its effort in the wrong place.

The arm that moved and meant nothing

Renaming dotfiles changed the response for a direct dotfile request from 403 to 404. It looks like a hit and it is easy to file as one. The effective outcome is identical: the file was not served either way. The pinned value forbids dotfiles explicitly, the default treats them as nonexistent, and the only difference is which of two refusals the caller receives.

This is the second pass running where an arm that clearly moved turned out to mean nothing, and the lesson is the same one: read what the value does, not whether it changed. It is also fragile in the same way. Against a handler whose default were to serve dotfiles rather than hide them, the identical rename would publish every dotfile under the public directory and nothing in the application would look different. The safety here belongs to the library's choice of default, not to the code.

The one option pinned to a value identical to the default produced no change on either target, as it did in all five areas this pass.

What to do about it

Exclude the option names your static handler reads. If you only exclude one, exclude the root: it is the option whose loss relocates everything else, and it is the one that produced a private key in our measurement.

Independently of protection, it is worth not having secrets in the parent directory of your public folder. That is a layout decision rather than a code decision, and it is the only mitigation in this article that keeps working when the configuration is wrong for any other reason - a typo, a refactor, a container image that changes the working directory.

Test the decision rather than the presence. A request for a real file just outside the public directory, asserted to return something other than 200, catches the root arm and the combined arm. A request for an API path, asserted to return JSON rather than HTML, catches the fallthrough arm. Both are cheap and both survive every future change to how the handler is configured.

Frequently asked questions

Does obfuscation break static file serving?

Not by itself. Protection alone, on all five presets we tested, behaved identically to the unprotected file. The results here required member renaming pointed at the option names the handler reads.

Which static-file option was the most dangerous to rename?

The root. Losing it moved the resolution base up one directory, and a request for an ordinary-looking path with no traversal segments returned a private key with a 200. It also broke the site visibly at the same time, because ordinary assets stopped resolving.

If losing the root breaks the site immediately, is it really dangerous?

The breakage limits how long the state lasts, not what happens during it. A key retrieved before the rollback is still retrieved after it, so the mitigation is to keep secrets out of the directory above your public folder rather than to rely on noticing quickly.

Why did two different arms both return 200 for the same URL?

Because one served the real file after the confinement moved, and the other served the application shell after a miss continued to the catch-all route. The status codes are identical and the bodies are not, which is why an assertion on the body or on the content type is worth more here than one on the status.

Did renaming the dotfiles option expose anything?

No. The response changed from 403 to 404 and the file was not served either way, because the library's default is to treat dotfiles as nonexistent. It is an arm that clearly moved and meant nothing, and it would mean a great deal against a handler whose default was to serve them.

How do the index and header options combine?

Losing the index setting let a directory request serve a user-uploaded index file; losing the header hook removed the attachment disposition that had been forcing such files to download. Together they are the stored cross-site scripting path through an upload feature, while separately each looks minor.

What should I exclude from member renaming here?

The handler's option names and the fields on the request object it reads. In our sample that was root, dotfiles, index, redirect, fallthrough, setHeaders and maxAge, plus the url field on the request and the status and body fields on the response.

Related reading