Exposed .git and a forgotten .env: how your source code leaks out
This is not exotic, and it is not a sophisticated attack. The site works, nothing looks
broken, and at your-site.com/.git/config the browser calmly shows you plain
text — and with it, the project's source code and its entire edit history walk out the
door. Let us look at where this comes from, what makes it genuinely dangerous and how to
close it in a single evening.
What the .git directory is and why it ended up on your server
.git is the service directory of the Git version control system: it holds the
project's whole history, meaning every version of every file a developer has ever saved.
The site itself does not need this directory to run — it exists only for the people who
write the code.
It has no business being on a production server, and yet it keeps turning up there. The reasons are the same dull handful:
- The project folder was copied to the server whole.
scp -r, an FTP client, “I just uploaded it through the hosting panel's file manager” — everything moves across, hidden directories included. They are hidden only for the file manager, by the way: the web server serves them like any other file. - The site was deployed with
git clonestraight into the directory it is served from. Convenient: updating comes down togit pull. The side effect is that.gitsits inside the public part of the site and is available to everyone. - The site root points at the wrong folder. On modern frameworks only a subfolder is meant to be public (usually
public/), and everything else lives one level up. If the hosting settings name the entire project folder as the root,.git,.envand all the rest of the contents go on display at once. - The web server configuration simply has no rule that forbids it. A stock Apache blocks only its own files such as
.htaccess, and nginx out of the box blocks nothing at all. Whatever lies in the root, it serves honestly. - The site was moved. Changing hosting or contractors almost always means copying the directory as is, along with everything that has piled up in it.
Notice that not one of these points involves malice or a rare mistake. This is the ordinary life of an ordinary site, which is why the finding shows up at companies of every size.
Why this is worse than “so they will see the source code”
An owner's first reaction is usually calm: “So what, there is nothing secret in there, it
is a plain PHP site.” The trouble is that an exposed .git is not “looking at a
couple of files”. It is access to the project's archive, from which the working
source code can be reconstructed in full — and with it, the entire history of
changes.
What that means in practice:
- The site's whole logic is visible. How payment is verified, how the download link for a document is built, what parameters a form accepts, where permission checks are in place and where they were forgotten. Nobody has to hunt for a vulnerability blindly any more — they can read it.
- The bundled components and their versions are visible — and published vulnerabilities are tied to versions.
.git/configholds the repository address, and sometimes a login and access token as well, if the repository was connected over HTTPS with credentials embedded in the URL. That is no longer about your site — it is about access to your entire code base, other projects included.- Developers' names and emails are visible in the commit history — a ready-made target list for emails “from your contractor”.
- And above all — secrets from earlier versions of files. More on that below, because this is where people get it wrong most often.
The key point: deleting a password from a file does not delete it from history
Git works like this: every save (a commit) records the state of the files at that moment and keeps it forever. A new commit does not overwrite the old one — it is added alongside. History is not a change log, it is a set of complete snapshots.
Hence the familiar story, which has played out in almost every project:
- In a hurry, a developer wrote the database password straight into a configuration file and committed the change.
- A week later he thought better of it, moved the password into environment variables and deleted it from the file. New commit.
- The current version of the code has no password in it. Everyone relaxes.
But the week-old commit has not gone anywhere — and the password sits in it in plain text. Anyone holding a copy of the repository reads not only the latest version of a file, but every previous one. Which means every key, password and token that made it into a commit even once stays available forever, even if it was pulled out of the working code the same day.
That is why an exposed .git is dangerous even on a brochure site with “nothing
to steal”: what matters is not the current code, but what once lay in that code and was
forgotten.
What usually lies next to it
An exposed .git is rarely the only finding: all these files share one cause —
the working folder was uploaded to the server whole. It is worth checking the entire set at
once.
| File | What is in it | What it costs you |
|---|---|---|
.env |
database password, payment gateway keys, mailing tokens, session secret | the worst case: credentials are handed over as a ready-made list, with no code reconstruction needed at all |
dump.sql, backup.zip, site.tar.gz |
a database export or a site archive, left in the root after a migration | customer database, orders, user password hashes — downloaded in a single request |
.svn |
the same as .git, but from the Subversion system |
turns up on older sites and gets probed by attackers just as automatically |
composer.lock, package.json |
a full list of libraries with exact versions | not a vulnerability in itself, but it spares the attacker the guesswork: what is outdated is right there |
.idea/, .vscode/, .DS_Store |
editor settings, sometimes server connection parameters; .DS_Store — a listing of the files in a folder |
reveals the project's internal structure and service paths |
phpinfo.php, test.php, info.php |
the full PHP configuration: versions, paths, modules, sometimes environment variables | left “for five minutes” during debugging and forgotten for years |
Attackers do not guess these addresses — they run through them automatically, from a list, against everyone in turn. So “our site is small, who would bother” does not apply here: nobody picked your site personally.
How to check your own site in a minute
You need no tools, a browser is enough. Open these one by one, substituting your own domain:
your-site.com/.git/configyour-site.com/.envyour-site.com/dump.sqlandyour-site.com/backup.zipyour-site.com/phpinfo.php
What the answer means:
| What you saw | What it means |
|---|---|
| The text of the file, or an offer to download it | Bad. The file is available to the whole internet right now. Go to the “How to close it” section — and if this is .git or .env, then afterwards be sure to read the section on reissuing secrets. |
| 403 Forbidden | Access is closed — already not bad, but a reason to look at the setup. A 403 confirms that the file exists, and it often means that only this one file is closed rather than the whole directory. Check /.git/HEAD and /.git/index while you are at it: it happens that one thing was closed and everything next to it stayed open. |
| 404 Not Found, or the site's ordinary error page | Fine. Either the file is not there, or the server is set up to answer 404 to such requests — and that is exactly the behaviour you want. |
Files left open like this are exactly what any external site check finds, free ones included. We have written separately about where the limits of automated scanners actually lie: what free vulnerability scanners find and what they will not show you.
How to close it
nginx
Add a rule to the server { ... } block that forbids serving anything starting
with a dot:
location ~ /\.(?!well-known) {
deny all;
}
It closes .git, .env, .svn, .idea and
any other hidden files and directories in one go.
The .well-known exception must stay. The
/.well-known/ directory also starts with a dot, but it is a service directory
and a public one: Let’s Encrypt uses it to confirm that the domain is yours when it issues
and renews a free TLS certificate. Close hidden files “across the board” and the certificate
will stop renewing — and about three months later the site will greet visitors with a
browser warning about an insecure connection. The (?!well-known) construct in
the rule means exactly one thing: “everything starting with a dot, except well-known”.
A couple of practical notes:
- Place this block above the other
location ~rules in the sameserver: nginx applies the first matching regular expression rule in config order. deny all;answers with code 403, which confirms that the file exists. If you would rather not confirm it, replace the line withreturn 404;.- After editing, test the configuration and then apply it:
nginx -t, thensystemctl reload nginx. Reloading without testing is a fine way to take the site down over a typo.
Backups in the root do not start with a dot, so they are closed by a separate rule:
location ~* \.(sql|zip|tar|gz|bak|old)$ {
return 404;
}
Before adding it, make sure the site does not serve such files legitimately: if you have a
section that offers a price list as a .zip, it will stop working. Better still,
do not keep dumps and archives inside the public folder at all — move them one level up.
Apache
For Apache 2.4, a single line in the .htaccess file in the site root is enough
(create the file if it does not exist):
RedirectMatch 404 /\.(?!well-known)
The logic is the same: any address containing a hidden file or directory gets a 404, while
.well-known keeps working. Backups are closed separately:
<FilesMatch "\.(sql|zip|tar|gz|bak|old)$">
Require all denied
</FilesMatch>
If your hosting forbids .htaccess or ignores the directives in it, the rule has
to go into the virtual host configuration — that is a job for the hosting provider or a
system administrator.
The right answer: do not publish service directories at all
A rule in the config is insurance, not a solution. The solution is for .git and
.env not to physically sit in the public part of the site:
- Publish the build output, not a working copy of the repository. Only the files the site needs in order to run should reach the server.
- If you copy by hand, exclude service files explicitly.
rsynchas--excludefor this: for example,rsync -a --exclude='.git' --exclude='.env' .... FTP clients have transfer filters. - Deploying with
git clone? Do not clone into the public folder. Keep the repository one level above the site root and serve only the subdirectory you need. - Check that the site root points at the public folder (
public/on typical frameworks) rather than at the whole project directory. It is one setting in the hosting panel, and it closes half the problems described here at once.
If the directory was exposed: closing access is only half the job
The most common and most expensive mistake at this step is to stop here. “We added the rule, it is a 404 now, case closed.” It is not closed.
While the directory was available, anyone could download its contents, and that leaves almost no trace: an ordinary request for an ordinary file, nothing suspicious in the logs. Automated crawlers go over other people's sites constantly and stockpile what they find — they may use the harvested keys weeks after you closed everything up. You have no way to prove that nobody took a copy. So you have to assume somebody did.
Every secret that was in the code and in the history counts as compromised and has to be reissued. The practical list:
- The database password — change it and update the application configuration. While you are at it, check whether the database is reachable from outside: if it is, close it.
- Payment gateway keys — reissue them in your acquirer's dashboard. A leaked key means not only somebody else's transactions, but also access to your payment data.
- Keys for external services and APIs — delivery, SMS, maps, CRM, warehouse, every integration. Revoke them by the list, not “the ones that seem important”.
- Email campaign tokens — with these, mail goes out under your name and on your domain's reputation, and it is your customers who receive it.
- The session secret and the application encryption key — with the old secret, a session cookie can be forged and someone can log in under another account without knowing the password. Note: changing the secret logs every user out — unpleasant, but the right price to pay.
- Access keys to the repository itself, if they were in
.git/config, and any SSH keys that ended up in the project. - Site administrator passwords — change them, and review the list of accounts while you are there: check that no new ones have appeared.
Reissuing keys is several hours of work and almost always comes with downtime for
integrations. The temptation to decide that “probably nobody got there in time” is strong.
But the cost of being wrong is asymmetric: reissuing keys for nothing costs you an evening,
while failing to reissue them in time costs you charges on your payment key and a mass
mailing under your name a month later, when nobody will connect it to an exposed
.git any more.
How to keep it from happening again
- Add
.envand files like it to.gitignore— then they never physically reach the repository. Keep an.env.examplenext to it with the same setting names but empty values: a new developer sees what has to be filled in, and no secret appears in the history. - Secrets belong in environment variables or in your hosting provider's vault, not in code files. The rule is simple: if a value cannot be shown to an outsider, it must not reach the repository even for a minute.
- Different keys for staging and production. The test environment is almost always protected worse, and that is where keys leak from most often. If the keys are the same, a leak from staging is a leak from the live site.
- Check by hand after every migration and every release. Changing hosting, changing contractors, moving to a new server — those are the three situations in which
.gitcomes back most often. The check takes a minute. - Set up a recurring external check. A manual check is good exactly once: it says nothing about the file that lands in the root six months from now, after a rushed Friday release. Only repeated monitoring is worth anything.
- If one such file turns up, look for the rest. They share a cause, so they rarely come alone.
Check your site for free
ShieldSafe looks for service files exposed to the internet — .git,
.env, forgotten dumps and debug pages — as well as known vulnerabilities in
the component versions you use. Every finding is explained in plain language, with wording
you can hand straight to a developer. Nothing to install on your server.