The bucket named private whose objects were public
Metadata
- Title: The bucket named private whose objects were public
- Source of the case: HackerOne report #202725 (Mapbox)
- AWS service(s): S3
- Risk archetype: object-level ACL override (bucket private, objects public)
- One-line hook: Can you prove a "private" bucket isn't leaking through its individual objects?
0. The challenge (what the reader does first)
Scenario given to the reader:
A bucket named mapbox-private has no public bucket policy and no public ACL at the
bucket level — the bucket ACL grants only Owner: FULL_CONTROL. But individual
objects inside have been uploaded with public-read ACLs, and there is no Public
Access Block to stop that. A sampled object's ACL grants AllUsers: READ.
Evidence they're handed (and nothing else):
{
"bucket": "mapbox-private",
"acl": {"grants": [{"grantee": "Owner", "permission": "FULL_CONTROL"}]},
"bucket_policy": {"Statement": []},
"public_access_block": null,
"objects_can_be_public": true,
"sample_object_acl": {"grants": [{"grantee": "AllUsers", "permission": "READ"}]}
}
- No AWS credentials. No live account. No scripts.
The questions they must answer from the evidence alone:
- The bucket-level config looks private — so what is actually exposed right now?
- The bucket policy is empty and the bucket ACL is owner-only — given
objects_can_be_public: true, what makes this a latent, growing exposure rather than a one-off? - Through path A — the bucket policy and bucket ACL — what is reachable?
- Through path B — per-object ACLs like the sampled
AllUsers: READ— what is reachable? - What single rule would have prevented any object from ever being public regardless of how it was uploaded?
1. The manual problem
Reasoning about this by hand fails at the bucket boundary. Read the bucket policy: empty. Read the bucket ACL: owner-only. Every bucket-level signal says private, and a manual review naturally stops there and signs off. The exposure lives one level down, in per-object ACLs, which S3 evaluates independently of the bucket configuration.
Confirming the real state would mean enumerating object ACLs across the whole bucket
— not a thing you can read off a single config page, and not something that scales
past a handful of objects. Worse, objects_can_be_public: true (the absence of a
Public Access Block) means the next upload with --acl public-read reopens the hole
even if you fix today's objects. The bucket name says "private," the bucket config
agrees, and the objects quietly disagree.
2. The reasoning wall (capture, don't invent)
| What they hit | What they said / would say |
|---|---|
| Bucket config says private | "Policy's empty, bucket ACL is owner-only. By the book this is locked down." |
| Objects override the bucket | "An object went up with public-read and S3 just honors it. The bucket never gets a say." |
| No backstop, so it recurs | "Even if I fix these objects, the next aws s3 cp --acl public-read reopens it." |
The insight the reader should reach on their own:
A private bucket does not imply private objects — S3 evaluates object ACLs on their own, and only a Public Access Block bridges that gap.
3. Why scanners miss or flatten it
A per-setting scanner inspects bucket-level configuration: bucket policy, bucket ACL, Public Access Block. On this bucket those all read as private (or, in the PAB's case, absent), so the scanner reports the bucket as not publicly exposed. That report is the trap. The specific thing it cannot see is the object-level ACL override: S3 allows any individual object to carry its own public-read grant that the bucket configuration neither reflects nor controls. The scanner's node — the bucket — looks clean while the leaf nodes — the objects — are public, and without a Public Access Block there is nothing at the bucket level that would even surface that contradiction.
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 snapshot the reader had: the owner-only bucket ACL, the empty
bucket policy, the missing Public Access Block, the
objects_can_be_publicflag, and a sampled object ACL.
{
"bucket": "mapbox-private",
"acl": {"grants": [{"grantee": "Owner", "permission": "FULL_CONTROL"}]},
"bucket_policy": {"Statement": []},
"public_access_block": null,
"objects_can_be_public": true,
"sample_object_acl": {"grants": [{"grantee": "AllUsers", "permission": "READ"}]}
}
- Object-level ACL facts are evaluated alongside bucket-level facts, so a clean bucket cannot mask a public object.
5. The reasoning Stave performs
- Control / invariant:
CTL.S3.ACL.OBJECT.001— no individual object may be public via an object-level ACL. - What it evaluates: Does any object ACL grant
AllUsers: READ(path B), even when the bucket policy and bucket ACL are private (path A is clean)? The control reads object-level grants, so an owner-only bucket ACL does not suppress the finding. - Verdict produced:
NON_COMPLIANT— the sampled object is public via its own ACL, and the absence of a Public Access Block means more can follow.
control: CTL.S3.ACL.OBJECT.001
asset: s3://mapbox-private (object-level ACL)
evidence: object ACL grants AllUsers READ while bucket policy/ACL are private; public_access_block absent
verdict: NON_COMPLIANT
6. The prevention artifact Stave produces
- Artifact: A Public Access Block configuration with
BlockPublicAclsandIgnorePublicAclsenabled. - What it forecloses: The latent, recurring state from question 2 — any object uploaded with a public-read ACL becoming individually accessible.
BlockPublicAclsrejects new public-ACL uploads;IgnorePublicAclsmakes existing public object ACLs ineffective. The bucket name finally matches the objects.
PublicAccessBlockConfiguration:
BlockPublicAcls: true # reject any new object uploaded with a public-read ACL
IgnorePublicAcls: true # ignore public grants on existing object ACLs
BlockPublicPolicy: true
RestrictPublicBuckets: true
7. What the team no longer does manually
| Before | After Stave |
|---|---|
| Sign off on a bucket because its bucket-level config reads private | Object-level ACLs are evaluated, so a private bucket can't hide public objects |
| Enumerate object ACLs by hand to find the public ones | One control verdict reports object-level public exposure deterministically |
| Re-fix the bucket every time a new public-ACL upload reopens it | A Public Access Block with BlockPublicAcls/IgnorePublicAcls forecloses it for good |
Positioning line for this case
Stave proves that objects in this "private" bucket are public through per-object ACLs the bucket config never reveals, and emits the Public Access Block with BlockPublicAcls/IgnorePublicAcls that makes a public object impossible.
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