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:
- What does
starts-with $key, ""actually constrain — and is there any key in the bucket it would reject? - 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
$keyto 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.) - Which path makes this exploitable — the
starts-withcondition carrying an empty value that gates nothing? - Is there a second condition that bounds the key (a tenant prefix, a content type, a path component), or is the empty
starts-withthe only key gate? - What single rule would have forced every issued policy to scope
$keyto 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 hit | What 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-withis 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$keyto 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 $keycondition 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 $keycondition 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
$keyto 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
| Before | After Stave |
|---|---|
Read each upload policy and reason about S3's starts-with semantics to catch an empty prefix | One control computes the condition's match set and flags unrestricted scope deterministically |
| Try to assess risk by auditing what is already in the document store | The control reasons over reachable keys, catching the unrestricted write before any tampering |
Trust that a present starts-with clause means the key is actually constrained | A 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-storeissues 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