My MIME Allowlist Wasn't Enforcing Anything

I wrote a comment months ago that asserted a security property. Last week I read it back and didn’t believe myself.

The comment sat above the function in src/lib/r2.ts that hands out presigned upload URLs, and it said this:

Only ContentType is signed, the browser replays it on the PUT.

That reads like a guarantee. The signature covers the content type, so a client can’t lie about what it’s uploading. Confident, specific, and load-bearing, because the whole upload design leans on it.

It was wrong. Not subtly wrong. The content type was never in the signature at all.

Why the uploads work this way

My site takes file uploads and puts them in Cloudflare R2. The bytes never touch my server. A client POSTs metadata to /api/upload, gets back a presigned PUT URL, and sends the file straight to R2.

There are good reasons for that shape. It saves bandwidth and memory on a small box, and my host’s edge proxy blocks multipart POSTs anyway, so routing file bytes through the app was never on the table.

The tradeoff is that the server stops being a gatekeeper. Once you hand someone a signed URL, the signature is the only thing standing between them and the bucket. So the server does its checking up front. src/pages/api/upload.ts validates the request against a Zod schema, and the type field is an enum derived from an allowlist:

const MIME_EXTENSIONS = {
  "image/png": ["png"],
  "image/jpeg": ["jpg", "jpeg"],
  // ...
  "application/pdf": ["pdf"],
} as const;

There’s a size cap at 10 MB, an extension cross-check so evil.html can’t ride in claiming to be a PNG, and the signed URL expires after 300 seconds. That’s a reasonable set of controls. I felt fine about it.

The probe

The doubt was cheap to resolve, so I resolved it. Two presigned URLs, same command, one with the fix and one without, printing the one query parameter that matters:

const cmd = () => new PutObjectCommand({
  Bucket: "b",
  Key: "uploads/x.png",
  ContentType: "image/png",
});

const before = new URL(await getSignedUrl(client, cmd(), { expiresIn: 300 }));
const after = new URL(await getSignedUrl(client, cmd(), {
  expiresIn: 300,
  signableHeaders: new Set(["content-type"]),
}));

Output:

BEFORE SignedHeaders: host
AFTER  SignedHeaders: content-type;host

host. That’s the entire list. I passed ContentType into the command, the SDK accepted it without complaint, and it never made it into X-Amz-SignedHeaders.

SigV4 query signing only binds the headers named in X-Amz-SignedHeaders. Everything else is free. A header that isn’t listed isn’t covered by the signature, so the server won’t reject a request that changes it. My URL committed to exactly one thing, the hostname, and left the rest open.

So the allowlist was real, the validation ran, the enum rejected anything outside those types, and none of it reached storage. A URL signed for image/png would accept an HTML file, a script, whatever you wanted, for five minutes. The check happened. It just wasn’t binding.

The parameter that looks like the fix

While digging through this I hit the trap that probably explains how I got it wrong.

There’s a query parameter called response-content-type. It shows up on presigned URLs, it has “content type” in the name, and it does nothing to protect an upload. It sets the Content-Type header S3 sends back when the object is read later. It’s response metadata. It doesn’t constrain the PUT.

If you’re skimming a signed URL looking for evidence that content type is handled, that parameter is right there, looking like proof.

The fix

One option on the presigner call:

const uploadUrl = await getSignedUrl(client, command, {
  expiresIn: PRESIGN_EXPIRY_SECONDS,
  signableHeaders: new Set(["content-type"]),
});

Now the header is in the signature. If the browser PUTs a different Content-Type than the one the server signed, R2 rejects the request. The allowlist finally reaches the bucket.

The comment got rewritten too, into something that describes a mechanism instead of promising an outcome:

Content-Type is part of the signature, the browser must replay the same header value on the PUT.

What I actually changed my mind about

The one line fix isn’t the interesting part. The interesting part is that a comment made a security claim, sat in the file for months, and nothing anywhere could tell me it had gone stale. Comments don’t run.

So the fix shipped with a contract test that does. It builds a real presigned URL with the actual SDK, no mocks, and asserts on the URL:

expect(signedHeaders).toContain("host");
expect(signedHeaders).toContain("content-type");
expect(url.searchParams.get("X-Amz-Expires")).toBe("300");
expect(url.searchParams.get("response-content-type")).toBeNull();

That last line is worth reviewing. It’s a test that exists to say “don’t come back and mistake the lookalike for the real thing.” Presigning is a pure local operation, no network call to R2, so the whole thing runs in about 300ms in CI.

Four things worth stealing from this if you hand out presigned upload URLs:

  1. Print X-Amz-SignedHeaders on a URL you’re issuing right now. If it says host, your content type isn’t enforced, no matter what you passed to the command.
  2. Passing ContentType to PutObjectCommand is not enforcement. You need signableHeaders: new Set(["content-type"]) on the presigner.
  3. response-content-type is a read-time hint. It’s not an upload control.
  4. Server-side validation that never reaches storage is decoration. Mine was correct and thorough and completely bypassable.

The uncomfortable version of this: I built the allowlist, wrote the enum, added the extension cross-check, capped the size, set a short expiry, and then documented a property the library never gave me. Every individual piece was right. The thing connecting them to the bucket wasn’t there.

Go print your signed headers.

Sources

I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].

Testing security javascript cloudflare Aws