Secretus logo

The Upload Filter Bypass Hiding in “; charset=utf-8”

·8 min read

Here is a file-upload check that looks correct and is not:

if (fileType.startsWith('image/') && fileType !== 'image/svg+xml') { renderInline(file) }

The intent is obvious and sound: render images inline, but never SVG, because SVG is an XML document that can carry <script> and event handlers. The author knew the risk and wrote a guard for it.

The guard fails against image/svg+xml; charset=utf-8. That string starts with image/, so the first condition passes. It is not equal to image/svg+xml — it has nine extra characters — so the second condition passes too. The SVG renders inline. Every browser, meanwhile, ignores the parameter entirely and treats the file as exactly what the guard tried to exclude.

We found this exact shape in our own receive path during a scheduled review on 3 August 2026 and fixed it the same day. It is worth writing up because the mistake is almost invisible on the page: the dangerous type is named, the comparison is explicit, and a reviewer's eye reports “SVG is handled.”

What a Content-Type actually is

A MIME type is not a string. It is a structure, and the WHATWG MIME Sniffing Standard gives its parts names. A type has a type, a subtype, and zero or more parameters. The type and subtype joined by a slash form what the spec calls the essence.

  • image/svg+xml — essence image/svg+xml, no parameters
  • image/svg+xml; charset=utf-8 — essence image/svg+xml
  • IMAGE/SVG+XML — essence image/svg+xml (essence is lowercased)
  • image/svg+xml ;charset=utf-8 — essence image/svg+xml (whitespace is stripped)
  • image/svg+xml;charset=utf-8;foo=bar — essence image/svg+xml

All five are the same type as far as any consumer is concerned. Only one of them matches === 'image/svg+xml'. That is the whole bug: the security decision is made on the raw string, and the rendering decision is made on the essence. Two components reading one value differently — the same shape as the URL parser differentials we covered in the fast-uri host-confusion advisories.

Note that this cuts both ways. An allow-list written as ['image/png','image/jpeg'].includes(fileType) has the mirror-image problem: it rejects a legitimate image/png; charset=binary. That one is merely a bug. The deny-list version is a vulnerability.

Why SVG specifically

SVG is the case that turns a validation slip into stored cross-site scripting. Unlike PNG or JPEG, an SVG is an XML document, and the SVG specification permits <script> elements, on* event-handler attributes, <foreignObject> containing arbitrary HTML, and external references.

The critical detail is how it is displayed. An SVG loaded through an <img> tag is rendered in a restricted mode: scripts do not run, external resources do not load. The same file inlined into the DOM, opened as a top-level document, or embedded via <object> or <iframe> executes in the origin that served it. If that origin is your application, the attacker's script now runs with your users' session, your local storage and your same-origin fetch privileges.

For an application whose entire security model rests on keeping decryption keys inside the browser, script execution in the page origin is the top of the severity scale. That is why we treat this class as a priority even where exploitation requires other conditions to line up.

The fix, and the test that keeps it fixed

Extract the essence before you compare. Split on the first semicolon, trim, lowercase:

const essence = fileType.split(';')[0].trim().toLowerCase();
if (essence.startsWith('image/') && essence !== 'image/svg+xml') { renderInline(file) }

Three operations, in that order, and each earns its place. split(';') drops parameters. trim() handles the legal whitespace before the delimiter. toLowerCase() defeats IMAGE/SVG+XML and every mixed-case variant. Miss any one and the guard is bypassable again.

Then write the test that pins the property rather than the code. A test that asserts the essence is compared is worth more than one asserting the SVG case is rejected, because it fails when someone reintroduces a raw-string comparison anywhere in the file. Ours checks both call sites in both receive paths, for the specific reason that the original bug existed in one place and had been faithfully copied to a second.

The general rule

Structured values must be parsed before they are compared. It applies well beyond MIME types:

  • Content-Type — compare the essence, never the header value.
  • URLs — compare url.hostname from a parsed URL, never a string prefix. startsWith('https://trusted.com') matches https://trusted.com.evil.example.
  • Email addresses — normalize before you key anything on them, or User+tag@Example.com and user@example.com become two identities.
  • File paths — resolve to an absolute path and check containment, rather than scanning for .. in the raw input.
  • Accept and Authorization headers — parse the grammar; do not substring-match it.

And the defence that holds when the parsing rule is missed anyway: do not put user content in your own origin. Serve uploads from a separate domain, send Content-Disposition: attachment and X-Content-Type-Options: nosniff, and keep a Content-Security-Policy strict enough that an injected script has nothing to execute against. Our delivery routes carry a CSP with no third-party script sources, plus no-store, no-referrer and noindex. Layered controls are what make a single validation slip a bug rather than an incident.

Go and check yours

This takes about five minutes. Search your codebase for comparisons against a content-type value — === 'image/, !== 'image/, includes('image/, indexOf('image/ — and for each hit, ask whether the value being compared has had its parameters stripped, its whitespace trimmed and its case normalized. If any of the three is missing, you have the bug.

Then check where that content type came from. If it arrives from the client, it is a claim rather than a fact, and the essence rule is a floor rather than a ceiling: validate the bytes, not the label.

Sources

Share a secret the safe way

Start a 14-day trial to send; recipients open one-time links without an account.

Try Secretus