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: {}
`
}
+44 -1
View File
@@ -119,7 +119,7 @@ func TestRenderForwardedHeaders_RemovesClientWritableHeaders(t *testing.T) {
if err := yaml.Unmarshal([]byte(RenderForwardedHeaders()), &doc); err != nil {
t.Fatalf("not YAML: %v", err)
}
h := doc["http"].(map[string]any)["middlewares"].(map[string]any)[ForwardedMiddleware].(map[string]any)["headers"].(map[string]any)["customRequestHeaders"].(map[string]any)
h := doc["http"].(map[string]any)["middlewares"].(map[string]any)[ForwardedHeadersMiddleware].(map[string]any)["headers"].(map[string]any)["customRequestHeaders"].(map[string]any)
for _, name := range []string{"X-Forwarded-Host", "X-Forwarded-Uri", "X-Forwarded-Method", "X-Forwarded-Prefix",
"X-Forwarded-Tls-Client-Cert", "X-Forwarded-Tls-Client-Cert-Info", "Forwarded", "True-Client-Ip", "X-Client-Ip"} {
if v, ok := h[name]; !ok || v != "" {
@@ -135,3 +135,46 @@ func TestRenderForwardedHeaders_RemovesClientWritableHeaders(t *testing.T) {
}
}
}
// R-1033 (P1): every websecure request passes the https-only redirect FIRST, then the clean-up + HSTS — read through the
// chain the entrypoint names, so a chain that drops either half (or names a middleware that is not defined) fails here.
// The CONSEQUENCE (an http visitor gets 301 to the same https URL, an https or LAN visitor is untouched, a forged header
// from an untrusted peer is ignored) was measured with traefik v3.7.13 on the bench: audits/https-only-2026-10-14/1A-6.
func TestRenderForwardedHeaders_HTTPSOnly(t *testing.T) {
var doc map[string]any
if err := yaml.Unmarshal([]byte(RenderForwardedHeaders()), &doc); err != nil {
t.Fatalf("not YAML: %v", err)
}
mws := doc["http"].(map[string]any)["middlewares"].(map[string]any)
chain, ok := mws[ForwardedMiddleware].(map[string]any)["chain"].(map[string]any)
if !ok {
t.Fatalf("%s must be a chain, got %v", ForwardedMiddleware, mws[ForwardedMiddleware])
}
parts, _ := chain["middlewares"].([]any)
if len(parts) != 2 || parts[0] != HTTPSOnlyMiddleware || parts[1] != ForwardedHeadersMiddleware {
t.Fatalf("the chain must be [%s, %s] in that order, got %v", HTTPSOnlyMiddleware, ForwardedHeadersMiddleware, parts)
}
for _, p := range parts {
if _, ok := mws[p.(string)]; !ok {
t.Errorf("the chain names %v but the file does not define it — every route on the box would break", p)
}
}
rs, ok := mws[HTTPSOnlyMiddleware].(map[string]any)["redirectScheme"].(map[string]any)
if !ok || rs["scheme"] != "https" || rs["permanent"] != true {
t.Errorf("%s must be redirectScheme{scheme: https, permanent: true}, got %v", HTTPSOnlyMiddleware, mws[HTTPSOnlyMiddleware])
}
h := mws[ForwardedHeadersMiddleware].(map[string]any)["headers"].(map[string]any)
if h["stsSeconds"] != HSTSMaxAgeSeconds || HSTSMaxAgeSeconds != 86400 {
t.Errorf("HSTS must be one day (86400) until the operator rules otherwise, got %v", h["stsSeconds"])
}
if h["stsIncludeSubdomains"] != false || h["stsPreload"] != false {
t.Errorf("HSTS must not include subdomains nor preload, got %v / %v", h["stsIncludeSubdomains"], h["stsPreload"])
}
r, ok := doc["http"].(map[string]any)["routers"].(map[string]any)[HTTPSOnlyCatchAllRouter].(map[string]any)
if !ok {
t.Fatalf("the catch-all router for unknown names over http is missing")
}
if r["rule"] != "Header(`X-Forwarded-Proto`, `http`)" || r["priority"] != 1 || r["service"] != "noop@internal" {
t.Errorf("catch-all must match ONLY X-Forwarded-Proto: http, at priority 1, to noop@internal; got %v", r)
}
}
@@ -302,3 +302,27 @@ func TestRenderCloudflared_HasAReadinessHealthcheck(t *testing.T) {
}
}
}
// R-1033 (P1): a box that carries the pre-v0.312.0 forwarded.yml (headers only, no https-only redirect), or a hand-edited
// one, gets the current file back on the next bring-up — so a released template reaches every box and drift does not
// survive a tick. The file is hot-loaded by traefik's file provider (watch: true): no traefik restart.
func TestEnsureForwardedHeaders_R1033_RewritesAnOldFile(t *testing.T) {
dir := t.TempDir()
dyn := filepath.Join(dir, "dynamic")
if err := os.MkdirAll(dyn, 0o755); err != nil {
t.Fatal(err)
}
old := "http:\n middlewares:\n " + infra.ForwardedMiddleware + ":\n headers:\n customRequestHeaders:\n X-Forwarded-Port: \"443\"\n"
touch(t, filepath.Join(dyn, "forwarded.yml"), old)
m := &Manager{logger: log.New(io.Discard, "", 0)}
if err := m.ensureForwardedHeaders(dir); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(filepath.Join(dyn, "forwarded.yml"))
if string(got) != infra.RenderForwardedHeaders() {
t.Fatalf("an old forwarded.yml must be replaced by the current render (https-only chain); got:\n%s", got)
}
if !strings.Contains(string(got), infra.HTTPSOnlyMiddleware) {
t.Fatal("the rewritten file must carry the https-only redirect")
}
}