|
| 1 | +// Package tlsprint provides an http.Transport that performs the TLS |
| 2 | +// handshake using uTLS with a Chrome 120 ClientHelloID. x.com's |
| 3 | +// /i/api/graphql/* path is fronted by Cloudflare Bot Management, and |
| 4 | +// Cloudflare matches on the TLS ClientHello fingerprint (JA3/JA4). |
| 5 | +// Go's stdlib net/http has a distinctive JA3 that Cloudflare flags as |
| 6 | +// "non-browser" and serves a challenge page instead of the real |
| 7 | +// response. Impersonating Chrome at the TLS layer is how we get past. |
| 8 | +// |
| 9 | +// Scope: TLS handshake only. We deliberately pin ALPN to http/1.1 so |
| 10 | +// Go's stdlib handles the HTTP layer. Chrome actually negotiates h2 in |
| 11 | +// ALPN, which means Cloudflare sees our http/1.1-only advertisement as |
| 12 | +// slightly-odd-but-not-suspicious. If a future commit needs full h2 |
| 13 | +// fingerprint parity, `github.com/bogdanfinn/tls-client` is the next |
| 14 | +// escalation. |
| 15 | +// |
| 16 | +// Plain HTTP connections (httptest servers in unit tests) go through |
| 17 | +// the standard DialContext path, so the transport is a drop-in for |
| 18 | +// local testing too. |
| 19 | +package tlsprint |
| 20 | + |
| 21 | +import ( |
| 22 | + "context" |
| 23 | + "net" |
| 24 | + "net/http" |
| 25 | + "time" |
| 26 | + |
| 27 | + utls "github.com/refraction-networking/utls" |
| 28 | +) |
| 29 | + |
| 30 | +// NewChromeTransport returns an *http.Transport wired to impersonate |
| 31 | +// Chrome 120 for HTTPS connections. Safe to reuse across requests |
| 32 | +// and goroutines (http.Transport is concurrency-safe). |
| 33 | +func NewChromeTransport() *http.Transport { |
| 34 | + dialer := &net.Dialer{ |
| 35 | + Timeout: 15 * time.Second, |
| 36 | + KeepAlive: 30 * time.Second, |
| 37 | + } |
| 38 | + return &http.Transport{ |
| 39 | + DialContext: dialer.DialContext, |
| 40 | + DialTLSContext: dialTLSWithUTLS(dialer, utls.HelloChrome_120), |
| 41 | + ForceAttemptHTTP2: false, |
| 42 | + MaxIdleConns: 16, |
| 43 | + IdleConnTimeout: 90 * time.Second, |
| 44 | + TLSHandshakeTimeout: 10 * time.Second, |
| 45 | + ExpectContinueTimeout: 1 * time.Second, |
| 46 | + } |
| 47 | +} |
| 48 | + |
| 49 | +// dialTLSWithUTLS returns a DialTLSContext hook that performs a uTLS |
| 50 | +// handshake with the given ClientHelloID. |
| 51 | +func dialTLSWithUTLS(dialer *net.Dialer, helloID utls.ClientHelloID) func(ctx context.Context, network, addr string) (net.Conn, error) { |
| 52 | + return func(ctx context.Context, network, addr string) (net.Conn, error) { |
| 53 | + raw, err := dialer.DialContext(ctx, network, addr) |
| 54 | + if err != nil { |
| 55 | + return nil, err |
| 56 | + } |
| 57 | + host, _, err := net.SplitHostPort(addr) |
| 58 | + if err != nil { |
| 59 | + raw.Close() |
| 60 | + return nil, err |
| 61 | + } |
| 62 | + config := &utls.Config{ |
| 63 | + ServerName: host, |
| 64 | + // Force h1 so Go's stdlib handles the HTTP layer. Chrome |
| 65 | + // actually advertises `h2, http/1.1` in ALPN; we don't |
| 66 | + // match that exactly, but JA3/JA4 is what Cloudflare's |
| 67 | + // bot-management layer checks first and matching the Hello |
| 68 | + // itself is the load-bearing part. |
| 69 | + NextProtos: []string{"http/1.1"}, |
| 70 | + } |
| 71 | + uconn := utls.UClient(raw, config, helloID) |
| 72 | + if err := uconn.HandshakeContext(ctx); err != nil { |
| 73 | + uconn.Close() |
| 74 | + return nil, err |
| 75 | + } |
| 76 | + return uconn, nil |
| 77 | + } |
| 78 | +} |
0 commit comments