Fix Cloudflare headless detection: use non-headless with hidden window
All checks were successful
Release / build (push) Successful in 1m37s

Cloudflare detects headless Chrome and loops the JS challenge forever.
Switch to non-headless mode with an off-screen window. Also save
Cloudflare cookies (cf_clearance, __cf_bm) after Chrome fallback so
subsequent plain HTTP requests can reuse them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
calic
2026-03-21 00:26:34 +01:00
parent ba3b73c3dd
commit 2cb89d3c54
2 changed files with 108 additions and 33 deletions

View File

@@ -5,17 +5,19 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/chromedp" "github.com/chromedp/chromedp"
"git.davoryn.de/calic/claude-statusline/internal/config" "git.davoryn.de/calic/claude-statusline/internal/config"
) )
// FetchViaChrome navigates to a URL using headless Chrome with the persistent // FetchViaChrome navigates to a URL using Chrome with the persistent browser
// browser profile (which has Cloudflare clearance cookies) and returns the // profile (which has Cloudflare clearance cookies) and returns the response
// response body. This bypasses Cloudflare JS challenges because Chrome runs // body. Uses non-headless mode with a minimized/hidden window to avoid
// real JavaScript. Falls back to non-headless if headless fails. // 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") profileDir := filepath.Join(config.ConfigDir(), "browser-profile")
if err := os.MkdirAll(profileDir, 0o755); err != nil { if err := os.MkdirAll(profileDir, 0o755); err != nil {
@@ -27,8 +29,15 @@ func FetchViaChrome(url string) ([]byte, error) {
execPath := findBrowserExec() 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[:], opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", true), chromedp.Flag("headless", false),
chromedp.Flag("window-position", "-32000,-32000"), // off-screen
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.UserDataDir(profileDir),
) )
if execPath != "" { if execPath != "" {
@@ -41,36 +50,73 @@ func FetchViaChrome(url string) ([]byte, error) {
ctx, cancel := chromedp.NewContext(allocCtx) ctx, cancel := chromedp.NewContext(allocCtx)
defer cancel() defer cancel()
// Set a total timeout for the operation // 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()
var body string // Navigate and wait for Cloudflare challenge to resolve.
err := chromedp.Run(ctx, // Poll the page content until we get valid JSON (not the challenge page).
chromedp.Navigate(url), if err := chromedp.Run(ctx, chromedp.Navigate(url)); err != nil {
// Wait for the body to have content (Cloudflare challenge resolves via JS) return nil, fmt.Errorf("chromedp navigate: %w", err)
chromedp.WaitReady("body"), }
// Chrome renders JSON API responses inside a <pre> tag
chromedp.Text("pre", &body, chromedp.ByQuery, chromedp.NodeVisible), // Poll for JSON response — Cloudflare challenge takes a few seconds to clear
) ticker := time.NewTicker(1 * time.Second)
if err != nil { defer ticker.Stop()
// Fallback: try extracting from body directly (some responses may not use <pre>)
var bodyFallback string for {
errFb := chromedp.Run(ctx, select {
chromedp.Text("body", &bodyFallback, chromedp.ByQuery), case <-ctx.Done():
) return nil, fmt.Errorf("chromedp fetch timed out waiting for JSON response")
if errFb == nil && bodyFallback != "" { case <-ticker.C:
return []byte(bodyFallback), nil 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 == "" {
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
_ = extractAndSaveCookies(ctx)
cancel() // graceful close, flushes cookies to profile
return []byte(body), nil
}
} }
return nil, fmt.Errorf("chromedp fetch: %w", err)
} }
}
if body == "" {
return nil, fmt.Errorf("chromedp fetch: empty response body") // extractAndSaveCookies saves cf_clearance and other Cloudflare cookies
} // alongside the session key, so plain HTTP requests can try them next time.
func extractAndSaveCookies(ctx context.Context) error {
// Gracefully close to flush cookies (including refreshed cf_clearance) cookies, err := network.GetCookies().Do(ctx)
cancel() if err != nil {
return err
return []byte(body), nil }
var parts []string
for _, c := range cookies {
if c.Domain == ".claude.ai" || c.Domain == "claude.ai" {
if c.Name == "cf_clearance" || c.Name == "__cf_bm" || c.Name == "_cfuvid" {
parts = append(parts, c.Name+"="+c.Value)
}
}
}
if len(parts) == 0 {
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

@@ -7,6 +7,9 @@ import (
"log" "log"
"math" "math"
"net/http" "net/http"
"os"
"path/filepath"
"strings"
"sync" "sync"
"time" "time"
@@ -34,13 +37,21 @@ type ParsedUsage struct {
type UpdateCallback func(ParsedUsage) type UpdateCallback func(ParsedUsage)
// doRequest performs an authenticated HTTP GET to the Claude API. // doRequest performs an authenticated HTTP GET to the Claude API.
// Includes any saved Cloudflare cookies from previous Chrome fallbacks.
func doRequest(url, sessionKey string) ([]byte, int, error) { func doRequest(url, sessionKey string) ([]byte, int, error) {
client := &http.Client{Timeout: 10 * time.Second} client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("GET", url, nil) req, err := http.NewRequest("GET", url, nil)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
req.Header.Set("Cookie", "sessionKey="+sessionKey)
cookie := "sessionKey=" + sessionKey
// Append Cloudflare cookies if available (saved by Chrome fallback)
if cfCookies := loadCFCookies(); cfCookies != "" {
cookie += "; " + cfCookies
}
req.Header.Set("Cookie", cookie)
req.Header.Set("User-Agent", userAgent) req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Referer", "https://claude.ai/") req.Header.Set("Referer", "https://claude.ai/")
@@ -58,6 +69,24 @@ func doRequest(url, sessionKey string) ([]byte, int, error) {
return body, resp.StatusCode, nil return body, resp.StatusCode, nil
} }
// loadCFCookies reads saved Cloudflare cookies from the cf-cookies file.
func loadCFCookies() string {
data, err := os.ReadFile(filepath.Join(config.ConfigDir(), "cf-cookies"))
if err != nil {
return ""
}
// File has one cookie per line (name=value), join with "; "
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
var valid []string
for _, l := range lines {
l = strings.TrimSpace(l)
if l != "" {
valid = append(valid, l)
}
}
return strings.Join(valid, "; ")
}
// fetchWithFallback tries a plain HTTP request first, then falls back to // fetchWithFallback tries a plain HTTP request first, then falls back to
// headless Chrome (which can solve Cloudflare JS challenges) on 403. // headless Chrome (which can solve Cloudflare JS challenges) on 403.
func fetchWithFallback(url, sessionKey string) ([]byte, error) { func fetchWithFallback(url, sessionKey string) ([]byte, error) {