detached de91544

feat(webkit): add Cloudflare Turnstile CAPTCHA workaround

Implements temporary CORS allowlist for challenges.cloudflare.com to work
around WebKit's incomplete COEP: credentialless support. This prevents
Turnstile widgets from failing due to CORS violations.

Changes:
- Added EnableTurnstileWorkaround config flag (default: true)
- WebView applies CORS allowlist for challenges.cloudflare.com when enabled
- Added resource logging to track CORP/COEP/COOP headers for debugging
- Disabled console.log() spam to stdout (DevTools console still works)

Technical details:
- Uses WebKit's SetCorsAllowlist() API to bypass same-origin policy
- Logs Turnstile resource loads with status codes and headers
- Helper functions: isTurnstileURL(), headerValue()
- Temporary until WebKitGTK properly implements COEP: credentialless

This allows Cloudflare-protected sites to function properly while maintaining
the option to disable the workaround via config if needed.
commit
de91544e335352bb7dc661ecf58d07cb40610e6a
author
bnema · · 2025-11-17 17:22 UTC
committer
bnema · · 2025-11-17 22:36 UTC
parent
202ed5a
signature
unsigned
3 files · +131 -24
M internal/app/browser/webview.go +14 -13
@@ -43,19 +43,20 @@ func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) {
4343
4444 // Build WebKit configuration with data directories for persistence
4545 cfg := &webkit.Config{
46- UserAgent: buildUserAgent(app.config),
47- EnableJavaScript: true,
48- EnableWebGL: true,
49- EnableMediaStream: true,
50- HardwareAcceleration: true,
51- DefaultFontSize: app.config.Appearance.DefaultFontSize,
52- MinimumFontSize: 8,
53- EnablePageCache: true, // Instant back/forward navigation (bfcache)
54- EnableSmoothScrolling: true, // Smooth scrolling animations
55- DataDir: webkitData,
56- CacheDir: webkitCache,
57- AppearanceConfigJSON: app.buildAppearanceConfigJSON(),
58- CreateWindow: true, // Default to creating a window for standalone WebViews
46+ UserAgent: buildUserAgent(app.config),
47+ EnableJavaScript: true,
48+ EnableWebGL: true,
49+ EnableMediaStream: true,
50+ HardwareAcceleration: true,
51+ DefaultFontSize: app.config.Appearance.DefaultFontSize,
52+ MinimumFontSize: 8,
53+ EnablePageCache: true, // Instant back/forward navigation (bfcache)
54+ EnableSmoothScrolling: true, // Smooth scrolling animations
55+ DataDir: webkitData,
56+ CacheDir: webkitCache,
57+ AppearanceConfigJSON: app.buildAppearanceConfigJSON(),
58+ CreateWindow: true, // Default to creating a window for standalone WebViews
59+ EnableTurnstileWorkaround: true,
5960 }
6061
6162 return cfg, nil
@@ -43,19 +43,20 @@ func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) {
43 43
44 // Build WebKit configuration with data directories for persistence44 // Build WebKit configuration with data directories for persistence
45 cfg := &webkit.Config{45 cfg := &webkit.Config{
46- UserAgent: buildUserAgent(app.config),46+ UserAgent: buildUserAgent(app.config),
47- EnableJavaScript: true,47+ EnableJavaScript: true,
48- EnableWebGL: true,48+ EnableWebGL: true,
49- EnableMediaStream: true,49+ EnableMediaStream: true,
50- HardwareAcceleration: true,50+ HardwareAcceleration: true,
51- DefaultFontSize: app.config.Appearance.DefaultFontSize,51+ DefaultFontSize: app.config.Appearance.DefaultFontSize,
52- MinimumFontSize: 8,52+ MinimumFontSize: 8,
53- EnablePageCache: true, // Instant back/forward navigation (bfcache)53+ EnablePageCache: true, // Instant back/forward navigation (bfcache)
54- EnableSmoothScrolling: true, // Smooth scrolling animations54+ EnableSmoothScrolling: true, // Smooth scrolling animations
55- DataDir: webkitData,55+ DataDir: webkitData,
56- CacheDir: webkitCache,56+ CacheDir: webkitCache,
57- AppearanceConfigJSON: app.buildAppearanceConfigJSON(),57+ AppearanceConfigJSON: app.buildAppearanceConfigJSON(),
58- CreateWindow: true, // Default to creating a window for standalone WebViews58+ CreateWindow: true, // Default to creating a window for standalone WebViews
59+ EnableTurnstileWorkaround: true,
59 }60 }
60 61
61 return cfg, nil62 return cfg, nil
M pkg/webkit/types.go +16 -10
@@ -65,20 +65,26 @@ type Config struct {
6565 // CreateWindow controls whether to create a standalone GTK Window for this WebView
6666 // Set to false for WebViews that will be embedded in workspace panes
6767 CreateWindow bool
68+
69+ // EnableTurnstileWorkaround temporarily disables CORS enforcement for the
70+ // Cloudflare Turnstile host to work around WebKit's credentialless COEP gap.
71+ // Remove once upstream WebKit properly supports COEP: credentialless.
72+ EnableTurnstileWorkaround bool
6873 }
6974
7075 // GetDefaultConfig returns a Config with sensible defaults
7176 func GetDefaultConfig() *Config {
7277 return &Config{
73- UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
74- EnableJavaScript: true,
75- EnableWebGL: true,
76- EnableMediaStream: true,
77- HardwareAcceleration: true,
78- DefaultFontSize: 16,
79- MinimumFontSize: 8,
80- EnablePageCache: true, // Instant back/forward navigation
81- EnableSmoothScrolling: true, // Better UX
82- CreateWindow: true, // Default to standalone window
78+ UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", // WebKitGTK default (same as Epiphany)
79+ EnableJavaScript: true,
80+ EnableWebGL: true,
81+ EnableMediaStream: true,
82+ HardwareAcceleration: true,
83+ DefaultFontSize: 16,
84+ MinimumFontSize: 8,
85+ EnablePageCache: true, // Instant back/forward navigation
86+ EnableSmoothScrolling: true, // Better UX
87+ CreateWindow: true, // Default to standalone window
88+ EnableTurnstileWorkaround: true,
8389 }
8490 }
@@ -65,20 +65,26 @@ type Config struct {
65 // CreateWindow controls whether to create a standalone GTK Window for this WebView65 // CreateWindow controls whether to create a standalone GTK Window for this WebView
66 // Set to false for WebViews that will be embedded in workspace panes66 // Set to false for WebViews that will be embedded in workspace panes
67 CreateWindow bool67 CreateWindow bool
68+
69+ // EnableTurnstileWorkaround temporarily disables CORS enforcement for the
70+ // Cloudflare Turnstile host to work around WebKit's credentialless COEP gap.
71+ // Remove once upstream WebKit properly supports COEP: credentialless.
72+ EnableTurnstileWorkaround bool
68 }73 }
69 74
70 // GetDefaultConfig returns a Config with sensible defaults75 // GetDefaultConfig returns a Config with sensible defaults
71 func GetDefaultConfig() *Config {76 func GetDefaultConfig() *Config {
72 return &Config{77 return &Config{
73- UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",78+ UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15", // WebKitGTK default (same as Epiphany)
74- EnableJavaScript: true,79+ EnableJavaScript: true,
75- EnableWebGL: true,80+ EnableWebGL: true,
76- EnableMediaStream: true,81+ EnableMediaStream: true,
77- HardwareAcceleration: true,82+ HardwareAcceleration: true,
78- DefaultFontSize: 16,83+ DefaultFontSize: 16,
79- MinimumFontSize: 8,84+ MinimumFontSize: 8,
80- EnablePageCache: true, // Instant back/forward navigation85+ EnablePageCache: true, // Instant back/forward navigation
81- EnableSmoothScrolling: true, // Better UX86+ EnableSmoothScrolling: true, // Better UX
82- CreateWindow: true, // Default to standalone window87+ CreateWindow: true, // Default to standalone window
88+ EnableTurnstileWorkaround: true,
83 }89 }
84 }90 }
M pkg/webkit/webview.go +101 -1
@@ -4,7 +4,9 @@ import (
44 "encoding/json"
55 "fmt"
66 "log"
7+ "net/url"
78 "strconv"
9+ "strings"
810 "sync"
911 "sync/atomic"
1012
@@ -13,6 +15,8 @@ import (
1315 "github.com/diamondburned/gotk4/pkg/gtk/v4"
1416 )
1517
18+const turnstileHost = "challenges.cloudflare.com"
19+
1620 var (
1721 viewIDCounter uint64
1822 viewRegistry = make(map[uint64]*WebView)
@@ -38,7 +42,7 @@ type WebView struct {
3842 onFaviconChanged func([]byte)
3943 onFaviconURIChanged func(pageURI, faviconURI string)
4044 onZoomChanged func(float64)
41- onLoadCommitted func(string) // Called when page load is committed (safe to apply zoom)
45+ onLoadCommitted func(string) // Called when page load is committed (safe to apply zoom)
4246 onPopupCreate func(*webkit.NavigationAction) *WebView // New WebKit create signal handler
4347 onReadyToShow func() // WebKit ready-to-show signal handler
4448 onClose func()
@@ -206,6 +210,11 @@ func (w *WebView) applyConfig() error {
206210 // Enable smooth scrolling for better UX
207211 settings.SetEnableSmoothScrolling(w.config.EnableSmoothScrolling)
208212
213+ // Disable console messages to stdout to prevent page script flooding
214+ // This stops malicious/buggy pages from spamming the terminal with console.log()
215+ // Inspector/DevTools console (F12) will still show all messages
216+ settings.SetEnableWriteConsoleMessagesToStdout(false)
217+
209218 return nil
210219 }
211220
@@ -303,6 +312,12 @@ func (w *WebView) setupEventHandlers() {
303312
304313 // Setup navigation policy handler for middle-click and Ctrl+click interception
305314 w.setupNavigationPolicyHandler()
315+
316+ // Log CORP/CORP headers for Cloudflare Turnstile resources to help diagnose COEP issues
317+ w.attachTurnstileResponseLogger()
318+
319+ // Apply temporary Turnstile workaround until WebKit supports COEP: credentialless
320+ w.applyTurnstileCorsAllowlist()
306321 }
307322
308323 // setupNavigationPolicyHandler sets up the decide-policy signal handler
@@ -381,6 +396,91 @@ func (w *WebView) setupNavigationPolicyHandler() {
381396 log.Printf("[webkit] Navigation policy handler attached to WebView ID %d", w.id)
382397 }
383398
399+func (w *WebView) applyTurnstileCorsAllowlist() {
400+ if w.view == nil || w.config == nil || !w.config.EnableTurnstileWorkaround {
401+ return
402+ }
403+
404+ pattern := fmt.Sprintf("https://%s/*", turnstileHost)
405+ w.view.SetCorsAllowlist([]string{pattern})
406+ log.Printf("[turnstile] Applied CORS allowlist for %s on WebView ID %d", turnstileHost, w.id)
407+}
408+
409+func (w *WebView) attachTurnstileResponseLogger() {
410+ if w.view == nil {
411+ return
412+ }
413+
414+ w.view.ConnectResourceLoadStarted(func(resource *webkit.WebResource, request *webkit.URIRequest) {
415+ if resource == nil || request == nil {
416+ return
417+ }
418+
419+ targetURL := request.URI()
420+ if targetURL == "" {
421+ targetURL = resource.URI()
422+ }
423+ if !isTurnstileURL(targetURL) {
424+ return
425+ }
426+
427+ method := request.HTTPMethod()
428+ if method == "" {
429+ method = "GET"
430+ }
431+
432+ referer := "<none>"
433+ if reqHeaders := request.HTTPHeaders(); reqHeaders != nil {
434+ if ref := strings.TrimSpace(reqHeaders.One("Referer")); ref != "" {
435+ referer = ref
436+ }
437+ }
438+
439+ resource.Connect("notify::response", func() {
440+ response := resource.Response()
441+ if response == nil {
442+ log.Printf("[turnstile] resource=%s method=%s status=<unknown> referer=%s corp=<missing> coep=<missing> coop=<missing>", targetURL, method, referer)
443+ return
444+ }
445+
446+ headers := response.HTTPHeaders()
447+ corp := headerValue(headers, "Cross-Origin-Resource-Policy")
448+ coep := headerValue(headers, "Cross-Origin-Embedder-Policy")
449+ coop := headerValue(headers, "Cross-Origin-Opener-Policy")
450+
451+ log.Printf("[turnstile] resource=%s method=%s status=%d referer=%s corp=%s coep=%s coop=%s",
452+ targetURL, method, response.StatusCode(), referer, corp, coep, coop)
453+ })
454+ })
455+}
456+
457+type headerLookup interface {
458+ One(string) string
459+}
460+
461+func headerValue(headers headerLookup, name string) string {
462+ if headers == nil {
463+ return "<missing>"
464+ }
465+ value := strings.TrimSpace(headers.One(name))
466+ if value == "" {
467+ return "<missing>"
468+ }
469+ return value
470+}
471+
472+func isTurnstileURL(raw string) bool {
473+ if raw == "" {
474+ return false
475+ }
476+ parsed, err := url.Parse(raw)
477+ if err != nil {
478+ return false
479+ }
480+ host := strings.ToLower(parsed.Hostname())
481+ return host == turnstileHost
482+}
483+
384484 // LoadURL loads the given URL in the WebView
385485 func (w *WebView) LoadURL(url string) error {
386486 w.mu.RLock()
@@ -4,7 +4,9 @@ import (
4 "encoding/json"4 "encoding/json"
5 "fmt"5 "fmt"
6 "log"6 "log"
7+ "net/url"
7 "strconv"8 "strconv"
9+ "strings"
8 "sync"10 "sync"
9 "sync/atomic"11 "sync/atomic"
10 12
@@ -13,6 +15,8 @@ import (
13 "github.com/diamondburned/gotk4/pkg/gtk/v4"15 "github.com/diamondburned/gotk4/pkg/gtk/v4"
14 )16 )
15 17
18+const turnstileHost = "challenges.cloudflare.com"
19+
16 var (20 var (
17 viewIDCounter uint6421 viewIDCounter uint64
18 viewRegistry = make(map[uint64]*WebView)22 viewRegistry = make(map[uint64]*WebView)
@@ -38,7 +42,7 @@ type WebView struct {
38 onFaviconChanged func([]byte)42 onFaviconChanged func([]byte)
39 onFaviconURIChanged func(pageURI, faviconURI string)43 onFaviconURIChanged func(pageURI, faviconURI string)
40 onZoomChanged func(float64)44 onZoomChanged func(float64)
41- onLoadCommitted func(string) // Called when page load is committed (safe to apply zoom)45+ onLoadCommitted func(string) // Called when page load is committed (safe to apply zoom)
42 onPopupCreate func(*webkit.NavigationAction) *WebView // New WebKit create signal handler46 onPopupCreate func(*webkit.NavigationAction) *WebView // New WebKit create signal handler
43 onReadyToShow func() // WebKit ready-to-show signal handler47 onReadyToShow func() // WebKit ready-to-show signal handler
44 onClose func()48 onClose func()
@@ -206,6 +210,11 @@ func (w *WebView) applyConfig() error {
206 // Enable smooth scrolling for better UX210 // Enable smooth scrolling for better UX
207 settings.SetEnableSmoothScrolling(w.config.EnableSmoothScrolling)211 settings.SetEnableSmoothScrolling(w.config.EnableSmoothScrolling)
208 212
213+ // Disable console messages to stdout to prevent page script flooding
214+ // This stops malicious/buggy pages from spamming the terminal with console.log()
215+ // Inspector/DevTools console (F12) will still show all messages
216+ settings.SetEnableWriteConsoleMessagesToStdout(false)
217+
209 return nil218 return nil
210 }219 }
211 220
@@ -303,6 +312,12 @@ func (w *WebView) setupEventHandlers() {
303 312
304 // Setup navigation policy handler for middle-click and Ctrl+click interception313 // Setup navigation policy handler for middle-click and Ctrl+click interception
305 w.setupNavigationPolicyHandler()314 w.setupNavigationPolicyHandler()
315+
316+ // Log CORP/CORP headers for Cloudflare Turnstile resources to help diagnose COEP issues
317+ w.attachTurnstileResponseLogger()
318+
319+ // Apply temporary Turnstile workaround until WebKit supports COEP: credentialless
320+ w.applyTurnstileCorsAllowlist()
306 }321 }
307 322
308 // setupNavigationPolicyHandler sets up the decide-policy signal handler323 // setupNavigationPolicyHandler sets up the decide-policy signal handler
@@ -381,6 +396,91 @@ func (w *WebView) setupNavigationPolicyHandler() {
381 log.Printf("[webkit] Navigation policy handler attached to WebView ID %d", w.id)396 log.Printf("[webkit] Navigation policy handler attached to WebView ID %d", w.id)
382 }397 }
383 398
399+func (w *WebView) applyTurnstileCorsAllowlist() {
400+ if w.view == nil || w.config == nil || !w.config.EnableTurnstileWorkaround {
401+ return
402+ }
403+
404+ pattern := fmt.Sprintf("https://%s/*", turnstileHost)
405+ w.view.SetCorsAllowlist([]string{pattern})
406+ log.Printf("[turnstile] Applied CORS allowlist for %s on WebView ID %d", turnstileHost, w.id)
407+}
408+
409+func (w *WebView) attachTurnstileResponseLogger() {
410+ if w.view == nil {
411+ return
412+ }
413+
414+ w.view.ConnectResourceLoadStarted(func(resource *webkit.WebResource, request *webkit.URIRequest) {
415+ if resource == nil || request == nil {
416+ return
417+ }
418+
419+ targetURL := request.URI()
420+ if targetURL == "" {
421+ targetURL = resource.URI()
422+ }
423+ if !isTurnstileURL(targetURL) {
424+ return
425+ }
426+
427+ method := request.HTTPMethod()
428+ if method == "" {
429+ method = "GET"
430+ }
431+
432+ referer := "<none>"
433+ if reqHeaders := request.HTTPHeaders(); reqHeaders != nil {
434+ if ref := strings.TrimSpace(reqHeaders.One("Referer")); ref != "" {
435+ referer = ref
436+ }
437+ }
438+
439+ resource.Connect("notify::response", func() {
440+ response := resource.Response()
441+ if response == nil {
442+ log.Printf("[turnstile] resource=%s method=%s status=<unknown> referer=%s corp=<missing> coep=<missing> coop=<missing>", targetURL, method, referer)
443+ return
444+ }
445+
446+ headers := response.HTTPHeaders()
447+ corp := headerValue(headers, "Cross-Origin-Resource-Policy")
448+ coep := headerValue(headers, "Cross-Origin-Embedder-Policy")
449+ coop := headerValue(headers, "Cross-Origin-Opener-Policy")
450+
451+ log.Printf("[turnstile] resource=%s method=%s status=%d referer=%s corp=%s coep=%s coop=%s",
452+ targetURL, method, response.StatusCode(), referer, corp, coep, coop)
453+ })
454+ })
455+}
456+
457+type headerLookup interface {
458+ One(string) string
459+}
460+
461+func headerValue(headers headerLookup, name string) string {
462+ if headers == nil {
463+ return "<missing>"
464+ }
465+ value := strings.TrimSpace(headers.One(name))
466+ if value == "" {
467+ return "<missing>"
468+ }
469+ return value
470+}
471+
472+func isTurnstileURL(raw string) bool {
473+ if raw == "" {
474+ return false
475+ }
476+ parsed, err := url.Parse(raw)
477+ if err != nil {
478+ return false
479+ }
480+ host := strings.ToLower(parsed.Hostname())
481+ return host == turnstileHost
482+}
483+
384 // LoadURL loads the given URL in the WebView484 // LoadURL loads the given URL in the WebView
385 func (w *WebView) LoadURL(url string) error {485 func (w *WebView) LoadURL(url string) error {
386 w.mu.RLock()486 w.mu.RLock()

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close