From cc07a34489db67b609143390966bb1d65499d42f Mon Sep 17 00:00:00 2001 From: Timothy Kim Date: Fri, 20 Mar 2026 15:11:56 -0400 Subject: [PATCH] fix nginx security headers not being applied to https responses nginx add_header inheritance meant X-Content-Type-Options and X-Frame-Options were silently dropped from all https server blocks. moved all security headers into a shared snippet (security-headers.inc) included per server block. added pytest-based header verification that auto-discovers sites from conf files. --- .gitea/workflows/deploy-nginx.yml | 11 + SECURITY.md | 538 +++++++++++++++++++++ docker/nginx/conf.d/garage.hantim.net.conf | 2 +- docker/nginx/conf.d/haanmind.net.conf | 2 +- docker/nginx/conf.d/hantim.net.conf | 2 +- docker/nginx/conf.d/hcsuzuki.net.conf | 2 +- docker/nginx/conf.d/s3.hantim.net.conf | 2 +- docker/nginx/conf.d/security-headers.inc | 5 + docker/nginx/conf.d/thekims.family.conf | 2 +- docker/nginx/conf.d/timothykim.net.conf | 2 +- docker/nginx/nginx.conf | 3 - test/test_security_headers.py | 60 +++ tools/app.sh | 2 +- 13 files changed, 622 insertions(+), 11 deletions(-) create mode 100644 SECURITY.md create mode 100644 docker/nginx/conf.d/security-headers.inc create mode 100644 test/test_security_headers.py diff --git a/.gitea/workflows/deploy-nginx.yml b/.gitea/workflows/deploy-nginx.yml index 9de5436..aceed54 100644 --- a/.gitea/workflows/deploy-nginx.yml +++ b/.gitea/workflows/deploy-nginx.yml @@ -16,3 +16,14 @@ jobs: echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key chmod 600 ~/.ssh/deploy_key ssh -o StrictHostKeyChecking=accept-new -i ~/.ssh/deploy_key deploy@${{ vars.DEPLOY_HOST }} deploy-nginx + + test: + needs: deploy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.x' + - run: pip install pytest + - run: pytest test/ -v diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f2a76b2 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,538 @@ +# Security Analysis + +Vulnerability assessment of the hantim-server infrastructure. Each finding +includes severity, affected files, explanation, and a proposed fix. Sorted +from highest to lowest severity. + +--- + +## HIGH + +### 1. Security headers silently dropped on all HTTPS responses + +**Affected:** `docker/nginx/nginx.conf`, all `conf.d/*.conf` + +The `http` block in `nginx.conf` defines: + +```nginx +add_header X-Content-Type-Options nosniff; +add_header X-Frame-Options DENY; +``` + +But nginx's `add_header` inheritance rule means these are **dropped** from +any block that defines its own `add_header`. Every HTTPS server block adds +`Strict-Transport-Security`, which causes the parent headers to stop being +inherited. The result: all site responses have HSTS but **no** +`X-Content-Type-Options` and **no** `X-Frame-Options`. + +This is a well-documented nginx gotcha. Sites are currently vulnerable to +clickjacking and MIME-type sniffing attacks. + +**Fix:** Add all security headers in every server block that uses +`add_header`, or move them into a shared snippet. The simplest approach is +a snippet file included from each HTTPS server block: + +```nginx +# docker/nginx/conf.d/security-headers.inc +add_header Strict-Transport-Security "max-age=63072000; preload" always; +add_header X-Content-Type-Options "nosniff" always; +add_header X-Frame-Options "DENY" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; +``` + +Then in each HTTPS server block, replace the standalone HSTS line with: + +```nginx +include /etc/nginx/conf.d/security-headers.inc; +``` + +And remove the (now-dead) `add_header` lines from `nginx.conf`'s `http` +block. + +--- + +### 2. Garage admin API exposed to the internet with only token auth + +**Affected:** `docker/nginx/conf.d/garage.hantim.net.conf` + +The Garage admin API (port 3903) is proxied through nginx to the public +internet at `garage.hantim.net`. The only protection is the Bearer token. +No IP restriction, no rate limiting, no fail2ban. Anyone who obtains or +brute-forces the admin token can create/delete buckets, manage access keys, +and read/write all stored data. + +**Fix:** Restrict access by source IP. Only the developer machine and +argento need access: + +```nginx +location / { + allow /32; + allow /32; + deny all; + + proxy_pass http://garage:3903; +} +``` + +If IP restriction isn't practical, add rate limiting at minimum: + +```nginx +limit_req_zone $binary_remote_addr zone=admin:1m rate=5r/s; + +location / { + limit_req zone=admin burst=10 nodelay; + proxy_pass http://garage:3903; +} +``` + +--- + +### 3. Docker group membership gives deploy user root-equivalent access + +**Affected:** `scripts/bootstrap.sh:98` + +```bash +usermod -aG docker deploy +``` + +Any member of the `docker` group can run +`docker run -v /:/host alpine chroot /host` and get full root access. This +undermines the SSH `command=` restriction and the scoped sudoers entry. + +The docker group is unnecessary here: the SSH `authorized_keys` entry runs +`sudo /opt/hantim/scripts/deploy.sh`, so deploy.sh already executes as +root. Docker commands within it don't need the deploy user in the docker +group. + +**Fix:** Remove the line. The sudoers entry already covers the deploy +flow: + +```bash +# Remove this line from bootstrap.sh: +# usermod -aG docker deploy +``` + +If the user is already in the group on an existing server: + +```bash +gpasswd -d deploy docker +``` + +--- + +### 4. All secrets fetched on every deploy (bws secret list in a loop) + +**Affected:** `scripts/deploy.sh:62`, `tools/app.sh:98` + +`deploy.sh` calls `bws secret list` (which returns **every** secret for +the machine account) once per line in `.env.keys`: + +```bash +while IFS= read -r line; do + ... + value=$(bws secret list | jq -r --arg key "$secret_key" ...) +done < "docker/$APP/.env.keys" +``` + +For garage with 3 secrets, this fetches the full secret list 3 times. All +secrets are materialized in a pipe buffer each time. A core dump, `/proc` +read, or process trace during this window exposes everything. + +**Fix:** Fetch the list once, filter locally: + +```bash +ALL_SECRETS=$(bws secret list) +while IFS= read -r line || [ -n "$line" ]; do + [ -z "$line" ] && continue + var_name="${line%%=*}" + secret_key="${line#*=}" + value=$(echo "$ALL_SECRETS" | jq -r --arg key "$secret_key" \ + '.[] | select(.key == $key) | .value') + if [ -z "$value" ]; then + echo "ERROR: Secret '$secret_key' not found in bws." + exit 1 + fi + env_content="${env_content}${var_name}=${value}"$'\n' +done < "docker/$APP/.env.keys" +unset ALL_SECRETS +``` + +Even better, use `bws secret get ` to fetch individual secrets by ID +instead of listing all. + +--- + +### 5. Garage RPC port open to the internet + +**Affected:** `docker/garage/compose.yml:12`, `scripts/configure.sh:20` + +Port 3901 is bound to the host and opened in the firewall. This is needed +for cluster replication with argento, but it means anyone on the internet +can attempt RPC connections. The only protection is the `rpc_secret`. + +**Fix:** Restrict the firewall rule to argento's IP only: + +```bash +# In configure.sh, replace: +firewall-cmd --permanent --add-port=3901/tcp + +# With: +firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="/32" port port="3901" protocol="tcp" accept' +``` + +--- + +## MEDIUM + +### 6. No SSL/TLS hardening in nginx + +**Affected:** `docker/nginx/nginx.conf`, all `conf.d/*.conf` + +No `ssl_protocols`, `ssl_ciphers`, `ssl_session_cache`, or OCSP stapling +is configured. The nginx:alpine image's defaults may allow TLS 1.0/1.1 and +weak cipher suites depending on the version. + +Certbot writes `options-ssl-nginx.conf` to `/etc/letsencrypt/` (which is +mounted into the container), but no conf file includes it. + +**Fix:** Add TLS hardening to `nginx.conf` inside the `http` block: + +```nginx +ssl_protocols TLSv1.2 TLSv1.3; +ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; +ssl_prefer_server_ciphers off; +ssl_session_cache shared:SSL:10m; +ssl_session_timeout 1d; +ssl_session_tickets off; +ssl_stapling on; +ssl_stapling_verify on; +resolver 1.1.1.1 8.8.8.8 valid=300s; +resolver_timeout 5s; +``` + +Or include certbot's config: + +```nginx +include /etc/letsencrypt/options-ssl-nginx.conf; +ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; +``` + +--- + +### 7. No default server block to catch unknown Host headers + +**Affected:** `docker/nginx/conf.d/` + +Without a catch-all server block, requests with unknown `Host` headers are +served by whichever conf file nginx loads first (alphabetically). This +leaks which sites are hosted and can be used for reconnaissance. + +**Fix:** Add a default server block: + +```nginx +# docker/nginx/conf.d/00-default.conf +server { + listen 80 default_server; + listen [::]:80 default_server; + listen 443 ssl default_server; + listen [::]:443 ssl default_server; + http2 on; + server_name _; + + ssl_certificate /etc/letsencrypt/live/hantim.net/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/hantim.net/privkey.pem; + + return 444; +} +``` + +The `444` status silently drops the connection. + +--- + +### 8. No rate limiting on any endpoint + +**Affected:** `docker/nginx/nginx.conf`, all `conf.d/*.conf` + +No `limit_req` or `limit_conn` zones are defined. The server is vulnerable +to brute-force attacks against the S3 and admin APIs, and to connection +exhaustion / slowloris attacks. + +**Fix:** Add rate limiting zones to `nginx.conf`: + +```nginx +http { + limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s; + limit_req_zone $binary_remote_addr zone=api:10m rate=2r/s; + ... +} +``` + +Apply them in server blocks: + +```nginx +# In s3.hantim.net.conf and garage.hantim.net.conf: +limit_req zone=api burst=20 nodelay; + +# In site server blocks: +limit_req zone=general burst=30 nodelay; +``` + +--- + +### 9. envsubst replaces all environment variables in garage.toml + +**Affected:** `docker/garage/Dockerfile:8` + +```dockerfile +CMD envsubst < /etc/garage.toml.tpl > /etc/garage.toml && exec /garage server +``` + +`envsubst` without arguments replaces every `$VAR` or `${VAR}` pattern. If +any other environment variable (e.g., `$HOME`, `$PATH`) matches a pattern +in the template, it will be silently substituted. This is fragile and could +break the config in unexpected ways. + +**Fix:** Specify the variables explicitly: + +```dockerfile +CMD envsubst '${RPC_SECRET} ${ARGENTO_NODE_ID} ${ADMIN_TOKEN}' < /etc/garage.toml.tpl > /etc/garage.toml && exec /garage server +``` + +--- + +### 10. StrictHostKeyChecking=accept-new trusts first connection blindly + +**Affected:** `.gitea/workflows/*.yml`, `tools/app.sh:367` + +All SSH connections use `-o StrictHostKeyChecking=accept-new`, which trusts +whatever host key is presented on first contact. A man-in-the-middle during +the first CI run (or after server reprovisioning) could intercept the +deploy connection and capture the SSH private key. + +**Fix:** Pin the server's host key in workflows. Add a step before SSH: + +```yaml +- name: Configure SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + echo "${{ vars.DEPLOY_HOST_KEY }}" >> ~/.ssh/known_hosts + ssh -o StrictHostKeyChecking=yes -i ~/.ssh/deploy_key deploy@${{ vars.DEPLOY_HOST }} deploy-nginx +``` + +Store the server's host public key as a Gitea variable (`DEPLOY_HOST_KEY`). + +--- + +### 11. API tokens visible in process listings + +**Affected:** `tools/app.sh` (lines 134, 139, 225, etc.) + +Secrets passed as curl header arguments appear in `ps aux` and +`/proc//cmdline` for the duration of the curl call: + +```bash +VULTR_AUTH="Authorization: Bearer $VULTR_API_KEY" +curl -s "$VULTR_API/domains/$APP" -H "$VULTR_AUTH" +``` + +**Fix:** Use curl's `--header @-` or `-H @` to read headers from +stdin/file instead of command arguments: + +```bash +VULTR_AUTH_FILE=$(mktemp) +echo "Authorization: Bearer $VULTR_API_KEY" > "$VULTR_AUTH_FILE" +chmod 600 "$VULTR_AUTH_FILE" +trap "rm -f $VULTR_AUTH_FILE" EXIT +curl -s "$VULTR_API/domains/$APP" -H "@$VULTR_AUTH_FILE" +``` + +Or use a `.netrc` file for authentication. + +--- + +### 12. No container resource limits + +**Affected:** All `docker/*/compose.yml` + +No memory or CPU limits are set on any container. A runaway container +(memory leak, fork bomb, or compromised process) can consume all host +resources and take down every service. + +**Fix:** Add resource limits to compose files: + +```yaml +services: + app: + ... + deploy: + resources: + limits: + memory: 128M + cpus: '0.5' +``` + +For nginx and garage, use higher limits appropriate to their workload. + +--- + +### 13. Brief nginx downtime during initial cert issuance + +**Affected:** `scripts/deploy.sh:87-114` + +When deploying nginx with new domains that need certs, the script stops +nginx, runs `certbot --standalone`, then starts nginx. During this window +(potentially minutes if multiple certs are needed), all sites are down. + +**Fix:** Issue certs via the `cert-` SSH command (which uses +`--webroot` and causes zero downtime) before deploying the nginx config. +The `app.sh` already does this (`cmd_cert` runs before nginx gets the new +config). For the standalone path, consider using certbot's `--preferred-challenges dns` +or pre-issuing certs in a separate step. + +--- + +### 14. Hardcoded Bitwarden secret UUIDs + +**Affected:** `scripts/bootstrap.sh:21-22` + +```bash +SECRET_REGISTRY_TOKEN="43f4a77d-7bb4-49ad-8044-b411016c5c6e" +SECRET_DEPLOY_PUBKEY="945760dd-90e2-46e5-98ad-b411016e3f3b" +``` + +UUIDs identify specific secrets. If this repo is exposed, an attacker +knows exactly which secret IDs to target. Using `bws secret list` with +name lookup (like the rest of the codebase does) is more consistent and +doesn't leak UUIDs. + +**Fix:** Use name-based lookup like `deploy.sh` and `app.sh` do: + +```bash +REGISTRY_TOKEN=$(bws secret list | jq -r '.[] | select(.key == "hantim-ci-registry-push") | .value') +DEPLOY_PUBKEY=$(bws secret list | jq -r '.[] | select(.key == "hantim-deploy-ssh-public-key") | .value') +``` + +--- + +### 15. certbot registers without email + +**Affected:** `scripts/deploy.sh:29, 109` + +```bash +certbot certonly --standalone --non-interactive --agree-tos \ + --register-unsafely-without-email ... +``` + +Without an email, Let's Encrypt cannot send expiration warnings. If the +certbot renewal timer/cron breaks silently, certificates expire without +notice, causing site outages. + +**Fix:** Register with an email: + +```bash +certbot certonly --standalone --non-interactive --agree-tos \ + -m admin@hantim.net ... +``` + +--- + +## LOW + +### 16. Certbot renewal hook has no error handling + +**Affected:** `scripts/configure.sh:26-30` + +```bash +cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'HOOK' +#!/bin/bash +docker exec nginx nginx -s reload +HOOK +``` + +If the nginx container is down when certs renew, the reload fails silently. +The new cert won't take effect until nginx is manually restarted. + +**Fix:** + +```bash +#!/bin/bash +set -e +if ! docker exec nginx nginx -s reload; then + logger -t certbot "ERROR: Failed to reload nginx after cert renewal" + exit 1 +fi +``` + +--- + +### 17. No audit logging for deploy operations + +**Affected:** `scripts/deploy.sh` + +There is no logging of who triggered a deploy, what changed, or when +secrets were accessed. If the server is compromised, there's no audit trail. + +**Fix:** Add logging to deploy.sh: + +```bash +logger -t hantim-deploy "deploy started: cmd=$CMD user=$(whoami) ssh_client=${SSH_CLIENT:-local}" +``` + +And at the end: + +```bash +logger -t hantim-deploy "deploy finished: cmd=$CMD" +``` + +--- + +### 18. git pull without explicit branch + +**Affected:** `scripts/deploy.sh:39, 51` + +```bash +git pull +``` + +If the repo's default tracking branch changes or is misconfigured, `git +pull` could pull the wrong branch. + +**Fix:** + +```bash +git pull origin main +``` + +--- + +## Positive security practices already in place + +- SSH `command=` restriction limits deploy user to specific commands +- Deploy user has `nologin` shell and scoped sudoers entry +- All scripts use `set -euo pipefail` +- HTTPS enforced everywhere with HTTP-to-HTTPS redirects +- HSTS with preload on all main server blocks +- `.env` files are `chmod 600` and gitignored +- BWS token file is `chmod 600` +- Docker network isolation: only nginx binds host ports +- Input validation on domain names with regex in `deploy.sh` and `app.sh` +- Idempotent scripts safe to re-run +- Certbot auto-renewal configured via timer or cron +- `nginx -t` config test before applying changes + +--- + +## Recommended priority + +1. Fix security headers (item 1) — actual bug, every site affected now +2. Restrict Garage admin API access (item 2) +3. Remove deploy user from docker group (item 3) +4. Add SSL/TLS hardening (item 6) +5. Add default server block (item 7) +6. Restrict Garage RPC firewall rule to argento IP (item 5) +7. Fix bws secret list loop (item 4) +8. Everything else as time permits diff --git a/docker/nginx/conf.d/garage.hantim.net.conf b/docker/nginx/conf.d/garage.hantim.net.conf index e1f8e69..648902b 100644 --- a/docker/nginx/conf.d/garage.hantim.net.conf +++ b/docker/nginx/conf.d/garage.hantim.net.conf @@ -22,7 +22,7 @@ server { ssl_certificate /etc/letsencrypt/live/garage.hantim.net/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/garage.hantim.net/privkey.pem; - add_header Strict-Transport-Security "max-age=63072000; preload" always; + include /etc/nginx/conf.d/security-headers.inc; location / { proxy_pass http://garage:3903; diff --git a/docker/nginx/conf.d/haanmind.net.conf b/docker/nginx/conf.d/haanmind.net.conf index b3a009e..7ebff9f 100644 --- a/docker/nginx/conf.d/haanmind.net.conf +++ b/docker/nginx/conf.d/haanmind.net.conf @@ -36,7 +36,7 @@ server { ssl_certificate /etc/letsencrypt/live/haanmind.net/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/haanmind.net/privkey.pem; - add_header Strict-Transport-Security "max-age=63072000; preload" always; + include /etc/nginx/conf.d/security-headers.inc; location /media/ { proxy_pass http://garage:3902/; diff --git a/docker/nginx/conf.d/hantim.net.conf b/docker/nginx/conf.d/hantim.net.conf index ab80708..094a1b8 100644 --- a/docker/nginx/conf.d/hantim.net.conf +++ b/docker/nginx/conf.d/hantim.net.conf @@ -36,7 +36,7 @@ server { ssl_certificate /etc/letsencrypt/live/hantim.net/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/hantim.net/privkey.pem; - add_header Strict-Transport-Security "max-age=63072000; preload" always; + include /etc/nginx/conf.d/security-headers.inc; location /media/ { proxy_pass http://garage:3902/; diff --git a/docker/nginx/conf.d/hcsuzuki.net.conf b/docker/nginx/conf.d/hcsuzuki.net.conf index 64f3b2b..cef0b88 100644 --- a/docker/nginx/conf.d/hcsuzuki.net.conf +++ b/docker/nginx/conf.d/hcsuzuki.net.conf @@ -36,7 +36,7 @@ server { ssl_certificate /etc/letsencrypt/live/hcsuzuki.net/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/hcsuzuki.net/privkey.pem; - add_header Strict-Transport-Security "max-age=63072000; preload" always; + include /etc/nginx/conf.d/security-headers.inc; location /media/ { proxy_pass http://garage:3902/; diff --git a/docker/nginx/conf.d/s3.hantim.net.conf b/docker/nginx/conf.d/s3.hantim.net.conf index 3ec8b81..2d8cd25 100644 --- a/docker/nginx/conf.d/s3.hantim.net.conf +++ b/docker/nginx/conf.d/s3.hantim.net.conf @@ -22,7 +22,7 @@ server { ssl_certificate /etc/letsencrypt/live/s3.hantim.net/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/s3.hantim.net/privkey.pem; - add_header Strict-Transport-Security "max-age=63072000; preload" always; + include /etc/nginx/conf.d/security-headers.inc; client_max_body_size 100M; diff --git a/docker/nginx/conf.d/security-headers.inc b/docker/nginx/conf.d/security-headers.inc new file mode 100644 index 0000000..73111f9 --- /dev/null +++ b/docker/nginx/conf.d/security-headers.inc @@ -0,0 +1,5 @@ +add_header Strict-Transport-Security "max-age=63072000; preload" always; +add_header X-Content-Type-Options "nosniff" always; +add_header X-Frame-Options "DENY" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; diff --git a/docker/nginx/conf.d/thekims.family.conf b/docker/nginx/conf.d/thekims.family.conf index 43dfedc..baf12d1 100644 --- a/docker/nginx/conf.d/thekims.family.conf +++ b/docker/nginx/conf.d/thekims.family.conf @@ -36,7 +36,7 @@ server { ssl_certificate /etc/letsencrypt/live/thekims.family/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/thekims.family/privkey.pem; - add_header Strict-Transport-Security "max-age=63072000; preload" always; + include /etc/nginx/conf.d/security-headers.inc; location /media/ { proxy_pass http://garage:3902/; diff --git a/docker/nginx/conf.d/timothykim.net.conf b/docker/nginx/conf.d/timothykim.net.conf index bee0af3..d09d1de 100644 --- a/docker/nginx/conf.d/timothykim.net.conf +++ b/docker/nginx/conf.d/timothykim.net.conf @@ -36,7 +36,7 @@ server { ssl_certificate /etc/letsencrypt/live/timothykim.net/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/timothykim.net/privkey.pem; - add_header Strict-Transport-Security "max-age=63072000; preload" always; + include /etc/nginx/conf.d/security-headers.inc; location /media/ { proxy_pass http://garage:3902/; diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index 364b7fb..4e0c893 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -13,8 +13,5 @@ http { access_log /var/log/nginx/access.log; error_log /var/log/nginx/error.log; - add_header X-Content-Type-Options nosniff; - add_header X-Frame-Options DENY; - include /etc/nginx/conf.d/*.conf; } diff --git a/test/test_security_headers.py b/test/test_security_headers.py new file mode 100644 index 0000000..be2b69a --- /dev/null +++ b/test/test_security_headers.py @@ -0,0 +1,60 @@ +"""Test that security headers are present on all HTTPS sites.""" + +import glob +import os +import re +import urllib.request +import ssl + +import pytest + +CONF_DIR = os.path.join(os.path.dirname(__file__), "..", "docker", "nginx", "conf.d") + + +def discover_sites(): + """Build HTTPS URLs from nginx conf files that include security-headers.inc.""" + sites = [] + for conf in sorted(glob.glob(os.path.join(CONF_DIR, "*.conf"))): + text = open(conf).read() + if "security-headers.inc" not in text: + continue + # Use the first server_name in the block that includes the headers + for block in re.split(r"(?=server\s*\{)", text): + if "security-headers.inc" in block: + m = re.search(r"server_name\s+([^;]+);", block) + if m: + sites.append(f"https://{m.group(1).split()[0]}") + break + return sites + + +SITES = discover_sites() + +EXPECTED_HEADERS = { + "Strict-Transport-Security": "max-age=63072000; preload", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Permissions-Policy": "geolocation=(), microphone=(), camera=()", +} + + +def get_headers(url): + ctx = ssl.create_default_context() + req = urllib.request.Request(url, method="HEAD") + try: + resp = urllib.request.urlopen(req, context=ctx, timeout=10) + return dict(resp.headers) + except urllib.error.HTTPError as e: + return dict(e.headers) + + +@pytest.mark.parametrize("url", SITES, ids=lambda u: u.split("//")[1]) +def test_security_headers(url): + headers = get_headers(url) + missing = [] + for name, expected in EXPECTED_HEADERS.items(): + actual = headers.get(name) + if actual != expected: + missing.append(f"{name}: expected '{expected}', got '{actual}'") + assert not missing, "\n".join(missing) diff --git a/tools/app.sh b/tools/app.sh index 3136f3e..7660db1 100755 --- a/tools/app.sh +++ b/tools/app.sh @@ -339,7 +339,7 @@ server { ssl_certificate /etc/letsencrypt/live/$APP/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/$APP/privkey.pem; - add_header Strict-Transport-Security "max-age=63072000; preload" always; + include /etc/nginx/conf.d/security-headers.inc; location /media/ { proxy_pass http://garage:3902/;