fix(nginx): stop pinning HSTS — actively clear it instead
The HTTPS server block sent Strict-Transport-Security: max-age=31536000; includeSubDomains. Browsers that visited HTTPS once cached the policy and then silently upgraded the still-open HTTP dashboard's fetches and frames to https — a scheme change is cross-origin, so every /rpc/v1 call died 'No Access-Control-Allow- Origin header' while the node was perfectly healthy (framework-pt 2026-09-01: the 'Failed to fetch' storm, dashboard 'not responding', every app frame mixed-content-blocked). Plain HTTP is a supported access mode BY DESIGN on this platform: the node's certificate is optional and self-signed (Settings → Node certificate, /ca.crt flow), and setup-node-ca.sh deliberately keeps port 80 serving for devices that haven't installed the CA. So: - port 80 sends no HSTS at all (with the rationale inline) - port 443 sends max-age=0, which ACTIVELY DELETES the policy already cached by affected browsers — leaving it absent would have kept every stranded browser broken for a year tests/lifecycle/bats/nginx-hsts.bats pins all three properties at the gate: no live policy on :80, max-age=0 (never 31536000) on :443, and no long-lived pin anywhere in the deployed config.
This commit is contained in:
@@ -34,7 +34,14 @@ server {
|
|||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
# NO HSTS on this node, by design (see the HTTPS block below for the
|
||||||
|
# active clear). The dashboard is deliberately reachable over plain
|
||||||
|
# HTTP on LANs/mDNS names where users have not installed the node CA —
|
||||||
|
# setup-node-ca.sh keeps port 80 serving for exactly that reason. A
|
||||||
|
# long-cache HSTS policy upgrades an already-open HTTP page's fetches to
|
||||||
|
# HTTPS; that scheme change is cross-origin, so every /rpc/v1 call died
|
||||||
|
# with "No Access-Control-Allow-Origin header" while the node was
|
||||||
|
# perfectly healthy (framework-pt, 2026-09-01: "Failed to fetch" storm).
|
||||||
add_header X-DNS-Prefetch-Control "off" always;
|
add_header X-DNS-Prefetch-Control "off" always;
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.basemaps.cartocdn.com https://tile.openstreetmap.org; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:* https:; frame-src 'self' http://$host:* https:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.basemaps.cartocdn.com https://tile.openstreetmap.org; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:* https:; frame-src 'self' http://$host:* https:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;
|
||||||
|
|
||||||
@@ -1009,7 +1016,14 @@ server {
|
|||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
# HSTS actively CLEARED (max-age=0), not set: this origin's certificate is
|
||||||
|
# optional/self-signed and plain-HTTP access is a supported mode. Earlier
|
||||||
|
# builds sent max-age=31536000 includeSubDomains, and browsers that had
|
||||||
|
# visited HTTPS once kept silently upgrading the HTTP dashboard's
|
||||||
|
# subresources afterwards — every fetch became cross-origin by scheme and
|
||||||
|
# was CORS-blocked. max-age=0 over HTTPS deletes that cached policy;
|
||||||
|
# never raise it on this origin unless HTTP access is retired first.
|
||||||
|
add_header Strict-Transport-Security "max-age=0" always;
|
||||||
add_header X-DNS-Prefetch-Control "off" always;
|
add_header X-DNS-Prefetch-Control "off" always;
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.basemaps.cartocdn.com https://tile.openstreetmap.org; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:* https:; frame-src 'self' http://$host:* https:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.basemaps.cartocdn.com https://tile.openstreetmap.org; font-src 'self' data:; connect-src 'self' ws: wss: http://$host:* https:; frame-src 'self' http://$host:* https:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env bats
|
||||||
|
# tests/lifecycle/bats/nginx-hsts.bats
|
||||||
|
#
|
||||||
|
# Regression guard for the 2026-09-01 framework-pt incident: the HTTPS server
|
||||||
|
# block sent `Strict-Transport-Security: max-age=31536000; includeSubDomains`.
|
||||||
|
# Browsers cached that policy, then silently upgraded the still-open
|
||||||
|
# plain-HTTP dashboard's fetches and frames to https. A scheme change makes
|
||||||
|
# the request cross-origin, so every /rpc/v1 call was CORS-blocked — the node
|
||||||
|
# looked "not responding" while being perfectly healthy, and every app frame
|
||||||
|
# died as mixed content.
|
||||||
|
#
|
||||||
|
# Plain HTTP is a SUPPORTED access mode on purpose: the node's certificate is
|
||||||
|
# optional/self-signed (Settings → Node certificate, /ca.crt flow), and
|
||||||
|
# setup-node-ca.sh deliberately keeps port 80 serving for devices that have
|
||||||
|
# not installed the CA. So this node must never pin a live HSTS policy —
|
||||||
|
# the HTTPS listener actively clears it with max-age=0 instead.
|
||||||
|
#
|
||||||
|
# Tiers: read-only (local curl + config inspection). Runs on the archy host.
|
||||||
|
|
||||||
|
@test "nginx :80 never sends a live HSTS policy" {
|
||||||
|
local hdr
|
||||||
|
hdr=$(curl -sD - -o /dev/null --max-time 8 http://127.0.0.1/health 2>/dev/null || true)
|
||||||
|
if grep -qi 'Strict-Transport-Security' <<<"$hdr"; then
|
||||||
|
grep -qi 'max-age=0' <<<"$hdr" \
|
||||||
|
|| fail ":80 answered with a live HSTS policy — an open HTTP dashboard's fetches get force-upgraded and CORS-blocked: $(grep -i 'Strict' <<<"$hdr")"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "nginx :443 actively clears HSTS (max-age=0), never pins it" {
|
||||||
|
# The HTTPS listener binds per-LAN-address (not loopback — tailscaled owns
|
||||||
|
# :443 on tailnet addresses), so probe the node's first global IPv4.
|
||||||
|
local addr hdr
|
||||||
|
addr=$(ip -o -4 addr show scope global 2>/dev/null \
|
||||||
|
| awk '{print $4}' | cut -d/ -f1 | grep -v '^100\.' | head -1)
|
||||||
|
[[ -n "$addr" ]] || skip "no LAN address to probe HTTPS on"
|
||||||
|
hdr=$(curl -skD - -o /dev/null --max-time 8 "https://$addr/health" 2>/dev/null || true)
|
||||||
|
if grep -qi 'Strict-Transport-Security' <<<"$hdr"; then
|
||||||
|
grep -qi 'max-age=0' <<<"$hdr" \
|
||||||
|
|| fail ":443 answered with a live HSTS policy — browsers cache it and then break the HTTP dashboard: $(grep -i 'Strict' <<<"$hdr")"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "deployed nginx config contains no long-lived HSTS pin" {
|
||||||
|
# Config-level guard: catches the pin even when no cert is installed yet
|
||||||
|
# (no TLS listener to probe), and catches it on both server blocks.
|
||||||
|
local conf
|
||||||
|
for conf in /etc/nginx/sites-available/archipelago \
|
||||||
|
/etc/nginx/sites-available/archipelago-http; do
|
||||||
|
[[ -r "$conf" ]] || continue
|
||||||
|
if grep -q 'Strict-Transport-Security.*max-age=31536000' "$conf"; then
|
||||||
|
fail "$conf still pins a year-long HSTS policy (includeSubDomains class)"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
true
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user