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.
|
// and a separate http.Client.Timeout would race with it.
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
prev := via[len(via)-1]
|
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{
|
redirectChain = append(redirectChain, RedirectStep{
|
||||||
From: prev.URL.String(),
|
From: prev.URL.String(),
|
||||||
To: req.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
|
// The transport's DialContext is pinned to the original
|
||||||
// (ip, port) and TLS ServerName 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