Skip to content

Commit e88b25b

Browse files
committed
feat(tls): wire utls Chrome 120 ClientHelloID to defeat Cloudflare on x.com
Live test surfaced the real reason for the 403s: x.com's /i/api/graphql/* path is fronted by Cloudflare Bot Management, which matches on the TLS ClientHello fingerprint (JA3/JA4). Go's stdlib crypto/tls has a distinctive non-browser JA3 that Cloudflare flags as "not a real browser" and serves an HTML challenge page instead of the real GraphQL response. The diagnostic body-dump from the previous commit made this unmistakable — error message contained: <title>Attention Required! | Cloudflare</title> That's a literal Cloudflare interstitial. Not an X auth error, not a features-blob problem, not a stale cookie, not the wrong profile. Cloudflare's bot management was rejecting the request before it ever reached X's gateway. (For context: this is also why XActions' CLI uses Puppeteer instead of its own HTTP scraper. Their HTTP path probably 403s on the same Cloudflare wall. Puppeteer drives a real Chrome which trivially passes because it IS Chrome — same TLS fingerprint, same h2 settings, same everything. Heavier solution; we're trying the lighter one first.) Approach: impersonate Chrome 120 at the TLS handshake layer using github.com/refraction-networking/utls. Cloudflare's basic JA3 check should now see a Chrome 120 ClientHello and let us through. If Cloudflare also inspects HTTP/2 frame ordering or runs a JS challenge, this isn't enough — the next escalation is bogdanfinn/tls-client (full HTTP/2 fingerprint) or chromedp (real Chrome via CDP). internal/tlsprint/tlsprint.go (new): NewChromeTransport() returns an *http.Transport whose DialTLSContext performs a uTLS handshake with utls.HelloChrome_120. ALPN is pinned to http/1.1 so Go's stdlib handles the HTTP layer (we cannot match Chrome's HTTP/2 frame fingerprint with Go stdlib anyway, so advertising h1-only is the cleanest cut). Plain HTTP connections fall through DialContext, so unit tests using httptest.NewServer keep working unchanged. api/client.go: api.New() default HTTPClient is now constructed with tlsprint.NewChromeTransport() instead of the bare http.Client. All existing tests still pass because httptest is HTTP, not HTTPS. cmd/doctor.go: Updated the TLS line from "no Chrome impersonation" warning to a Success line announcing "Chrome 120 impersonation via uTLS". go.mod / go.sum: github.com/refraction-networking/utls v1.8.2, plus its indirect deps (brotli, klauspost/compress). Expected next run output: ✓ using chrome / Tammie (...) » imported 14 cookie(s): ... » verifying session against X (UserByRestId via twid)... » GET https://x.com/i/api/graphql/.../UserByRestId?… → 200 (Xms) ✓ logged in as @yourhandle (Your Name) If it's STILL Cloudflare HTML in the body, the next commit wires chromedp.
1 parent ffc1bd0 commit e88b25b

5 files changed

Lines changed: 112 additions & 9 deletions

File tree

api/client.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import (
1616
"strings"
1717
"sync"
1818
"time"
19+
20+
"github.com/thevibeworks/x-cli/internal/tlsprint"
1921
)
2022

2123
// Client is the HTTP transport for x-cli. One per authenticated session.
@@ -57,11 +59,22 @@ type Options struct {
5759

5860
func New(opts Options) *Client {
5961
if opts.HTTPClient == nil {
60-
// 15s per request is generous for a single GraphQL call. Combined
61-
// with the (lowered) default retry count of 1, total worst-case
62-
// wall-clock for one client.GraphQL is ~30s — short enough that
63-
// an interactive user does not assume the program is hung.
64-
opts.HTTPClient = &http.Client{Timeout: 15 * time.Second}
62+
// The default HTTP client is wired with a uTLS Chrome 120
63+
// ClientHelloID round-tripper. x.com's /i/api/graphql/* path
64+
// is behind Cloudflare Bot Management which JA3-fingerprints
65+
// clients; Go's stdlib TLS gets flagged as non-browser and
66+
// served a challenge page. Impersonating Chrome at the TLS
67+
// handshake defeats that. See internal/tlsprint/ for detail.
68+
//
69+
// 15s per request is generous for a single GraphQL call. With
70+
// the (lowered) default retry count of 1, total worst-case
71+
// wall-clock for one client.GraphQL is ~30s — short enough
72+
// that an interactive user does not assume the program is
73+
// hung.
74+
opts.HTTPClient = &http.Client{
75+
Transport: tlsprint.NewChromeTransport(),
76+
Timeout: 15 * time.Second,
77+
}
6578
}
6679
if opts.UserAgent == "" {
6780
opts.UserAgent = defaultUserAgent

cmd/doctor.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,13 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
8686

8787
// 4. TLS impersonation
8888
//
89-
// v0.1 does not wrap the http.Client with a uTLS round-tripper, so our
90-
// TLS ClientHello does not match a real Chrome. Say so loudly — it's the
91-
// biggest single fingerprinting delta between x-cli and a browser.
92-
cmdutil.Warn("tls: no Chrome impersonation (v0.1); JA3/JA4 differs from real browser")
89+
// The default http.Client is wired with a uTLS Chrome 120
90+
// ClientHelloID round-tripper (internal/tlsprint). This matches
91+
// Chrome's JA3/JA4 fingerprint at the TLS handshake, which is
92+
// what Cloudflare Bot Management on x.com's /i/api/graphql/* path
93+
// inspects. Without this, every call gets a Cloudflare challenge
94+
// page instead of the real response.
95+
cmdutil.Success("tls: Chrome 120 impersonation via uTLS")
9396

9497
if !ok {
9598
return fmt.Errorf("doctor reports one or more issues")

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go 1.24.0
44

55
require (
66
github.com/browserutils/kooky v0.2.9
7+
github.com/refraction-networking/utls v1.8.2
78
github.com/spf13/cobra v1.9.1
89
github.com/spf13/viper v1.20.1
910
github.com/zalando/go-keyring v0.2.7
@@ -12,6 +13,7 @@ require (
1213
)
1314

1415
require (
16+
github.com/andybalholm/brotli v1.0.6 // indirect
1517
github.com/browserutils/ese v0.0.0-20260314233042-37b6a03a93ce // indirect
1618
github.com/danieljoos/wincred v1.2.3 // indirect
1719
github.com/fsnotify/fsnotify v1.8.0 // indirect
@@ -21,6 +23,7 @@ require (
2123
github.com/gonuts/binary v0.2.0 // indirect
2224
github.com/inconshreveable/mousetrap v1.1.0 // indirect
2325
github.com/keybase/go-keychain v0.0.1 // indirect
26+
github.com/klauspost/compress v1.17.4 // indirect
2427
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
2528
github.com/pierrec/lz4/v4 v4.1.26 // indirect
2629
github.com/sagikazarmark/locafero v0.7.0 // indirect

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
2+
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
13
github.com/browserutils/ese v0.0.0-20260314233042-37b6a03a93ce h1:xb/LXUukZgVLMRnTUyEiCfMNH7KUCFOS4aOZnc/N+H8=
24
github.com/browserutils/ese v0.0.0-20260314233042-37b6a03a93ce/go.mod h1:Rj9TJxm7cExxJmdec83sr8cjvyF3raBsszTFviSo/6U=
35
github.com/browserutils/kooky v0.2.9 h1:EY1evaA/nbrFXt6F5PFUXDxoxzQNxC4IZdsVEf+wtiQ=
@@ -26,6 +28,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
2628
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
2729
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
2830
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
31+
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
32+
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
2933
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
3034
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
3135
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -36,6 +40,8 @@ github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY
3640
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
3741
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
3842
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
43+
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
44+
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
3945
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
4046
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
4147
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=

internal/tlsprint/tlsprint.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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

Comments
 (0)