Add Cloudflare 403 fallback via headless Chrome
All checks were successful
Release / build (push) Successful in 1m40s

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 <noreply@anthropic.com>
This commit is contained in:
calic
2026-03-21 00:13:21 +01:00
parent 47165ce02c
commit ba3b73c3dd
3 changed files with 119 additions and 15 deletions

View File

@@ -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/). 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 ## [0.3.0] — 2026-02-26
Full rewrite from Node.js + Python to Go. Each platform gets a single static binary — no runtime dependencies. 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 - Tray icon visibility — switched to Claude orange with full opacity at larger size
- Block comment syntax error in cron example - 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.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 [0.2.0]: https://git.davoryn.de/calic/claude-statusline/releases/tag/v0.2.0

76
internal/browser/fetch.go Normal file
View File

@@ -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 <pre> tag
chromedp.Text("pre", &body, chromedp.ByQuery, chromedp.NodeVisible),
)
if err != nil {
// Fallback: try extracting from body directly (some responses may not use <pre>)
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
}

View File

@@ -4,11 +4,13 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"math" "math"
"net/http" "net/http"
"sync" "sync"
"time" "time"
"git.davoryn.de/calic/claude-statusline/internal/browser"
"git.davoryn.de/calic/claude-statusline/internal/config" "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 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. // DiscoverOrgID fetches the first organization UUID from the API.
func DiscoverOrgID(sessionKey string) (string, error) { func DiscoverOrgID(sessionKey string) (string, error) {
body, status, err := doRequest(apiBase+"/api/organizations", sessionKey) body, err := fetchWithFallback(apiBase+"/api/organizations", sessionKey)
if err != nil { if err != nil {
return "", fmt.Errorf("request failed: %w", err) return "", err
}
if status == 401 || status == 403 {
return "", fmt.Errorf("auth_expired")
}
if status != 200 {
return "", fmt.Errorf("HTTP %d", status)
} }
var orgs []struct { 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) 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 != 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 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 var data CacheData
if err := json.Unmarshal(body, &data); err != nil { if err := json.Unmarshal(body, &data); err != nil {