Skip to main content

The static site that published its own .git history

Metadata

  • Title: The static site that published its own .git history
  • Source of the case: HackerOne report #2383486 (Mozilla)
  • AWS service(s): S3
  • Risk archetype: sensitive prefix exposed inside an intentionally public bucket
  • One-line hook: Can you prove a public static-site bucket isn't also serving its own repository?

0. The challenge (what the reader does first)

Scenario given to the reader:

A public S3 bucket serves a community static site. The bucket policy grants anonymous s3:GetObject on every object — that's how the site is served. But a .git directory was synced alongside the site files, so .git/HEAD, .git/config, and .git/refs/ are now public objects too. There is no Public Access Block.

Evidence they're handed (and nothing else):

{
"bucket": "mozilla-community-site",
"bucket_policy": {"Statement": [{"Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::mozilla-community-site/*"}]},
"public_access_block": null,
"notable_prefixes": [".git/HEAD", ".git/config", ".git/refs/"]
}
  • No AWS credentials. No live account. No scripts.

The questions they must answer from the evidence alone:

  1. Public read on a static site is intended — so which specific objects make this unsafe right now?
  2. Why is an exposed .git/ prefix a latent risk far larger than the files themselves — what could it reconstruct?
  3. Through path A — normal static-site assets under the public s3:GetObject grant — what is exposed, and is that acceptable?
  4. Through path B — the .git/ prefix under the same grant — what becomes reconstructable?
  5. What single rule would have kept the public grant serving the site while refusing to serve sensitive prefixes like .git/?

1. The manual problem

The grant is uniform: s3:GetObject on arn:aws:s3:::mozilla-community-site/*. That wildcard makes every object public, and a manual reviewer reading the policy sees exactly what they expect for a static site — anonymous read of the content. The policy gives no hint that some of those objects are a Git repository.

To catch this by hand you'd have to step outside the policy entirely and inventory the object keys, then recognize .git/HEAD and .git/config as repository internals rather than site assets. The policy and the prefix listing live in different places, and "the site is public on purpose" actively discourages anyone from auditing what else the wildcard is serving. With no Public Access Block and a single broad grant, nothing draws a line between "site files" and "everything that happened to get synced into the same bucket."


2. The reasoning wall (capture, don't invent)

What they hitWhat they said / would say
One wildcard grant covers everything"The policy just says /*. It can't tell me a .git folder is in there."
Public-on-purpose hid the sensitive prefix"It's a public site, so a public-read finding looked completely normal."
.git is worse than loose files"That's not a few leaked files — that's the whole repo history, rebuildable."

The insight the reader should reach on their own:

A blanket public grant exposes whatever ended up in the bucket, not only what you meant to publish.


3. Why scanners miss or flatten it

A per-setting scanner evaluates the policy and reports "bucket allows public read." On a static-site bucket that's both true and expected, so it's dismissed. The scanner operates on the grant, not on the object keys the grant covers, so it has no way to notice that .git/HEAD, .git/config, and .git/refs/ are sitting under the same wildcard. The specific thing it cannot see is the sensitivity of the prefix: that within an intentionally public bucket, particular object paths constitute a source repository whose history (commit messages, author emails, every version of every file, and any secret ever committed) can be reconstructed with tools like git-dumper. The scanner flattens "public site" and "public repository" into the same green check.


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 public-read bucket policy, the missing Public Access Block, and the notable object prefixes present in the bucket.
{
"bucket": "mozilla-community-site",
"bucket_policy": {"Statement": [{"Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::mozilla-community-site/*"}]},
"public_access_block": null,
"notable_prefixes": [".git/HEAD", ".git/config", ".git/refs/"]
}
  • Object prefixes are evaluated against the public grant, so a uniform /* policy is checked for sensitive paths it inadvertently exposes.

5. The reasoning Stave performs

  • Control / invariant: CTL.S3.PUBLIC.PREFIX.001 — a protected prefix (e.g. .git/) must not be publicly readable.
  • What it evaluates: The bucket has anonymous s3:GetObject on /* (path A — acceptable for the site). The control then asks whether that grant covers a protected prefix like .git/ (path B). The intended public read does not excuse the sensitive prefix; the presence of .git/ under the wildcard triggers the finding.
  • Verdict produced: NON_COMPLIANT — a protected prefix is publicly readable.
control: CTL.S3.PUBLIC.PREFIX.001
asset: s3://mozilla-community-site/.git/
evidence: protected prefix ".git/" (HEAD, config, refs/) is covered by public s3:GetObject on /*
verdict: NON_COMPLIANT

6. The prevention artifact Stave produces

  • Artifact: A bucket-policy Deny that refuses anonymous reads under the .git/ prefix while the broad public-read grant continues serving the site.
  • What it forecloses: The latent state from question 2 — a sensitive prefix being reconstructable into the full repository via the same public grant that serves the site. The site stays public; .git/ stops being served.
{
"Effect": "Deny",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::mozilla-community-site/.git/*"
}

7. What the team no longer does manually

BeforeAfter Stave
Read a uniform /* public grant and assume it serves only site filesThe grant is evaluated against object prefixes, surfacing sensitive paths it covers
Manually inventory keys to spot a synced .git directoryOne control verdict flags protected prefixes publicly readable
Discover a source-code leak only after someone runs git-dumperA committed Deny on .git/* forecloses the leak on every future deploy

Positioning line for this case

Stave proves that this public static-site bucket also exposes its .git/ history through the same wildcard grant, and emits the Deny that keeps the site public while refusing to serve the repository.


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