Stealth mode: defeat Cloudflare automation detection
Some checks failed
Release / build (push) Failing after 1m5s

Drop DefaultExecAllocatorOptions (includes --enable-automation),
add disable-blink-features=AutomationControlled, patch
navigator.webdriver via JS, and inject sessionKey cookie via CDP
into a dedicated fetch profile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
calic
2026-03-21 00:42:03 +01:00
parent 5abdee06ff
commit a11bdb7267

View File

@@ -15,32 +15,42 @@ import (
"git.davoryn.de/calic/claude-statusline/internal/config" "git.davoryn.de/calic/claude-statusline/internal/config"
) )
// FetchViaChrome navigates to a URL using Chrome with the persistent browser // FetchViaChrome navigates to a URL using Chrome with a dedicated browser
// profile (which has Cloudflare clearance cookies) and returns the response // profile and returns the response body. Uses stealth flags to avoid
// body. Uses non-headless mode with a minimized/hidden window to avoid // Cloudflare's automation detection (navigator.webdriver, etc.).
// Cloudflare's headless detection, which causes infinite challenge loops.
func FetchViaChrome(url string) ([]byte, error) { func FetchViaChrome(url string) ([]byte, error) {
profileDir := filepath.Join(config.ConfigDir(), "browser-profile") // Use a dedicated fetch profile separate from the login profile.
profileDir := filepath.Join(config.ConfigDir(), "fetch-profile")
if err := os.MkdirAll(profileDir, 0o755); err != nil { if err := os.MkdirAll(profileDir, 0o755); err != nil {
return nil, fmt.Errorf("create browser profile dir: %w", err) return nil, fmt.Errorf("create fetch profile dir: %w", err)
} }
// Remove stale lock file from unclean shutdown
_ = os.Remove(filepath.Join(profileDir, "SingletonLock")) _ = os.Remove(filepath.Join(profileDir, "SingletonLock"))
execPath := findBrowserExec() execPath := findBrowserExec()
// Use non-headless mode: Cloudflare detects headless Chrome and loops // Start with minimal options — NOT DefaultExecAllocatorOptions, which
// the JS challenge forever. A real (but hidden) browser window passes. // includes flags that Cloudflare detects (--enable-automation, etc.).
opts := append(chromedp.DefaultExecAllocatorOptions[:], opts := []chromedp.ExecAllocatorOption{
chromedp.Flag("headless", false), chromedp.NoFirstRun,
chromedp.Flag("window-position", "-32000,-32000"), // off-screen chromedp.NoDefaultBrowserCheck,
chromedp.UserDataDir(profileDir),
// Stealth: disable automation indicators
chromedp.Flag("disable-blink-features", "AutomationControlled"),
chromedp.Flag("enable-automation", false),
chromedp.Flag("disable-infobars", true),
// Window: off-screen so it doesn't flash
chromedp.Flag("window-position", "-32000,-32000"),
chromedp.Flag("window-size", "1,1"), chromedp.Flag("window-size", "1,1"),
chromedp.Flag("disable-gpu", true), chromedp.Flag("disable-gpu", true),
chromedp.Flag("no-first-run", true),
chromedp.Flag("disable-extensions", true), chromedp.Flag("disable-extensions", true),
chromedp.UserDataDir(profileDir), chromedp.Flag("no-first-run", true),
)
// Use non-headless — Cloudflare detects headless mode
chromedp.Flag("headless", false),
}
if execPath != "" { if execPath != "" {
opts = append(opts, chromedp.ExecPath(execPath)) opts = append(opts, chromedp.ExecPath(execPath))
} }
@@ -51,19 +61,42 @@ func FetchViaChrome(url string) ([]byte, error) {
ctx, cancel := chromedp.NewContext(allocCtx) ctx, cancel := chromedp.NewContext(allocCtx)
defer cancel() defer cancel()
// Total timeout for the operation
ctx, timeoutCancel := context.WithTimeout(ctx, 30*time.Second) ctx, timeoutCancel := context.WithTimeout(ctx, 30*time.Second)
defer timeoutCancel() defer timeoutCancel()
// Navigate and wait for Cloudflare challenge to resolve. // Inject the session key cookie before navigating
log.Printf("chrome-fetch: navigating to %s (profile: %s)", url, profileDir) sessionKey := config.GetSessionKey()
if sessionKey != "" {
err := chromedp.Run(ctx,
chromedp.Navigate("about:blank"),
setCookieAction("sessionKey", sessionKey, ".claude.ai"),
)
if err != nil {
log.Printf("chrome-fetch: cookie injection failed: %v", err)
}
}
// Patch navigator.webdriver to false via CDP
err := chromedp.Run(ctx,
chromedp.ActionFunc(func(ctx context.Context) error {
_, _, _, err := chromedp.Targets(ctx)
_ = err
return nil
}),
chromedp.Evaluate(`Object.defineProperty(navigator, 'webdriver', {get: () => undefined})`, nil),
)
if err != nil {
log.Printf("chrome-fetch: webdriver patch failed: %v", err)
}
log.Printf("chrome-fetch: navigating to %s (exec: %q)", url, execPath)
if err := chromedp.Run(ctx, chromedp.Navigate(url)); err != nil { if err := chromedp.Run(ctx, chromedp.Navigate(url)); err != nil {
log.Printf("chrome-fetch: navigate failed: %v", err) log.Printf("chrome-fetch: navigate failed: %v", err)
return nil, fmt.Errorf("chromedp navigate: %w", err) return nil, fmt.Errorf("chromedp navigate: %w", err)
} }
log.Printf("chrome-fetch: navigation complete, polling for JSON...") log.Printf("chrome-fetch: navigation complete, polling for JSON...")
// Poll for JSON response — Cloudflare challenge takes a few seconds to clear // Poll for JSON response — Cloudflare challenge takes a few seconds
ticker := time.NewTicker(1 * time.Second) ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop() defer ticker.Stop()
@@ -73,12 +106,10 @@ func FetchViaChrome(url string) ([]byte, error) {
return nil, fmt.Errorf("chromedp fetch timed out waiting for JSON response") return nil, fmt.Errorf("chromedp fetch timed out waiting for JSON response")
case <-ticker.C: case <-ticker.C:
var body string var body string
// Try <pre> first (Chrome wraps JSON in <pre> tags)
err := chromedp.Run(ctx, err := chromedp.Run(ctx,
chromedp.Text("pre", &body, chromedp.ByQuery), chromedp.Text("pre", &body, chromedp.ByQuery),
) )
if err != nil || body == "" { if err != nil || body == "" {
// Fallback: try body directly
_ = chromedp.Run(ctx, _ = chromedp.Run(ctx,
chromedp.Text("body", &body, chromedp.ByQuery), chromedp.Text("body", &body, chromedp.ByQuery),
) )
@@ -88,15 +119,12 @@ func FetchViaChrome(url string) ([]byte, error) {
log.Printf("chrome-fetch: page body empty, waiting...") log.Printf("chrome-fetch: page body empty, waiting...")
continue continue
} }
// Check if we got actual JSON (starts with [ or {), not a challenge page
if body[0] == '[' || body[0] == '{' { if body[0] == '[' || body[0] == '{' {
log.Printf("chrome-fetch: got JSON response (%d bytes)", len(body)) log.Printf("chrome-fetch: got JSON response (%d bytes)", len(body))
// Also extract any fresh cookies for future plain HTTP attempts
_ = extractAndSaveCookies(ctx) _ = extractAndSaveCookies(ctx)
cancel() // graceful close, flushes cookies to profile cancel()
return []byte(body), nil return []byte(body), nil
} }
// Log a snippet of what we got (challenge page, login redirect, etc.)
snippet := body snippet := body
if len(snippet) > 200 { if len(snippet) > 200 {
snippet = snippet[:200] snippet = snippet[:200]
@@ -106,8 +134,20 @@ func FetchViaChrome(url string) ([]byte, error) {
} }
} }
// setCookieAction sets a cookie via the DevTools protocol.
func setCookieAction(name, value, domain string) chromedp.Action {
return chromedp.ActionFunc(func(ctx context.Context) error {
expr := network.SetCookie(name, value).
WithDomain(domain).
WithPath("/").
WithHTTPOnly(true).
WithSecure(true)
return expr.Do(ctx)
})
}
// extractAndSaveCookies saves cf_clearance and other Cloudflare cookies // extractAndSaveCookies saves cf_clearance and other Cloudflare cookies
// alongside the session key, so plain HTTP requests can try them next time. // so plain HTTP requests can try them on subsequent polls.
func extractAndSaveCookies(ctx context.Context) error { func extractAndSaveCookies(ctx context.Context) error {
cookies, err := network.GetCookies().Do(ctx) cookies, err := network.GetCookies().Do(ctx)
if err != nil { if err != nil {
@@ -127,7 +167,6 @@ func extractAndSaveCookies(ctx context.Context) error {
return nil return nil
} }
// Write Cloudflare cookies to a file the fetcher can read
cfPath := filepath.Join(config.ConfigDir(), "cf-cookies") cfPath := filepath.Join(config.ConfigDir(), "cf-cookies")
return os.WriteFile(cfPath, []byte(strings.Join(parts, "\n")+"\n"), 0o600) return os.WriteFile(cfPath, []byte(strings.Join(parts, "\n")+"\n"), 0o600)
} }