v0.312.0: every public name answers only over https; HSTS one day (R-1033, P1)
gates / gates (push) Successful in 48s

forwarded.yml: felhom-forwarded is now a chain [felhom-https-only (redirectScheme https, 301 when the trusted tunnel
says X-Forwarded-Proto: http), felhom-forwarded-headers (the clean-up + HSTS 86400, no subdomains, no preload)] plus a
priority-1 catch-all router for unknown names over http. traefik.yml unchanged; hot-loaded.
Tests red-proofed. Evidence: felhom.eu documentation/audits/https-only-2026-10-14/.
This commit is contained in:
2026-10-14 22:39:58 +02:00
parent 9b95e8e09b
commit 8bc7ec54cd
7 changed files with 158 additions and 25 deletions
+54 -10
View File
@@ -88,6 +88,16 @@ const (
TunnelIPRange = "172.16.253.4/30" // where docker may put anything else: never .2 or .3
// ForwardedMiddleware is the entrypoint middleware every websecure request passes (RenderForwardedHeaders).
ForwardedMiddleware = "felhom-forwarded"
// R-1033 (P1, `09` §3 decision 244): the parts of the ForwardedMiddleware CHAIN. HTTPSOnlyMiddleware sends a visitor
// who reached Cloudflare over plain http (the tunnel's trusted X-Forwarded-Proto: http) to the same https URL;
// ForwardedHeadersMiddleware is the header clean-up plus HSTS. HTTPSOnlyCatchAllRouter does the same for a name no
// app route matches (an entrypoint middleware runs only on a matched router).
ForwardedHeadersMiddleware = "felhom-forwarded-headers"
HTTPSOnlyMiddleware = "felhom-https-only"
HTTPSOnlyCatchAllRouter = "felhom-https-only-catchall"
// HSTSMaxAgeSeconds is ONE DAY on purpose (decision 244 brief): no includeSubDomains, no preload — a longer value is
// the operator's decision (register row), because a browser keeps it even if the box is ever served without https.
HSTSMaxAgeSeconds = 86400
)
// TraefikData is the per-customer input for the traefik stack. ACMEEmail empty → no Let's Encrypt
@@ -309,23 +319,49 @@ http:
}
// RenderForwardedHeaders returns the dynamic file defining the entrypoint middleware every websecure request passes
// (traefik.yml names it). Once traefik trusts the tunnel's address it KEEPS the forwarded headers that hop carries,
// and Cloudflare passes a client's own X-Forwarded-Host and X-Forwarded-Port through unchanged (measured,
// audits/visitors-2026-10-01/A/M2) — so this removes every header in which a client could write a host, a path or an
// address, and fixes the port: both paths reach traefik on 443. X-Forwarded-For stays (traefik appends the hop it saw;
// readers take it from the RIGHT), X-Real-Ip stays (traefik's peer — Cloudflare strips a client's, measured M2),
// CF-Connecting-IP stays (the controller believes it only when the hop is the tunnel). A request's Host header still
// says which app it is for, so an app that falls back from X-Forwarded-Host to Host gets the same name.
// Static — no per-customer input. Pinned by TestRenderForwardedHeaders_RemovesClientWritableHeaders.
// (traefik.yml names it). Since R-1033 (P1, controller v0.312.0) ForwardedMiddleware is a CHAIN of two:
//
// 1. HTTPSOnlyMiddleware — every public name answers only over https. A visitor who typed http:// reaches Cloudflare
// over plain http; the tunnel still delivers to traefik's :443 and says so in X-Forwarded-Proto: http, which traefik
// keeps because the hop is the trusted tunnel address (any other peer's X-Forwarded-Proto is replaced by traefik's
// own — measured on the bench, audits/https-only-2026-10-14/1A-6). redirectScheme honours that header: 301 to the
// same https URL, path and query kept; an https visitor (X-Forwarded-Proto: https) and a LAN visitor are unchanged.
// The redirect happens before any app sees the request, so a login page is never served — and a password never
// sent — over http. The Cloudflare-side switch („Always Use HTTPS") needs a zone permission the box keys lack.
// 2. ForwardedHeadersMiddleware — the clean-up: once traefik trusts the tunnel's address it KEEPS the forwarded headers
// that hop carries, and Cloudflare passes a client's own X-Forwarded-Host and X-Forwarded-Port through unchanged
// (measured, audits/visitors-2026-10-01/A/M2) — so this removes every header in which a client could write a host, a
// path or an address, and fixes the port: both paths reach traefik on 443. X-Forwarded-For stays (traefik appends the
// hop it saw; readers take it from the RIGHT), X-Real-Ip stays (traefik's peer — Cloudflare strips a client's,
// measured M2), CF-Connecting-IP stays (the controller believes it only when the hop is the tunnel). A request's
// Host header still says which app it is for. It also sets HSTS for HSTSMaxAgeSeconds, no subdomains, no preload.
//
// HTTPSOnlyCatchAllRouter (priority 1, only X-Forwarded-Proto: http) redirects a name no app route matches, so an
// unknown name over http is a redirect too while an unknown name over https stays traefik's 404.
// Static — no per-customer input. Pinned by TestRenderForwardedHeaders_RemovesClientWritableHeaders and
// TestRenderForwardedHeaders_HTTPSOnly (both read the chain, so removing either half fails a test).
func RenderForwardedHeaders() string {
return `# Traefik dynamic config — the forwarded-header clean-up every websecure request passes. Managed by felhom-controller.
# WARNING: auto-generated at base-infra bring-up. Manual edits are overwritten. traefik.yml names this middleware on its
return `# Traefik dynamic config — the chain every websecure request passes: https only (R-1033), then the forwarded-header
# clean-up + HSTS. Managed by felhom-controller.
# WARNING: auto-generated at base-infra bring-up. Manual edits are overwritten. traefik.yml names ` + ForwardedMiddleware + ` on its
# websecure entrypoint, so a missing file would break every route: it is written before traefik.yml.
# An empty value REMOVES the header (traefik headers middleware).
http:
middlewares:
` + ForwardedMiddleware + `:
chain:
middlewares:
- ` + HTTPSOnlyMiddleware + `
- ` + ForwardedHeadersMiddleware + `
` + HTTPSOnlyMiddleware + `:
redirectScheme:
scheme: https
permanent: true
` + ForwardedHeadersMiddleware + `:
headers:
stsSeconds: ` + fmt.Sprint(HSTSMaxAgeSeconds) + `
stsIncludeSubdomains: false
stsPreload: false
customRequestHeaders:
X-Forwarded-Port: "443"
X-Forwarded-Host: ""
@@ -340,6 +376,14 @@ http:
X-Cluster-Client-Ip: ""
Client-Ip: ""
X-Original-Forwarded-For: ""
routers:
` + HTTPSOnlyCatchAllRouter + `:
rule: "Header(` + "`X-Forwarded-Proto`, `http`" + `)"
priority: 1
entryPoints:
- websecure
service: noop@internal
tls: {}
`
}