mirror of
https://github.com/lunchcat/sif.git
synced 2026-08-02 08:47:38 -07:00
roundTripper.RoundTrip injects the global cookie, custom headers and user agent on every hop, including redirect requests. because that injection sits below the client's redirect handling, it undoes net/http's own stripping of Cookie/Authorization/Www-Authenticate when a redirect crosses hosts, so a target that 302s to an attacker host would receive the scan's secret cookie and bearer token. req.Response is only set on requests net/http built to follow a redirect, and its Request field is the prior hop, so comparing hosts there is enough to detect a cross-host hop without any extra state. skip the sensitive header classes on that hop and keep injecting everything else as before.
846 lines
25 KiB
Go
846 lines
25 KiB
Go
/*
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
: :
|
|
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
|
: ▄█ █ █▀ · BSD 3-Clause License :
|
|
: :
|
|
: (c) 2022-2026 vmfunc, xyzeva, :
|
|
: lunchcat alumni & contributors :
|
|
: :
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
*/
|
|
|
|
package httpx
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// resetConfig clears the package-level transport so each test starts clean.
|
|
func resetConfig(t *testing.T) {
|
|
t.Helper()
|
|
mu.Lock()
|
|
configured = nil
|
|
mu.Unlock()
|
|
}
|
|
|
|
// captureServer records the headers of the last request it served.
|
|
func captureServer(t *testing.T, seen *http.Header) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
*seen = r.Header.Clone()
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
func get(t *testing.T, client *http.Client, url string) {
|
|
t.Helper()
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, http.NoBody)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("do request: %v", err)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func TestClientBeforeConfigure(t *testing.T) {
|
|
resetConfig(t)
|
|
|
|
var seen http.Header
|
|
srv := captureServer(t, &seen)
|
|
|
|
// a client must work with no Configure call so existing code is unaffected.
|
|
get(t, Client(5*time.Second), srv.URL)
|
|
|
|
if seen == nil {
|
|
t.Fatal("request never reached the server")
|
|
}
|
|
}
|
|
|
|
func TestConfigureHeadersAndCookie(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
opts Options
|
|
wantKey string
|
|
wantValue string
|
|
}{
|
|
{
|
|
name: "custom header injected",
|
|
opts: Options{Headers: []string{"X-Test: sif"}},
|
|
wantKey: "X-Test",
|
|
wantValue: "sif",
|
|
},
|
|
{
|
|
name: "cookie injected",
|
|
opts: Options{Cookie: "session=abc"},
|
|
wantKey: "Cookie",
|
|
wantValue: "session=abc",
|
|
},
|
|
{
|
|
name: "user agent injected",
|
|
opts: Options{UserAgent: "sif-scanner"},
|
|
wantKey: "User-Agent",
|
|
wantValue: "sif-scanner",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
resetConfig(t)
|
|
|
|
if err := Configure(tt.opts); err != nil {
|
|
t.Fatalf("Configure: %v", err)
|
|
}
|
|
|
|
var seen http.Header
|
|
srv := captureServer(t, &seen)
|
|
get(t, Client(5*time.Second), srv.URL)
|
|
|
|
if got := seen.Get(tt.wantKey); got != tt.wantValue {
|
|
t.Errorf("header %q = %q, want %q", tt.wantKey, got, tt.wantValue)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConfigureHeaderDoesNotOverride(t *testing.T) {
|
|
resetConfig(t)
|
|
|
|
if err := Configure(Options{Headers: []string{"X-Test: global"}}); err != nil {
|
|
t.Fatalf("Configure: %v", err)
|
|
}
|
|
|
|
var seen http.Header
|
|
srv := captureServer(t, &seen)
|
|
|
|
// a caller that sets the header explicitly must win over the global default.
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, http.NoBody)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
req.Header.Set("X-Test", "caller")
|
|
resp, err := Client(5 * time.Second).Do(req)
|
|
if err != nil {
|
|
t.Fatalf("do request: %v", err)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
if got := seen.Get("X-Test"); got != "caller" {
|
|
t.Errorf("X-Test = %q, want caller (caller value must not be overridden)", got)
|
|
}
|
|
}
|
|
|
|
func TestConfigureInvalidHeader(t *testing.T) {
|
|
resetConfig(t)
|
|
|
|
// a header without ": " should fail loud rather than silently dropping.
|
|
if err := Configure(Options{Headers: []string{"missing-separator"}}); err == nil {
|
|
t.Fatal("expected error for malformed header, got nil")
|
|
}
|
|
}
|
|
|
|
func TestConfigureInvalidProxy(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
proxy string
|
|
}{
|
|
{name: "unsupported scheme", proxy: "ftp://localhost:1080"},
|
|
{name: "malformed url", proxy: "://nope"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
resetConfig(t)
|
|
if err := Configure(Options{Proxy: tt.proxy}); err == nil {
|
|
t.Errorf("expected error for proxy %q, got nil", tt.proxy)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRateLimit(t *testing.T) {
|
|
resetConfig(t)
|
|
|
|
const ratePerSec = 5
|
|
if err := Configure(Options{RateLimit: ratePerSec}); err != nil {
|
|
t.Fatalf("Configure: %v", err)
|
|
}
|
|
|
|
var seen http.Header
|
|
srv := captureServer(t, &seen)
|
|
client := Client(5 * time.Second)
|
|
|
|
// at 5 req/s the limiter starts with a full burst, so the first batch is
|
|
// immediate and the next request must wait roughly one tick. fire burst+1
|
|
// requests and assert the extra one forced a measurable delay.
|
|
const requests = ratePerSec + 1
|
|
start := time.Now()
|
|
for i := 0; i < requests; i++ {
|
|
get(t, client, srv.URL)
|
|
}
|
|
elapsed := time.Since(start)
|
|
|
|
// one request beyond the burst should cost about 1/rate; allow slack but
|
|
// require a non-trivial delay so an unthrottled client fails this.
|
|
minDelay := time.Second / ratePerSec / 2
|
|
if elapsed < minDelay {
|
|
t.Errorf("expected rate limiting to add >= %v of delay, got %v", minDelay, elapsed)
|
|
}
|
|
}
|
|
|
|
func TestRateLimitUnlimited(t *testing.T) {
|
|
resetConfig(t)
|
|
|
|
// RateLimit 0 means no limiter is installed; requests should fly through.
|
|
if err := Configure(Options{RateLimit: 0}); err != nil {
|
|
t.Fatalf("Configure: %v", err)
|
|
}
|
|
|
|
mu.RLock()
|
|
rt, ok := configured.(*roundTripper)
|
|
mu.RUnlock()
|
|
if !ok {
|
|
t.Fatal("configured transport is not *roundTripper")
|
|
}
|
|
if rt.limiter != nil {
|
|
t.Error("expected no limiter when RateLimit is 0")
|
|
}
|
|
}
|
|
|
|
func TestIdlePerHost(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
threads int
|
|
want int
|
|
}{
|
|
{name: "below floor clamps up", threads: 1, want: minIdleConnsPerHost},
|
|
{name: "zero clamps up", threads: 0, want: minIdleConnsPerHost},
|
|
{name: "at floor", threads: minIdleConnsPerHost, want: minIdleConnsPerHost},
|
|
{name: "above floor passes through", threads: 64, want: 64},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := idlePerHost(tt.threads); got != tt.want {
|
|
t.Errorf("idlePerHost(%d) = %d, want %d", tt.threads, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildTransportTuning(t *testing.T) {
|
|
const threads = 32
|
|
tr, err := buildTransport("", threads)
|
|
if err != nil {
|
|
t.Fatalf("buildTransport: %v", err)
|
|
}
|
|
|
|
if tr.MaxIdleConns != maxIdleConns {
|
|
t.Errorf("MaxIdleConns = %d, want %d", tr.MaxIdleConns, maxIdleConns)
|
|
}
|
|
if tr.MaxIdleConnsPerHost != threads {
|
|
t.Errorf("MaxIdleConnsPerHost = %d, want %d", tr.MaxIdleConnsPerHost, threads)
|
|
}
|
|
if tr.MaxConnsPerHost != 0 {
|
|
t.Errorf("MaxConnsPerHost = %d, want 0 (unbounded)", tr.MaxConnsPerHost)
|
|
}
|
|
if tr.IdleConnTimeout != idleConnTimeout {
|
|
t.Errorf("IdleConnTimeout = %v, want %v", tr.IdleConnTimeout, idleConnTimeout)
|
|
}
|
|
if !tr.ForceAttemptHTTP2 {
|
|
t.Error("ForceAttemptHTTP2 = false, want true")
|
|
}
|
|
}
|
|
|
|
func TestDrainClose(t *testing.T) {
|
|
resetConfig(t)
|
|
|
|
// serve a body the caller never reads; DrainClose must drain it so the conn
|
|
// is eligible for reuse rather than abandoned mid-stream.
|
|
const body = "sif response body that the caller never reads"
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
io.WriteString(w, body)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, http.NoBody)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
resp, err := Client(5 * time.Second).Do(req)
|
|
if err != nil {
|
|
t.Fatalf("do request: %v", err)
|
|
}
|
|
|
|
DrainClose(resp)
|
|
|
|
// after DrainClose the body is closed; a further read must fail.
|
|
if _, err := resp.Body.Read(make([]byte, 1)); err == nil {
|
|
t.Error("expected read after DrainClose to fail on a closed body")
|
|
}
|
|
}
|
|
|
|
func TestDrainCloseNil(t *testing.T) {
|
|
// a nil response (e.g. an errored request) must not panic.
|
|
DrainClose(nil)
|
|
DrainClose(&http.Response{})
|
|
}
|
|
|
|
// countConns wraps a test server with a ConnState hook that tallies how many
|
|
// distinct tcp conns the server saw. distinct conns == failed reuse.
|
|
func countConns(t *testing.T) (*httptest.Server, func() int) {
|
|
t.Helper()
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
conns = make(map[net.Conn]struct{})
|
|
)
|
|
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
// always write a body so reuse depends on the caller draining it.
|
|
io.WriteString(w, "ok")
|
|
}))
|
|
srv.Config.ConnState = func(c net.Conn, state http.ConnState) {
|
|
if state != http.StateNew {
|
|
return
|
|
}
|
|
mu.Lock()
|
|
conns[c] = struct{}{}
|
|
mu.Unlock()
|
|
}
|
|
srv.Start()
|
|
t.Cleanup(srv.Close)
|
|
|
|
return srv, func() int {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return len(conns)
|
|
}
|
|
}
|
|
|
|
func TestTransportReusesConnections(t *testing.T) {
|
|
resetConfig(t)
|
|
|
|
const (
|
|
threads = 8
|
|
requests = 30
|
|
)
|
|
if err := Configure(Options{Threads: threads}); err != nil {
|
|
t.Fatalf("Configure: %v", err)
|
|
}
|
|
|
|
srv, distinct := countConns(t)
|
|
|
|
// fire N sequential requests through the tuned client, draining each body so
|
|
// the conn returns to the pool. a working pool serves all of them on one conn.
|
|
client := Client(5 * time.Second)
|
|
for i := 0; i < requests; i++ {
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, http.NoBody)
|
|
if err != nil {
|
|
t.Fatalf("new request %d: %v", i, err)
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("do request %d: %v", i, err)
|
|
}
|
|
DrainClose(resp)
|
|
}
|
|
|
|
// sequential reuse should land on exactly one conn; allow a tiny margin for
|
|
// the rare race where a conn is reaped between requests.
|
|
const maxReuseConns = 2
|
|
if got := distinct(); got > maxReuseConns {
|
|
t.Errorf("tuned client opened %d conns for %d requests, want <= %d (pool not reusing)",
|
|
got, requests, maxReuseConns)
|
|
}
|
|
}
|
|
|
|
func TestBareClientDoesNotReuse(t *testing.T) {
|
|
srv, distinct := countConns(t)
|
|
|
|
// the control: a bare DefaultTransport client whose caller closes but never
|
|
// drains the body. go can't reuse a half-read conn, so each request dials
|
|
// fresh - this is exactly the pre-tuning behavior we're fixing.
|
|
client := &http.Client{
|
|
Timeout: 5 * time.Second,
|
|
Transport: http.DefaultTransport.(*http.Transport).Clone(),
|
|
}
|
|
|
|
const requests = 30
|
|
for i := 0; i < requests; i++ {
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, http.NoBody)
|
|
if err != nil {
|
|
t.Fatalf("new request %d: %v", i, err)
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("do request %d: %v", i, err)
|
|
}
|
|
// close without draining - the leak that kills reuse.
|
|
resp.Body.Close()
|
|
}
|
|
|
|
// most requests should have dialed a fresh conn. don't demand exactly N (the
|
|
// scheduler occasionally reuses one), just that it's clearly not pooling.
|
|
const minDistinct = requests / 2
|
|
if got := distinct(); got < minDistinct {
|
|
t.Errorf("bare client opened only %d conns for %d requests, want >= %d "+
|
|
"(expected near-zero reuse without draining)", got, requests, minDistinct)
|
|
}
|
|
}
|
|
|
|
// BenchmarkConnReuse contrasts the tuned, draining client against a bare client
|
|
// that closes without draining. the reported conns/op metric is the distinct
|
|
// tcp conns one pass of `requests` opened - tuned≈1, bare≈requests - so the
|
|
// README can quote real before/after reuse numbers. the conn map is reset per
|
|
// iteration so the metric stays a per-pass count and the bare path doesn't
|
|
// accumulate b.N*requests live sockets and exhaust the ephemeral port range.
|
|
//
|
|
// run the bare sub-bench with a bounded -benchtime (e.g. -benchtime 5x): its
|
|
// whole point is that it can't reuse, so a large b.N floods the local port
|
|
// space with TIME_WAIT sockets. the tuned sub-bench reuses and runs unbounded.
|
|
func BenchmarkConnReuse(b *testing.B) {
|
|
const requests = 50
|
|
|
|
run := func(b *testing.B, drain bool, client *http.Client) {
|
|
b.Helper()
|
|
var (
|
|
mu sync.Mutex
|
|
conns = make(map[net.Conn]struct{})
|
|
)
|
|
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
io.WriteString(w, strings.Repeat("x", 256))
|
|
}))
|
|
srv.Config.ConnState = func(c net.Conn, state http.ConnState) {
|
|
if state != http.StateNew {
|
|
return
|
|
}
|
|
mu.Lock()
|
|
conns[c] = struct{}{}
|
|
mu.Unlock()
|
|
}
|
|
srv.Start()
|
|
defer srv.Close()
|
|
|
|
var lastPass int
|
|
b.ResetTimer()
|
|
for n := 0; n < b.N; n++ {
|
|
mu.Lock()
|
|
conns = make(map[net.Conn]struct{})
|
|
mu.Unlock()
|
|
for i := 0; i < requests; i++ {
|
|
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, http.NoBody)
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
b.Fatalf("do: %v", err)
|
|
}
|
|
if drain {
|
|
DrainClose(resp)
|
|
} else {
|
|
resp.Body.Close()
|
|
}
|
|
}
|
|
// close idle conns between passes so the bare client's per-pass
|
|
// sockets land in TIME_WAIT and free up before the next pass.
|
|
client.CloseIdleConnections()
|
|
mu.Lock()
|
|
lastPass = len(conns)
|
|
mu.Unlock()
|
|
}
|
|
b.StopTimer()
|
|
|
|
// distinct conns for a single pass of `requests`.
|
|
b.ReportMetric(float64(lastPass), "conns/op")
|
|
}
|
|
|
|
b.Run("tuned-drain", func(b *testing.B) {
|
|
resetBench()
|
|
tr, err := buildTransport("", 8)
|
|
if err != nil {
|
|
b.Fatalf("buildTransport: %v", err)
|
|
}
|
|
run(b, true, &http.Client{Timeout: 5 * time.Second, Transport: tr})
|
|
})
|
|
|
|
b.Run("bare-noDrain", func(b *testing.B) {
|
|
run(b, false, &http.Client{
|
|
Timeout: 5 * time.Second,
|
|
Transport: http.DefaultTransport.(*http.Transport).Clone(),
|
|
})
|
|
})
|
|
}
|
|
|
|
// resetBench clears the package transport without a *testing.T for benchmarks.
|
|
func resetBench() {
|
|
mu.Lock()
|
|
configured = nil
|
|
mu.Unlock()
|
|
}
|
|
|
|
// retrySequenceServer serves codes[n] on the n-th hit, repeating the last once
|
|
// the slice runs out; retryable codes carry Retry-After: 0 to keep tests fast.
|
|
func retrySequenceServer(t *testing.T, hits *atomic.Int64, codes ...int) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
n := int(hits.Add(1)) - 1
|
|
code := codes[len(codes)-1]
|
|
if n < len(codes) {
|
|
code = codes[n]
|
|
}
|
|
if code == http.StatusTooManyRequests || code == http.StatusServiceUnavailable {
|
|
w.Header().Set("Retry-After", "0")
|
|
}
|
|
w.WriteHeader(code)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
// getStatus performs a GET and returns the final status code the caller sees.
|
|
func getStatus(t *testing.T, client *http.Client, url string) int {
|
|
t.Helper()
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, http.NoBody)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("do request: %v", err)
|
|
}
|
|
resp.Body.Close()
|
|
return resp.StatusCode
|
|
}
|
|
|
|
func TestRetryRecoversAfter429(t *testing.T) {
|
|
resetConfig(t)
|
|
|
|
var hits atomic.Int64
|
|
srv := retrySequenceServer(t, &hits, http.StatusTooManyRequests, http.StatusOK)
|
|
if err := Configure(Options{MaxRetries: 2}); err != nil {
|
|
t.Fatalf("Configure: %v", err)
|
|
}
|
|
|
|
if code := getStatus(t, Client(5*time.Second), srv.URL); code != http.StatusOK {
|
|
t.Errorf("status = %d, want 200 after retry", code)
|
|
}
|
|
if got := hits.Load(); got != 2 {
|
|
t.Errorf("server hits = %d, want 2 (initial + one retry)", got)
|
|
}
|
|
}
|
|
|
|
func TestRetryRecoversAfter503(t *testing.T) {
|
|
resetConfig(t)
|
|
|
|
var hits atomic.Int64
|
|
srv := retrySequenceServer(t, &hits, http.StatusServiceUnavailable, http.StatusOK)
|
|
if err := Configure(Options{MaxRetries: 2}); err != nil {
|
|
t.Fatalf("Configure: %v", err)
|
|
}
|
|
|
|
if code := getStatus(t, Client(5*time.Second), srv.URL); code != http.StatusOK {
|
|
t.Errorf("status = %d, want 200 after retry", code)
|
|
}
|
|
if got := hits.Load(); got != 2 {
|
|
t.Errorf("server hits = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestRetryDisabled(t *testing.T) {
|
|
resetConfig(t)
|
|
|
|
var hits atomic.Int64
|
|
srv := retrySequenceServer(t, &hits, http.StatusTooManyRequests, http.StatusOK)
|
|
if err := Configure(Options{MaxRetries: 0}); err != nil {
|
|
t.Fatalf("Configure: %v", err)
|
|
}
|
|
|
|
if code := getStatus(t, Client(5*time.Second), srv.URL); code != http.StatusTooManyRequests {
|
|
t.Errorf("status = %d, want 429 with retries off", code)
|
|
}
|
|
if got := hits.Load(); got != 1 {
|
|
t.Errorf("server hits = %d, want 1 (no retry)", got)
|
|
}
|
|
}
|
|
|
|
func TestRetryExhausted(t *testing.T) {
|
|
resetConfig(t)
|
|
|
|
var hits atomic.Int64
|
|
srv := retrySequenceServer(t, &hits, http.StatusTooManyRequests) // always 429
|
|
if err := Configure(Options{MaxRetries: 2}); err != nil {
|
|
t.Fatalf("Configure: %v", err)
|
|
}
|
|
|
|
if code := getStatus(t, Client(5*time.Second), srv.URL); code != http.StatusTooManyRequests {
|
|
t.Errorf("status = %d, want 429 after exhausting retries", code)
|
|
}
|
|
if got := hits.Load(); got != 3 {
|
|
t.Errorf("server hits = %d, want 3 (initial + 2 retries)", got)
|
|
}
|
|
}
|
|
|
|
func TestRetryIgnoresNonRetryableStatus(t *testing.T) {
|
|
resetConfig(t)
|
|
|
|
var hits atomic.Int64
|
|
srv := retrySequenceServer(t, &hits, http.StatusInternalServerError, http.StatusOK)
|
|
if err := Configure(Options{MaxRetries: 2}); err != nil {
|
|
t.Fatalf("Configure: %v", err)
|
|
}
|
|
|
|
if code := getStatus(t, Client(5*time.Second), srv.URL); code != http.StatusInternalServerError {
|
|
t.Errorf("status = %d, want 500 (not retried)", code)
|
|
}
|
|
if got := hits.Load(); got != 1 {
|
|
t.Errorf("server hits = %d, want 1 (500 not retried)", got)
|
|
}
|
|
}
|
|
|
|
func TestRetryReplaysRequestBody(t *testing.T) {
|
|
resetConfig(t)
|
|
|
|
var hits atomic.Int64
|
|
var bmu sync.Mutex
|
|
var bodies []string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
bmu.Lock()
|
|
bodies = append(bodies, string(body))
|
|
bmu.Unlock()
|
|
if hits.Add(1) == 1 {
|
|
w.Header().Set("Retry-After", "0")
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
if err := Configure(Options{MaxRetries: 2}); err != nil {
|
|
t.Fatalf("Configure: %v", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, srv.URL, strings.NewReader("payload"))
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
resp, err := Client(5 * time.Second).Do(req)
|
|
if err != nil {
|
|
t.Fatalf("do request: %v", err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 after body replay", resp.StatusCode)
|
|
}
|
|
|
|
bmu.Lock()
|
|
defer bmu.Unlock()
|
|
if len(bodies) != 2 {
|
|
t.Fatalf("server saw %d requests, want 2", len(bodies))
|
|
}
|
|
for i, body := range bodies {
|
|
if body != "payload" {
|
|
t.Errorf("body[%d] = %q, want %q (rewind dropped the body)", i, body, "payload")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRetryAfterHeader(t *testing.T) {
|
|
noHeader := &http.Response{Header: http.Header{}}
|
|
if got := retryAfter(noHeader, 0); got != retryBackoffBase {
|
|
t.Errorf("missing header: attempt 0 = %v, want %v", got, retryBackoffBase)
|
|
}
|
|
if got := retryAfter(noHeader, 1); got != 2*retryBackoffBase {
|
|
t.Errorf("missing header: attempt 1 = %v, want %v", got, 2*retryBackoffBase)
|
|
}
|
|
if got := retryAfter(noHeader, 1000); got != retryAfterCap {
|
|
t.Errorf("missing header: attempt 1000 = %v, want cap %v", got, retryAfterCap)
|
|
}
|
|
|
|
withSeconds := func(v string) *http.Response {
|
|
return &http.Response{Header: http.Header{"Retry-After": {v}}}
|
|
}
|
|
if got := retryAfter(withSeconds("3"), 0); got != 3*time.Second {
|
|
t.Errorf("Retry-After 3 = %v, want 3s", got)
|
|
}
|
|
if got := retryAfter(withSeconds("0"), 5); got != 0 {
|
|
t.Errorf("Retry-After 0 = %v, want 0", got)
|
|
}
|
|
if got := retryAfter(withSeconds("9999"), 0); got != retryAfterCap {
|
|
t.Errorf("Retry-After 9999 = %v, want cap %v", got, retryAfterCap)
|
|
}
|
|
if got := retryAfter(withSeconds("soon"), 0); got != retryBackoffBase {
|
|
t.Errorf("Retry-After junk = %v, want backoff %v", got, retryBackoffBase)
|
|
}
|
|
|
|
future := time.Now().Add(5 * time.Second).UTC().Format(http.TimeFormat)
|
|
if got := retryAfter(withSeconds(future), 0); got <= 0 || got > 5*time.Second {
|
|
t.Errorf("Retry-After http-date = %v, want (0, 5s]", got)
|
|
}
|
|
}
|
|
|
|
func TestCapDuration(t *testing.T) {
|
|
cases := []struct{ in, want time.Duration }{
|
|
{-time.Second, 0},
|
|
{0, 0},
|
|
{5 * time.Second, 5 * time.Second},
|
|
{retryAfterCap, retryAfterCap},
|
|
{retryAfterCap + time.Second, retryAfterCap},
|
|
}
|
|
for _, c := range cases {
|
|
if got := capDuration(c.in); got != c.want {
|
|
t.Errorf("capDuration(%v) = %v, want %v", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSleepCtxCancelled(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if err := sleepCtx(ctx, time.Hour); err == nil {
|
|
t.Error("sleepCtx on a cancelled context should return its error, not block")
|
|
}
|
|
}
|
|
|
|
// hostMappingTransport clones the default transport but resolves the given
|
|
// hostnames to the supplied local listener addresses, so a test can force a
|
|
// redirect across two genuinely distinct hostnames (not just distinct ports
|
|
// on 127.0.0.1, which net/http's redirect header stripping treats as the
|
|
// same host and never strips).
|
|
func hostMappingTransport(t *testing.T, hostToAddr map[string]string) *http.Transport {
|
|
t.Helper()
|
|
dialer := &net.Dialer{}
|
|
base := http.DefaultTransport.(*http.Transport).Clone()
|
|
base.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
host, _, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if mapped, ok := hostToAddr[host]; ok {
|
|
addr = mapped
|
|
}
|
|
return dialer.DialContext(ctx, network, addr)
|
|
}
|
|
return base
|
|
}
|
|
|
|
// exercises the crossHostRedirect fix in roundTripper.RoundTrip (httpx.go).
|
|
func TestRedirectStripsSensitiveHeadersCrossHost(t *testing.T) {
|
|
var attackerSeen, targetSeen http.Header
|
|
|
|
attacker := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
|
attackerSeen = r.Header.Clone()
|
|
}))
|
|
defer attacker.Close()
|
|
|
|
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
targetSeen = r.Header.Clone()
|
|
if r.URL.Path == "/redirect" {
|
|
w.Header().Set("Location", "http://attacker.evil/loot")
|
|
w.WriteHeader(http.StatusFound)
|
|
}
|
|
}))
|
|
defer target.Close()
|
|
|
|
base := hostMappingTransport(t, map[string]string{
|
|
"target.test": target.Listener.Addr().String(),
|
|
"attacker.evil": attacker.Listener.Addr().String(),
|
|
})
|
|
|
|
rt := &roundTripper{
|
|
base: base,
|
|
cookie: "session=secret",
|
|
headers: map[string]string{"Authorization": "Bearer topsecret"},
|
|
userAgent: "sif-scanner",
|
|
}
|
|
client := &http.Client{Transport: rt}
|
|
|
|
resp, err := client.Get("http://target.test/redirect")
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
DrainClose(resp)
|
|
|
|
if targetSeen == nil {
|
|
t.Fatal("target never received the initial request")
|
|
}
|
|
if targetSeen.Get("Cookie") != "session=secret" {
|
|
t.Errorf("target Cookie = %q, want it injected on the first hop", targetSeen.Get("Cookie"))
|
|
}
|
|
if targetSeen.Get("Authorization") != "Bearer topsecret" {
|
|
t.Errorf("target Authorization = %q, want it injected on the first hop", targetSeen.Get("Authorization"))
|
|
}
|
|
|
|
if attackerSeen == nil {
|
|
t.Fatal("redirect never reached the attacker host")
|
|
}
|
|
if got := attackerSeen.Get("Cookie"); got != "" {
|
|
t.Errorf("attacker Cookie = %q, want empty (cross-host redirect must not leak it)", got)
|
|
}
|
|
if got := attackerSeen.Get("Authorization"); got != "" {
|
|
t.Errorf("attacker Authorization = %q, want empty (cross-host redirect must not leak it)", got)
|
|
}
|
|
if got := attackerSeen.Get("User-Agent"); got != "sif-scanner" {
|
|
t.Errorf("attacker User-Agent = %q, want sif-scanner (non-sensitive headers still inject)", got)
|
|
}
|
|
}
|
|
|
|
// TestRedirectPreservesSensitiveHeadersSameHost is the control: a same-host
|
|
// redirect must keep behaving exactly as before the fix.
|
|
func TestRedirectPreservesSensitiveHeadersSameHost(t *testing.T) {
|
|
var finalSeen http.Header
|
|
|
|
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/redirect":
|
|
w.Header().Set("Location", "http://target.test/dest")
|
|
w.WriteHeader(http.StatusFound)
|
|
case "/dest":
|
|
finalSeen = r.Header.Clone()
|
|
}
|
|
}))
|
|
defer target.Close()
|
|
|
|
base := hostMappingTransport(t, map[string]string{
|
|
"target.test": target.Listener.Addr().String(),
|
|
})
|
|
|
|
rt := &roundTripper{
|
|
base: base,
|
|
cookie: "session=secret",
|
|
headers: map[string]string{"Authorization": "Bearer topsecret"},
|
|
userAgent: "sif-scanner",
|
|
}
|
|
client := &http.Client{Transport: rt}
|
|
|
|
resp, err := client.Get("http://target.test/redirect")
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
DrainClose(resp)
|
|
|
|
if finalSeen == nil {
|
|
t.Fatal("redirect never reached /dest")
|
|
}
|
|
if finalSeen.Get("Cookie") != "session=secret" {
|
|
t.Errorf("same-host Cookie = %q, want session=secret (same-host redirects keep it)", finalSeen.Get("Cookie"))
|
|
}
|
|
if finalSeen.Get("Authorization") != "Bearer topsecret" {
|
|
t.Errorf("same-host Authorization = %q, want Bearer topsecret (same-host redirects keep it)", finalSeen.Get("Authorization"))
|
|
}
|
|
}
|