Aws
-
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.tsthat 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.tsvalidates 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.htmlcan’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;hosthost. That’s the entire list. I passedContentTypeinto the command, the SDK accepted it without complaint, and it never made it intoX-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/pngwould 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 theContent-Typeheader 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-Typethan 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:
- Print
X-Amz-SignedHeaderson a URL you’re issuing right now. If it sayshost, your content type isn’t enforced, no matter what you passed to the command. - Passing
ContentTypetoPutObjectCommandis not enforcement. You needsignableHeaders: new Set(["content-type"])on the presigner. response-content-typeis a read-time hint. It’s not an upload control.- 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
- AWS s3-request-presigner README — documents
signableHeadersfor enforcing non-x-amz-*headers on presigned requests - Authenticating Requests: Using Query Parameters (SigV4) — how
X-Amz-SignedHeadersdefines what the signature covers - Cloudflare R2 presigned URLs — R2 generates presigned URLs server-side with no network call, and can restrict Content-Type
- Cloudflare R2 S3 API compatibility — which S3 behaviors R2 actually implements
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].
- Print
-
Bun Was Faster. I Still Kept the AWS SDK.
I ran a benchmark that should have ended the argument.
One of my sites runs on Bun 1.4 and stores files in Cloudflare R2, which exposes an S3-compatible API. The application still used the AWS SDK for object reads, writes, metadata checks, deletes, and presigned upload URLs.
Bun has a native
S3Client. Replacing the AWS packages looked like exactly the kind of cleanup I wanted: fewer dependencies, less memory, and less code between the application and the runtime.Then I measured it.
For 10,000 presigned URLs, Bun’s native client finished in about 46 milliseconds. The AWS SDK took about 1,464 milliseconds.
After loading the client, the Bun process used roughly 12.3 MB of RSS compared with 40.9 MB for the AWS path. After the full run, it was about 19.5 MB versus 97 MB.
Removing the AWS client and presigner would also remove 25 AWS and Smithy packages from the lockfile.
Faster. Smaller. Fewer dependencies. Easy decision, right?
I kept the AWS SDK.
The Benchmark Was Real, but Incomplete
These numbers came from a single local run on macOS using Bun 1.4. They aren’t a statistically rigorous performance study, and presigning is offline CPU work. No object storage request happens while the URL is generated.
Still, the difference was large enough to matter. Bun’s native implementation was doing far less work.
The problem was not performance. The problem was behavioral parity.
An S3 client in this application does more than produce a valid signature. It has to preserve the exact metadata and security rules that the rest of the upload pipeline expects.
Two details stopped the migration.
Cache-Control Disappeared on Writes
The application writes two kinds of public objects:
- Generated image variants with a one-year cache lifetime
- Versioned static assets with a one-year, immutable cache policy
Those are stored as object metadata through the
Cache-Controlheader. Cloudflare R2 supports that metadata through its S3-compatible API.Bun 1.4’s S3 client exposes a fixed set of metadata options: content type, content encoding, and content disposition. It doesn’t take arbitrary request headers, and it doesn’t take
Cache-Control.I wanted to verify the behavior rather than infer it from the type definitions, so I pointed the client at a local HTTP capture server and inspected the outgoing PUT request.
This correctly emitted
Content-Type:await client.write("image.png", bytes, { type: "image/png", });Adding a
cacheControlproperty did nothing. Passing aResponsewith aCache-Controlheader did nothing. In both cases, the outgoing request omitted the header.The upload would work, but the stored object wouldn’t have the cache policy the application depends on. That’s not a drop-in replacement.
The Presigned URL Signed the Wrong Thing
The second blocker was more subtle.
The browser uploads files directly to object storage using a short-lived presigned PUT URL. If the server approves an
image/pngupload, the signature should require the browser to sendContent-Type: image/png.Bun’s presigner accepts a
typeoption:client.presign("uploads/image.png", { method: "PUT", expiresIn: 300, type: "image/png", });That looks right until you inspect the URL.
The generated query contained
response-content-type=image/png, whileX-Amz-SignedHeaderscontained onlyhost. The type affected response metadata. It didn’t require the upload request to contain a matchingContent-Typeheader.Bun 1.4 doesn’t expose an equivalent to the AWS presigner’s custom
signableHeadersoption. So I could generate a working PUT URL, but I couldn’t express the security contract I needed.Again, it worked. It just didn’t do the same job.
The Evaluation Found a Bug in the Existing Code
This part was worth the whole exercise.
The existing AWS code created a PUT command with
ContentType: "image/png", and the surrounding comments said the header was signed. The tests checked thatContentTypewas present on the command.They never inspected the final URL.
The AWS JavaScript presigner does not sign
Content-Typeby default. Its own documentation says to opt in withsignableHeaders:const uploadUrl = await getSignedUrl(client, command, { expiresIn: 300, signableHeaders: new Set(["content-type"]), });Once I generated a real URL with dummy credentials, the gap was obvious. Before the fix,
X-Amz-SignedHeaderscontained onlyhost. After the fix, it contained bothhostandcontent-type.I replaced the mocked-only assertion with an offline contract test that generates the actual URL and checks the signed headers and five-minute expiration.
So the failed migration still improved the application. It corrected a security assumption that the old test suite had been politely agreeing with.
What Would Make Me Reconsider?
This is a no for Bun 1.4, not a no forever.
I will revisit the native client when all of these are true:
- S3 writes can send arbitrary object metadata, including
Cache-Control. - Presigned PUT URLs can require request headers such as
Content-Type. - I can run the full flow against an isolated R2 test prefix: PUT, HEAD, read, list, delete, CORS, content type, and cache metadata.
The performance upside is sitting there waiting. A 32x difference in this micro-benchmark and dozens fewer dependency packages are both compelling.
But performance comes after correctness. A faster client that silently changes cache behavior or weakens an upload constraint isn’t an optimization. It’s a regression with good benchmark numbers.
I wanted the native implementation to win. This time, the boring dependency stayed.
Sources
- Bun S3 runtime documentation
- AWS SDK for JavaScript v3 S3 presigner documentation
- Cloudflare R2 presigned URL documentation
- Cloudflare R2 S3 API compatibility
- Cloudflare R2 metadata header mapping
- Bun issue #16048: allow for custom S3 headers/query params — open since December 2024
- Bun issue #18016: missing ResponseCacheControl options in s3.presign() — open since March 2025
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].
-
When You Should Skip Terraform Entirely
The last post in this series made the case that OpenTofu is the no-regrets default for new infrastructure projects. That’s true for the broad case of cloud-agnostic or multi-cloud setups where HCL parity, provider breadth, and a Linux Foundation governance model matter.
It’s also not the whole story. There are at least three common scenarios where the right answer in 2026 isn’t Terraform or OpenTofu. It’s the cloud-native tool the hyperscaler ships with its platform. AWS has CloudFormation and the CDK. Azure has Bicep. GCP has Config Connector. Each one is technically superior to Terraform inside its own ecosystem, and each one removes a category of operational pain that Terraform inflicts.
If you reflexively reach for Terraform every time, you’re probably overpaying in complexity for a multi-cloud option you’ll never exercise.
The Small AWS-Native Startup: Use CDK
If your engineering team is small, you’re shipping a SaaS product, and you’re 100% on AWS, you should probably ignore Terraform entirely. The right tool is the AWS Cloud Development Kit, layered on top of CloudFormation.
The fundamental win is that CloudFormation eliminates state management. There is no
terraform.tfstatefile. No S3 bucket to provision. No DynamoDB lock table. No state-encryption configuration to figure out. The state lives in the AWS control plane, AWS manages locking and consistency, and your CI pipeline doesn’t need to know about any of that. For a small team, that’s a meaningful operational tax you don’t pay.The CDK is the part that makes this pleasant. It lets you define infrastructure in TypeScript, Python, Java, C#, or Go; so the languages your application engineers already know. There’s no HCL learning curve, no Sentinel policy DSL, no jq-in-bash to manipulate plan output. You write code, the CDK synthesizes CloudFormation templates, CloudFormation provisions the infrastructure.
The objection people raise is “what if you go multi-cloud later?” In practice, most SaaS startups don’t. They get acquired, they pivot, or they grow large enough to have a dedicated platform team that does the migration deliberately. Optimizing for a hypothetical multi-cloud future that 90% of teams will never need is the textbook definition of premature abstraction. If you’re an AWS-native startup with fewer than 50 engineers and no concrete plans to leave AWS, the cost of running Terraform-as-multi-cloud-insurance is higher than the cost of a future migration that probably won’t happen.
The Azure Enterprise: Bicep, Unless You Need More
For organizations heavily invested in Microsoft’s stack, so Azure for compute, Azure DevOps for CI/CD and EntraID for identity; Bicep is the technically correct choice for most workloads.
Bicep is Azure’s domain-specific language for infrastructure, designed as a replacement for the verbose ARM JSON templates everyone hated. Like CloudFormation, it’s stateless. You submit a desired-state Bicep file to the ARM control plane and ARM reconciles. No state file, no remote backend, no risk of corruption. Authentication is whatever RBAC permissions the deploying identity already has, with no provider credential configuration required.
Bicep also gets day-zero feature support for new Azure capabilities. When Microsoft ships a new service, you can use it in Bicep the same day. The Terraform AzureRM provider has historically lagged by weeks or months, occasionally longer.
The catch is scope. Bicep manages Azure. That’s the entire surface area. Larger organizations tend to need management of things outside Azure too: GitHub repositories and branch protection, EntraID groups, Datadog monitors, PagerDuty escalation policies, whatever SaaS services your platform touches. Bicep has no answer for any of that.
That leaves two paths. The first is a hybrid: Bicep for Azure, separate tools for everything else, accept the cost of context-switching and the inability to express cross-domain dependencies in a single deployment. The second is Terraform or OpenTofu for everything, accepting the heavier operational tax of stateful IaC, in exchange for one tool that can do all of it. Neither is wrong; they’re different tradeoffs against the same constraint.
The decision rule: if you’re managing only Azure resources, use Bicep. If you have cross-domain provisioning needs and you’d rather not maintain two parallel IaC stacks, Terraform (or OpenTofu) earns its keep.
The GCP/Kubernetes Shop: Hybrid by Design
For organizations heavily committed to Google Cloud and running most workloads on GKE, the right architecture isn’t either/or. It’s a hybrid that uses Terraform for the foundation and Config Connector for the application layer.
Config Connector is a GCP-shipped Kubernetes add-on. It lets you manage GCP resources — Cloud SQL instances, Pub/Sub topics, storage buckets, service accounts — as standard Kubernetes Custom Resources. You write a YAML manifest, you
kubectl apply, and a controller in the cluster reconciles the real-world GCP resource to match.The differentiator is continuous reconciliation. Terraform is episodic: it checks state at
planandapplytime, and the rest of the time your infrastructure is unmonitored. If someone clicks around in the GCP console and manually changes a setting, Terraform won’t notice until the next pipeline run. Config Connector runs a controller loop that polls continuously. Manual drift gets reverted in real time.The right architectural boundary:
- Platform layer (Terraform/OpenTofu): VPCs, subnets, foundational IAM, the GKE clusters themselves. These are slow-moving, security-critical, and you want a deliberate pipeline approval flow for them.
- Application layer (Config Connector): Application-specific buckets, databases, service accounts, Pub/Sub topics. Application teams own these via the same YAML manifests they use for their pods, with the same GitOps workflow they already understand.
This pattern gives platform teams strict guardrails on the foundation while letting application developers self-serve the resources their services need, without filing a Terraform PR every time they want a new bucket.
The Decision Rule
The honest version of all of this: Terraform/OpenTofu is the right answer when you need cross-domain or cross-cloud governance. For everything else, the cloud-native tool is usually less work, more current with the platform, and avoids the operational tax of state management.
A reasonable decision tree:
- Single-cloud, small team, AWS: AWS CDK + CloudFormation.
- Single-cloud, single-domain, Azure: Bicep.
- GCP with heavy Kubernetes use: Hybrid — Terraform/OpenTofu for foundation, Config Connector for application resources.
- Multi-cloud, or cross-domain platform engineering (GitHub + cloud + identity + monitoring): OpenTofu.
The mistake I think most teams are making is to default to Terraform because it’s the tool the senior engineer learned in their last job. The platform-engineering pitch … “we’ll standardize on Terraform so we can move to any cloud later” is correct in theory but almost never exercised in practice. If your team isn’t using the cross-cloud capability today, you’re paying for an insurance policy you’ll never collect on.
Next post in this series digs into the other side of that calculation: what HCP Terraform actually costs in 2026, and why even teams that need cloud-agnostic IaC are looking for the exit from the commercial orchestration platforms.
Sources
- Bicep Vs Terraform: Choosing The Best IaC Tool For Azure — Synextra
- Terraform vs Bicep vs ARM Templates 2026 Compared — Exodata
- Comparing Terraform and Bicep — Microsoft Learn
- Terraform vs Bicep vs ARM: Lessons from the Trenches — Vaibhav Gujral
- How to Use the GCP Config Connector with Terraform — OneUptime
- How Config Connector compares for infrastructure management — Google Cloud Blog
- Are Terraform’s days numbered? — Alistair Grew
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].