omeryanbas.com

Ömer Yanbaş

General Manager, Ticofab Yazılım

SecurityWeb

How a misconfigured root exposes your .env to the internet

When the web root points at the project directory and the server falls back to any file that exists, your .env and .git are one plain request away.

A routine check on a platform we run returned something that should not have existed: the contents of an environment file, in a browser, with no authentication and no clever trick. A second request returned the version control configuration. Nothing had been broken into. The server was doing exactly what it had been told to do, which was to serve any file that existed under the directory it had been pointed at.

What actually happens

Two ordinary settings combine into one hole. The first is a root that points at the project directory rather than at the public subdirectory inside it. The second is a fallback rule that serves whatever file exists at the requested path before handing anything to the application.

server {
  server_name example.com;
  root /srv/app;          # the project, not the public folder

  location / {
    try_files $uri $uri/ /index.php?$query_string;
  }
}

To the server, .env is an ordinary file. The leading dot is a convention that hides a file from a default shell listing and nothing more. try_files $uri asks the filesystem whether /srv/app/.env exists, the answer is yes, and it is returned with whatever content type the server can guess, which for an unknown extension is usually plain text. That is the most readable possible outcome for whoever asked.

The same applies to the version control directory. With directory listing off you cannot browse it, but browsing is not required, because the paths inside it are fixed and public knowledge. /.git/config gives the remote addresses and often a username. /.git/HEAD and the index lead to the pack files, and from the pack files the entire history can be reconstructed, including every secret that was ever committed and removed in a later commit.

This is not a targeted attack. Requests for /.env arrive on any public address within hours of a name pointing at it, from scanners that try a list of a few hundred paths. You are a row in a list, not a target, which is why the exposure window matters more than your obscurity.

How to test your own site

One list of paths and one loop. Run it from outside your network, against the public name, not against localhost:

for p in /.env /.env.local /.env.production /.env.bak \
         /.git/HEAD /.git/config /.gitignore \
         /docker-compose.yml /package.json /composer.lock \
         /.DS_Store /.htpasswd /backup.sql /config.php~ ; do
  res=$(curl -s -o /dev/null -w '%{http_code} %{size_download}' "https://example.com$p")
  printf '%-26s %s\n' "$p" "$res"
done

The output reads quickly, because you only care about one pattern:

/.env                      200 412
/.git/HEAD                 200 23
/.git/config               200 287
/package.json              200 1184
/backup.sql                404 0

Anything that is not 404 0 deserves a look. A 403 is better than a 200 but still tells a scanner that the file exists, and a 200 with a nonzero body means the file was delivered.

Repeat the loop for every hostname the machine answers on, including the bare IP address and any staging or legacy name. A vhost that nobody uses is usually the one carrying the oldest configuration, and that is the same drift problem as a control panel and an nginx file that disagree.

The fix

Four changes, in order of how much they actually protect you.

  1. Point the root at a public directory only. Everything above it becomes unreachable regardless of any other rule, and this is the one fix that does not depend on remembering a list of bad paths.
root /srv/app/public;
  1. Deny dotfiles and version control paths anyway, as a second layer:
location ~ /\.(?!well-known/) {
  deny all;
  access_log off;
  log_not_found off;
  return 404;
}

location ~ /\.(git|svn|hg)(/|$) {
  return 404;
}

The (?!well-known/) part matters. Certificate renewal uses /.well-known/acme-challenge/, and a blanket dotfile deny is one of the common ways people break renewal and then find out sixty days later.

  1. Keep secrets out of the served tree entirely. The environment file belongs one level above the public directory, or in the process environment where the file does not exist on disk at all. Database dumps, deployment archives and editor backups belong nowhere near the tree, not even temporarily, because temporarily is long enough.

  2. Return 404 rather than 403 for anything denied. A 403 is a confirmation, and confirmations are what a scanner is collecting. Serving a real 404 for things that should not be found is the same principle as serving a real 404 instead of redirecting to the home page.

If it has already happened

The order is not the intuitive one, and getting it wrong costs you the only thing that matters, which is time.

  • Rotate first. Every credential in that file has been public since the moment it became fetchable, not since you noticed. Database passwords, API keys, mail credentials, signing and webhook secrets, session keys. Rotate before you touch the server configuration, because fixing the configuration does not unpublish anything.
  • Fix the configuration second, and verify it from outside with the loop above.
  • Read the access logs third, to size the incident rather than to decide whether to rotate:
grep -E '/\.(env|git)' /var/log/nginx/access.log* \
  | awk '{print $1, $7, $9, $10}' \
  | sort | uniq -c | sort -rn | head -20

Read the status and the bytes sent together. A 200 with a body means it was delivered. Count distinct source addresses, and look for a second request from the same address that used something the first one found, because that is the difference between a scan and an intrusion.

  • Plan for the side effects of rotation instead of being surprised by them. Rotating the session key logs everyone out, and rotating a webhook secret means the other side has to be updated in the same window.
  • If the version control directory was exposed, treat every secret that ever appeared in that history as public, including ones deleted in a later commit. History is the point of version control.

How to check it worked

The denied paths answer with a real 404 and no body, and the one dotfile path that has to keep working still works:

curl -s -o /dev/null -w '%{http_code} %{size_download}\n' https://example.com/.env
# 404 0
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' https://example.com/.git/config
# 404 0

printf 'ok\n' > /srv/app/public/.well-known/acme-challenge/probe
curl -s https://example.com/.well-known/acme-challenge/probe
# ok

Then run the full path loop again. Every line should read 404 0, including on the staging name and on the bare address.

What to watch out for

  • A second server block, a default server or an old staging vhost on the same machine still carrying the original root. The fix is per server block, and the one nobody uses is the one nobody checks.
  • Files served by the application rather than by the web server. If a framework route takes a path from the URL and reads it, no web server rule will save you, and the deny rules above never run.
  • Leftovers with harmless extensions: .env.save, config.php~, .DS_Store, which lists filenames, and lockfiles, which list exact dependency versions and therefore exactly which known vulnerabilities you have.
  • A cache or CDN in front of the origin can keep serving the leaked file after you fix the origin. Purge it, then test against the public name rather than the origin.
  • Adding these rules inside a location block can silently drop headers you set elsewhere, which is the same inheritance trap as the nginx rule that quietly drops your security headers. Check the headers after changing the configuration, not only the status codes.

The underlying mistake is treating the web root as a place where files live rather than as a published surface. Everything under it is published, now and after the next deploy adds a file nobody thought about, so the safe version is a directory that contains only what is meant to be public and a deny rule for the rest. Test it the way an outsider would, with a list of paths and a loop, on every name the machine answers on. It takes a minute, and it is the cheapest minute in this entire article.

Questions and answers

Can someone really download my .env file?
Yes, if the file sits under the directory the web server was pointed at and a rule serves whatever exists at the requested path. The server has no concept of a hidden file. It checks whether the path exists on disk, finds it, and returns it, usually as plain text because it cannot guess a better content type.
Is an exposed .git directory dangerous if directory listing is off?
Yes. Nothing needs to be listed because the paths are fixed and public knowledge. From .git/config an attacker reads the remote URLs, and from the index and the pack files the whole source history can be reconstructed, including any secret that was ever committed and later removed.
Should the server return 403 or 404 for these paths?
404. A 403 confirms that the file exists, which is useful information for anyone scanning. Returning 404 for denied paths costs nothing and makes a scan indistinguishable from an ordinary miss.
What is the right order of response after a leak?
Rotate, then fix, then investigate. Every credential in the file has been public since the moment it was fetchable, not since you noticed, so rotation cannot wait for the configuration change or for log analysis. Fix the configuration next and verify it from outside. Read the logs last, to size the incident and see whether anything was used.