AWRA OpsHub Search

The Type Was Guessed From the Bytes

When the product hands your browser a stored file, the type it declares is worked out from the file's own bytes. That guess is why one header matters more on those routes than anywhere else in the application — and why it is set before the list of routes that skip everything else.

Security & Compliance AWRA OpsHub Team 12 min read

A file does not know what it is. Something has to decide, and on the way out of a server that decision is a guess made from the first few bytes.

When a browser is handed a file, the response says what kind of thing it is — an image, a PDF, plain text. That declaration determines what the browser does with it: draw it, open a viewer, or treat it as inert data to be saved.

For a stored file that somebody uploaded, the declaration is derived by inspecting the file. Not from its name, which anybody can choose, but from its contents, which is better and is still an inference. A file whose leading bytes look like one thing is declared to be that thing.

Why the guess is the interesting part

Suppose a file is stored whose contents are, in fact, a web page — markup, with a script in it. It is served back with whatever type the inspection concludes. If a browser can be persuaded to treat that response as a page rather than as data, the script runs, and it runs on your domain, with whatever the browser already trusts about your domain.

Browsers historically made this considerably easier by second-guessing the declared type. Faced with a response labelled one thing whose contents looked like another, they would help — treating it as what it appeared to be rather than what it claimed. The behaviour is called content sniffing, and it existed because a great many servers labelled things wrongly and users blamed the browser.

The helpfulness is the vulnerability. A browser correcting your Content-Type is a browser overriding your decision about what a file is allowed to be.

There is one header that switches it off. It says: take the type I gave you, and if I was wrong, fail rather than improvise.

Where it matters most, and where it was nearly absent

Now the part of this that is a genuine finding rather than a description of a well-known header.

The security headers on this application are applied by one piece of middleware, which carries a list of routes it skips. Roughly half that list is payment provider callbacks — machine-to-machine endpoints where a browser policy is meaningless. The other half is routes that serve a stored file inline.

Those file routes are exempt for a specific and legitimate reason. The policy the middleware applies includes a rule forbidding the page from being displayed inside a frame — a good rule, and the one that stops the in-app file viewer from showing a PDF, because the viewer works by framing it.

So the exemption was added, and it took the whole method with it. Including the one header those routes needed more than any other route in the application.

The shape of this mistake is worth more than the mistake

An exemption is written to solve one problem — the framing rule — and it is expressed as skipping everything, because that is how the code was arranged. The intended effect and the actual effect differ by everything else the method did. Nothing breaks, no test fails, and the routes that lost the most are the routes the exemption existed to accommodate. The general form: when you exempt something, check what you exempted it from, not just what you meant to exempt it from.

The fix is one line of ordering. The header is set before the exemption is consulted, so every route gets it including the exempt ones, and the exemption then skips only what it was actually for. And the response helper that serves stored files sets the same header itself, so it is present twice by two independent routes.

Belt and braces on a security header looks redundant and is not. The helper protects a file served by a path the middleware might not cover; the middleware protects a route whose author never reached for the helper. Either alone is a single point of failure.

What else the helper does

It is a short piece of code and everything in it is doing something.

  • It refuses anything that is not a readable file, with a not-found rather than an error — so a path pointing somewhere unexpected produces the same answer as a path pointing nowhere, and the response does not describe what went wrong.
  • It falls back to a deliberately meaningless type when the inspection yields nothing. A type meaning "unidentified bytes" is the correct answer to an unidentifiable file, and it is the one type no browser will render.
  • It omits the length header rather than sending a wrong one when the size cannot be determined, because a Content-Length that disagrees with the body is worse than one that is absent.
  • It defaults caching to private. The two callers serving genuinely public assets pass a public value explicitly, which is the right way round — a caching mistake then requires somebody to have typed something.

The other lesson in that list

A list of exempt routes has a second failure mode, and it is quieter than the first.

An entry that matches no route today looks harmless. It is not: it is a live exemption sitting on a path, waiting for somebody to add a route there. The route is added, works, is reviewed and ships — carrying an exemption from a security policy that nobody in that conversation knew existed. This has happened here, on a different list, to a path that had long since stopped existing.

The habit worth adopting is to treat every exemption list as something with a review date, and to check for entries matching nothing when you look. An exemption should be justified by a route, and a route that has gone should take its exemption with it.

The neighbouring lesson on the same routes

These file-serving paths have taught this codebase the same class of lesson twice. The controller behind the secure attachment routes checks which organisation the record belongs to and nothing else — by design, which means the permission has to come from the route. Nine of those routes carried no permission at all until they were audited, because the sibling routes beside them did, and a module that looks covered reads as finished. Both findings are about a route inheriting less than the routes around it appear to promise.

Four questions for any system that serves you back your own uploads

How is the type of a stored file decided?

A good answer sounds like

From its contents, not its filename.

What ours actually is

From its contents, with an unidentified fallback that no browser renders. A system trusting the uploaded filename is trusting the uploader.

Do you send the header that stops content sniffing on those routes?

A good answer sounds like

Yes, and it is a floor rather than a per-route choice.

What ours actually is

Yes, set before the exemption list is consulted so every route receives it, and set again by the response helper. Ask specifically about the file routes — they are the ones most likely to be exempt from something.

Are any routes exempt from your security headers?

A good answer sounds like

Yes, here is the list and why.

What ours actually is

Yes — provider callbacks, and file routes that the framing rule would otherwise break. A no here usually means nobody has looked.

When was that exemption list last checked against your routes?

A good answer sounds like

Recently, and entries matching nothing were removed.

What ours actually is

A question we hold ourselves to, having found a stale exemption on a path that no longer existed. An entry matching no route is a live exemption waiting for a route.

The file-serving ledger, precisely

What AWRA OpsHub does today

  • The declared type of a stored file derived from its contents rather than from its filename or its extension.
  • A fallback to an unidentified type when inspection yields nothing, which is the one type no browser will attempt to render.
  • The header disabling content sniffing set on every route, before the exemption list is consulted, so an exempt route keeps it.
  • The same header set independently by the response helper that serves stored files, so the protection survives either layer being missed.
  • A test pinning that ordering, so a future edit moving the header after the exemption check fails the build rather than silently removing it from the routes that need it most.
  • A not-found response for anything that is not a readable file, so a path pointing somewhere unexpected does not describe what went wrong.
  • The length header omitted rather than sent wrongly when a file size cannot be determined.
  • Caching defaulting to private, with the two genuinely public callers passing a public value explicitly.

More we can add to your workspace

  • Serving stored files from a separate domain, which is the structural answer: content that executes there cannot reach anything that trusts the main domain, whatever type it is declared as.
  • A restrictive declared type for user uploads regardless of what inspection concludes, so an uploaded file is served as inert data unless it is one of a small set of types deliberately rendered.
  • An automated check that every exemption entry matches a live route, so an entry outliving its route is caught rather than waiting for a new route to inherit it.
  • A narrower policy for the file routes in place of exempting them from the whole method, so they lose only the framing rule rather than everything.
  • Content inspection at upload as well as at serving, so a file whose contents disagree with its stated purpose is refused on the way in.

Where we point you to a specialist

  • We would decline to serve a stored file with a type taken from its uploaded filename. The filename is chosen by whoever uploaded the file, and treating it as a statement of fact about the contents is the assumption this entire article exists to avoid.
  • Where a regulator or a customer requires that uploaded documents be scanned, quarantined or held in a particular store, that requirement governs and we will implement it rather than presenting content-type handling as equivalent. Whether such a requirement binds you is a question for your own advisers.
  • We hold that a security header should be a floor set for every route, with exemptions naming the specific rule they need lifted. An exemption expressed as skipping an entire method removes protections nobody in the conversation intended to discuss, and that is a structural position rather than a preference about this one header.

Serving user uploads from a separate domain is the structural fix and the one that stops this class of problem rather than managing it. An automated check that every exemption matches a live route is the small one, and it closes the stale-entry failure permanently.

Scope, not a ceiling

From a managed risk to a removed one

Everything above manages the consequences of serving somebody else's bytes from your own domain. Two pieces of work change the shape of the problem rather than the handling of it.

A separate domain for uploads

The structural answer. Content served from a domain that trusts nothing cannot reach a session on the domain that does, whatever a browser decides it is.

A narrow declared type for uploads

Serving an uploaded file as inert data unless it is one of a small set deliberately rendered, so the inspection result decides how to display rather than whether to execute.

An exemption list checked against the routes

A build-time check that every entry matches something live, so an exemption cannot outlive the route that justified it and wait for a new one.

We publish scope, not dates.

Scope file-serving hardening

Go and read your own exemption list

Whatever your equivalent is — security headers, request verification, rate limits — open the list, and check two things. What each entry is exempt from, as opposed to what it was meant to be exempt from. And whether every entry still matches a route that exists. Both checks take a few minutes and neither is ever prompted by anything going wrong.

Talk through file handling

Frequently asked questions

What is content sniffing?

A browser behaviour of second-guessing the type a server declared, by looking at the contents and deciding what the response really appears to be. It exists because many servers historically labelled files wrongly and users blamed the browser rather than the site. The problem is that it converts a server decision about what a file may be treated as into a browser inference, and an inference can be influenced by whoever supplied the bytes.

Why is the type not simply taken from the filename?

Because the filename is chosen by whoever uploaded the file and carries no information about its contents. Deriving the type from the bytes is materially better and is still an inference, which is exactly why the header disabling sniffing matters alongside it: the inference decides what to declare, and the header stops the browser overriding the declaration.

Why are some routes exempt from the security headers at all?

Two reasons. Payment provider callbacks are machine-to-machine, so a browser policy on them is meaningless. File routes are exempt because the policy includes a rule forbidding the page from being shown inside a frame, and the in-app file viewer works by framing the file. The exemption is legitimate; expressing it as skipping the whole method was the defect, because it also removed the one header those routes needed most.

How is that fixed without breaking the file viewer?

By setting the sniffing header before the exemption list is consulted, so every route receives it and the exemption then skips only the rest. The framing rule is still lifted for those routes and the viewer still works. A test pins the ordering, so a later edit that moves the header after the check fails the build rather than quietly removing it again.

Is the header set twice really necessary?

It is deliberate rather than accidental. The middleware covers every route including ones whose author never reached for the shared response helper; the helper covers a file served by a path the middleware might not match. Either layer alone is a single point of failure for a protection whose absence is invisible, and the cost of the duplication is one line.

Does this article cover what happens when a file is uploaded?

No, and the distinction is deliberate. Everything here concerns the serving side — what is declared and what headers accompany it. What is accepted at upload, and whether contents are inspected at that point, is separate code that is not described by any claim made here.

Help Center

Need a quick answer while you read?

Run inventory, procurement, assets, sales, and field work with approved AWRA guidance for setup, migration, integrations, security, pricing, and support.

Search all approved AWRA public help articles.

Open Help Center