// 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), }) } }) }