2 Commits

Author SHA1 Message Date
calic
a11bdb7267 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>
2026-03-21 00:42:03 +01:00
calic
5abdee06ff Add diagnostic logging to Chrome fallback
All checks were successful
Release / build (push) Successful in 1m32s
Log navigation, polling state, and response snippets so we can
diagnose whether the fallback fails due to Cloudflare challenge,
login redirect, profile lock, or other issues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 00:34:41 +01:00
2 changed files with 82 additions and 30 deletions

View File

@@ -3,6 +3,7 @@ package browser
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
@@ -14,32 +15,42 @@ import (
"git.davoryn.de/calic/claude-statusline/internal/config"
)
// FetchViaChrome navigates to a URL using Chrome with the persistent browser
// profile (which has Cloudflare clearance cookies) and returns the response
// body. Uses non-headless mode with a minimized/hidden window to avoid
// Cloudflare's headless detection, which causes infinite challenge loops.
// FetchViaChrome navigates to a URL using Chrome with a dedicated browser
// profile and returns the response body. Uses stealth flags to avoid
// Cloudflare's automation detection (navigator.webdriver, etc.).
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 {
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"))
execPath := findBrowserExec()
// Use non-headless mode: Cloudflare detects headless Chrome and loops
// the JS challenge forever. A real (but hidden) browser window passes.
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", false),
chromedp.Flag("window-position", "-32000,-32000"), // off-screen
// Start with minimal options — NOT DefaultExecAllocatorOptions, which
// includes flags that Cloudflare detects (--enable-automation, etc.).
opts := []chromedp.ExecAllocatorOption{
chromedp.NoFirstRun,
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("disable-gpu", true),
chromedp.Flag("no-first-run", 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 != "" {
opts = append(opts, chromedp.ExecPath(execPath))
}
@@ -50,17 +61,42 @@ func FetchViaChrome(url string) ([]byte, error) {
ctx, cancel := chromedp.NewContext(allocCtx)
defer cancel()
// Total timeout for the operation
ctx, timeoutCancel := context.WithTimeout(ctx, 30*time.Second)
defer timeoutCancel()
// Navigate and wait for Cloudflare challenge to resolve.
// Poll the page content until we get valid JSON (not the challenge page).
if err := chromedp.Run(ctx, chromedp.Navigate(url)); err != nil {
return nil, fmt.Errorf("chromedp navigate: %w", err)
// Inject the session key cookie before navigating
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)
}
}
// Poll for JSON response — Cloudflare challenge takes a few seconds to clear
// 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 {
log.Printf("chrome-fetch: navigate failed: %v", err)
return nil, fmt.Errorf("chromedp navigate: %w", err)
}
log.Printf("chrome-fetch: navigation complete, polling for JSON...")
// Poll for JSON response — Cloudflare challenge takes a few seconds
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
@@ -70,33 +106,48 @@ func FetchViaChrome(url string) ([]byte, error) {
return nil, fmt.Errorf("chromedp fetch timed out waiting for JSON response")
case <-ticker.C:
var body string
// Try <pre> first (Chrome wraps JSON in <pre> tags)
err := chromedp.Run(ctx,
chromedp.Text("pre", &body, chromedp.ByQuery),
)
if err != nil || body == "" {
// Fallback: try body directly
_ = chromedp.Run(ctx,
chromedp.Text("body", &body, chromedp.ByQuery),
)
}
body = strings.TrimSpace(body)
if body == "" {
log.Printf("chrome-fetch: page body empty, waiting...")
continue
}
// Check if we got actual JSON (starts with [ or {), not a challenge page
if body[0] == '[' || body[0] == '{' {
// Also extract any fresh cookies for future plain HTTP attempts
log.Printf("chrome-fetch: got JSON response (%d bytes)", len(body))
_ = extractAndSaveCookies(ctx)
cancel() // graceful close, flushes cookies to profile
cancel()
return []byte(body), nil
}
snippet := body
if len(snippet) > 200 {
snippet = snippet[:200]
}
log.Printf("chrome-fetch: non-JSON body (%d bytes): %s", len(body), snippet)
}
}
}
// 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
// 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 {
cookies, err := network.GetCookies().Do(ctx)
if err != nil {
@@ -116,7 +167,6 @@ func extractAndSaveCookies(ctx context.Context) error {
return nil
}
// Write Cloudflare cookies to a file the fetcher can read
cfPath := filepath.Join(config.ConfigDir(), "cf-cookies")
return os.WriteFile(cfPath, []byte(strings.Join(parts, "\n")+"\n"), 0o600)
}

View File

@@ -102,11 +102,13 @@ func fetchWithFallback(url, sessionKey string) ([]byte, error) {
}
if status == 403 {
// Likely a Cloudflare JS challenge — fall back to headless Chrome
log.Printf("HTTP 403 for %s, falling back to headless Chrome", url)
log.Printf("HTTP 403 for %s, falling back to Chrome", url)
chromeBody, chromeErr := browser.FetchViaChrome(url)
if chromeErr != nil {
return nil, fmt.Errorf("auth_expired") // treat as auth failure if Chrome also fails
log.Printf("Chrome fallback failed: %v", chromeErr)
return nil, fmt.Errorf("auth_expired")
}
log.Printf("Chrome fallback succeeded (%d bytes)", len(chromeBody))
return chromeBody, nil
}
return nil, fmt.Errorf("HTTP %d", status)