WSWhat Scene?

Research · 6 min read

An overwrite that was a create

A rule in our own code said clients could never modify an uploaded file. It never ran. Cloud Storage evaluates an upload over an existing object as a create, not an update, so the person being audited could quietly replace their own submitted attachment. Found by a test's first run.

The rule that looked right

Clients using our dashboard can attach files to a request. Those attachments are evidence: they are what a piece of work was scoped against, and what a disagreement later gets settled by. So the rule was that a client may upload an attachment and may never change it afterwards.

That was written more or less exactly as you would expect, and it reads correctly:

match /users/{uid}/requests/{requestId}/{filename} {
  allow read:   if isOwner(uid) || isStaff();
  allow create: if isOwner(uid) && ...;

  // Clients never mutate or delete uploads, preserve the audit trail.
  allow update, delete: if false;
}

Read that as a person and it is unambiguous. Creating is allowed under conditions, updating is denied outright, deleting is denied outright. The comment says what it is for. It passed review, and it was wrong.

What actually happens on an overwrite

Cloud Storage does not have an update operation for object contents in the sense the rule assumes. Uploading to a path that already holds an object does not modify that object. It writes a new one at the same path, and the platform evaluates that write as a create.

So the request was matched against the create rule. The create rule checked that the caller owned the path, that the file was under 10 MB, that its content type was on the allowlist and that the filename matched a safe pattern. A client re-uploading over their own attachment satisfies all four, because it is their path and their file.

The line denying updates was never reached. Not bypassed, not overridden: never evaluated, on any request, ever. It had the shape of a control and the effect of a comment.

The failure is not that the rule was permissive. It is that the rule was answering a question the platform never asked it.

What that was worth to an attacker

Less than it sounds in one direction and more in another, and both halves are worth stating plainly.

It was not a data breach. The path is bound to the caller's own uid and request id, so nobody could reach another client's files. Reading was still correctly restricted. Nobody could plant a file anywhere they did not already own.

What it did give was silent revision of the record. A client could replace an attachment they had already submitted, at the same path, with the same filename, after we had read it. The person being audited could rewrite their own submission, and nothing in the interface, the database or the logs would show that the file had changed.

For a studio that keeps client work under agreed scope, that is the specific thing the attachment exists to prevent. An audit trail that can be edited by the party it is about is not an audit trail. It is a claim.

How it was found, which is the uncomfortable part

It was found on 8 August 2026 by the first test run that had ever executed these rules.

Our rules test command had been running with a flag that started only the Firestore emulator. The Storage emulator was never started, so the storage rules were never loaded and never exercised. The suite was green the entire time. It reported success on a set of rules it had not read.

Switching the storage emulator on and writing 23 assertions against it took the suite from 77 checks to 100. One of those 23 failed immediately. There was no clever hypothesis and no incident: the hole had been there as long as the feature had, and the only reason it surfaced is that somebody finally pointed a test at it.

A green pipeline is a statement about what ran. It is not a statement about what exists. Ours had been reporting a pass on rules it was not loading, which is indistinguishable from a pass on rules that work.

We are stating that it was never exploited, and we can state it because the path is per-client and the objects are ours. We are not stating that we would have noticed if it had been, because we would not have.

The fix

One condition, on the rule that actually runs:

allow create: if isOwner(uid)
  && resource == null          // <- the whole fix
  && request.resource.size < 10 * 1024 * 1024
  && request.resource.contentType.matches(...)
  && filename.matches('^[A-Za-z0-9._ -]{1,200}$');

In a storage rule, resource is the object already at that path and request.resource is the one being written. So resource == null means nothing is there yet. It is a direct statement of the thing we actually wanted, which is that this write may only happen into empty space.

The reason this is the right fix rather than a patch is that it does not depend on how the platform classifies the write. Whether an overwrite counts as a create or an update, whether that classification changes in a future release, the condition holds either way. The original rule depended on a classification we had assumed and never checked.

The same guard went onto the deliverables path, where staff upload files to clients, for the same reason in the opposite direction: a deliverable a client has already been told about must not be swappable underneath them. Replacing one means uploading under a new filename, which is what the application already did anyway.

The deny lines stayed. They are still correct, they are still the intent, and someone reading the file should see them. They are simply no longer the thing doing the work.

The regression test

Two assertions pin it, and they are written as denials because that is the only useful shape here:

  • storage: client CANNOT overwrite an existing attachment
  • storage: staff CANNOT overwrite an existing deliverable

They run in CI on every push. As of the most recent census our rules suite holds 106 assertions, of which 78 assert that something is refused and 23 cover Cloud Storage specifically. The negative share is the number that matters: a rule which fails open passes every test that only checks the happy path, so a rules suite that is mostly positive is decorative.

The rule itself also carries a comment saying what the guard is for and asking the next person not to remove it, because the guard looks redundant next to a deny line and it is not.

What to take from it

  1. A rule that never runs is indistinguishable from a rule that passes. Neither produces an error, a log line, or a failing test. The only way to tell them apart is to assert the behaviour you want and watch the assertion fail before you fix anything.
  2. Check how your platform classifies the operation rather than how the word sounds. Overwrite reads like update in English and is a create here. That gap is where the bug lived.
  3. Prefer conditions on state over conditions on operation type. resource == null is a fact about the world. allow update is a fact about a taxonomy, and taxonomies are the platform's to change.
  4. Test the layer, not just the feature. The feature worked perfectly throughout. The layer underneath it had never been loaded.
  5. Check what your test command actually starts. Ours was passing a flag that quietly excluded an entire ruleset, and nothing about a green run said so.

We are publishing this because the reasoning is more useful than the embarrassment is costly, and because the class of bug generalises well past Firebase. Any system where a write can be classified more than one way has this shape available to it.

What this does not cover

  • This is one platform's behaviour at one point in time. The general lesson, that a rule guarding an operation type is only as good as your assumption about which type applies, transfers. The specific rule syntax does not.
  • We state that it was not exploited because the affected paths are per-client and hold our own clients' files. We do not claim we would have detected exploitation, because the whole nature of the defect is that a replacement left no trace.
  • The fix is verified by two assertions against the Firebase emulator. An emulator agreeing with a rule is not the same as production agreeing with it, though for rules evaluation it is the closest check available.
  • We have not audited every other place in this codebase where an operation-type assumption might be doing load-bearing work. The storage paths were fixed and tested; the general sweep is honest future work rather than something already done.

Sources

  • storage.rules and scripts/test-rules.mjs in the whatscene.in repository. The rule, the two named regression assertions, and the commit that introduced both. Assertion counts from a census of the suite. Run: commit b1173d5, 2026-08-08, and scripts/authz-evidence.mjs, 2026-08-24.
  • Cloud Storage Security Rules, Firebase. Retrieved 24 August 2026.

Revisions

  • 24 August 2026 First published. The defect itself was fixed on 8 August 2026.

This page is revised in place rather than replaced, so its address does not change.

Next step

Want this built, not just explained?