From 664c242980f6aed859cd5a4b94e44f7bdf648878 Mon Sep 17 00:00:00 2001 From: ekke Date: Tue, 11 Aug 2026 12:53:20 +0200 Subject: [PATCH] Apache hardening: block .git and .env disclosure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preventive, not incident-driven. No LUPMIS host is known to have been compromised; these rules exist so the most common automated attack against a PHP deployment cannot succeed. Scanners probe every public host for /.env (database passwords, API keys) and /.git/. The second is the more damaging: given /.git/index together with /.git/objects an attacker can reconstruct the whole repository, including its history — so any credential ever committed is exposed even after rotation. That is directly relevant here, because the minio-uploads integration has had access keys written into its PHP files and those files are tracked in Gitea. - public/.htaccess: deny hidden paths and files that should never be served. - docs/apache-hardening.conf: the same rules for vhost/server config, which is where they belong — .htaccess depends on AllowOverride. Also covers logging the real client address through the openresty proxy (mod_remoteip), and blocking at the proxy so requests never reach the application. Two details that are easy to get wrong, and are handled: - does NOT stop /.git/config. FilesMatch tests the basename only, and there the basename is "config". Blocking a hidden directory needs a rule that sees the whole path: mod_rewrite in .htaccess, in server config. - The SPA fallback only rewrites paths that do not exist (!-f), so a real .env on disk skips it and is served as a plain file. The deny rules therefore run before the fallback, not after. .well-known is exempt, or ACME certificate renewal would silently break. Verified against a live Apache instance with a planted .env and .git tree: /.env and /./.env return 403; /.git/config, /.git/HEAD, /.git/objects/... and /backup.sql return 404; /.well-known/acme-challenge/..., /assets/*.js and /manifest.json still return 200. Nothing in the document root is caught — the only dotfile there is .htaccess, which Apache never serves. These rules are damage limitation. The fix is to keep .git and .env out of a document root: deploy build output rather than a working copy, and hold secrets in environment variables outside the served tree. Co-Authored-By: Claude Opus 4.8 --- dist/.htaccess | 49 +++++++++++++ docs/apache-hardening.conf | 142 +++++++++++++++++++++++++++++++++++++ public/.htaccess | 49 +++++++++++++ 3 files changed, 240 insertions(+) create mode 100644 docs/apache-hardening.conf diff --git a/dist/.htaccess b/dist/.htaccess index 5169852..ac79df4 100644 --- a/dist/.htaccess +++ b/dist/.htaccess @@ -13,9 +13,58 @@ DirectoryIndex index.php index.html SetHandler application/x-httpd-php +# Second line of defence for hidden FILES (.env, .htpasswd, …), in case +# mod_rewrite is not loaded. This does not cover hidden directories such as +# /.git/config — see the mod_rewrite block below, which does. +# Both Apache 2.4 and 2.2 syntaxes are given so this works either way. +# +# Access control runs before mod_rewrite, so a hidden file answers 403 here +# while a hidden directory answers 404 from the rewrite below. Both refuse to +# serve the content, which is the point; the differing codes are cosmetic. + + + Require all denied + + + Order allow,deny + Deny from all + + + RewriteEngine On + # ------------------------------------------------------------------ + # Block hidden paths (.git, .env, .svn, …) — MUST stay first + # ------------------------------------------------------------------ + # Scanners routinely fetch /.env and /.git/config, and if /.git/index + # plus /.git/objects/… can be read the whole repository — including its + # history and any credential ever committed — can be reconstructed. + # + # Two things make this easy to get wrong: + # + # 1. does NOT stop /.git/config. FilesMatch tests + # the basename only, and the basename there is "config". Blocking a + # hidden *directory* needs a rule that sees the whole path, which in + # .htaccess means mod_rewrite ( is server-config only). + # + # 2. The SPA fallback further down only rewrites paths that do NOT + # exist (!-f / !-d). A real .env on disk therefore skips the + # fallback and gets served as a plain file. So this rule has to come + # before it, not after. + # + # .well-known is deliberately exempt: blocking it breaks Let's Encrypt + # (ACME) certificate issuance and renewal. + # + # 404 rather than 403, so a probe learns nothing about what exists. + RewriteCond %{REQUEST_URI} !^/\.well-known/ + RewriteRule (^|/)\. - [R=404,L] + + # Files that are never meant to be fetched over HTTP, if one is ever + # deployed into the document root by mistake. + RewriteRule \.(sql|sqlite|sqlite3|db|bak|backup|old|orig|save|swp|log|ini|conf|key|pem|crt|p12|pfx)$ - [R=404,L,NC] + RewriteRule (^|/)(composer\.(json|lock)|package(-lock)?\.json|yarn\.lock|Dockerfile|docker-compose\.ya?ml|\.dockerignore)$ - [R=404,L,NC] + # Clean URL for the iframe embed endpoint: /embed → embed.php # Must come BEFORE the SPA fallback so /embed doesn't get routed to # index.php. Query strings (?mode=permit&...) pass through automatically. diff --git a/docs/apache-hardening.conf b/docs/apache-hardening.conf new file mode 100644 index 0000000..3a61980 --- /dev/null +++ b/docs/apache-hardening.conf @@ -0,0 +1,142 @@ +# ============================================================================ +# LUPMIS2 — Apache hardening against source-code and secret disclosure +# ============================================================================ +# +# WHY THIS EXISTS +# --------------- +# This is preventive hardening. No LUPMIS host is known to have been +# compromised — these rules are here so that the most common automated attack +# against a PHP deployment cannot succeed. +# +# Scanners continuously probe every public host for two things: +# +# /.env — application secrets: database passwords, API keys +# /.git/… — the repository, if a working copy was deployed +# +# The second is the more damaging of the two. Given /.git/index together with +# /.git/objects, an attacker can reconstruct the entire repository: every file +# and every past commit. That means any credential ever committed is exposed, +# even if it has since been changed, because it remains in the history. +# +# That last point is directly relevant to LUPMIS. The minio-uploads +# integration has had access keys written into upload.php, download.php and +# get_file_url.php, and those files are tracked in Gitea — so the keys are in +# the history regardless of later rotation. The exposure is only theoretical +# while that repository is private and its .git directory is not web-served; +# these rules keep the second half of that sentence true. +# +# WHICH HOSTS +# ----------- +# Apply to EVERY vhost. The risk is not equal across them: +# +# • The PWA is not currently exposed — its document root is dist/, which +# contains neither .env nor .git. +# • Any app deployed by copying a working directory (a git clone) into the +# document root is exposed by construction. The minio-uploads project, for +# example, carries a .git directory and composer files. +# +# Checking is quick, from outside the network: +# curl -sS -o /dev/null -w '%{http_code}\n' https://HOST/.env +# curl -sS -o /dev/null -w '%{http_code}\n' https://HOST/.git/config +# Anything other than 403 or 404 needs attention. +# +# HOW TO USE +# ---------- +# Preferred — in the vhost or server config, where it cannot be disabled by a +# stray AllowOverride and applies to the whole tree: +# +# +# ... +# Include /etc/apache2/conf-available/lupmis-hardening.conf +# +# +# Then: a2enconf lupmis-hardening && apachectl configtest && systemctl reload apache2 +# +# If you can only use .htaccess, the equivalent rules are already in the PWA's +# public/.htaccess — but note .htaccess needs AllowOverride to be enabled, so +# the vhost form is the safer of the two. +# +# THIS IS DEFENCE IN DEPTH, NOT THE FIX +# ------------------------------------- +# The real fix is that .git and .env must not be inside a document root at all. +# Deploy build output (or `git archive`), not a working copy, and keep secrets +# in environment variables outside the served tree. These rules are what +# protects you when that goes wrong again. +# ============================================================================ + + +# --------------------------------------------------------------------------- +# 1. Hidden directories and files (.git, .env, .svn, .hg, .DS_Store, …) +# --------------------------------------------------------------------------- +# is server-config only, which is exactly the context this +# file is meant for, and it covers the whole path — so unlike it +# does stop /.git/config. +# +# .well-known is exempt on purpose: blocking it breaks Let's Encrypt (ACME) +# certificate issuance and renewal. + + + Require all denied + + + + Require all denied + + +# mod_rewrite equivalent — also covers odd forms such as /./.env, which the +# captured log shows being used, and returns 404 rather than 403 so a probe +# learns nothing about what is present. + + RewriteEngine On + RewriteCond %{REQUEST_URI} !^/\.well-known/ + RewriteRule (^|/)\. - [R=404,L] + + + +# --------------------------------------------------------------------------- +# 2. Files that should never be served, if one lands in the document root +# --------------------------------------------------------------------------- + + Require all denied + + + + Require all denied + + + +# --------------------------------------------------------------------------- +# 3. Do not advertise the software version, and do not list directories +# --------------------------------------------------------------------------- +# ServerTokens / ServerSignature are global directives — put them in the main +# server config, not inside a . +# +# ServerTokens Prod +# ServerSignature Off + +Options -Indexes + + +# --------------------------------------------------------------------------- +# 4. Proxy — make the logs usable +# --------------------------------------------------------------------------- +# Where Apache sits behind the openresty reverse proxy, it logs the proxy's +# Docker-internal address rather than the real caller. If that is the case +# here, an access log cannot tell you who probed the site, and the traffic +# cannot be blocked or reported. Have the proxy pass the caller through: +# +# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +# proxy_set_header X-Real-IP $remote_addr; +# +# and have Apache trust it, so %a in the log is the real address: +# +# RemoteIPHeader X-Forwarded-For +# RemoteIPInternalProxy 172.20.0.0/16 +# LogFormat "%a %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined +# +# (RemoteIPHeader needs mod_remoteip: a2enmod remoteip) +# +# Blocking at the proxy as well as here is worthwhile — it stops the request +# before it reaches the application at all: +# +# location ~ /\.(?!well-known) { return 404; } diff --git a/public/.htaccess b/public/.htaccess index 5169852..ac79df4 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -13,9 +13,58 @@ DirectoryIndex index.php index.html SetHandler application/x-httpd-php +# Second line of defence for hidden FILES (.env, .htpasswd, …), in case +# mod_rewrite is not loaded. This does not cover hidden directories such as +# /.git/config — see the mod_rewrite block below, which does. +# Both Apache 2.4 and 2.2 syntaxes are given so this works either way. +# +# Access control runs before mod_rewrite, so a hidden file answers 403 here +# while a hidden directory answers 404 from the rewrite below. Both refuse to +# serve the content, which is the point; the differing codes are cosmetic. + + + Require all denied + + + Order allow,deny + Deny from all + + + RewriteEngine On + # ------------------------------------------------------------------ + # Block hidden paths (.git, .env, .svn, …) — MUST stay first + # ------------------------------------------------------------------ + # Scanners routinely fetch /.env and /.git/config, and if /.git/index + # plus /.git/objects/… can be read the whole repository — including its + # history and any credential ever committed — can be reconstructed. + # + # Two things make this easy to get wrong: + # + # 1. does NOT stop /.git/config. FilesMatch tests + # the basename only, and the basename there is "config". Blocking a + # hidden *directory* needs a rule that sees the whole path, which in + # .htaccess means mod_rewrite ( is server-config only). + # + # 2. The SPA fallback further down only rewrites paths that do NOT + # exist (!-f / !-d). A real .env on disk therefore skips the + # fallback and gets served as a plain file. So this rule has to come + # before it, not after. + # + # .well-known is deliberately exempt: blocking it breaks Let's Encrypt + # (ACME) certificate issuance and renewal. + # + # 404 rather than 403, so a probe learns nothing about what exists. + RewriteCond %{REQUEST_URI} !^/\.well-known/ + RewriteRule (^|/)\. - [R=404,L] + + # Files that are never meant to be fetched over HTTP, if one is ever + # deployed into the document root by mistake. + RewriteRule \.(sql|sqlite|sqlite3|db|bak|backup|old|orig|save|swp|log|ini|conf|key|pem|crt|p12|pfx)$ - [R=404,L,NC] + RewriteRule (^|/)(composer\.(json|lock)|package(-lock)?\.json|yarn\.lock|Dockerfile|docker-compose\.ya?ml|\.dockerignore)$ - [R=404,L,NC] + # Clean URL for the iframe embed endpoint: /embed → embed.php # Must come BEFORE the SPA fallback so /embed doesn't get routed to # index.php. Query strings (?mode=permit&...) pass through automatically.