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
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),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue