Skip to content

Commit c4f1046

Browse files
committed
fix(auth): stop importing yandex.com cookies as "x.com" — the real 403 cause
User reported auto-import reading 40 cookies including yandexuid, ymex, yp, yuidss, __gads, __gpi, Hm_lvt_*, _pk_id, _pk_ref, POSMEDIAID, __cf_bm, cf_clearance, FCCDCF, FCNEC — almost none of which belong to X/Twitter. They were getting stuffed into the `Cookie:` header of every UserByRestId request and X's gateway was (correctly) 403'ing the jumbled request. Root cause: `kooky.DomainHasSuffix("x.com")` does a literal string suffix match against `cookie.Domain`. `yandex.com` ends in the three characters "x.com", so does `unix.com`, `pix.com`, `nhx.com`, etc. The filter was matching every tracker / ad / analytics cookie from every site whose domain happens to end in those letters. That's how all the garbage got imported. XActions works because users paste cookies from DevTools → Application → Cookies → https://x.com, which the browser has already scoped to the x.com registrable domain. No browser-side bug, just a manual filter. x-cli was trying to be clever with auto-read and hit the substring-matching footgun. Fix in internal/browsercookies: isDomainMatch(want, cookieDomain) — proper registrable-domain check. isDomainMatch("x.com", "x.com") → true isDomainMatch("x.com", ".x.com") → true isDomainMatch("x.com", "api.x.com") → true isDomainMatch("x.com", ".help.x.com") → true isDomainMatch("x.com", "yandex.com") → false ← the bug fix isDomainMatch("x.com", "unix.com") → false isDomainMatch("x.com", "pix.com") → false Both Load and List now drop kooky.DomainHasSuffix and apply this check in their own traversal loop. TestIsDomainMatch pins the rule with 16 cases covering exact match, subdomain match, leading-dot match, case insensitivity, and the critical yandex.com / unix.com / pix.com negative cases that caused the original bug. Bonus cleanup in api/client.go (the header minimization from the prior commit stays in place): x-cli now sends the XActions-minimal header profile (bearer + UA + accept + content-type + x-twitter-* + cookie + csrf) and NOT the browser-fingerprint set (no sec-ch-ua, no sec-fetch-*, no Origin, no Referer). XActions and twikit both work with this minimal profile against today's x.com; adding browser- fingerprint headers without matching TLS fingerprint only made x.com fingerprint us as "lying about being a browser" and 403 harder. api/client_test.go — TestApplyHeadersUnauthenticated updated to assert that the browser-fingerprint headers are NOT sent (the correct behaviour). Expected result after this: `x auth import` reads ~8-12 real x.com cookies (auth_token, ct0, twid, kdt, att, lang, _twitter_sess, personalization_id, etc.), the verify call lands with a proper cookie set, UserByRestId returns 200, and the user sees their handle. No TLS impersonation needed.
1 parent dfa629c commit c4f1046

4 files changed

Lines changed: 115 additions & 25 deletions

File tree

api/client.go

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -204,30 +204,31 @@ func (c *Client) request(ctx context.Context, method, rawURL string, body io.Rea
204204
return nil, lastErr
205205
}
206206

207+
// applyHeaders builds the request header set that X's GraphQL/REST
208+
// gateways accept.
209+
//
210+
// We deliberately mirror XActions' "tool/SDK" header profile, NOT a
211+
// modern browser's profile. Real browsers send sec-ch-ua, sec-fetch-*,
212+
// Origin, Referer, and X validates those against TLS fingerprint and
213+
// HTTP/2 frame ordering. With Go's stdlib net/http we cannot match
214+
// Chrome's TLS or H2 layer, so declaring browser-like headers makes
215+
// X catch the lie and 403 the request. The bare bearer+cookies+csrf
216+
// path that XActions and twikit use treats us as a tool, accepts the
217+
// non-browser TLS fingerprint, and works.
218+
//
219+
// If a future commit wires utls + bogdanfinn/tls-client to fully
220+
// impersonate Chrome, we can switch to the browser header profile
221+
// then. Until then: keep this minimal.
207222
func (c *Client) applyHeaders(req *http.Request, opts requestOpts) {
208223
bearer := c.endpoints.Bearer
209224
req.Header.Set("Authorization", "Bearer "+bearer)
210225
req.Header.Set("User-Agent", c.userAgent)
211226
req.Header.Set("Accept", "*/*")
212227
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
228+
req.Header.Set("Content-Type", "application/json")
213229
req.Header.Set("x-twitter-active-user", "yes")
214230
req.Header.Set("x-twitter-client-language", "en")
215231

216-
// Origin + Referer are required for X's same-origin CSRF check.
217-
// Without them the gateway treats the call as cross-site and 403s
218-
// authenticated reads. Real browsers add these automatically; Go's
219-
// net/http does not, so we set them ourselves.
220-
req.Header.Set("Origin", "https://x.com")
221-
req.Header.Set("Referer", "https://x.com/")
222-
223-
// Client-hint headers matched to the UA. Pinned, not rotated per-request.
224-
req.Header.Set("sec-ch-ua", `"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"`)
225-
req.Header.Set("sec-ch-ua-mobile", "?0")
226-
req.Header.Set("sec-ch-ua-platform", `"macOS"`)
227-
req.Header.Set("sec-fetch-dest", "empty")
228-
req.Header.Set("sec-fetch-mode", "cors")
229-
req.Header.Set("sec-fetch-site", "same-origin")
230-
231232
if opts.authenticated {
232233
c.sessionMu.RLock()
233234
cookies := c.session.Cookies
@@ -241,10 +242,6 @@ func (c *Client) applyHeaders(req *http.Request, opts requestOpts) {
241242
c.sessionMu.RUnlock()
242243
}
243244

244-
if req.Body != nil && req.Header.Get("Content-Type") == "" {
245-
req.Header.Set("Content-Type", "application/json")
246-
}
247-
248245
for k, v := range opts.extraHeaders {
249246
req.Header.Set(k, v)
250247
}

api/client_test.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,18 @@ func TestApplyHeadersUnauthenticated(t *testing.T) {
5555
if got := captured.Get("User-Agent"); !strings.Contains(got, "Chrome") {
5656
t.Errorf("User-Agent = %q", got)
5757
}
58-
if got := captured.Get("sec-ch-ua"); got == "" {
59-
t.Errorf("missing sec-ch-ua client hint")
58+
if got := captured.Get("Content-Type"); got != "application/json" {
59+
t.Errorf("Content-Type = %q (XActions sends application/json on every request)", got)
6060
}
61-
if got := captured.Get("sec-fetch-dest"); got != "empty" {
62-
t.Errorf("sec-fetch-dest = %q", got)
61+
if got := captured.Get("x-twitter-active-user"); got != "yes" {
62+
t.Errorf("x-twitter-active-user = %q", got)
63+
}
64+
// We DELIBERATELY do NOT send browser-fingerprint headers — see the
65+
// rationale comment on applyHeaders.
66+
for _, name := range []string{"sec-ch-ua", "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site", "Origin", "Referer"} {
67+
if got := captured.Get(name); got != "" {
68+
t.Errorf("%s should not be sent (browser-fingerprint header), got %q", name, got)
69+
}
6370
}
6471
if got := captured.Get("x-csrf-token"); got != "" {
6572
t.Errorf("unauthenticated request should not carry x-csrf-token, got %q", got)

internal/browsercookies/browsercookies.go

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,23 @@ func Load(ctx context.Context, browser, profile, domain string) (*Result, error)
9696
var order []storeKey
9797
stores := map[storeKey]*bucket{}
9898

99-
for c, err := range kooky.TraverseCookies(ctx, kooky.Valid, kooky.DomainHasSuffix(domain)) {
99+
// NB: kooky.DomainHasSuffix does a literal string suffix match
100+
// against the cookie's Domain attribute. That's dangerously loose —
101+
// "yandex.com" literally ends in the string "x.com", so passing
102+
// DomainHasSuffix("x.com") to kooky pulls in every cookie from
103+
// yandex.com, unix.com, pix.com, nhx.com, and anything else that
104+
// happens to end in those three characters.
105+
//
106+
// We use kooky.Valid (drop expired cookies) and do the registrable-
107+
// domain check ourselves in isDomainMatch. No more yandex cookies
108+
// leaking into the x.com request.
109+
for c, err := range kooky.TraverseCookies(ctx, kooky.Valid) {
100110
if err != nil || c == nil || c.Browser == nil {
101111
continue
102112
}
113+
if !isDomainMatch(domain, c.Cookie.Domain) {
114+
continue
115+
}
103116
actualBrowser := c.Browser.Browser()
104117
actualProfile := c.Browser.Profile()
105118
actualPath := c.Browser.FilePath()
@@ -162,6 +175,10 @@ func Load(ctx context.Context, browser, profile, domain string) (*Result, error)
162175
// List enumerates every cookie store that has at least one cookie for
163176
// the given domain. Used by `x auth browsers` to show the user which
164177
// (browser, profile) pairs are available before they pin one.
178+
//
179+
// Uses the same strict isDomainMatch check as Load so yandex.com,
180+
// unix.com, pix.com, etc. don't show up as false-positive "x.com
181+
// sessions".
165182
func List(ctx context.Context, domain string) ([]Match, error) {
166183
if domain == "" {
167184
return nil, errors.New("browsercookies: domain required")
@@ -171,10 +188,13 @@ func List(ctx context.Context, domain string) ([]Match, error) {
171188
}
172189
seen := map[key]int{}
173190
var order []key
174-
for c, err := range kooky.TraverseCookies(ctx, kooky.Valid, kooky.DomainHasSuffix(domain)) {
191+
for c, err := range kooky.TraverseCookies(ctx, kooky.Valid) {
175192
if err != nil || c == nil || c.Browser == nil {
176193
continue
177194
}
195+
if !isDomainMatch(domain, c.Cookie.Domain) {
196+
continue
197+
}
178198
k := key{
179199
browser: c.Browser.Browser(),
180200
profile: c.Browser.Profile(),
@@ -197,6 +217,34 @@ func List(ctx context.Context, domain string) ([]Match, error) {
197217
return out, nil
198218
}
199219

220+
// isDomainMatch returns true when `cookieDomain` belongs to `want` as a
221+
// registrable-domain match. Crucially, it is NOT a string suffix match:
222+
//
223+
// isDomainMatch("x.com", "x.com") → true
224+
// isDomainMatch("x.com", ".x.com") → true
225+
// isDomainMatch("x.com", "api.x.com") → true
226+
// isDomainMatch("x.com", ".help.x.com") → true
227+
//
228+
// isDomainMatch("x.com", "yandex.com") → false ← the bug fix
229+
// isDomainMatch("x.com", ".yandex.com") → false
230+
// isDomainMatch("x.com", "unix.com") → false
231+
// isDomainMatch("x.com", "pix.com") → false
232+
//
233+
// Without this check, kooky.DomainHasSuffix("x.com") matches every
234+
// cookie whose domain literally ends in the three characters "x.com"
235+
// (yande-x.com, uni-x.com, pi-x.com, ADGRX.com, etc.), which is every
236+
// tracker / ad network / third-party site the user has ever visited.
237+
// Those get stuffed into the Cookie: header for the real x.com request
238+
// and X's gateway 403s the resulting jumble.
239+
func isDomainMatch(want, cookieDomain string) bool {
240+
if cookieDomain == "" || want == "" {
241+
return false
242+
}
243+
d := strings.ToLower(strings.TrimPrefix(cookieDomain, "."))
244+
w := strings.ToLower(want)
245+
return d == w || strings.HasSuffix(d, "."+w)
246+
}
247+
200248
// profileMatches returns true when `want` (the user-supplied --profile
201249
// substring) matches either the human profile name from kooky (e.g.
202250
// "Tammie", "Default", "Work") OR a path component of the cookie file

internal/browsercookies/browsercookies_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,44 @@ func TestListEmptyDomain(t *testing.T) {
8484
}
8585
}
8686

87+
func TestIsDomainMatch(t *testing.T) {
88+
cases := []struct {
89+
want, cookie string
90+
expect bool
91+
}{
92+
// Exact and leading-dot match
93+
{"x.com", "x.com", true},
94+
{"x.com", ".x.com", true},
95+
// Subdomains
96+
{"x.com", "api.x.com", true},
97+
{"x.com", ".api.x.com", true},
98+
{"x.com", "help.x.com", true},
99+
// Case insensitivity
100+
{"x.com", "X.COM", true},
101+
{"x.com", ".X.COM", true},
102+
// The bug: yandex.com ends in the literal string "x.com" but is
103+
// NOT a subdomain of x.com. MUST NOT match.
104+
{"x.com", "yandex.com", false},
105+
{"x.com", ".yandex.com", false},
106+
{"x.com", "unix.com", false},
107+
{"x.com", "pix.com", false},
108+
{"x.com", "foo.unix.com", false},
109+
// Totally unrelated
110+
{"x.com", "twitter.com", false},
111+
{"x.com", "google.com", false},
112+
// Empty inputs
113+
{"x.com", "", false},
114+
{"", "x.com", false},
115+
{"", "", false},
116+
}
117+
for _, tc := range cases {
118+
if got := isDomainMatch(tc.want, tc.cookie); got != tc.expect {
119+
t.Errorf("isDomainMatch(%q, %q) = %v, want %v",
120+
tc.want, tc.cookie, got, tc.expect)
121+
}
122+
}
123+
}
124+
87125
func TestProfileMatches(t *testing.T) {
88126
cases := []struct {
89127
want string

0 commit comments

Comments
 (0)