v0.312.0: every public name answers only over https; HSTS one day (R-1033, P1)
gates / gates (push) Successful in 48s
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:
@@ -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: {}
|
||||
`
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user