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.
This commit is contained in:
@@ -16,3 +16,14 @@ jobs:
|
|||||||
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
|
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
|
||||||
chmod 600 ~/.ssh/deploy_key
|
chmod 600 ~/.ssh/deploy_key
|
||||||
ssh -o StrictHostKeyChecking=accept-new -i ~/.ssh/deploy_key deploy@${{ vars.DEPLOY_HOST }} deploy-nginx
|
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
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ server {
|
|||||||
ssl_certificate /etc/letsencrypt/live/garage.hantim.net/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/garage.hantim.net/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/garage.hantim.net/privkey.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 / {
|
location / {
|
||||||
proxy_pass http://garage:3903;
|
proxy_pass http://garage:3903;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ server {
|
|||||||
ssl_certificate /etc/letsencrypt/live/haanmind.net/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/haanmind.net/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/haanmind.net/privkey.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/ {
|
location /media/ {
|
||||||
proxy_pass http://garage:3902/;
|
proxy_pass http://garage:3902/;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ server {
|
|||||||
ssl_certificate /etc/letsencrypt/live/hantim.net/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/hantim.net/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/hantim.net/privkey.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/ {
|
location /media/ {
|
||||||
proxy_pass http://garage:3902/;
|
proxy_pass http://garage:3902/;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ server {
|
|||||||
ssl_certificate /etc/letsencrypt/live/hcsuzuki.net/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/hcsuzuki.net/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/hcsuzuki.net/privkey.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/ {
|
location /media/ {
|
||||||
proxy_pass http://garage:3902/;
|
proxy_pass http://garage:3902/;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ server {
|
|||||||
ssl_certificate /etc/letsencrypt/live/s3.hantim.net/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/s3.hantim.net/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/s3.hantim.net/privkey.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;
|
client_max_body_size 100M;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -36,7 +36,7 @@ server {
|
|||||||
ssl_certificate /etc/letsencrypt/live/thekims.family/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/thekims.family/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/thekims.family/privkey.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/ {
|
location /media/ {
|
||||||
proxy_pass http://garage:3902/;
|
proxy_pass http://garage:3902/;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ server {
|
|||||||
ssl_certificate /etc/letsencrypt/live/timothykim.net/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/timothykim.net/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/timothykim.net/privkey.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/ {
|
location /media/ {
|
||||||
proxy_pass http://garage:3902/;
|
proxy_pass http://garage:3902/;
|
||||||
|
|||||||
@@ -13,8 +13,5 @@ http {
|
|||||||
access_log /var/log/nginx/access.log;
|
access_log /var/log/nginx/access.log;
|
||||||
error_log /var/log/nginx/error.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;
|
include /etc/nginx/conf.d/*.conf;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
+1
-1
@@ -339,7 +339,7 @@ server {
|
|||||||
ssl_certificate /etc/letsencrypt/live/$APP/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/$APP/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/$APP/privkey.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/ {
|
location /media/ {
|
||||||
proxy_pass http://garage:3902/;
|
proxy_pass http://garage:3902/;
|
||||||
|
|||||||
Reference in New Issue
Block a user