fetch: every outbound request carries one generic browser User-Agent
Stripping forwarding headers kept our IP and chain out of reach, but the
User-Agent went through untouched. Clients that fell back to their own name
("crawlkit-roles", "crawlkit-virk", ...) tied all ten exits together as one
actor, and one missing UA meant Go-http-client. The proxy now overwrites the
UA on every request (WEIRCON_USER_AGENT, default current Firefox) and the
egress prober uses the same string.
Also dropped: From, Sec-Ch-Ua* (would contradict a Firefox UA) and any
X-Weircon-* by prefix. New end-to-end test drives the whole handler and
checks what the target actually receives.
Claude-Session: https://claude.ai/code/session_01AUj1LSMmrowcomkn1ciAgj
This commit is contained in:
+27
-3
@@ -35,6 +35,10 @@ type Config struct {
|
||||
Timeout time.Duration
|
||||
UIEnabled bool
|
||||
|
||||
// UserAgent replaces whatever User-Agent the caller sent, on every
|
||||
// outbound request, so no client can identify us by forgetting to set one.
|
||||
UserAgent string
|
||||
|
||||
// Rotation drain tuning.
|
||||
DrainTimeout time.Duration // max wait for in-flight to clear during drain
|
||||
DrainSettle time.Duration // quiet period after in-flight hits zero (the "before")
|
||||
@@ -53,6 +57,7 @@ func loadConfig() (Config, error) {
|
||||
AdminListen: envStr("WEIRCON_ADMIN_LISTEN", "127.0.0.1:8081"),
|
||||
Timeout: time.Duration(envInt("WEIRCON_REQUEST_TIMEOUT_SEC", 30)) * time.Second,
|
||||
UIEnabled: envBool("WEIRCON_UI_ENABLED", true),
|
||||
UserAgent: envStr("WEIRCON_USER_AGENT", defaultUserAgent),
|
||||
DrainTimeout: time.Duration(envInt("WEIRCON_DRAIN_TIMEOUT_SEC", 25)) * time.Second,
|
||||
DrainSettle: time.Duration(envInt("WEIRCON_DRAIN_SETTLE_SEC", 2)) * time.Second,
|
||||
DrainWarmup: time.Duration(envInt("WEIRCON_DRAIN_WARMUP_SEC", 3)) * time.Second,
|
||||
@@ -165,6 +170,12 @@ func (p *poolTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return p.pool[rc.proxyID].RoundTrip(req)
|
||||
}
|
||||
|
||||
// defaultUserAgent is a current, common desktop browser. A Firefox string is
|
||||
// deliberate: Firefox sends no Sec-CH-UA client hints, so stripping those (see
|
||||
// stripIdentifying) leaves a header set that is consistent with the UA. Bump it
|
||||
// every few releases via WEIRCON_USER_AGENT; a years-old version is a bot tell.
|
||||
const defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:156.0) Gecko/20100101 Firefox/156.0"
|
||||
|
||||
// stripHeaders are removed from the outbound request so nothing about the
|
||||
// caller or the proxy chain can reach the target. It covers our own control
|
||||
// headers plus every standard forwarding / client-IP header that a fronting
|
||||
@@ -195,6 +206,8 @@ var stripHeaders = []string{
|
||||
"Fastly-Client-Ip",
|
||||
"Fly-Client-Ip",
|
||||
"X-Appengine-User-Ip",
|
||||
// identity headers a client library may add on its own
|
||||
"From",
|
||||
}
|
||||
|
||||
// stripIdentifying removes every header that could reveal the caller or the
|
||||
@@ -206,12 +219,22 @@ func stripIdentifying(h http.Header) {
|
||||
h.Del(k)
|
||||
}
|
||||
for k := range h {
|
||||
if strings.HasPrefix(k, "X-Forwarded") {
|
||||
// X-Weircon-*: any control header we add later is ours, never the target's.
|
||||
// Sec-Ch-Ua*: client hints would contradict the Firefox UA we set.
|
||||
if strings.HasPrefix(k, "X-Forwarded") || strings.HasPrefix(k, "X-Weircon-") ||
|
||||
strings.HasPrefix(k, "Sec-Ch-Ua") {
|
||||
h.Del(k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// anonymize is everything done to an outbound request's headers: drop what
|
||||
// identifies the caller or the chain, then impose the generic browser UA.
|
||||
func anonymize(h http.Header, userAgent string) {
|
||||
stripIdentifying(h)
|
||||
h.Set("User-Agent", userAgent)
|
||||
}
|
||||
|
||||
// parsePin reads the optional X-WEIRCON-PROXY-ID header. It returns (-1, nil)
|
||||
// when no pin is requested (caller should pick at random). The actual slot
|
||||
// reservation — and the draining check — happens in the manager, not here.
|
||||
@@ -269,7 +292,7 @@ func newHandler(cfg Config, pool []*http.Transport, mgr *proxyManager) http.Hand
|
||||
pr.Out.Host = rc.target.Host
|
||||
pr.Out.Method = rc.method
|
||||
pr.Out.RequestURI = ""
|
||||
stripIdentifying(pr.Out.Header)
|
||||
anonymize(pr.Out.Header, cfg.UserAgent)
|
||||
},
|
||||
Transport: &poolTransport{pool: pool},
|
||||
ModifyResponse: func(resp *http.Response) error {
|
||||
@@ -379,7 +402,8 @@ func main() {
|
||||
// after it rotates back in so its new egress IP appears promptly rather
|
||||
// than waiting for the next periodic pass.
|
||||
if cfg.ProbeInterval > 0 {
|
||||
pr := &prober{mgr: mgr, pool: pool, url: cfg.ProbeURL, timeout: cfg.ProbeTimeout}
|
||||
pr := &prober{mgr: mgr, pool: pool, url: cfg.ProbeURL, timeout: cfg.ProbeTimeout,
|
||||
userAgent: cfg.UserAgent}
|
||||
mgr.afterUndrain = func(id int) {
|
||||
time.AfterFunc(cfg.DrainWarmup+500*time.Millisecond, func() { pr.probe(id) })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user