cloudflare
-
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].
-
How I Would Build Observability for an Autonomous Agent
I have not built a full production observability stack for an autonomous agent.
I’ve built lots of small wrappers around existing coding harnesses. I have a pretty good idea how quickly their output can turn into a wall of model responses, tool calls, and subprocess logs. But I have not run LangChain across a Kubernetes cluster or operated an LLM router at scale.
So this is not a postmortem. It’s a design exercise.
If one of my wrappers became an always-on agent service tomorrow, what would I need to see when it failed? Where would I put that telemetry on Azure, Railway, or Cloudflare? And which parts of the design should stay the same no matter where it runs?
Start With the Trace, Not the Platform
An agent run is a distributed trace hiding inside a loop.
There is a request that starts the work. The agent calls a model, the model requests a tool, the tool talks to another service, and the result goes back into the model. Repeat that enough times and a normal application log becomes hard to follow because the interesting question is not just, “What failed?” It is, “What sequence of decisions got us here?”
I would use OpenTelemetry and give every run one root trace. Each model call and tool call becomes a child span. Structured logs carry the same trace and span IDs, so I can move from a failed run to the exact log event without searching timestamps and hoping I found the right one.
The minimum useful event would look something like this:
{ "event": "agent.tool.completed", "agent_run_id": "run_01K0...", "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", "span_id": "00f067aa0ba902b7", "step": 7, "tool": "github.get_issue", "duration_ms": 842, "status": "error", "error_type": "rate_limit" }That gives me four things I can debug:
- The run: which request or scheduled job started the work?
- The model: which provider and model answered, how long did it take, and how many tokens did it use?
- The tool: what operation ran, how long did it take, and did it succeed?
- The sequence: what happened immediately before the failure?
I would keep an
agent_run_idbecause it is useful in a UI and support ticket, but I would not use it as a replacement for a trace ID. The trace context needs to travel across HTTP calls, queues, model gateways, and anything else the agent touches.Do Not Log Everything
The tempting version of agent observability records every prompt, response, tool argument, environment variable, and file body. It is also a convenient way to copy credentials and private data into the system with the broadest internal access.
My default would be structural telemetry first. Model name, token counts, latency, tool name, result status, retry count, and safe error categories are useful without storing the full content.
Prompt and tool payload capture would be an explicit policy, not a debug switch someone leaves enabled for six months. I would use an allowlist, redact before export, encrypt whatever remains, and give raw payloads a shorter retention period than ordinary metrics.
This also answers where an AI gateway fits. The gateway is the natural place to record model latency, provider errors, and token usage. It cannot see the agent’s local decisions or tool calls, so it is one part of the trace, not the entire observability system.
The Same Design in Three Places
The trace model stays the same. The deployment choice changes how much infrastructure I have to own.
Azure: The Integrated Option
If the agent already lived in Azure, I would use the Azure Monitor OpenTelemetry distribution and send telemetry to Application Insights, backed by a Log Analytics workspace. That gives the system a managed place for traces, logs, metrics, exceptions, queries, and retention policies.
Microsoft Foundry can also trace model calls, tool calls, intermediate steps, tokens, latency, and errors into Application Insights. I would treat that as an accelerator, not an excuse to skip the application-level design. Some of its agent tracing paths are still in preview, and content tracing can include sensitive prompts and outputs.
Azure is the option I would choose for an organization already paying the Azure complexity tax. It has the most integrated telemetry path of these three and the governance controls a larger company is likely to ask for. For a personal agent prototype, it is probably more platform than I need.
Railway: The Straightforward Container
Railway is where I would start for a small agent that needs a normal process, a Docker image, background work, and the freedom to use ordinary libraries.
I would instrument the application with OpenTelemetry and export traces over OTLP to an external observability backend. Railway’s built-in logs and container metrics are useful for deployment health, CPU, memory, disk, and network usage. They are not a distributed tracing backend for the agent itself.
That split is fine. Railway runs the service, while the application owns its telemetry schema and exports it somewhere designed to query traces. I would also make sure the exporter flushes on
SIGTERM, because a clean deployment is not helpful if the final spans disappear during shutdown.This is the least ceremonial option. It is also the one where I would have to make a separate decision about the telemetry backend and its retention cost.
Cloudflare: The API-Oriented Agent
Cloudflare gets interesting when the agent mostly calls models and HTTP APIs instead of running shell commands against a workspace.
Workers can collect logs and traces automatically, and Cloudflare can export both in OpenTelemetry format to an external destination. I would still add application spans for the model and tool semantics because automatic request tracing cannot know which prompt, tool, or agent step matters to me. The OTLP export is also currently beta and does not export Worker metrics.
For state, I would use a Durable Object when one run needs a single coordination point, D1 for relational records across runs, and R2 for larger artifacts or archived transcripts. If the agent needs retries, sleeps, or a human callback, Cloudflare Workflows is a better fit than trying to keep one HTTP request alive.
I would not put a coding agent that needs arbitrary processes, a writable repository, and long CPU-heavy tool calls into a Worker just because the edge sounds nice. Cloudflare’s runtime has real CPU, memory, connection, and logging limits. For that workload, I would run the agent in a container and consider Cloudflare for the gateway or API edge instead.
What I Would Pick
For my first version, probably Railway. It matches the small wrappers I already build, lets the agent behave like a normal application, and leaves me free to try different tracing backends without moving the workload.
I would pick Azure when the surrounding organization already uses Azure Monitor and needs one managed governance story. I would pick Cloudflare when the agent is an API orchestrator and its tools already live on the network.
A mistake would be choosing a platform or dashboard first and calling that observability solved. I would start small with a trace per run or tool call with structured logging turned on, but no prompt content until there is a redaction or sanitization policy feature in place.
I haven’t built this stack yet. But that is how I would start building it.
Sources & References
- OpenTelemetry Logs specification — trace and span correlation in structured logs.
- Azure Monitor OpenTelemetry — supported telemetry signals and Application Insights setup.
- Microsoft Foundry tracing — agent trace contents, storage, and data-handling considerations.
- Railway third-party observability — application-side OpenTelemetry export and shutdown guidance.
- Railway metrics — native container and service metrics.
- Cloudflare Workers traces — automatic tracing and native retention.
- Cloudflare OpenTelemetry export — OTLP trace and log export capabilities and current limitations.
- Cloudflare Durable Objects — per-instance coordination and transactional storage.
- Cloudflare Workflows — durable multi-step execution, retries, and external events.
- Cloudflare D1 — relational storage for records shared across runs.
- Cloudflare R2 — object storage for larger artifacts and archived transcripts.
- Cloudflare Workers limits — CPU, memory, connection, and log limits.
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].
-
Sakeeb Rahman (@sakeeb.rahman) on Threads
Last week I wrote about Clawdbot’s security risks—running an autonomous agent with shell access on your primary machine. This week Cloudflare shipped Moltworker, a way to run Moltbot entirely on…