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>
97 lines
4.5 KiB
ApacheConf
97 lines
4.5 KiB
ApacheConf
# ============================================================================
|
|
# LUPMIS2 PWA — Apache config
|
|
# ============================================================================
|
|
|
|
# Apache's default DirectoryIndex order serves index.html before index.php.
|
|
# We need the opposite so the SSO-aware index.php gets a chance to run first,
|
|
# inject session data into the page, and then return the index.html content.
|
|
DirectoryIndex index.php index.html
|
|
|
|
# Make sure .php files are executed (defensive — usually enabled site-wide,
|
|
# but explicit here in case the deployment dropped this association).
|
|
<FilesMatch "\.php$">
|
|
SetHandler application/x-httpd-php
|
|
</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>
|
|
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
|
|
# Must come BEFORE the SPA fallback so /embed doesn't get routed to
|
|
# index.php. Query strings (?mode=permit&...) pass through automatically.
|
|
RewriteRule ^embed/?$ embed.php [L]
|
|
|
|
# Common single-page-app behaviour: if a route doesn't map to a real file
|
|
# or directory, send the request to index.php so the PWA can handle it
|
|
# client-side. Comment out this block if hash-based routing is preferred.
|
|
RewriteCond %{REQUEST_FILENAME} !-f
|
|
RewriteCond %{REQUEST_FILENAME} !-d
|
|
RewriteRule ^ index.php [L]
|
|
</IfModule>
|
|
|
|
# Iframe-policy override for the embed endpoint. Some Apache deployments set
|
|
# `X-Frame-Options: SAMEORIGIN` as a default security header for every
|
|
# response — that prevents `permits.lupmis4luspa.org` from framing
|
|
# `pwa.lupmis4luspa.org/embed`, even though our Content-Security-Policy
|
|
# `frame-ancestors` directive explicitly allows it. Safari prefers
|
|
# `X-Frame-Options` when both are present, so we have to remove it.
|
|
#
|
|
# We unset it ONLY for embed.php (so index.php still inherits the
|
|
# site-wide SAMEORIGIN protection against clickjacking). embed.php's own
|
|
# `Content-Security-Policy: frame-ancestors` header (set in PHP) is then
|
|
# the sole iframe-policy header and permits the configured embedder.
|
|
<IfModule mod_headers.c>
|
|
<Files "embed.php">
|
|
Header always unset X-Frame-Options
|
|
</Files>
|
|
</IfModule>
|