Add redirect-chain rules per RFC 9110 §15.4
This commit is contained in:
parent
2250902a94
commit
27a30638f4
3 changed files with 416 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
242
checker/rules_redirect_chain.go
Normal file
242
checker/rules_redirect_chain.go
Normal 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),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
167
checker/rules_redirect_chain_test.go
Normal file
167
checker/rules_redirect_chain_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue