Skip to main content

The upload policy with no destination it would refuse

Metadata

  • Title: The upload policy with no destination it would refuse
  • Source of the case: Real HackerOne report #764243 (BCM)
  • AWS service(s): S3 (presigned POST upload policy)
  • Risk archetype: Latent unrestricted write — a valid signed-upload policy whose key condition admits every key in the bucket
  • One-line hook: Can you prove this upload policy restricts where a holder may write at all?

0. The challenge (what the reader does first)

Scenario given to the reader:

A file-upload API hands clients a presigned POST policy for the bcm-document-store bucket. The policy is well-formed and unexpired. It carries a starts-with condition on $key — but the prefix value is the empty string. You have the policy below and nothing else.

Evidence they're handed (and nothing else):

{
"bucket": "bcm-document-store",
"upload_policy": {
"conditions": [["starts-with", "$key", ""]],
"expiration": "2024-12-31T00:00:00Z"
}
}
  • The JSON export above. No AWS credentials. No live account. No scripts.

The questions they must answer from the evidence alone:

  1. What does starts-with $key, "" actually constrain — and is there any key in the bucket it would reject?
  2. No malicious object has been written yet, but an empty prefix matches every possible key — what is the latent blast radius the moment a holder sets $key to a path the application itself reads, serves, or trusts? (The exposure is structural: every path in the bucket is already a legal target, with no further misconfiguration.)
  3. Which path makes this exploitable — the starts-with condition carrying an empty value that gates nothing?
  4. Is there a second condition that bounds the key (a tenant prefix, a content type, a path component), or is the empty starts-with the only key gate?
  5. What single rule would have forced every issued policy to scope $key to a real per-tenant prefix instead of the empty string?

1. The manual problem

To answer by hand you have to read the condition as S3 evaluates it: starts-with $key, "" matches every string, because every string starts with the empty string. The condition is present, it is well-formed, and it does nothing. That is the trap — the policy looks constrained because there is a starts-with clause sitting right where a real prefix would go, and a reviewer skims past it satisfied that "the key is scoped." You have to know S3's matching semantics to see that an empty prefix is the absence of a constraint wearing a constraint's clothes. And nothing in the bucket is wrong yet — no injected config, no overwritten document. The danger is entirely in what the policy permits, so inspecting current contents proves nothing. By hand there is no clean way to prove "a holder of this policy can already write to any path in the bucket, including the ones the application trusts."


2. The reasoning wall

What they hitWhat they said / would say
A starts-with clause is present, so the key looks scoped"There was a condition on $key. I saw the word starts-with and ticked the box. I never read the value as empty."
An empty prefix is a no-op that reads as a constraint"It's the most dangerous kind of restriction — the kind that's technically there and enforces nothing."
Nothing is broken yet, so nothing alarms"There was no bad file to point at. The policy just quietly accepted any destination, and that's not something a 'what's wrong now' review surfaces."

The insight the reader should reach on their own:

An empty starts-with is not a scope, it is the absence of one in disguise — every key in the bucket is already a legal write target, and proving it means evaluating what the condition matches, not what the bucket currently holds.


3. Why scanners miss or flatten it

A per-setting scanner checks that the policy is syntactically valid: the conditions array is well-formed, the expiration is in the future, and $key carries a starts-with condition — present and correctly typed. Every field check passes. What the scanner cannot do is evaluate the semantics of the prefix value: that "" matches all keys and therefore grants unrestricted write scope, violating any isolation or path-confinement invariant. To a field-level check, "has a starts-with condition on $key" is true, so the box is ticked; it does not compute that the condition's match set is the entire bucket. The specific thing it cannot see is that this valid, present condition restricts nothing — a tenant-shared prefix (uploads/) at least looks suspicious, but an empty prefix is invisible to a checklist that only asks "is a key condition present?" rather than "what does it actually permit?"


Pivot point. Everything above is the gap. Everything below is Stave filling it. The reader has now done the work and hit the wall. Only now does the tool appear.


4. The evidence Stave consumes

The same static export the reader had — no new privileges, no live cloud:

{
"bucket": "bcm-document-store",
"upload_policy": {
"conditions": [["starts-with", "$key", ""]],
"expiration": "2024-12-31T00:00:00Z"
}
}

Stave normalizes this into an observation snapshot: an upload-policy asset whose key-scope condition is an evaluable fact, so the predicate can compute the match set of the starts-with value and compare it against the per-tenant boundary the application declares.


5. The reasoning Stave performs

  • Control / invariant: CTL.S3.TENANT.ISOLATION.001 — a presigned upload policy must scope $key to a single tenant's non-empty prefix; an empty prefix (which matches every key) is a violation.
  • What it evaluates: The control reads the starts-with $key condition and computes its match set. An empty prefix matches all keys, so the policy admits writes to every path in the bucket — including paths the application itself reads, serves, or trusts. It evaluates reachable keys, not stored keys, so it fires even though no malicious object exists yet.
  • Verdict produced: The control fires NON_COMPLIANT on the unrestricted scope. If the key condition is missing or its value cannot be resolved to a bounded prefix, it reports the finding rather than assuming the scope is safe.
Issue: bcm-document-store — upload policy key scope is unrestricted

CTL.S3.TENANT.ISOLATION.001 NON_COMPLIANT
asset: s3://bcm-document-store (presigned upload policy)
evidence: condition starts-with $key "" matches every key in the bucket;
no bounding prefix (expected a per-tenant prefix)
verdict: any policy holder can write to any path, including application
config and served content — latent unrestricted write

security_state: NON_COMPLIANT

6. The prevention artifact Stave produces

  • Artifact: A corrected presigned POST policy whose starts-with $key condition is scoped to the requesting tenant's own non-empty prefix, issued per-tenant at signing time.
  • What it forecloses: The latent state from question 2 — a holder setting $key to an application-trusted path (a config file, served HTML, another tenant's document). Once the condition is bound to a real per-tenant prefix, the signed policy cannot authorize a write outside that prefix, so overwriting config or injecting served content becomes unreachable rather than merely unobserved.
# Corrected presigned POST policy — bounded, per-tenant key scope
{
"expiration": "2024-12-31T00:00:00Z",
"conditions": [
{"bucket": "bcm-document-store"},
["starts-with", "$key", "uploads/${tenant_id}/"],
["content-length-range", 0, 10485760]
]
}
# The empty prefix is replaced with a real per-tenant prefix substituted from
# the authenticated session at signing time. A request with $key = "config/app.json"
# or "merchants/other/doc.pdf" no longer satisfies the condition and S3 rejects
# the upload — the policy now has destinations it will refuse.

7. What the team no longer does manually

BeforeAfter Stave
Read each upload policy and reason about S3's starts-with semantics to catch an empty prefixOne control computes the condition's match set and flags unrestricted scope deterministically
Try to assess risk by auditing what is already in the document storeThe control reasons over reachable keys, catching the unrestricted write before any tampering
Trust that a present starts-with clause means the key is actually constrainedA corrected per-tenant policy template makes a bounded, non-empty prefix the only issuable shape

Positioning line for this case

Stave proves that bcm-document-store issues an upload policy whose empty prefix matches every key, proves any holder can already overwrite application-trusted paths even though none has, and emits the bounded per-tenant policy that makes unrestricted writes unreachable.


Reuse checklist

  • A reader could attempt section 0 with zero Stave knowledge
  • Stave is not named or shown before the pivot point
  • Section 2 quotes are real (or honestly plausible), not slogans
  • Section 3 names the specific thing per-setting tools can't see
  • Section 6 closes the exact latent state raised in section 0, question 2
  • The title names the failure, not the product