010 —Security
Public web root: seven file types you should never expose
A WordPress install live since 2014 will tell you everything about itself if you know which seven files to ask for. None of them should be there.
A small Amsterdam agency sent over a Loom at 23:41 last month. Their client's WordPress install, live since 2014, had just received a politely worded warrant from Cloudflare. The forensics report named the source as the site itself. Someone had walked off with wp-config.php.bak from the document root, decrypted the salts, and quietly used the database to host a credential-stuffing rig. The agency had inherited the site eighteen months earlier. The backup file had been there since the original developer's last edit in 2018.
Nothing about that incident is exotic. Every legacy site we audit has at least one of the seven file types below sitting in plain sight on its public web root. None of them require a vulnerability to read. They are simply HTTP GETs.
The seven-minute audit
The audit is mechanical. From a laptop, with the site's hostname in $DOMAIN, run a small set of curl -I requests and read the status code. Anything that returns 200 is a finding. 403 or 404 means the file is missing or already blocked at the server level. You do not need any internal access for this pass. That is the point: an attacker doesn't either.
DOMAIN="example.com"
for path in .env .git/config wp-config.php.bak backup.sql phpinfo.php \
composer.lock wp-content/debug.log; do
printf "%-30s " "$path"
curl -s -o /dev/null -w "%{http_code}\n" "https://$DOMAIN/$path"
doneThat seven-line loop is the entire audit. The rest of this post is what each 200 means and how to make it a 404 by the end of the day.
.env and the dotenv family
Modern Laravel, Symfony, and Bedrock-style WordPress installs ship a .env file at the project root for database credentials, app keys, and third-party API tokens. The convention is good. The accident is when the project root is the document root, which it routinely is on cPanel and shared hosting. A request to https://example.com/.env then returns the file verbatim, because Apache does not block dotfiles by default outside of .htaccess and .htpasswd.
The fix is two lines in your virtual host or top-level .htaccess:
<FilesMatch "^\.env">
Require all denied
</FilesMatch>For Nginx, the equivalent inside the server block:
location ~ /\.env { deny all; return 404; }Then move the file out of the document root entirely if your stack allows it. Most modern PHP frameworks support an explicit env path in their bootstrap.
The .git directory
An exposed .git/ folder is the worst finding on this list because it almost always yields the entire source tree, including credentials, migration files, and every secret that has ever been committed and later "removed." Tools like git-dumper reconstruct the repo from .git/config, .git/HEAD, and the pack files in under a minute.
Test it:
curl -s "https://example.com/.git/config" | head -n 3If you see [core], the entire history is downloadable. The remediation follows the same pattern as .env:
RedirectMatch 404 /\.gitThe better fix is to never deploy via git pull on production. Build the artefact elsewhere and rsync only the files you intend to serve. The Apache project's .htaccess guide is worth re-reading on the precedence rules here.
SQL dumps in /backup/ and /old/
The 2018 senior developer who renamed the old site to /old/ and dropped a backup.sql into /backup/ before the migration is, statistically, the single largest source of credential leaks we see in audits. The dumps include hashed admin passwords (often unsalted MD5 on truly old installs), email lists, and customer addresses.
The path list to check is short and almost always the same:
/backup.sql,/dump.sql,/database.sql/backup/,/backups/,/old/,/tmp//site_2018.tar.gz,/wp_backup.zip
Remove them. There is no version of this where the file deserves to stay on the public web root.
Editor and OS scratch files
Three flavours, all preventable. Vim leaves .swp and .swo files when an editor crashes. Many editors and FTP clients write filename~ or filename.bak on save. macOS sprinkles .DS_Store across every directory it touches. The .DS_Store case is the most underestimated: it contains the full directory listing as a binary blob, and tools like ds_store_exp turn it back into a path map within seconds.
One block handles all three:
<FilesMatch "(\.(bak|old|orig|swp|swo)|~|\.DS_Store|Thumbs\.db)$">
Require all denied
</FilesMatch>The same regex translated for Nginx works equally well. The cost is one config block. The saving is every "I just edited wp-config quickly over FTP" incident that follows.
Diagnostic scripts
The phpinfo.php file that the previous developer left behind in 2019 to "check whether mod_rewrite is loaded" is still there. It returns the full output of phpinfo(), which includes the document root, the PHP binary path, every loaded extension, every environment variable, and frequently a database password if it is set via SetEnv. Variants we see in audits: info.php, test.php, i.php, pi.php, adminer.php, db.php.
The check:
for f in phpinfo.php info.php test.php adminer.php; do
curl -s -o /dev/null -w "$f: %{http_code}\n" "https://example.com/$f"
doneThe fix is deletion. There is no production reason for any of these files to exist on a public web root. If you genuinely need a server-info endpoint, gate it behind HTTP basic auth at the server level and put it on a non-obvious path.
Lockfiles and version manifests
This one is less obviously exploitable but still belongs on the list. A public composer.lock or package-lock.json tells anyone with a CVE feed exactly which versions of which packages you are running. From there it is one search away from a known unpatched vulnerability. The same applies to composer.json, package.json, and yarn.lock.
None of these files have any business being downloaded by a browser. They are build-time artefacts. Block by filename, or move the project root above the document root and let the framework load them from there.
Debug logs
The seventh and last is the one that goes stale and gets forgotten. WordPress's WP_DEBUG_LOG writes to wp-content/debug.log by default. Drupal's error_log can land in sites/default/files/. Magento 1 left its var/log/ directory wide open under the document root in default installs. Each of these files accumulates SQL queries, stack traces with full file paths, and occasionally credentials that were logged in transit.
Disable debug logging in production. If a developer flips it on to investigate something specific, the log path should sit outside the web root. WordPress's own debugging documentation covers the relevant constants. The cleanest pattern is define('WP_DEBUG_LOG', '/var/log/wp/example-com.log'); with the directory owned by the web user and unreadable to anything else.
After the audit
The output of the seven-minute curl loop should, after one afternoon of remediation, be seven clean 404s. Lock that state in by re-running the same loop from a cron job once a week and emailing the diff. The audit is cheap. The negligence is what costs.
When we built Pier for editing legacy sites over FTP and SFTP, we kept hitting this same audit by hand on every project, so we added it as a one-click sweep alongside the MySQL editor. Every deletion goes through the same version history as any other edit, so an over-eager cleanup is one click away from being undone.
The smallest thing to do today: paste the curl loop at the top of this post into a terminal, point it at one site you maintain, and read the status codes. Whatever returns 200, write it down. Tomorrow is for fixing.
— Questions —
Why does Apache serve .env files by default?
Apache only blocks .htaccess and .htpasswd out of the box. Every other dotfile, including .env and .git, is served unless you add an explicit FilesMatch rule in your config or top-level .htaccess.
Is a 403 on /.git/ enough to consider the directory safe?
No. Many servers block the directory listing but still serve individual files inside, which is enough for git-dumper to reconstruct the repo. Verify by requesting /.git/HEAD specifically and confirming a 404.
Where should debug logs go if not the web root?
Outside the document root entirely. On Linux, /var/log/wp/site.log owned by the web user with mode 0640 works. In WordPress, set WP_DEBUG_LOG to the absolute path rather than leaving it true.
Are composer.lock and package-lock.json really a security issue?
They are not exploitable on their own, but they hand an attacker an exact dependency inventory to cross-reference against CVE databases. Treat them as reconnaissance leaks and block them from the public web root.