From b7439b23e358b7e573ca6a2541b14331e37ce25e 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 ++++ 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 +- 12 files changed, 84 insertions(+), 11 deletions(-) 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/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/;