242 lines
8.1 KiB
Go
242 lines
8.1 KiB
Go
// 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),
|
|
})
|
|
}
|
|
})
|
|
}
|