← All articles
Information disclosure

Exposed .git directories: how source code leaks from production

A deploy that copies your repository into the web root can expose its entire history — source, config, credentials. How attackers find and download it.

Version control lives on developer laptops and build servers, not on the public internet — but every so often a whole .git directory ends up sitting in a site's web root, one HTTP request away from anyone. When that happens, the exposure is rarely just the current code. Git keeps the full history, so an attacker can often recover every version of every file the repository has ever held, including secrets a developer thought they had deleted.

How a .git directory ends up web-accessible

A Git repository stores everything it needs in a single hidden folder at the project root: .git. That folder is not meant to be served to browsers, but two common deployment habits put it there anyway.

  • Deploying with git clone or git pull. Treating the live server as a checkout is convenient — you pull to deploy — but it leaves a live .git directory next to your application files. If the document root points at the repository root, that folder is now web-reachable.
  • Copying the whole tree. An rsync, scp, FTP upload, or archive extraction that includes hidden files carries .git along with the code. Nothing warns you; the deploy simply succeeds with an extra folder attached.

The reason it becomes fetchable is that most web servers happily serve any file under the document root unless told otherwise, and the contents of .git are ordinary static files. No directory listing is required — an attacker who knows Git's layout can request individual paths by name.

How the exposure is detected

Git's internal layout is completely deterministic, which makes the exposure trivial to confirm without any guesswork. The usual tell is a request for /.git/HEAD: on a normal site that returns a 404, but a leaked repository returns a small text file whose body looks like ref: refs/heads/main. A request for /.git/config is a second confirmation — it returns the repository's configuration, often including the remote URL the code was cloned from.

A 200 response carrying that recognisable Git content is unambiguous. From there the important point for defenders is what it enables, not the precise sequence of requests.

Reconstructing the source without a directory listing

Many operators assume that turning off automatic directory listings (autoindex) protects them. It does not. Because Git's structure is predictable, the object graph can be rebuilt by following references rather than browsing folders:

  • /.git/HEAD names the current branch.
  • /.git/refs/ and /.git/packed-refs map branch and tag names to commit hashes.
  • Each commit hash points at a loose object at a path derived from the hash (for example /.git/objects/ab/cdef...), or is bundled into a packfile under /.git/objects/pack/.

Every object references the ones it depends on, so a fetcher that starts at HEAD can walk the entire chain — commits, trees, and file blobs — pulling each one as a plain static download. This is the "dumb HTTP" transport: it needs no cooperation from a Git server, just an ordinary web server returning files. Off-the-shelf tools exist that automate this walk over refs and pack files and rebuild a working copy of the repository. The result is not a snapshot of the current site — it is the project's full, browsable history.

Why this is a critical exposure

Source-code disclosure is severe on its own. With the code in hand, an attacker no longer has to guess: internal API endpoints, authorisation logic, input-validation rules, hidden admin paths, and third-party integrations are all laid out plainly. Any vulnerability in that code becomes far easier to find and exploit with the source than by black-box probing.

The sharper danger is the history. Repositories routinely accumulate secrets that were committed and later "removed" — an API key hardcoded during debugging, a database password in an old config, a checked-in .env file, a private key. This is where a subtle but critical fact matters:

Deleting a secret from the latest commit does not remove it from the repository. Git is designed to preserve history. When you edit a file and commit, the old version is retained as a prior object, reachable from earlier commits. So a credential that was committed in March and "deleted" in April is still sitting in the March commit — and anyone who reconstructs the repository can read it. Scrubbing a secret from the current tree is not remediation; it only hides the value from someone reading the newest files.

How to fix it

There are three independent things to do, and all three matter.

  1. Never deploy the .git directory. Ship build artifacts, not a checkout. Use a CI pipeline that copies only what the site needs, or export a clean tree with git archive rather than cloning onto the server. Where the framework supports it, point the web server's document root at a public subdirectory (for example a public/ folder) so the repository root — and .git with it — sits above the served path and can never be requested.
  2. Block dotfiles at the web server as a belt-and-braces backstop. Deny any request for a path segment beginning with a dot, so a stray .git, .env, or .svn can't be served even if it is deployed by mistake:
    # nginx
    location ~ /\.(?!well-known) {
        deny all;
        return 404;
    }
    # Apache
    <DirectoryMatch "/\.git/">
        Require all denied
    </DirectoryMatch>
  3. Rotate every secret that was ever committed. If a credential lived in the history, treat it as compromised and change it at the source — issue new keys, reset passwords, revoke tokens. Rewriting history to purge the value (with tools such as git filter-repo or BFG, followed by a force push) is worth doing for hygiene, but rotation is the part that actually protects you, because you can never be sure who already pulled a copy.

The wider point: secrets don't belong in code

An exposed .git directory is one way credentials leak, but the underlying lesson generalises. Secrets embedded in a repository — or in shipped HTML and JavaScript — outlive the commit that removed them and travel wherever the code travels. The durable fix is to keep them out of version control entirely, supplying them through environment variables or a secrets manager, and to scan for accidental commits before they reach production. This is closely related to other information-disclosure and configuration issues, from missing HTTP security headers to the outdated plugins and themes that so often ship a wider attack surface than intended.

How Cyber Report checks this

An external Cyber Report scan probes for the tell-tale Git paths — /.git/HEAD and /.git/config — as part of its sensitive-files audit, and flags an exposed .git directory as a critical finding. When exposure is confirmed, the scan attempts to reconstruct the history over dumb HTTP and runs a git-history secret scanner across it to surface any committed API keys or credentials. To avoid ever exfiltrating your secrets, we deliberately do not validate candidate credentials against their issuers — findings are reported so you can rotate them, never tested against a live provider.

See what we scan, or run a scan on your own domain to see where you stand.