Add redirect-chain rules per RFC 9110 §15.4

This commit is contained in:
nemunaire 2026-04-27 10:09:21 +07:00
commit 27a30638f4
3 changed files with 416 additions and 1 deletions

View file

@ -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

View file

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

View file

@ -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)
}
}