From ba3b73c3ddb6c2f7c7d2120a9c09e9e15eab375d Mon Sep 17 00:00:00 2001 From: calic Date: Sat, 21 Mar 2026 00:13:21 +0100 Subject: [PATCH] Add Cloudflare 403 fallback via headless Chrome Plain HTTP requests to claude.ai get blocked by Cloudflare JS challenges (403). The fetcher now falls back to headless Chrome using the persistent browser profile, which can solve challenges natively and reuses existing cf_clearance cookies. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 10 +++++ internal/browser/fetch.go | 76 +++++++++++++++++++++++++++++++++++++ internal/fetcher/fetcher.go | 48 +++++++++++++++-------- 3 files changed, 119 insertions(+), 15 deletions(-) create mode 100644 internal/browser/fetch.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e344af2..6c35d92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [0.5.0] — 2026-03-21 + +### Fixed +- **Cloudflare 403 bypass** — API requests blocked by Cloudflare JS challenges now fall back to headless Chrome with the persistent browser profile, which can solve the challenges natively + +### Added +- `internal/browser/fetch.go` — headless Chrome API fetcher using chromedp with the existing browser profile (reuses Cloudflare clearance cookies) +- `fetchWithFallback()` in fetcher — tries plain HTTP first, falls back to headless Chrome on 403 + ## [0.3.0] — 2026-02-26 Full rewrite from Node.js + Python to Go. Each platform gets a single static binary — no runtime dependencies. @@ -44,5 +53,6 @@ First tagged release. Includes the CLI statusline, standalone usage fetcher, cro - Tray icon visibility — switched to Claude orange with full opacity at larger size - Block comment syntax error in cron example +[0.5.0]: https://git.davoryn.de/calic/claude-statusline/releases/tag/v0.5.0 [0.3.0]: https://git.davoryn.de/calic/claude-statusline/releases/tag/v0.3.0 [0.2.0]: https://git.davoryn.de/calic/claude-statusline/releases/tag/v0.2.0 diff --git a/internal/browser/fetch.go b/internal/browser/fetch.go new file mode 100644 index 0000000..47e517c --- /dev/null +++ b/internal/browser/fetch.go @@ -0,0 +1,76 @@ +package browser + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/chromedp/chromedp" + + "git.davoryn.de/calic/claude-statusline/internal/config" +) + +// FetchViaChrome navigates to a URL using headless Chrome with the persistent +// browser profile (which has Cloudflare clearance cookies) and returns the +// response body. This bypasses Cloudflare JS challenges because Chrome runs +// real JavaScript. Falls back to non-headless if headless fails. +func FetchViaChrome(url string) ([]byte, error) { + profileDir := filepath.Join(config.ConfigDir(), "browser-profile") + if err := os.MkdirAll(profileDir, 0o755); err != nil { + return nil, fmt.Errorf("create browser profile dir: %w", err) + } + + // Remove stale lock file from unclean shutdown + _ = os.Remove(filepath.Join(profileDir, "SingletonLock")) + + execPath := findBrowserExec() + + opts := append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.Flag("headless", true), + chromedp.UserDataDir(profileDir), + ) + if execPath != "" { + opts = append(opts, chromedp.ExecPath(execPath)) + } + + allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...) + defer allocCancel() + + ctx, cancel := chromedp.NewContext(allocCtx) + defer cancel() + + // Set a total timeout for the operation + ctx, timeoutCancel := context.WithTimeout(ctx, 30*time.Second) + defer timeoutCancel() + + var body string + err := chromedp.Run(ctx, + chromedp.Navigate(url), + // Wait for the body to have content (Cloudflare challenge resolves via JS) + chromedp.WaitReady("body"), + // Chrome renders JSON API responses inside a
 tag
+		chromedp.Text("pre", &body, chromedp.ByQuery, chromedp.NodeVisible),
+	)
+	if err != nil {
+		// Fallback: try extracting from body directly (some responses may not use 
)
+		var bodyFallback string
+		errFb := chromedp.Run(ctx,
+			chromedp.Text("body", &bodyFallback, chromedp.ByQuery),
+		)
+		if errFb == nil && bodyFallback != "" {
+			return []byte(bodyFallback), nil
+		}
+		return nil, fmt.Errorf("chromedp fetch: %w", err)
+	}
+
+	if body == "" {
+		return nil, fmt.Errorf("chromedp fetch: empty response body")
+	}
+
+	// Gracefully close to flush cookies (including refreshed cf_clearance)
+	cancel()
+
+	return []byte(body), nil
+}
diff --git a/internal/fetcher/fetcher.go b/internal/fetcher/fetcher.go
index 9670891..1ef62d9 100644
--- a/internal/fetcher/fetcher.go
+++ b/internal/fetcher/fetcher.go
@@ -4,11 +4,13 @@ import (
 	"encoding/json"
 	"fmt"
 	"io"
+	"log"
 	"math"
 	"net/http"
 	"sync"
 	"time"
 
+	"git.davoryn.de/calic/claude-statusline/internal/browser"
 	"git.davoryn.de/calic/claude-statusline/internal/config"
 )
 
@@ -56,17 +58,36 @@ func doRequest(url, sessionKey string) ([]byte, int, error) {
 	return body, resp.StatusCode, nil
 }
 
+// fetchWithFallback tries a plain HTTP request first, then falls back to
+// headless Chrome (which can solve Cloudflare JS challenges) on 403.
+func fetchWithFallback(url, sessionKey string) ([]byte, error) {
+	body, status, err := doRequest(url, sessionKey)
+	if err != nil {
+		return nil, fmt.Errorf("request failed: %w", err)
+	}
+	if status == 200 {
+		return body, nil
+	}
+	if status == 401 {
+		return nil, fmt.Errorf("auth_expired")
+	}
+	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)
+		chromeBody, chromeErr := browser.FetchViaChrome(url)
+		if chromeErr != nil {
+			return nil, fmt.Errorf("auth_expired") // treat as auth failure if Chrome also fails
+		}
+		return chromeBody, nil
+	}
+	return nil, fmt.Errorf("HTTP %d", status)
+}
+
 // DiscoverOrgID fetches the first organization UUID from the API.
 func DiscoverOrgID(sessionKey string) (string, error) {
-	body, status, err := doRequest(apiBase+"/api/organizations", sessionKey)
+	body, err := fetchWithFallback(apiBase+"/api/organizations", sessionKey)
 	if err != nil {
-		return "", fmt.Errorf("request failed: %w", err)
-	}
-	if status == 401 || status == 403 {
-		return "", fmt.Errorf("auth_expired")
-	}
-	if status != 200 {
-		return "", fmt.Errorf("HTTP %d", status)
+		return "", err
 	}
 
 	var orgs []struct {
@@ -96,16 +117,13 @@ func FetchUsage(sessionKey, orgID string) (*CacheData, string, error) {
 	}
 
 	url := fmt.Sprintf("%s/api/organizations/%s/usage", apiBase, orgID)
-	body, status, err := doRequest(url, sessionKey)
+	body, err := fetchWithFallback(url, sessionKey)
 	if err != nil {
+		if err.Error() == "auth_expired" {
+			return &CacheData{Error: "auth_expired", Status: 403}, orgID, err
+		}
 		return &CacheData{Error: "fetch_failed", Message: err.Error()}, orgID, err
 	}
-	if status == 401 || status == 403 {
-		return &CacheData{Error: "auth_expired", Status: status}, orgID, fmt.Errorf("auth_expired")
-	}
-	if status != 200 {
-		return &CacheData{Error: "api_error", Status: status}, orgID, fmt.Errorf("HTTP %d", status)
-	}
 
 	var data CacheData
 	if err := json.Unmarshal(body, &data); err != nil {