mirror of
https://github.com/lunchcat/sif.git
synced 2026-07-28 22:40:54 -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.
375 lines
12 KiB
Go
375 lines
12 KiB
Go
/*
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
: :
|
|
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
|
|
: ▄█ █ █▀ · BSD 3-Clause License :
|
|
: :
|
|
: (c) 2022-2026 vmfunc, xyzeva, :
|
|
: lunchcat alumni & contributors :
|
|
: :
|
|
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
|
|
*/
|
|
|
|
// Package httpx is the shared http layer every scanner talks through, so a
|
|
// single Configure call wires proxy, custom headers, cookies and rate limiting
|
|
// into every outbound request without touching scanner signatures.
|
|
package httpx
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/net/proxy"
|
|
"golang.org/x/time/rate"
|
|
)
|
|
|
|
// allowed proxy schemes
|
|
const (
|
|
schemeHTTP = "http"
|
|
schemeHTTPS = "https"
|
|
schemeSOCKS5 = "socks5"
|
|
)
|
|
|
|
// a header is "Key: Value"; this is the separator between the two halves.
|
|
const headerSep = ": "
|
|
|
|
// burst lets the limiter absorb a small spike before pacing kicks in; a burst
|
|
// equal to the per-second rate keeps the cap honest over any one-second window.
|
|
const limiterBurstPerRate = 1
|
|
|
|
// transport pool tuning. go's default transport caps idle conns per host at 2
|
|
// and reuse only kicks in once a response body is fully drained, so without
|
|
// these a high thread count just thrashes the dialer instead of pooling.
|
|
const (
|
|
// total idle conns kept warm across every host we hit in a run.
|
|
maxIdleConns = 512
|
|
// floor for per-host idle conns so a single-target run still pools even
|
|
// when the thread count is tiny.
|
|
minIdleConnsPerHost = 8
|
|
// how long an idle conn lingers before the pool reaps it.
|
|
idleConnTimeout = 90 * time.Second
|
|
// keepalive probe interval for live conns; mirrors go's default dialer so
|
|
// the socks5 branch doesn't silently lose os-level keepalive.
|
|
dialKeepAlive = 30 * time.Second
|
|
// dial timeout for the socks5 branch; matches go's default dialer.
|
|
dialTimeout = 30 * time.Second
|
|
)
|
|
|
|
// drainCap bounds how much of an unread body DrainClose will copy before
|
|
// closing; a body larger than this isn't worth slurping just to reuse the
|
|
// conn, so we cap the read and let the conn be discarded instead.
|
|
const drainCap = 16 << 10
|
|
|
|
// Options carries the runtime knobs that apply to every outbound request.
|
|
// RateLimit is requests/sec (0 = unlimited); Headers are "Key: Value" strings.
|
|
type Options struct {
|
|
Proxy string
|
|
Headers []string
|
|
Cookie string
|
|
UserAgent string
|
|
RateLimit int
|
|
// MaxRetries is how many 429/503 responses to retry with backoff (0 = off).
|
|
MaxRetries int
|
|
// Threads is the scan worker count; it sizes the per-host idle pool so
|
|
// concurrent workers hitting one target reuse conns instead of dialing fresh.
|
|
Threads int
|
|
}
|
|
|
|
// configured holds the package-level transport built once by Configure. nil
|
|
// means Configure was never called, so Client falls back to a plain client.
|
|
var (
|
|
mu sync.RWMutex
|
|
configured http.RoundTripper
|
|
)
|
|
|
|
// Configure builds the shared transport once at startup from opts. Calling it
|
|
// again replaces the previous configuration.
|
|
//
|
|
//nolint:gocritic // signature is the package's stable startup api; called once.
|
|
func Configure(opts Options) error {
|
|
base, err := buildTransport(opts.Proxy, opts.Threads)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
headers, err := parseHeaders(opts.Headers)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var limiter *rate.Limiter
|
|
if opts.RateLimit > 0 {
|
|
limiter = rate.NewLimiter(rate.Limit(opts.RateLimit), opts.RateLimit*limiterBurstPerRate)
|
|
}
|
|
|
|
rt := &roundTripper{
|
|
base: base,
|
|
headers: headers,
|
|
cookie: opts.Cookie,
|
|
userAgent: opts.UserAgent,
|
|
limiter: limiter,
|
|
maxRetries: opts.MaxRetries,
|
|
}
|
|
|
|
mu.Lock()
|
|
configured = rt
|
|
mu.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
// Client returns an http client wired to the configured transport. It works
|
|
// before Configure is ever called (plain transport) so existing code and tests
|
|
// behave unchanged. A zero timeout means no timeout, matching http.Client.
|
|
func Client(timeout time.Duration) *http.Client {
|
|
mu.RLock()
|
|
rt := configured
|
|
mu.RUnlock()
|
|
|
|
return &http.Client{Timeout: timeout, Transport: rt}
|
|
}
|
|
|
|
// buildTransport clones the default transport, tunes its pool for the worker
|
|
// count and applies the proxy. An empty proxy leaves the default behavior
|
|
// (respects HTTP_PROXY env) intact.
|
|
func buildTransport(proxyURL string, threads int) (*http.Transport, error) {
|
|
tr, ok := http.DefaultTransport.(*http.Transport)
|
|
if !ok {
|
|
// unreachable in practice, but never trust an assertion silently.
|
|
return nil, fmt.Errorf("default transport is not *http.Transport")
|
|
}
|
|
transport := tr.Clone()
|
|
|
|
// size the idle pool so every worker can keep its conn warm. per-host idle
|
|
// must clear the thread count or workers past the cap re-dial each request;
|
|
// MaxConnsPerHost stays 0 (unbounded) so the limiter, not the pool, paces us.
|
|
transport.MaxIdleConns = maxIdleConns
|
|
transport.MaxIdleConnsPerHost = idlePerHost(threads)
|
|
transport.MaxConnsPerHost = 0
|
|
transport.IdleConnTimeout = idleConnTimeout
|
|
transport.ForceAttemptHTTP2 = true
|
|
|
|
if proxyURL == "" {
|
|
return transport, nil
|
|
}
|
|
|
|
parsed, err := url.Parse(proxyURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse proxy url %q: %w", proxyURL, err)
|
|
}
|
|
|
|
switch parsed.Scheme {
|
|
case schemeHTTP, schemeHTTPS:
|
|
transport.Proxy = http.ProxyURL(parsed)
|
|
case schemeSOCKS5:
|
|
// socks5 needs a custom dialer. proxy.SOCKS5 takes a forward dialer, so
|
|
// hand it our own net.Dialer with keepalive set - the default
|
|
// proxy.Direct has none, which would kill os-level conn pooling.
|
|
fwd := &net.Dialer{Timeout: dialTimeout, KeepAlive: dialKeepAlive}
|
|
dialer, err := proxy.SOCKS5("tcp", parsed.Host, nil, fwd)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("socks5 proxy %q: %w", proxyURL, err)
|
|
}
|
|
ctxDialer, ok := dialer.(proxy.ContextDialer)
|
|
if !ok {
|
|
return nil, fmt.Errorf("socks5 proxy %q: dialer lacks context support", proxyURL)
|
|
}
|
|
transport.DialContext = ctxDialer.DialContext
|
|
default:
|
|
return nil, fmt.Errorf("unsupported proxy scheme %q (want http/https/socks5)", parsed.Scheme)
|
|
}
|
|
|
|
return transport, nil
|
|
}
|
|
|
|
// idlePerHost picks the per-host idle pool size: at least the worker count so
|
|
// no worker re-dials, never below the floor so a small thread count still pools.
|
|
func idlePerHost(threads int) int {
|
|
if threads < minIdleConnsPerHost {
|
|
return minIdleConnsPerHost
|
|
}
|
|
return threads
|
|
}
|
|
|
|
// DrainClose fully reads (up to drainCap) and closes resp.Body. go only returns
|
|
// a conn to the idle pool when the body is read to EOF, so a caller that only
|
|
// closes leaks the conn and forces a fresh dial next time. Call this instead of
|
|
// a bare resp.Body.Close() to keep the pool warm. Safe on a nil response.
|
|
func DrainClose(resp *http.Response) {
|
|
if resp == nil || resp.Body == nil {
|
|
return
|
|
}
|
|
// the read result is intentionally ignored: we're discarding the body and
|
|
// about to close it, so a copy error changes nothing we can act on.
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, drainCap))
|
|
resp.Body.Close()
|
|
}
|
|
|
|
// parseHeaders splits each "Key: Value" entry on the first ": ". Entries
|
|
// without the separator are rejected so a typo fails loud instead of silently.
|
|
// The returned map is always non-nil so callers can range it unconditionally.
|
|
func parseHeaders(raw []string) (map[string]string, error) {
|
|
headers := make(map[string]string, len(raw))
|
|
for i := 0; i < len(raw); i++ {
|
|
key, value, ok := strings.Cut(raw[i], headerSep)
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid header %q (want \"Key: Value\")", raw[i])
|
|
}
|
|
headers[key] = value
|
|
}
|
|
|
|
return headers, nil
|
|
}
|
|
|
|
// sensitiveHeaders mirrors net/http's own redirect header policy (see
|
|
// shouldCopyHeaderOnRedirect / copyHeaders in net/http/client.go): these are
|
|
// the headers the stdlib strips itself when a redirect crosses hosts, so
|
|
// injecting them back in at the transport layer would defeat that stripping.
|
|
var sensitiveHeaders = map[string]bool{
|
|
"Cookie": true,
|
|
"Cookie2": true,
|
|
"Authorization": true,
|
|
"Www-Authenticate": true,
|
|
}
|
|
|
|
// roundTripper paces and decorates each request before delegating to base.
|
|
type roundTripper struct {
|
|
base *http.Transport
|
|
headers map[string]string
|
|
cookie string
|
|
userAgent string
|
|
limiter *rate.Limiter
|
|
maxRetries int
|
|
}
|
|
|
|
func (rt *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
// req.Response is only populated when req was built to follow a redirect
|
|
// (see net/http's Request.Response doc comment); its Request is the prior
|
|
// hop, so a host mismatch here means this hop crossed hosts.
|
|
crossHostRedirect := req.Response != nil && req.Response.Request != nil &&
|
|
req.Response.Request.URL.Host != req.URL.Host
|
|
|
|
// only set what the caller hasn't already; a scanner that explicitly sets a
|
|
// header (e.g. an api key) must win over the global default.
|
|
for key, value := range rt.headers {
|
|
if crossHostRedirect && sensitiveHeaders[http.CanonicalHeaderKey(key)] {
|
|
continue
|
|
}
|
|
if req.Header.Get(key) == "" {
|
|
req.Header.Set(key, value)
|
|
}
|
|
}
|
|
if rt.cookie != "" && !crossHostRedirect && req.Header.Get("Cookie") == "" {
|
|
req.Header.Set("Cookie", rt.cookie)
|
|
}
|
|
if rt.userAgent != "" && req.Header.Get("User-Agent") == "" {
|
|
req.Header.Set("User-Agent", rt.userAgent)
|
|
}
|
|
|
|
for attempt := 0; ; attempt++ {
|
|
if rt.limiter != nil {
|
|
if err := rt.limiter.Wait(req.Context()); err != nil {
|
|
return nil, fmt.Errorf("rate limiter: %w", err)
|
|
}
|
|
}
|
|
|
|
resp, err := rt.base.RoundTrip(req)
|
|
if err != nil || attempt >= rt.maxRetries || !retryableStatus(resp.StatusCode) {
|
|
return resp, err
|
|
}
|
|
|
|
// back off and retry, unless the body can't be replayed.
|
|
if !rewind(req) {
|
|
return resp, nil
|
|
}
|
|
wait := retryAfter(resp, attempt)
|
|
DrainClose(resp)
|
|
|
|
if err := sleepCtx(req.Context(), wait); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
|
|
func retryableStatus(code int) bool {
|
|
return code == http.StatusTooManyRequests || code == http.StatusServiceUnavailable
|
|
}
|
|
|
|
const (
|
|
retryAfterCap = 20 * time.Second
|
|
retryBackoffBase = 500 * time.Millisecond
|
|
// clamp the shift so a large -max-retries can't overflow the duration.
|
|
retryBackoffMaxShift = 16
|
|
)
|
|
|
|
// retryAfter honors a Retry-After header (delta-seconds or HTTP-date) and
|
|
// otherwise falls back to capped exponential backoff.
|
|
func retryAfter(resp *http.Response, attempt int) time.Duration {
|
|
if v := strings.TrimSpace(resp.Header.Get("Retry-After")); v != "" {
|
|
if secs, err := strconv.Atoi(v); err == nil && secs >= 0 {
|
|
return capDuration(time.Duration(secs) * time.Second)
|
|
}
|
|
if t, err := http.ParseTime(v); err == nil {
|
|
return capDuration(time.Until(t))
|
|
}
|
|
}
|
|
shift := attempt
|
|
if shift > retryBackoffMaxShift {
|
|
shift = retryBackoffMaxShift
|
|
}
|
|
return capDuration(retryBackoffBase << shift)
|
|
}
|
|
|
|
// capDuration clamps d to [0, retryAfterCap].
|
|
func capDuration(d time.Duration) time.Duration {
|
|
switch {
|
|
case d < 0:
|
|
return 0
|
|
case d > retryAfterCap:
|
|
return retryAfterCap
|
|
default:
|
|
return d
|
|
}
|
|
}
|
|
|
|
// rewind restores req.Body for a resend. Only a GetBody-backed body (set by
|
|
// net/http for the in-memory bodies sif uses) is replayable; a nil or NoBody
|
|
// request needs nothing, anything else can't be retried.
|
|
func rewind(req *http.Request) bool {
|
|
if req.Body == nil || req.Body == http.NoBody {
|
|
return true
|
|
}
|
|
if req.GetBody == nil {
|
|
return false
|
|
}
|
|
body, err := req.GetBody()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
req.Body = body
|
|
return true
|
|
}
|
|
|
|
// sleepCtx waits for d or until ctx is cancelled, whichever comes first.
|
|
func sleepCtx(ctx context.Context, d time.Duration) error {
|
|
if d <= 0 {
|
|
return nil
|
|
}
|
|
timer := time.NewTimer(d)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-timer.C:
|
|
return nil
|
|
}
|
|
}
|