Exposed .env files & open .git directories
Two classic deployment mistakes — a `.env` served as plain text and a browsable `/.git/` directory — hand attackers your secrets and your entire source tree, so learn to detect them by parsing the response, not trusting the status code.
Two of the most common ways a web app leaks its own secrets have nothing to do with clever exploitation — they are files that should never have been reachable over HTTP but are. A `.env` file served as plain text hands over database credentials and API keys directly. An exposed `/.git/` directory lets an attacker reconstruct your entire source tree, including secrets that were committed and later 'removed'. Both are misconfigurations, both are trivially findable at scale, and both are avoidable.
The exposed .env file
Frameworks like Laravel, Rails, and countless Node apps read configuration from a .env file in the project root. The problem starts when the project root is the webroot (or .env gets copied into public/, dist/, or htdocs/). A web server that doesn't recognise the file just serves it like any other static asset — text/plain, HTTP 200 — and now anyone who requests https://target/.env reads it in their browser.
A real .env is unambiguous once you see it. It is a flat list of KEY=value lines, and the values are exactly the things you most want to protect:
APP_KEY=base64:9sL2f...redacted
DB_HOST=127.0.0.1
DB_DATABASE=prod
DB_USERNAME=app
DB_PASSWORD=hunter2-not-really
STRIPE_SECRET_KEY=sk_live_51H...
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=...
MAIL_PASSWORD=...There is no exploit chain here. The database password, the live Stripe key, and the AWS credentials are read straight off the page. If the DB host is publicly reachable or the AWS keys are over-privileged, the app is fully compromised in one request.
The open .git directory
When you git init and deploy by copying the working directory, the hidden .git/ folder rides along. If it lands in the webroot and directory listing or direct file access is allowed, an attacker doesn't need a listing at all — Git's layout is predictable, so they can request known paths and rebuild the repo. The key files are:
.git/HEAD— points at the current branch (e.g.ref: refs/heads/main)..git/config— remote URLs and sometimes embedded credentials..git/index— the staged tree: every tracked file path plus its object hash..git/refs/and.git/logs/HEAD— commit hashes, including history..git/objects/— the actual content, as loose objects (objects/ab/cdef...) and packfiles (objects/pack/*.pack).
With those, an attacker walks the object graph: read HEAD -> resolve the branch ref to a commit -> read its tree -> fetch each blob -> zlib-decompress. Tooling like git-dumper automates the whole thing, pulling the index and objects and checking out a working copy locally. The result is not just current source — it is the full history. Secrets that were committed once and 'deleted' in a later commit still exist as reachable objects, so git log -p on the recovered repo surfaces old passwords, keys, and internal hostnames.
# Reconstruct a source tree from an exposed .git (authorised targets only)
git-dumper https://target/.git/ ./loot
# Then mine the recovered history for secrets
cd loot
git log -p | grep -iE 'password|secret|api[_-]?key|token'Detecting it — why a 200 is not proof
The naive check is 'request /.env and /.git/HEAD, and if the status is 200, it's exposed.' That is wrong and produces constant false positives. Most modern deployments are single-page apps behind a catch-all route: any unmatched path returns 200 OK with the app's index.html. So /.env returns 200 — but the body is <!doctype html>, not credentials. You must parse the response body and confirm it is the real thing.
- For
.env: confirm the body looks like dotenv — plain text,KEY=valuelines, no HTML tags. A response whoseContent-Typeistext/htmlor whose body starts with<is the SPA fallback, not a leak. - For
/.git/HEAD: confirm the body is a tiny plain-text file, typically starting withref: refs/(or, on a detached HEAD, a raw 40-char hex hash). HTML means no leak. - For
/.git/config: confirm INI-style content with a[core]section and usually[remote "origin"]. That structure is unmistakable and hard to fake by accident. - As a strong confirmation for Git: fetch
/.git/indexand check it begins with the 4-byte magic signatureDIRC.
# Status alone lies; inspect the body.
curl -s https://target/.git/HEAD | head -c 64
# real: ref: refs/heads/main
# fake: <!doctype html><html> ... (SPA catch-all -> NOT exposed)
curl -s https://target/.env | head -3
# real: APP_KEY=... / DB_PASSWORD=...
# fake: <!doctype html> ... (ignore it)
# Git index magic check
curl -s https://target/.git/index | head -c 4 # -> DIRC when realRequesting these paths, and especially running git-dumper, is active testing against a live system. Only do it on assets you own or have explicit written authorisation to test. Reconstructing a source tree or reading credentials on someone else's site without permission is unauthorised access, regardless of how the files were exposed.
When you build a detector, key the decision on the parsed body, then record evidence — the matched key names from .env, or the ref: line and DIRC magic from Git. Storing the proof (not just 'found: true') is what separates a real finding from a false positive, and it's exactly what a report reviewer needs to trust it.
The fix
Defence is layered, and any one layer is enough to stop the leak — but do several, because deployments drift.
- Keep them out of the webroot. Point the server's document root at
public/(ordist/) only..envand.git/live one level up, in the project root, and are never inside the served directory. - Deny dotfiles at the server. Block requests for any path containing a dot-prefixed segment — but carve out
.well-known/first, or a blanket dot-deny will also break the ACME/Let's Encrypt HTTP-01 challenge andsecurity.txt. Nginx:location ~ /\.(?!well-known) { deny all; return 404; }. Apache:RedirectMatch 404 /\.(git|env)or a<FilesMatch>/.htaccessrule. This catches.git,.env,.svn, and friends in one rule. - Don't deploy the `.git` directory. Build an artifact and ship that (
rsync --exclude='.git', a Docker build stage, or CI that copies only built output). The repo metadata should never reach production. - `.gitignore` your secrets. Ensure
.env(and.env.*local variants) are ignored so they are never committed in the first place — this also keeps them out of any recovered history. - Add a CI check. Fail the pipeline if
.envis tracked, or run a scan (e.g. gitleaks/trufflehog) so a committed secret blocks the merge before it ships. - Rotate after any exposure. If a secret was ever reachable, treat it as compromised: rotate the key, change the DB password, and revoke the token. Removing the file does not un-leak what was already served or already in history.