Blog

A user's file cannot execute on your origin

2026-07-29

An isometric illustration of a pool table on six legs, with corner pockets and a cluster of balls on its baize

Somebody uploads an SVG to your app. SVG is an image, so you serve it as one. SVG is also a document format that can contain script, so what you have actually done is host an attacker’s JavaScript on your origin, with your cookies, under your domain.

This is one of the oldest holes in file upload and it is still everywhere, because the safe behavior looks like a bug — the user uploaded an image and you are refusing to display it inline.

pithy add storage puts your users’ files in your own R2 and treats the bytes on the way out as untrusted, which is what they are.

Untrusted on the way out

Three things happen to every object response:

  • nosniff, so a browser cannot decide your text/plain is really HTML.
  • A locked-down Content-Security-Policy on the response itself.
  • Active types are served as an attachment, whatever was stored — HTML, SVG, anything +xml, script.

That last one is the important one, and it is deliberately not configurable per request. A file that could execute is downloaded rather than rendered. Your users can still store SVGs and still get them back; what they cannot do is run one in your origin’s context.

Keys are server-derived

A client never names an object.

You supply a logical path — the thing that means something to your application, which is stored, indexed and listable. The actual storage key is derived server-side and opaque.

The reason is enumeration. If a client picks the key, or can compute it, then knowing one object tells you about the object beside it. Opaque server-derived keys mean the only way to reach a file is through a route that checks whether you may.

Per-owner scoping and byte quotas sit on top of that, so “this user’s files” is a real boundary rather than a naming convention.

The transfer parts you would otherwise write

Multipart uploads for large files. Range for seeking and resumable downloads. ETag so a client can revalidate instead of re-downloading. Content-Disposition handled correctly. Server-side copy, so duplicating an object does not mean streaming it through your Worker and back.

And revocable share links, which is the feature everybody adds late: a link you can hand out and later un-hand-out.

None of that is interesting to build and all of it is annoying to get right.

The daily reconcile, in both directions

Storage has two systems that can drift: rows in your database describing objects, and objects in your bucket.

They drift in both directions, and most implementations only think about one:

  • A row with no object — an upload that was recorded and never completed. Your UI shows a file that 404s.
  • An object with no row — bytes you are paying to store that nothing references and nobody can reach.

A daily Cloudflare Workflow reconciles both. It is a Workflow rather than a cron handler because it is long-running work over a lot of objects that has to survive a restart and pick up where it stopped.

The second direction is the one that quietly costs money. Nobody notices orphaned bytes, because by definition nothing is looking at them.

One seam, several holders

ObjectStore is a seam any capability can hold, which is how the pieces stay separate without duplicating each other.

Media presigns through this ObjectStore — against its own bucket and its own credential name — and inherits none of storage’s tables or routes for doing so. Two capabilities, one abstraction over object storage, no shared schema.

That is why storage takes no position on what the bytes are. No transcoding, no thumbnails, no AI enrichment. Those are media’s job. Storage stores and serves; media understands.

Provisioning is a separate, deliberate step

pithy add storage writes the bindings and touches no Cloudflare account. Nothing is created, nothing is billed, nothing exists yet.

pithy storage provision creates the bucket.

Splitting those two is worth doing. Adding a capability to your config should be reversible and free. Creating infrastructure in your account should be an action you took on purpose.

What it does not do

It does not scan for malware. Serving active types as attachments stops a file executing in your origin; it says nothing about whether the file is dangerous once someone opens it on their machine.

It does not do access control beyond ownership. Whether user A may see user B’s file is your policy, expressed in your routes.

And it does not deduplicate. Two identical uploads are two objects, because deciding that two files are “the same” is a judgment with real consequences for deletion.

Objects belong to an authenticated owner, so compose auth too.