diff --git a/checker/collect.go b/checker/collect.go index ed12a90..153ab32 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -221,10 +221,16 @@ func runProbe(ctx context.Context, host, ip, scheme string, port uint16, timeout // and a separate http.Client.Timeout would race with it. CheckRedirect: func(req *http.Request, via []*http.Request) error { prev := via[len(via)-1] + // req.Response is the 3xx response that triggered this hop; + // it carries the redirecting status code (301/302/307/308…). + status := 0 + if req.Response != nil { + status = req.Response.StatusCode + } redirectChain = append(redirectChain, RedirectStep{ From: prev.URL.String(), To: req.URL.String(), - Status: 0, // populated post-hoc below if available + Status: status, }) // The transport's DialContext is pinned to the original // (ip, port) and TLS ServerName is pinned to the original diff --git a/checker/rules_redirect_chain.go b/checker/rules_redirect_chain.go new file mode 100644 index 0000000..c5b1f15 --- /dev/null +++ b/checker/rules_redirect_chain.go @@ -0,0 +1,242 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. + +package checker + +import ( + "context" + "fmt" + "net/url" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func init() { + RegisterRule(&redirectChainRule{}) + RegisterRule(&redirectPermanenceRule{}) +} + +// MaxRecommendedRedirectHops is the soft upper bound for a healthy redirect +// chain. RFC 9110 §15.4 does not mandate a hard cap, but every additional +// hop adds latency, defeats HSTS for the intermediate hop, and degrades +// the user experience; popular guidance (Google, Mozilla, web.dev) treats +// 3+ hops as a smell worth surfacing. +const MaxRecommendedRedirectHops = 3 + +// redirectChainRule inspects the redirect chain captured during probing +// and flags the three classic anti-patterns called out by RFC 9110 §15.4 +// and operational guidance: +// +// - a loop (the same URL appears twice in the chain); +// - excessive length (more hops than MaxRecommendedRedirectHops); +// - a scheme downgrade (HTTPS → HTTP at any hop), which strips transport +// security and silently invalidates HSTS expectations. +// +// Each probe contributes its own state so multi-IP deployments can show +// per-backend divergence. +type redirectChainRule struct{} + +func (r *redirectChainRule) Name() string { return "http.redirect_chain" } +func (r *redirectChainRule) Description() string { + return "Inspects the redirect chain (RFC 9110 §15.4) for loops, excessive length, and scheme downgrades." +} + +func (r *redirectChainRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadHTTPData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if len(data.Probes) == 0 { + return []sdk.CheckState{unknownState("http.redirect_chain.no_probes", "No probes were attempted.")} + } + + var states []sdk.CheckState + anyChain := false + for _, p := range data.Probes { + if len(p.RedirectChain) == 0 { + continue + } + anyChain = true + + if loopAt, found := redirectLoop(p.RedirectChain); found { + states = append(states, sdk.CheckState{ + Status: sdk.StatusWarn, + Code: "http.redirect_chain.loop", + Subject: p.Address, + Message: fmt.Sprintf("Redirect loop detected: %s reappears in the chain.", loopAt), + Meta: map[string]any{"chain": chainSummary(p.RedirectChain)}, + }) + continue + } + + if downgradeAt, found := redirectDowngrade(p.RedirectChain); found { + states = append(states, sdk.CheckState{ + Status: sdk.StatusWarn, + Code: "http.redirect_chain.downgrade", + Subject: p.Address, + Message: fmt.Sprintf("Redirect chain downgrades from HTTPS to HTTP at %q.", downgradeAt), + Meta: map[string]any{ + "fix": "Ensure no hop in the redirect chain switches from https:// back to http://.", + "chain": chainSummary(p.RedirectChain), + }, + }) + continue + } + + if len(p.RedirectChain) > MaxRecommendedRedirectHops { + states = append(states, sdk.CheckState{ + Status: sdk.StatusWarn, + Code: "http.redirect_chain.too_long", + Subject: p.Address, + Message: fmt.Sprintf("Redirect chain has %d hops (recommended ≤ %d).", len(p.RedirectChain), MaxRecommendedRedirectHops), + Meta: map[string]any{ + "fix": "Collapse intermediate redirects so a single hop reaches the canonical URL.", + "chain": chainSummary(p.RedirectChain), + }, + }) + continue + } + + states = append(states, sdk.CheckState{ + Status: sdk.StatusOK, + Code: "http.redirect_chain.ok", + Subject: p.Address, + Message: fmt.Sprintf("Redirect chain is %d hop(s), no loop, no downgrade.", len(p.RedirectChain)), + }) + } + + if !anyChain { + return []sdk.CheckState{passState("http.redirect_chain.none", "No redirects observed on any probe.")} + } + return states +} + +// redirectLoop returns the first URL that appears as both source and +// destination (or as destination twice) in the chain, signalling a cycle. +func redirectLoop(chain []RedirectStep) (string, bool) { + seen := make(map[string]struct{}, len(chain)+1) + for _, step := range chain { + key := canonicalURL(step.From) + if _, ok := seen[key]; ok { + return step.From, true + } + seen[key] = struct{}{} + } + if len(chain) > 0 { + last := canonicalURL(chain[len(chain)-1].To) + if _, ok := seen[last]; ok { + return chain[len(chain)-1].To, true + } + } + return "", false +} + +// redirectDowngrade returns the first hop whose source is HTTPS and +// destination is HTTP. RFC 9110 does not forbid this, but it strips +// transport security and is universally treated as a misconfiguration. +func redirectDowngrade(chain []RedirectStep) (string, bool) { + for _, step := range chain { + from, errF := url.Parse(step.From) + to, errT := url.Parse(step.To) + if errF != nil || errT != nil { + continue + } + if strings.EqualFold(from.Scheme, "https") && strings.EqualFold(to.Scheme, "http") { + return step.From + " → " + step.To, true + } + } + return "", false +} + +func canonicalURL(s string) string { + u, err := url.Parse(s) + if err != nil { + return strings.ToLower(strings.TrimSpace(s)) + } + u.Scheme = strings.ToLower(u.Scheme) + u.Host = strings.ToLower(u.Host) + if u.Path == "" { + u.Path = "/" + } + u.Fragment = "" + return u.String() +} + +func chainSummary(chain []RedirectStep) []string { + out := make([]string, 0, len(chain)) + for _, s := range chain { + if s.Status != 0 { + out = append(out, fmt.Sprintf("%d %s → %s", s.Status, s.From, s.To)) + } else { + out = append(out, fmt.Sprintf("%s → %s", s.From, s.To)) + } + } + return out +} + +// redirectPermanenceRule scrutinises the very first hop of any HTTP probe +// that ends up on HTTPS: per RFC 9110 §15.4, 301 (Moved Permanently) and +// 308 (Permanent Redirect) are cacheable and signal that user-agents may +// rewrite future requests, which is exactly what an HTTP→HTTPS upgrade +// wants. 302/303/307 are temporary and force the client to re-resolve +// every time, defeating browser optimisations and HSTS preload eligibility +// guidance from hstspreload.org. +type redirectPermanenceRule struct{} + +func (r *redirectPermanenceRule) Name() string { return "http.redirect_permanence" } +func (r *redirectPermanenceRule) Description() string { + return "HTTP→HTTPS upgrade should use 301 or 308 (permanent) rather than 302/307 (temporary)." +} + +func (r *redirectPermanenceRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadHTTPData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + const okMsg = "HTTP→HTTPS upgrade uses a permanent redirect (301/308) on every probe." + return EvalAggregateByScheme(data, "http", "http.redirect_permanence", okMsg, func(p HTTPProbe, emit func(sdk.CheckState)) { + if len(p.RedirectChain) == 0 { + return + } + first := p.RedirectChain[0] + from, errF := url.Parse(first.From) + to, errT := url.Parse(first.To) + if errF != nil || errT != nil { + return + } + // We only care about the HTTP→HTTPS upgrade hop; other shapes + // (HTTPS→HTTPS canonicalisation, locale redirects, …) belong to + // the chain rule. + if !strings.EqualFold(from.Scheme, "http") || !strings.EqualFold(to.Scheme, "https") { + return + } + switch first.Status { + case 301, 308: + // Good; aggregated to the single OK state below. + case 0: + emit(sdk.CheckState{ + Status: sdk.StatusInfo, + Code: "http.redirect_permanence.unknown", + Subject: p.Address, + Message: "Could not determine the status code of the HTTP→HTTPS redirect.", + }) + case 302, 303, 307: + emit(sdk.CheckState{ + Status: sdk.StatusWarn, + Code: "http.redirect_permanence.temporary", + Subject: p.Address, + Message: fmt.Sprintf("HTTP→HTTPS upgrade returns %d (temporary). Prefer 301 or 308 so clients cache the upgrade.", first.Status), + Meta: map[string]any{"fix": "Configure your web server to answer plain HTTP with `301 Moved Permanently` (or `308 Permanent Redirect`) pointing to the https:// URL."}, + }) + default: + emit(sdk.CheckState{ + Status: sdk.StatusInfo, + Code: "http.redirect_permanence.unexpected", + Subject: p.Address, + Message: fmt.Sprintf("HTTP→HTTPS upgrade uses an unusual status code: %d.", first.Status), + }) + } + }) +} diff --git a/checker/rules_redirect_chain_test.go b/checker/rules_redirect_chain_test.go new file mode 100644 index 0000000..b44f997 --- /dev/null +++ b/checker/rules_redirect_chain_test.go @@ -0,0 +1,167 @@ +// This file is part of the happyDomain (R) project. +// Copyright (c) 2020-2026 happyDomain +// Authors: Pierre-Olivier Mercier, et al. + +package checker + +import ( + "testing" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func TestRedirectChainRule_NoProbes(t *testing.T) { + states := runRule(t, &redirectChainRule{}, &HTTPData{}, nil) + mustStatus(t, states, sdk.StatusUnknown) + if !hasCode(states, "http.redirect_chain.no_probes") { + t.Errorf("expected no_probes: %+v", states) + } +} + +func TestRedirectChainRule_NoRedirects(t *testing.T) { + p := httpsProbe("a:443") + states := runRule(t, &redirectChainRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil) + mustStatus(t, states, sdk.StatusOK) + if !hasCode(states, "http.redirect_chain.none") { + t.Errorf("expected redirect_chain.none: %+v", states) + } +} + +func TestRedirectChainRule_OK(t *testing.T) { + p := httpProbe("a:80") + p.RedirectChain = []RedirectStep{ + {From: "http://example.test/", To: "https://example.test/", Status: 301}, + } + states := runRule(t, &redirectChainRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil) + mustStatus(t, states, sdk.StatusOK) + if !hasCode(states, "http.redirect_chain.ok") { + t.Errorf("expected redirect_chain.ok: %+v", states) + } +} + +func TestRedirectChainRule_Loop(t *testing.T) { + p := httpProbe("a:80") + p.RedirectChain = []RedirectStep{ + {From: "http://example.test/a", To: "http://example.test/b", Status: 302}, + {From: "http://example.test/b", To: "http://example.test/a", Status: 302}, + } + states := runRule(t, &redirectChainRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil) + mustStatus(t, states, sdk.StatusWarn) + if !hasCode(states, "http.redirect_chain.loop") { + t.Errorf("expected redirect_chain.loop: %+v", states) + } +} + +func TestRedirectChainRule_Downgrade(t *testing.T) { + p := httpsProbe("a:443") + p.RedirectChain = []RedirectStep{ + {From: "https://example.test/", To: "http://example.test/legacy", Status: 302}, + } + states := runRule(t, &redirectChainRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil) + mustStatus(t, states, sdk.StatusWarn) + if !hasCode(states, "http.redirect_chain.downgrade") { + t.Errorf("expected redirect_chain.downgrade: %+v", states) + } +} + +func TestRedirectChainRule_TooLong(t *testing.T) { + p := httpProbe("a:80") + p.RedirectChain = []RedirectStep{ + {From: "http://example.test/1", To: "http://example.test/2", Status: 301}, + {From: "http://example.test/2", To: "http://example.test/3", Status: 301}, + {From: "http://example.test/3", To: "http://example.test/4", Status: 301}, + {From: "http://example.test/4", To: "https://example.test/5", Status: 301}, + } + states := runRule(t, &redirectChainRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil) + mustStatus(t, states, sdk.StatusWarn) + if !hasCode(states, "http.redirect_chain.too_long") { + t.Errorf("expected redirect_chain.too_long: %+v", states) + } +} + +func TestRedirectChainRule_LoopTakesPrecedenceOverDowngrade(t *testing.T) { + // When both anomalies are present, the loop is reported first since + // it explains downstream weirdness. + p := httpsProbe("a:443") + p.RedirectChain = []RedirectStep{ + {From: "https://example.test/x", To: "http://example.test/x", Status: 302}, + {From: "http://example.test/x", To: "https://example.test/x", Status: 302}, + } + states := runRule(t, &redirectChainRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil) + mustStatus(t, states, sdk.StatusWarn) + if !hasCode(states, "http.redirect_chain.loop") { + t.Errorf("expected loop to take precedence: %+v", states) + } +} + +func TestRedirectPermanenceRule_NoProbes(t *testing.T) { + states := runRule(t, &redirectPermanenceRule{}, &HTTPData{Probes: []HTTPProbe{httpsProbe("a:443")}}, nil) + mustStatus(t, states, sdk.StatusUnknown) +} + +func TestRedirectPermanenceRule_NoRedirect(t *testing.T) { + p := httpProbe("a:80") + p.StatusCode = 200 + states := runRule(t, &redirectPermanenceRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil) + mustStatus(t, states, sdk.StatusOK) +} + +func TestRedirectPermanenceRule_Permanent(t *testing.T) { + for _, code := range []int{301, 308} { + p := httpProbe("a:80") + p.RedirectChain = []RedirectStep{ + {From: "http://example.test/", To: "https://example.test/", Status: code}, + } + states := runRule(t, &redirectPermanenceRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil) + mustStatus(t, states, sdk.StatusOK) + if !hasCode(states, "http.redirect_permanence.ok") { + t.Errorf("status %d: expected ok: %+v", code, states) + } + } +} + +func TestRedirectPermanenceRule_Temporary(t *testing.T) { + for _, code := range []int{302, 303, 307} { + p := httpProbe("a:80") + p.RedirectChain = []RedirectStep{ + {From: "http://example.test/", To: "https://example.test/", Status: code}, + } + states := runRule(t, &redirectPermanenceRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil) + mustStatus(t, states, sdk.StatusWarn) + if !hasCode(states, "http.redirect_permanence.temporary") { + t.Errorf("status %d: expected temporary: %+v", code, states) + } + } +} + +func TestRedirectPermanenceRule_UnknownStatus(t *testing.T) { + p := httpProbe("a:80") + p.RedirectChain = []RedirectStep{ + {From: "http://example.test/", To: "https://example.test/", Status: 0}, + } + states := runRule(t, &redirectPermanenceRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil) + mustStatus(t, states, sdk.StatusInfo) + if !hasCode(states, "http.redirect_permanence.unknown") { + t.Errorf("expected redirect_permanence.unknown: %+v", states) + } +} + +func TestRedirectPermanenceRule_IgnoresNonUpgradeChain(t *testing.T) { + // An HTTP probe whose first hop stays in HTTP (path canonicalisation, + // trailing-slash, www stripping before the TLS bump…) is not in scope + // for this rule, so a 302 there must not raise a warning. A second + // probe is included so the per-probe iteration has another candidate. + first := httpProbe("a:80") + first.RedirectChain = []RedirectStep{ + {From: "http://example.test/", To: "http://www.example.test/", Status: 302}, + } + second := httpProbe("b:80") + second.RedirectChain = []RedirectStep{ + {From: "http://example.test/", To: "https://example.test/", Status: 301}, + } + states := runRule(t, &redirectPermanenceRule{}, &HTTPData{Probes: []HTTPProbe{first, second}}, nil) + mustStatus(t, states, sdk.StatusOK) + if !hasCode(states, "http.redirect_permanence.ok") { + t.Errorf("HTTP-only first hop should not trigger a warning: %+v", states) + } +}