Apache hardening: block .git and .env disclosure
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: - <FilesMatch "^\."> 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, <DirectoryMatch> 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 <noreply@anthropic.com>
This commit is contained in:
parent
7d26c740df
commit
664c242980
49
dist/.htaccess
vendored
49
dist/.htaccess
vendored
@ -13,9 +13,58 @@ DirectoryIndex index.php index.html
|
|||||||
SetHandler application/x-httpd-php
|
SetHandler application/x-httpd-php
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
<FilesMatch "^\.">
|
||||||
|
<IfModule mod_authz_core.c>
|
||||||
|
Require all denied
|
||||||
|
</IfModule>
|
||||||
|
<IfModule !mod_authz_core.c>
|
||||||
|
Order allow,deny
|
||||||
|
Deny from all
|
||||||
|
</IfModule>
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
<IfModule mod_rewrite.c>
|
<IfModule mod_rewrite.c>
|
||||||
RewriteEngine On
|
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. <FilesMatch "^\."> 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 (<DirectoryMatch> 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
|
# Clean URL for the iframe embed endpoint: /embed → embed.php
|
||||||
# Must come BEFORE the SPA fallback so /embed doesn't get routed to
|
# Must come BEFORE the SPA fallback so /embed doesn't get routed to
|
||||||
# index.php. Query strings (?mode=permit&...) pass through automatically.
|
# index.php. Query strings (?mode=permit&...) pass through automatically.
|
||||||
|
|||||||
142
docs/apache-hardening.conf
Normal file
142
docs/apache-hardening.conf
Normal file
@ -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:
|
||||||
|
#
|
||||||
|
# <VirtualHost *:443>
|
||||||
|
# ...
|
||||||
|
# Include /etc/apache2/conf-available/lupmis-hardening.conf
|
||||||
|
# </VirtualHost>
|
||||||
|
#
|
||||||
|
# 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, …)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# <DirectoryMatch> is server-config only, which is exactly the context this
|
||||||
|
# file is meant for, and it covers the whole path — so unlike <FilesMatch> it
|
||||||
|
# does stop /.git/config.
|
||||||
|
#
|
||||||
|
# .well-known is exempt on purpose: blocking it breaks Let's Encrypt (ACME)
|
||||||
|
# certificate issuance and renewal.
|
||||||
|
|
||||||
|
<DirectoryMatch "/\.(?!well-known)">
|
||||||
|
Require all denied
|
||||||
|
</DirectoryMatch>
|
||||||
|
|
||||||
|
<FilesMatch "^\.">
|
||||||
|
Require all denied
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteCond %{REQUEST_URI} !^/\.well-known/
|
||||||
|
RewriteRule (^|/)\. - [R=404,L]
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Files that should never be served, if one lands in the document root
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
<FilesMatch "\.(sql|sqlite|sqlite3|db|bak|backup|old|orig|save|swp|swo|log|ini|conf|key|pem|crt|p12|pfx|yml|yaml)$">
|
||||||
|
Require all denied
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
<FilesMatch "^(composer\.(json|lock)|package(-lock)?\.json|yarn\.lock|Dockerfile|docker-compose\.ya?ml|Makefile|README\.md|\.htpasswd)$">
|
||||||
|
Require all denied
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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 <VirtualHost>.
|
||||||
|
#
|
||||||
|
# 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; }
|
||||||
@ -13,9 +13,58 @@ DirectoryIndex index.php index.html
|
|||||||
SetHandler application/x-httpd-php
|
SetHandler application/x-httpd-php
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
<FilesMatch "^\.">
|
||||||
|
<IfModule mod_authz_core.c>
|
||||||
|
Require all denied
|
||||||
|
</IfModule>
|
||||||
|
<IfModule !mod_authz_core.c>
|
||||||
|
Order allow,deny
|
||||||
|
Deny from all
|
||||||
|
</IfModule>
|
||||||
|
</FilesMatch>
|
||||||
|
|
||||||
<IfModule mod_rewrite.c>
|
<IfModule mod_rewrite.c>
|
||||||
RewriteEngine On
|
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. <FilesMatch "^\."> 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 (<DirectoryMatch> 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
|
# Clean URL for the iframe embed endpoint: /embed → embed.php
|
||||||
# Must come BEFORE the SPA fallback so /embed doesn't get routed to
|
# Must come BEFORE the SPA fallback so /embed doesn't get routed to
|
||||||
# index.php. Query strings (?mode=permit&...) pass through automatically.
|
# index.php. Query strings (?mode=permit&...) pass through automatically.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user