post: The Upload Allowlist Didn't Bind the Content-Type Header_□×

The Upload Allowlist Didn't Bind the Content-Type Header

The upload endpoint checked MIME types against an allowlist before issuing a presigned R2 URL. The URL didn’t require the client to use the approved Content-Type header.

Validation ran in the application. The upload went directly from the client to R2, where that particular restriction wasn’t part of the signature.

What the signature covered

The endpoint accepted file metadata, checked it, and returned a presigned PUT URL. The client then sent the bytes to R2 without routing them through the application server.

The presigning code passed ContentType to PutObjectCommand. A comment said the browser had to replay that value on the upload. But the generated URL’s X-Amz-SignedHeaders parameter contained only:

host

That doesn’t mean the signature covered only the hostname. SigV4 also binds the request method, path, and query parameters. It meant Content-Type wasn’t among the headers bound by this signature.

Passing a value into an SDK command and requiring that value in the eventual request turned out to be separate things. The generated URL was the place to check which one had happened.

Making the header part of the request contract

The fix added content-type to the presigner’s explicit set of signable headers:

const command = new PutObjectCommand({
  Bucket: bucket,
  Key: key,
  ContentType: approvedContentType,
});

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

The signed-header list then became:

content-type;host

The client must send the signed Content-Type value. Changing it causes signature validation to fail.

The code now explicitly requested the header signing described in the original comment. Setting ContentType alone hadn’t been sufficient.

A signed header is still only a header

This fix doesn’t prove the uploaded bytes are an image. A client can label non-image bytes as image/png while sending exactly the header the signature requires.

The filename extension check doesn’t establish that either. Nor does checking a client-reported size prove the eventual upload respects the application’s size limit. Those checks validate the metadata submitted to the application; validating the uploaded object needs its own enforcement.

Binding Content-Type closes the header-substitution gap. Checking the uploaded file still needs separate validation.

Test the generated URL

The regression test uses the real SDK to generate a URL and checks the signed-header list. With static test credentials, that can run locally without uploading an object or contacting R2.

That test has a useful, limited job: catch a change that stops signing Content-Type. It doesn’t replace an upload integration test, and it doesn’t inspect file contents.

The original comment wasn’t enough to establish the behavior. Neither was the presence of ContentType in the command constructor. The useful evidence was in the request the SDK produced.

For this kind of wrapper code, that’s where I want the assertion: on the generated request, not just the options passed into the library.

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].

START
llbbl.exeprojects/posts/experiments/subscribe.dlg
© 2026v1.0.0