detached 41800e7

fix(cef): keep clipboard bridge off page CSP

commit
41800e7c2705364b1d8fe0f1431deb7ad56b4bc3
author
brice · · 2026-04-18 13:42 UTC
parent
b703063
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +360 -44
M internal/infrastructure/cef/content_injector.go +126 -3
@@ -387,9 +387,9 @@ const redditDirectVideoJS = `(function(){
387387
388388 // clipboardSelectionFetchBridgeJS keeps the lightweight fetch bridge enabled
389389 // while the native renderer bridge remains disabled due to an OSR startup
390-// regression. It mirrors explicit clipboard text from both DOM selections and
391-// input/textarea selections, and also reasserts browser focus when an editable
392-// element gains DOM focus.
390+// regression. It mirrors explicit clipboard text from DOM selections,
391+// input/textarea selections, and async Clipboard API writes, and also
392+// reasserts browser focus when an editable element gains DOM focus.
393393 const clipboardSelectionFetchBridgeJS = `(function(){
394394 if (window.__dumberClipboardBridge) return;
395395 window.__dumberClipboardBridge = true;
@@ -443,6 +443,24 @@ const clipboardSelectionFetchBridgeJS = `(function(){
443443 }).catch(function(){});
444444 }
445445
446+ function mirrorClipboardItems(items) {
447+ try {
448+ if (!items || typeof items.length !== 'number') return;
449+ Array.prototype.forEach.call(items, function(item) {
450+ if (!item || !item.types || item.types.indexOf('text/plain') === -1 || typeof item.getType !== 'function') return;
451+ Promise.resolve(item.getType('text/plain'))
452+ .then(function(blob) {
453+ if (!blob || typeof blob.text !== 'function') return '';
454+ return blob.text();
455+ })
456+ .then(function(text) {
457+ if (text) sendToClipboard(text);
458+ })
459+ .catch(function(){});
460+ });
461+ } catch (_) {}
462+ }
463+
446464 document.addEventListener('copy', function() {
447465 var text = getSelectedText();
448466 if (text) sendToClipboard(text);
@@ -455,6 +473,111 @@ const clipboardSelectionFetchBridgeJS = `(function(){
455473 if (event && event.isTrusted === false) return;
456474 if (isEditable(event && event.target)) sendFocusSync();
457475 }, true);
476+
477+ try {
478+ var clipboardObj = navigator && navigator.clipboard ? navigator.clipboard : null;
479+ var clipboardProto = (typeof Clipboard !== 'undefined' && Clipboard.prototype) ||
480+ (clipboardObj && typeof Object.getPrototypeOf === 'function' ? Object.getPrototypeOf(clipboardObj) : null);
481+
482+ if (clipboardProto && typeof clipboardProto.writeText === 'function' && !clipboardProto.__dumberWriteTextPatched) {
483+ clipboardProto.__dumberWriteTextPatched = true;
484+ var originalProtoWriteText = clipboardProto.writeText;
485+ var wrappedProtoWriteText = function(text) {
486+ var normalized = text == null ? '' : String(text);
487+ var result = originalProtoWriteText.call(this, normalized);
488+ if (result && typeof result.then === 'function') {
489+ return result.then(function(value) {
490+ if (normalized) sendToClipboard(normalized);
491+ return value;
492+ });
493+ }
494+ if (normalized) sendToClipboard(normalized);
495+ return result;
496+ };
497+ try {
498+ Object.defineProperty(clipboardProto, 'writeText', {
499+ configurable: true,
500+ writable: true,
501+ value: wrappedProtoWriteText
502+ });
503+ } catch (_) {
504+ clipboardProto.writeText = wrappedProtoWriteText;
505+ }
506+ } else if (clipboardObj && typeof clipboardObj.writeText === 'function' && !window.__dumberClipboardWriteTextPatched) {
507+ window.__dumberClipboardWriteTextPatched = true;
508+ var originalWriteText = clipboardObj.writeText.bind(clipboardObj);
509+ var wrappedWriteText = function(text) {
510+ var normalized = text == null ? '' : String(text);
511+ var result = originalWriteText(normalized);
512+ if (result && typeof result.then === 'function') {
513+ return result.then(function(value) {
514+ if (normalized) sendToClipboard(normalized);
515+ return value;
516+ });
517+ }
518+ if (normalized) sendToClipboard(normalized);
519+ return result;
520+ };
521+ try {
522+ Object.defineProperty(clipboardObj, 'writeText', {
523+ configurable: true,
524+ writable: true,
525+ value: wrappedWriteText
526+ });
527+ } catch (_) {
528+ clipboardObj.writeText = wrappedWriteText;
529+ }
530+ }
531+
532+ if (clipboardProto && typeof clipboardProto.write === 'function' && !clipboardProto.__dumberWritePatched) {
533+ clipboardProto.__dumberWritePatched = true;
534+ var originalProtoWrite = clipboardProto.write;
535+ var wrappedProtoWrite = function(items) {
536+ var result = originalProtoWrite.apply(this, arguments);
537+ if (result && typeof result.then === 'function') {
538+ return result.then(function(value) {
539+ mirrorClipboardItems(items);
540+ return value;
541+ });
542+ }
543+ mirrorClipboardItems(items);
544+ return result;
545+ };
546+ try {
547+ Object.defineProperty(clipboardProto, 'write', {
548+ configurable: true,
549+ writable: true,
550+ value: wrappedProtoWrite
551+ });
552+ } catch (_) {
553+ clipboardProto.write = wrappedProtoWrite;
554+ }
555+ } else if (clipboardObj && typeof clipboardObj.write === 'function' && !window.__dumberClipboardWritePatched) {
556+ window.__dumberClipboardWritePatched = true;
557+ var originalWrite = clipboardObj.write.bind(clipboardObj);
558+ var wrappedWrite = function(items) {
559+ var result = originalWrite(items);
560+ if (result && typeof result.then === 'function') {
561+ return result.then(function(value) {
562+ mirrorClipboardItems(items);
563+ return value;
564+ });
565+ }
566+ mirrorClipboardItems(items);
567+ return result;
568+ };
569+ try {
570+ Object.defineProperty(clipboardObj, 'write', {
571+ configurable: true,
572+ writable: true,
573+ value: wrappedWrite
574+ });
575+ } catch (_) {
576+ clipboardObj.write = wrappedWrite;
577+ }
578+ }
579+ } catch (_) {}
580+
458581 if (isEditable(document.activeElement)) sendFocusSync();
459582 })();`
460583
@@ -387,9 +387,9 @@ const redditDirectVideoJS = `(function(){
387 387
388 // clipboardSelectionFetchBridgeJS keeps the lightweight fetch bridge enabled388 // clipboardSelectionFetchBridgeJS keeps the lightweight fetch bridge enabled
389 // while the native renderer bridge remains disabled due to an OSR startup389 // while the native renderer bridge remains disabled due to an OSR startup
390-// regression. It mirrors explicit clipboard text from both DOM selections and390+// regression. It mirrors explicit clipboard text from DOM selections,
391-// input/textarea selections, and also reasserts browser focus when an editable391+// input/textarea selections, and async Clipboard API writes, and also
392-// element gains DOM focus.392+// reasserts browser focus when an editable element gains DOM focus.
393 const clipboardSelectionFetchBridgeJS = `(function(){393 const clipboardSelectionFetchBridgeJS = `(function(){
394 if (window.__dumberClipboardBridge) return;394 if (window.__dumberClipboardBridge) return;
395 window.__dumberClipboardBridge = true;395 window.__dumberClipboardBridge = true;
@@ -443,6 +443,24 @@ const clipboardSelectionFetchBridgeJS = `(function(){
443 }).catch(function(){});443 }).catch(function(){});
444 }444 }
445 445
446+ function mirrorClipboardItems(items) {
447+ try {
448+ if (!items || typeof items.length !== 'number') return;
449+ Array.prototype.forEach.call(items, function(item) {
450+ if (!item || !item.types || item.types.indexOf('text/plain') === -1 || typeof item.getType !== 'function') return;
451+ Promise.resolve(item.getType('text/plain'))
452+ .then(function(blob) {
453+ if (!blob || typeof blob.text !== 'function') return '';
454+ return blob.text();
455+ })
456+ .then(function(text) {
457+ if (text) sendToClipboard(text);
458+ })
459+ .catch(function(){});
460+ });
461+ } catch (_) {}
462+ }
463+
446 document.addEventListener('copy', function() {464 document.addEventListener('copy', function() {
447 var text = getSelectedText();465 var text = getSelectedText();
448 if (text) sendToClipboard(text);466 if (text) sendToClipboard(text);
@@ -455,6 +473,111 @@ const clipboardSelectionFetchBridgeJS = `(function(){
455 if (event && event.isTrusted === false) return;473 if (event && event.isTrusted === false) return;
456 if (isEditable(event && event.target)) sendFocusSync();474 if (isEditable(event && event.target)) sendFocusSync();
457 }, true);475 }, true);
476+
477+ try {
478+ var clipboardObj = navigator && navigator.clipboard ? navigator.clipboard : null;
479+ var clipboardProto = (typeof Clipboard !== 'undefined' && Clipboard.prototype) ||
480+ (clipboardObj && typeof Object.getPrototypeOf === 'function' ? Object.getPrototypeOf(clipboardObj) : null);
481+
482+ if (clipboardProto && typeof clipboardProto.writeText === 'function' && !clipboardProto.__dumberWriteTextPatched) {
483+ clipboardProto.__dumberWriteTextPatched = true;
484+ var originalProtoWriteText = clipboardProto.writeText;
485+ var wrappedProtoWriteText = function(text) {
486+ var normalized = text == null ? '' : String(text);
487+ var result = originalProtoWriteText.call(this, normalized);
488+ if (result && typeof result.then === 'function') {
489+ return result.then(function(value) {
490+ if (normalized) sendToClipboard(normalized);
491+ return value;
492+ });
493+ }
494+ if (normalized) sendToClipboard(normalized);
495+ return result;
496+ };
497+ try {
498+ Object.defineProperty(clipboardProto, 'writeText', {
499+ configurable: true,
500+ writable: true,
501+ value: wrappedProtoWriteText
502+ });
503+ } catch (_) {
504+ clipboardProto.writeText = wrappedProtoWriteText;
505+ }
506+ } else if (clipboardObj && typeof clipboardObj.writeText === 'function' && !window.__dumberClipboardWriteTextPatched) {
507+ window.__dumberClipboardWriteTextPatched = true;
508+ var originalWriteText = clipboardObj.writeText.bind(clipboardObj);
509+ var wrappedWriteText = function(text) {
510+ var normalized = text == null ? '' : String(text);
511+ var result = originalWriteText(normalized);
512+ if (result && typeof result.then === 'function') {
513+ return result.then(function(value) {
514+ if (normalized) sendToClipboard(normalized);
515+ return value;
516+ });
517+ }
518+ if (normalized) sendToClipboard(normalized);
519+ return result;
520+ };
521+ try {
522+ Object.defineProperty(clipboardObj, 'writeText', {
523+ configurable: true,
524+ writable: true,
525+ value: wrappedWriteText
526+ });
527+ } catch (_) {
528+ clipboardObj.writeText = wrappedWriteText;
529+ }
530+ }
531+
532+ if (clipboardProto && typeof clipboardProto.write === 'function' && !clipboardProto.__dumberWritePatched) {
533+ clipboardProto.__dumberWritePatched = true;
534+ var originalProtoWrite = clipboardProto.write;
535+ var wrappedProtoWrite = function(items) {
536+ var result = originalProtoWrite.apply(this, arguments);
537+ if (result && typeof result.then === 'function') {
538+ return result.then(function(value) {
539+ mirrorClipboardItems(items);
540+ return value;
541+ });
542+ }
543+ mirrorClipboardItems(items);
544+ return result;
545+ };
546+ try {
547+ Object.defineProperty(clipboardProto, 'write', {
548+ configurable: true,
549+ writable: true,
550+ value: wrappedProtoWrite
551+ });
552+ } catch (_) {
553+ clipboardProto.write = wrappedProtoWrite;
554+ }
555+ } else if (clipboardObj && typeof clipboardObj.write === 'function' && !window.__dumberClipboardWritePatched) {
556+ window.__dumberClipboardWritePatched = true;
557+ var originalWrite = clipboardObj.write.bind(clipboardObj);
558+ var wrappedWrite = function(items) {
559+ var result = originalWrite(items);
560+ if (result && typeof result.then === 'function') {
561+ return result.then(function(value) {
562+ mirrorClipboardItems(items);
563+ return value;
564+ });
565+ }
566+ mirrorClipboardItems(items);
567+ return result;
568+ };
569+ try {
570+ Object.defineProperty(clipboardObj, 'write', {
571+ configurable: true,
572+ writable: true,
573+ value: wrappedWrite
574+ });
575+ } catch (_) {
576+ clipboardObj.write = wrappedWrite;
577+ }
578+ }
579+ } catch (_) {}
580+
458 if (isEditable(document.activeElement)) sendFocusSync();581 if (isEditable(document.activeElement)) sendFocusSync();
459 })();`582 })();`
460 583
M internal/infrastructure/cef/content_injector_test.go +10 -0
@@ -24,3 +24,13 @@ func TestClipboardSelectionFetchBridgeJS_PostsTrustedFocusSyncRequests(t *testin
2424 require.Contains(t, clipboardSelectionFetchBridgeJS, "isEditable(document.activeElement)")
2525 require.Contains(t, clipboardSelectionFetchBridgeJS, "sendFocusSync()")
2626 }
27+
28+func TestClipboardSelectionFetchBridgeJS_PatchesAsyncClipboardAPIs(t *testing.T) {
29+ require.Contains(t, clipboardSelectionFetchBridgeJS, "navigator && navigator.clipboard")
30+ require.Contains(t, clipboardSelectionFetchBridgeJS, "clipboardProto.writeText")
31+ require.Contains(t, clipboardSelectionFetchBridgeJS, "clipboardObj.writeText")
32+ require.Contains(t, clipboardSelectionFetchBridgeJS, "clipboardProto.write")
33+ require.Contains(t, clipboardSelectionFetchBridgeJS, "clipboardObj.write")
34+ require.Contains(t, clipboardSelectionFetchBridgeJS, "sendToClipboard(normalized)")
35+ require.Contains(t, clipboardSelectionFetchBridgeJS, "mirrorClipboardItems(items)")
36+}
@@ -24,3 +24,13 @@ func TestClipboardSelectionFetchBridgeJS_PostsTrustedFocusSyncRequests(t *testin
24 require.Contains(t, clipboardSelectionFetchBridgeJS, "isEditable(document.activeElement)")24 require.Contains(t, clipboardSelectionFetchBridgeJS, "isEditable(document.activeElement)")
25 require.Contains(t, clipboardSelectionFetchBridgeJS, "sendFocusSync()")25 require.Contains(t, clipboardSelectionFetchBridgeJS, "sendFocusSync()")
26 }26 }
27+
28+func TestClipboardSelectionFetchBridgeJS_PatchesAsyncClipboardAPIs(t *testing.T) {
29+ require.Contains(t, clipboardSelectionFetchBridgeJS, "navigator && navigator.clipboard")
30+ require.Contains(t, clipboardSelectionFetchBridgeJS, "clipboardProto.writeText")
31+ require.Contains(t, clipboardSelectionFetchBridgeJS, "clipboardObj.writeText")
32+ require.Contains(t, clipboardSelectionFetchBridgeJS, "clipboardProto.write")
33+ require.Contains(t, clipboardSelectionFetchBridgeJS, "clipboardObj.write")
34+ require.Contains(t, clipboardSelectionFetchBridgeJS, "sendToClipboard(normalized)")
35+ require.Contains(t, clipboardSelectionFetchBridgeJS, "mirrorClipboardItems(items)")
36+}
M internal/infrastructure/cef/internal_urls.go +32 -1
@@ -11,6 +11,7 @@ const (
1111 actualInternalScheme = "https"
1212 actualInternalHost = "dumber.invalid"
1313 actualInternalOrigin = actualInternalScheme + "://" + actualInternalHost
14+ internalAPIPathPrefix = "/api/"
1415 )
1516
1617 func isConceptualInternalURL(raw string) bool {
@@ -49,7 +50,7 @@ func toActualInternalURL(raw string) string {
4950 case trimmedPath == "":
5051 // Keep page roots at /home instead of /home/ so relative assets resolve
5152 // from the origin root.
52- case strings.HasPrefix(trimmedPath, "api/"):
53+ case strings.HasPrefix(trimmedPath, strings.TrimPrefix(internalAPIPathPrefix, "/")):
5354 actualPath = "/" + trimmedPath
5455 case path.Ext(trimmedPath) != "":
5556 actualPath = "/" + trimmedPath
@@ -67,6 +68,36 @@ func toActualInternalURL(raw string) string {
6768 return actual.String()
6869 }
6970
71+func resolveAPIPath(u *url.URL) (string, bool) {
72+ if u == nil {
73+ return "", false
74+ }
75+
76+ if strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost) {
77+ if strings.HasPrefix(u.Path, internalAPIPathPrefix) {
78+ return u.Path, true
79+ }
80+ return "", false
81+ }
82+
83+ if !strings.EqualFold(u.Scheme, "dumb") {
84+ return "", false
85+ }
86+
87+ if strings.EqualFold(u.Host, "api") {
88+ if u.Path == "" {
89+ return "/api", true
90+ }
91+ return internalAPIPathPrefix[:len(internalAPIPathPrefix)-1] + u.Path, true
92+ }
93+
94+ if strings.HasPrefix(u.Path, internalAPIPathPrefix) {
95+ return u.Path, true
96+ }
97+
98+ return "", false
99+}
100+
70101 func toConceptualInternalURL(raw string) string {
71102 if raw == "" || !isActualInternalURL(raw) {
72103 return raw
@@ -11,6 +11,7 @@ const (
11 actualInternalScheme = "https"11 actualInternalScheme = "https"
12 actualInternalHost = "dumber.invalid"12 actualInternalHost = "dumber.invalid"
13 actualInternalOrigin = actualInternalScheme + "://" + actualInternalHost13 actualInternalOrigin = actualInternalScheme + "://" + actualInternalHost
14+ internalAPIPathPrefix = "/api/"
14 )15 )
15 16
16 func isConceptualInternalURL(raw string) bool {17 func isConceptualInternalURL(raw string) bool {
@@ -49,7 +50,7 @@ func toActualInternalURL(raw string) string {
49 case trimmedPath == "":50 case trimmedPath == "":
50 // Keep page roots at /home instead of /home/ so relative assets resolve51 // Keep page roots at /home instead of /home/ so relative assets resolve
51 // from the origin root.52 // from the origin root.
52- case strings.HasPrefix(trimmedPath, "api/"):53+ case strings.HasPrefix(trimmedPath, strings.TrimPrefix(internalAPIPathPrefix, "/")):
53 actualPath = "/" + trimmedPath54 actualPath = "/" + trimmedPath
54 case path.Ext(trimmedPath) != "":55 case path.Ext(trimmedPath) != "":
55 actualPath = "/" + trimmedPath56 actualPath = "/" + trimmedPath
@@ -67,6 +68,36 @@ func toActualInternalURL(raw string) string {
67 return actual.String()68 return actual.String()
68 }69 }
69 70
71+func resolveAPIPath(u *url.URL) (string, bool) {
72+ if u == nil {
73+ return "", false
74+ }
75+
76+ if strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost) {
77+ if strings.HasPrefix(u.Path, internalAPIPathPrefix) {
78+ return u.Path, true
79+ }
80+ return "", false
81+ }
82+
83+ if !strings.EqualFold(u.Scheme, "dumb") {
84+ return "", false
85+ }
86+
87+ if strings.EqualFold(u.Host, "api") {
88+ if u.Path == "" {
89+ return "/api", true
90+ }
91+ return internalAPIPathPrefix[:len(internalAPIPathPrefix)-1] + u.Path, true
92+ }
93+
94+ if strings.HasPrefix(u.Path, internalAPIPathPrefix) {
95+ return u.Path, true
96+ }
97+
98+ return "", false
99+}
100+
70 func toConceptualInternalURL(raw string) string {101 func toConceptualInternalURL(raw string) string {
71 if raw == "" || !isActualInternalURL(raw) {102 if raw == "" || !isActualInternalURL(raw) {
72 return raw103 return raw
M internal/infrastructure/cef/internal_urls_test.go +54 -1
@@ -1,6 +1,9 @@
11 package cef
22
3-import "testing"
3+import (
4+ "net/url"
5+ "testing"
6+)
47
58 func TestToActualInternalURL(t *testing.T) {
69 t.Parallel()
@@ -47,6 +50,56 @@ func TestToActualInternalURL(t *testing.T) {
4750 }
4851 }
4952
53+func TestResolveAPIPath(t *testing.T) {
54+ t.Parallel()
55+
56+ tests := []struct {
57+ name string
58+ in string
59+ want string
60+ ok bool
61+ }{
62+ {
63+ name: "conceptual api host",
64+ in: "dumb://api/clipboard-set",
65+ want: "/api/clipboard-set",
66+ ok: true,
67+ },
68+ {
69+ name: "conceptual page api path",
70+ in: "dumb://home/api/message",
71+ want: "/api/message",
72+ ok: true,
73+ },
74+ {
75+ name: "actual internal api path",
76+ in: "https://dumber.invalid/api/focus-sync",
77+ want: "/api/focus-sync",
78+ ok: true,
79+ },
80+ {
81+ name: "non api path",
82+ in: "https://dumber.invalid/home",
83+ want: "",
84+ ok: false,
85+ },
86+ }
87+
88+ for _, tt := range tests {
89+ t.Run(tt.name, func(t *testing.T) {
90+ t.Parallel()
91+ parsed, err := url.Parse(tt.in)
92+ if err != nil {
93+ t.Fatalf("url.Parse(%q) error = %v", tt.in, err)
94+ }
95+ got, ok := resolveAPIPath(parsed)
96+ if got != tt.want || ok != tt.ok {
97+ t.Fatalf("resolveAPIPath(%q) = (%q, %v), want (%q, %v)", tt.in, got, ok, tt.want, tt.ok)
98+ }
99+ })
100+ }
101+}
102+
50103 func TestToConceptualInternalURL(t *testing.T) {
51104 t.Parallel()
52105
@@ -1,6 +1,9 @@
1 package cef1 package cef
2 2
3-import "testing"3+import (
4+ "net/url"
5+ "testing"
6+)
4 7
5 func TestToActualInternalURL(t *testing.T) {8 func TestToActualInternalURL(t *testing.T) {
6 t.Parallel()9 t.Parallel()
@@ -47,6 +50,56 @@ func TestToActualInternalURL(t *testing.T) {
47 }50 }
48 }51 }
49 52
53+func TestResolveAPIPath(t *testing.T) {
54+ t.Parallel()
55+
56+ tests := []struct {
57+ name string
58+ in string
59+ want string
60+ ok bool
61+ }{
62+ {
63+ name: "conceptual api host",
64+ in: "dumb://api/clipboard-set",
65+ want: "/api/clipboard-set",
66+ ok: true,
67+ },
68+ {
69+ name: "conceptual page api path",
70+ in: "dumb://home/api/message",
71+ want: "/api/message",
72+ ok: true,
73+ },
74+ {
75+ name: "actual internal api path",
76+ in: "https://dumber.invalid/api/focus-sync",
77+ want: "/api/focus-sync",
78+ ok: true,
79+ },
80+ {
81+ name: "non api path",
82+ in: "https://dumber.invalid/home",
83+ want: "",
84+ ok: false,
85+ },
86+ }
87+
88+ for _, tt := range tests {
89+ t.Run(tt.name, func(t *testing.T) {
90+ t.Parallel()
91+ parsed, err := url.Parse(tt.in)
92+ if err != nil {
93+ t.Fatalf("url.Parse(%q) error = %v", tt.in, err)
94+ }
95+ got, ok := resolveAPIPath(parsed)
96+ if got != tt.want || ok != tt.ok {
97+ t.Fatalf("resolveAPIPath(%q) = (%q, %v), want (%q, %v)", tt.in, got, ok, tt.want, tt.ok)
98+ }
99+ })
100+ }
101+}
102+
50 func TestToConceptualInternalURL(t *testing.T) {103 func TestToConceptualInternalURL(t *testing.T) {
51 t.Parallel()104 t.Parallel()
52 105
M internal/infrastructure/cef/scheme_handler.go +67 -37
@@ -125,8 +125,17 @@ func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ s
125125 return h.newRawResourceHandler(http.StatusOK, "text/html; charset=utf-8", []byte(buildCEFCrashPageHTML(originalURI)))
126126 }
127127
128- // Keep dumb:// as the app-level abstraction, but redirect CEF loads to a
129- // normal HTTPS origin so Chromium applies the standard web security model.
128+ // Route API requests before redirecting conceptual dumb:// URLs to the
129+ // internal HTTPS origin. External pages use dumb://api/... for clipboard and
130+ // focus bridges specifically to bypass page CSP; redirecting those requests
131+ // to https://dumber.invalid makes site connect-src policies block them.
132+ if apiPath, ok := resolveAPIPath(u); ok {
133+ return h.handleAPI(browser, method, apiPath, request)
134+ }
135+
136+ // Keep dumb:// as the app-level abstraction, but redirect CEF page/asset
137+ // loads to a normal HTTPS origin so Chromium applies the standard web
138+ // security model.
130139 if isConceptualInternalURL(reqURL) {
131140 targetURL := toActualInternalURL(reqURL)
132141 if targetURL != "" && targetURL != reqURL {
@@ -134,30 +143,27 @@ func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ s
134143 }
135144 }
136145
137- // Route API requests.
138- path := u.Path
139-
140- if strings.HasPrefix(path, "/api/") {
141- return h.handleAPI(browser, method, path, request)
142- }
143-
144146 // Serve static assets.
145147 return h.handleAsset(u)
146148 }
147149
148150 // handleAPI routes API requests to the message router or built-in handlers.
149151 func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path string, request purecef.Request) purecef.ResourceHandler {
152+ if strings.EqualFold(method, http.MethodOptions) {
153+ return h.newAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil)
154+ }
155+
150156 switch {
151157 case path == "/api/message" && strings.EqualFold(method, "POST"):
152158 body := readBodyFromHeader(request)
153159 if body == nil {
154- return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
160+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
155161 }
156162 resp, err := h.messageRouter.HandleMessage(h.ctx, 0, body)
157163 if err != nil {
158- return h.newJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})
164+ return h.newAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})
159165 }
160- return h.newRawResourceHandler(http.StatusOK, "application/json", resp)
166+ return h.newAPIRawResourceHandler(http.StatusOK, "application/json", resp)
161167
162168 case path == "/api/config" && (method == "" || strings.EqualFold(method, "GET")):
163169 return h.handleConfigAPI(h.currentConfigPayload)
@@ -175,29 +181,29 @@ func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path stri
175181 return h.handleFocusSync(request, browser)
176182
177183 default:
178- return h.newJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"})
184+ return h.newAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"})
179185 }
180186 }
181187
182188 func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler {
183189 if h.transcoder == nil {
184- return h.newJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"})
190+ return h.newAPIJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"})
185191 }
186192
187193 reqURL := request.GetURL()
188194 parsed, err := url.Parse(reqURL)
189195 if err != nil {
190- return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid request URL"})
196+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid request URL"})
191197 }
192198
193199 sourceURL := strings.TrimSpace(parsed.Query().Get("src"))
194200 if sourceURL == "" {
195- return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "missing src"})
201+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "missing src"})
196202 }
197203
198204 sourceParsed, err := url.Parse(sourceURL)
199205 if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != "https") {
200- return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"})
206+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"})
201207 }
202208
203209 headers := make(map[string]string)
@@ -242,9 +248,9 @@ func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) {
242248 func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purecef.ResourceHandler {
243249 data, err := resolveConfigPayload(build)
244250 if err != nil {
245- return h.newJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})
251+ return h.newAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})
246252 }
247- return h.newRawResourceHandler(http.StatusOK, "application/json", data)
253+ return h.newAPIRawResourceHandler(http.StatusOK, "application/json", data)
248254 }
249255
250256 // handleClipboardSet receives copied text from JS copy/cut events and writes
@@ -262,11 +268,11 @@ func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.
262268 body := readBodyFromHeader(request)
263269 if body == nil {
264270 h.logger.Debug().Msg("cef: clipboard-set — empty body (no X-Dumber-Body header)")
265- return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
271+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
266272 }
267273 if len(body) > maxClipboardBytes {
268274 h.logger.Warn().Int("body_len", len(body)).Msg("cef: clipboard-set — payload too large")
269- return h.newJSONResourceHandler(http.StatusRequestEntityTooLarge, map[string]string{"error": "payload too large"})
275+ return h.newAPIJSONResourceHandler(http.StatusRequestEntityTooLarge, map[string]string{"error": "payload too large"})
270276 }
271277
272278 var payload struct {
@@ -274,7 +280,7 @@ func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.
274280 }
275281 if err := json.Unmarshal(body, &payload); err != nil || payload.Text == "" {
276282 h.logger.Debug().Int("body_len", len(body)).Msg("cef: clipboard-set — invalid or empty payload")
277- return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid payload"})
283+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid payload"})
278284 }
279285
280286 h.logger.Debug().Int("text_len", len(payload.Text)).Msg("cef: clipboard-set — forwarding to GDK clipboard")
@@ -285,7 +291,7 @@ func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.
285291 h.logger.Warn().Msg("cef: clipboard-set — onClipboardSet callback not wired")
286292 }
287293
288- return h.newJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
294+ return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
289295 }
290296
291297 func (h *dumbSchemeHandler) handleFocusSync(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
@@ -295,11 +301,11 @@ func (h *dumbSchemeHandler) handleFocusSync(request purecef.Request, browser pur
295301 }
296302 if !strings.EqualFold(bridgeAction, dumberBridgeActionFocusSync) {
297303 h.logger.Warn().Msg("cef: focus-sync — rejected request without trusted bridge action header")
298- return h.newJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
304+ return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
299305 }
300306 if browser == nil {
301307 h.logger.Debug().Msg("cef: focus-sync — browser unavailable")
302- return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})
308+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})
303309 }
304310
305311 if h.onEditableFocus != nil {
@@ -308,17 +314,13 @@ func (h *dumbSchemeHandler) handleFocusSync(request purecef.Request, browser pur
308314 h.logger.Debug().Msg("cef: focus-sync — onEditableFocus callback not wired")
309315 }
310316
311- return h.newJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
317+ return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
312318 }
313319
314320 func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler {
315- return purecef.NewResourceHandler(&staticResourceHandler{
316- contentType: "text/plain; charset=utf-8",
317- statusCode: status,
318- headers: map[string]string{
319- "Location": location,
320- "Cache-Control": "no-store",
321- },
321+ return newStaticResourceHandler(status, "text/plain; charset=utf-8", nil, map[string]string{
322+ "Location": location,
323+ "Cache-Control": "no-store",
322324 })
323325 }
324326
@@ -476,26 +478,54 @@ type staticResourceHandler struct {
476478 offset int
477479 }
478480
479-func (h *dumbSchemeHandler) newRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler {
481+func apiResponseHeaders(extra map[string]string) map[string]string {
482+ headers := map[string]string{
483+ "Access-Control-Allow-Origin": "*",
484+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
485+ "Access-Control-Allow-Headers": strings.Join([]string{
486+ "Content-Type",
487+ "X-Dumber-Body",
488+ "X-Dumber-Bridge-Action",
489+ }, ", "),
490+ "Access-Control-Max-Age": "86400",
491+ "Cache-Control": "no-store",
492+ }
493+ for name, value := range extra {
494+ headers[name] = value
495+ }
496+ return headers
497+}
498+
499+func newStaticResourceHandler(status int, contentType string, data []byte, headers map[string]string) purecef.ResourceHandler {
480500 return cefNewResourceHandler(&staticResourceHandler{
481501 data: data,
482502 contentType: contentType,
483503 statusCode: status,
504+ headers: headers,
484505 })
485506 }
486507
508+func (h *dumbSchemeHandler) newRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler {
509+ return newStaticResourceHandler(status, contentType, data, nil)
510+}
511+
512+func (h *dumbSchemeHandler) newAPIRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler {
513+ return newStaticResourceHandler(status, contentType, data, apiResponseHeaders(nil))
514+}
515+
487516 func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler {
488517 escaped := html.EscapeString(msg)
489518 body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped)
490519 return h.newRawResourceHandler(status, "text/html; charset=utf-8", []byte(body))
491520 }
492521
493-func (h *dumbSchemeHandler) newJSONResourceHandler(status int, v any) purecef.ResourceHandler {
522+func (h *dumbSchemeHandler) newAPIJSONResourceHandler(status int, v any) purecef.ResourceHandler {
494523 data, err := json.Marshal(v)
495524 if err != nil {
496- return h.newErrorResourceHandler(http.StatusInternalServerError, "JSON encoding failed")
525+ fallback, _ := json.Marshal(map[string]string{"error": "JSON encoding failed"})
526+ return h.newAPIRawResourceHandler(http.StatusInternalServerError, "application/json", fallback)
497527 }
498- return h.newRawResourceHandler(status, "application/json", data)
528+ return h.newAPIRawResourceHandler(status, "application/json", data)
499529 }
500530
501531 // Open handles the request immediately (synchronous).
@@ -125,8 +125,17 @@ func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ s
125 return h.newRawResourceHandler(http.StatusOK, "text/html; charset=utf-8", []byte(buildCEFCrashPageHTML(originalURI)))125 return h.newRawResourceHandler(http.StatusOK, "text/html; charset=utf-8", []byte(buildCEFCrashPageHTML(originalURI)))
126 }126 }
127 127
128- // Keep dumb:// as the app-level abstraction, but redirect CEF loads to a128+ // Route API requests before redirecting conceptual dumb:// URLs to the
129- // normal HTTPS origin so Chromium applies the standard web security model.129+ // internal HTTPS origin. External pages use dumb://api/... for clipboard and
130+ // focus bridges specifically to bypass page CSP; redirecting those requests
131+ // to https://dumber.invalid makes site connect-src policies block them.
132+ if apiPath, ok := resolveAPIPath(u); ok {
133+ return h.handleAPI(browser, method, apiPath, request)
134+ }
135+
136+ // Keep dumb:// as the app-level abstraction, but redirect CEF page/asset
137+ // loads to a normal HTTPS origin so Chromium applies the standard web
138+ // security model.
130 if isConceptualInternalURL(reqURL) {139 if isConceptualInternalURL(reqURL) {
131 targetURL := toActualInternalURL(reqURL)140 targetURL := toActualInternalURL(reqURL)
132 if targetURL != "" && targetURL != reqURL {141 if targetURL != "" && targetURL != reqURL {
@@ -134,30 +143,27 @@ func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ s
134 }143 }
135 }144 }
136 145
137- // Route API requests.
138- path := u.Path
139-
140- if strings.HasPrefix(path, "/api/") {
141- return h.handleAPI(browser, method, path, request)
142- }
143-
144 // Serve static assets.146 // Serve static assets.
145 return h.handleAsset(u)147 return h.handleAsset(u)
146 }148 }
147 149
148 // handleAPI routes API requests to the message router or built-in handlers.150 // handleAPI routes API requests to the message router or built-in handlers.
149 func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path string, request purecef.Request) purecef.ResourceHandler {151 func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path string, request purecef.Request) purecef.ResourceHandler {
152+ if strings.EqualFold(method, http.MethodOptions) {
153+ return h.newAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil)
154+ }
155+
150 switch {156 switch {
151 case path == "/api/message" && strings.EqualFold(method, "POST"):157 case path == "/api/message" && strings.EqualFold(method, "POST"):
152 body := readBodyFromHeader(request)158 body := readBodyFromHeader(request)
153 if body == nil {159 if body == nil {
154- return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})160+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
155 }161 }
156 resp, err := h.messageRouter.HandleMessage(h.ctx, 0, body)162 resp, err := h.messageRouter.HandleMessage(h.ctx, 0, body)
157 if err != nil {163 if err != nil {
158- return h.newJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})164+ return h.newAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})
159 }165 }
160- return h.newRawResourceHandler(http.StatusOK, "application/json", resp)166+ return h.newAPIRawResourceHandler(http.StatusOK, "application/json", resp)
161 167
162 case path == "/api/config" && (method == "" || strings.EqualFold(method, "GET")):168 case path == "/api/config" && (method == "" || strings.EqualFold(method, "GET")):
163 return h.handleConfigAPI(h.currentConfigPayload)169 return h.handleConfigAPI(h.currentConfigPayload)
@@ -175,29 +181,29 @@ func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path stri
175 return h.handleFocusSync(request, browser)181 return h.handleFocusSync(request, browser)
176 182
177 default:183 default:
178- return h.newJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"})184+ return h.newAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"})
179 }185 }
180 }186 }
181 187
182 func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler {188 func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler {
183 if h.transcoder == nil {189 if h.transcoder == nil {
184- return h.newJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"})190+ return h.newAPIJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"})
185 }191 }
186 192
187 reqURL := request.GetURL()193 reqURL := request.GetURL()
188 parsed, err := url.Parse(reqURL)194 parsed, err := url.Parse(reqURL)
189 if err != nil {195 if err != nil {
190- return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid request URL"})196+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid request URL"})
191 }197 }
192 198
193 sourceURL := strings.TrimSpace(parsed.Query().Get("src"))199 sourceURL := strings.TrimSpace(parsed.Query().Get("src"))
194 if sourceURL == "" {200 if sourceURL == "" {
195- return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "missing src"})201+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "missing src"})
196 }202 }
197 203
198 sourceParsed, err := url.Parse(sourceURL)204 sourceParsed, err := url.Parse(sourceURL)
199 if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != "https") {205 if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != "https") {
200- return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"})206+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"})
201 }207 }
202 208
203 headers := make(map[string]string)209 headers := make(map[string]string)
@@ -242,9 +248,9 @@ func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) {
242 func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purecef.ResourceHandler {248 func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purecef.ResourceHandler {
243 data, err := resolveConfigPayload(build)249 data, err := resolveConfigPayload(build)
244 if err != nil {250 if err != nil {
245- return h.newJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})251+ return h.newAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})
246 }252 }
247- return h.newRawResourceHandler(http.StatusOK, "application/json", data)253+ return h.newAPIRawResourceHandler(http.StatusOK, "application/json", data)
248 }254 }
249 255
250 // handleClipboardSet receives copied text from JS copy/cut events and writes256 // handleClipboardSet receives copied text from JS copy/cut events and writes
@@ -262,11 +268,11 @@ func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.
262 body := readBodyFromHeader(request)268 body := readBodyFromHeader(request)
263 if body == nil {269 if body == nil {
264 h.logger.Debug().Msg("cef: clipboard-set — empty body (no X-Dumber-Body header)")270 h.logger.Debug().Msg("cef: clipboard-set — empty body (no X-Dumber-Body header)")
265- return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})271+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
266 }272 }
267 if len(body) > maxClipboardBytes {273 if len(body) > maxClipboardBytes {
268 h.logger.Warn().Int("body_len", len(body)).Msg("cef: clipboard-set — payload too large")274 h.logger.Warn().Int("body_len", len(body)).Msg("cef: clipboard-set — payload too large")
269- return h.newJSONResourceHandler(http.StatusRequestEntityTooLarge, map[string]string{"error": "payload too large"})275+ return h.newAPIJSONResourceHandler(http.StatusRequestEntityTooLarge, map[string]string{"error": "payload too large"})
270 }276 }
271 277
272 var payload struct {278 var payload struct {
@@ -274,7 +280,7 @@ func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.
274 }280 }
275 if err := json.Unmarshal(body, &payload); err != nil || payload.Text == "" {281 if err := json.Unmarshal(body, &payload); err != nil || payload.Text == "" {
276 h.logger.Debug().Int("body_len", len(body)).Msg("cef: clipboard-set — invalid or empty payload")282 h.logger.Debug().Int("body_len", len(body)).Msg("cef: clipboard-set — invalid or empty payload")
277- return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid payload"})283+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid payload"})
278 }284 }
279 285
280 h.logger.Debug().Int("text_len", len(payload.Text)).Msg("cef: clipboard-set — forwarding to GDK clipboard")286 h.logger.Debug().Int("text_len", len(payload.Text)).Msg("cef: clipboard-set — forwarding to GDK clipboard")
@@ -285,7 +291,7 @@ func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.
285 h.logger.Warn().Msg("cef: clipboard-set — onClipboardSet callback not wired")291 h.logger.Warn().Msg("cef: clipboard-set — onClipboardSet callback not wired")
286 }292 }
287 293
288- return h.newJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})294+ return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
289 }295 }
290 296
291 func (h *dumbSchemeHandler) handleFocusSync(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {297 func (h *dumbSchemeHandler) handleFocusSync(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
@@ -295,11 +301,11 @@ func (h *dumbSchemeHandler) handleFocusSync(request purecef.Request, browser pur
295 }301 }
296 if !strings.EqualFold(bridgeAction, dumberBridgeActionFocusSync) {302 if !strings.EqualFold(bridgeAction, dumberBridgeActionFocusSync) {
297 h.logger.Warn().Msg("cef: focus-sync — rejected request without trusted bridge action header")303 h.logger.Warn().Msg("cef: focus-sync — rejected request without trusted bridge action header")
298- return h.newJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})304+ return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
299 }305 }
300 if browser == nil {306 if browser == nil {
301 h.logger.Debug().Msg("cef: focus-sync — browser unavailable")307 h.logger.Debug().Msg("cef: focus-sync — browser unavailable")
302- return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})308+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})
303 }309 }
304 310
305 if h.onEditableFocus != nil {311 if h.onEditableFocus != nil {
@@ -308,17 +314,13 @@ func (h *dumbSchemeHandler) handleFocusSync(request purecef.Request, browser pur
308 h.logger.Debug().Msg("cef: focus-sync — onEditableFocus callback not wired")314 h.logger.Debug().Msg("cef: focus-sync — onEditableFocus callback not wired")
309 }315 }
310 316
311- return h.newJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})317+ return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
312 }318 }
313 319
314 func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler {320 func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler {
315- return purecef.NewResourceHandler(&staticResourceHandler{321+ return newStaticResourceHandler(status, "text/plain; charset=utf-8", nil, map[string]string{
316- contentType: "text/plain; charset=utf-8",322+ "Location": location,
317- statusCode: status,323+ "Cache-Control": "no-store",
318- headers: map[string]string{
319- "Location": location,
320- "Cache-Control": "no-store",
321- },
322 })324 })
323 }325 }
324 326
@@ -476,26 +478,54 @@ type staticResourceHandler struct {
476 offset int478 offset int
477 }479 }
478 480
479-func (h *dumbSchemeHandler) newRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler {481+func apiResponseHeaders(extra map[string]string) map[string]string {
482+ headers := map[string]string{
483+ "Access-Control-Allow-Origin": "*",
484+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
485+ "Access-Control-Allow-Headers": strings.Join([]string{
486+ "Content-Type",
487+ "X-Dumber-Body",
488+ "X-Dumber-Bridge-Action",
489+ }, ", "),
490+ "Access-Control-Max-Age": "86400",
491+ "Cache-Control": "no-store",
492+ }
493+ for name, value := range extra {
494+ headers[name] = value
495+ }
496+ return headers
497+}
498+
499+func newStaticResourceHandler(status int, contentType string, data []byte, headers map[string]string) purecef.ResourceHandler {
480 return cefNewResourceHandler(&staticResourceHandler{500 return cefNewResourceHandler(&staticResourceHandler{
481 data: data,501 data: data,
482 contentType: contentType,502 contentType: contentType,
483 statusCode: status,503 statusCode: status,
504+ headers: headers,
484 })505 })
485 }506 }
486 507
508+func (h *dumbSchemeHandler) newRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler {
509+ return newStaticResourceHandler(status, contentType, data, nil)
510+}
511+
512+func (h *dumbSchemeHandler) newAPIRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler {
513+ return newStaticResourceHandler(status, contentType, data, apiResponseHeaders(nil))
514+}
515+
487 func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler {516 func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler {
488 escaped := html.EscapeString(msg)517 escaped := html.EscapeString(msg)
489 body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped)518 body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped)
490 return h.newRawResourceHandler(status, "text/html; charset=utf-8", []byte(body))519 return h.newRawResourceHandler(status, "text/html; charset=utf-8", []byte(body))
491 }520 }
492 521
493-func (h *dumbSchemeHandler) newJSONResourceHandler(status int, v any) purecef.ResourceHandler {522+func (h *dumbSchemeHandler) newAPIJSONResourceHandler(status int, v any) purecef.ResourceHandler {
494 data, err := json.Marshal(v)523 data, err := json.Marshal(v)
495 if err != nil {524 if err != nil {
496- return h.newErrorResourceHandler(http.StatusInternalServerError, "JSON encoding failed")525+ fallback, _ := json.Marshal(map[string]string{"error": "JSON encoding failed"})
526+ return h.newAPIRawResourceHandler(http.StatusInternalServerError, "application/json", fallback)
497 }527 }
498- return h.newRawResourceHandler(status, "application/json", data)528+ return h.newAPIRawResourceHandler(status, "application/json", data)
499 }529 }
500 530
501 // Open handles the request immediately (synchronous).531 // Open handles the request immediately (synchronous).
M internal/infrastructure/cef/scheme_handler_test.go +71 -0
@@ -13,6 +13,18 @@ import (
1313 "github.com/stretchr/testify/require"
1414 )
1515
16+func expectAPIResponseHeaders(response *cefmocks.MockResponse) {
17+ response.EXPECT().SetHeaderByName("Access-Control-Allow-Origin", "*", int32(1)).Once()
18+ response.EXPECT().SetHeaderByName("Access-Control-Allow-Methods", "GET, POST, OPTIONS", int32(1)).Once()
19+ response.EXPECT().SetHeaderByName(
20+ "Access-Control-Allow-Headers",
21+ "Content-Type, X-Dumber-Body, X-Dumber-Bridge-Action",
22+ int32(1),
23+ ).Once()
24+ response.EXPECT().SetHeaderByName("Access-Control-Max-Age", "86400", int32(1)).Once()
25+ response.EXPECT().SetHeaderByName("Cache-Control", "no-store", int32(1)).Once()
26+}
27+
1628 func TestResolveConfigPayload_UsesInjectedBuilder(t *testing.T) {
1729 data, err := resolveConfigPayload(func() ([]byte, error) {
1830 return []byte(`{"engine_type":"cef"}`), nil
@@ -78,6 +90,7 @@ func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) {
7890 response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
7991 response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()
8092 response.EXPECT().SetMimeType("application/json").Once()
93+ expectAPIResponseHeaders(response)
8194
8295 handler := h.handleAPI(nil, http.MethodPost, "/api/clipboard-set", request)
8396 require.NotNil(t, handler)
@@ -103,6 +116,7 @@ func TestSchemeHandler_APIFocusSyncRejectsRequestsWithoutTrustedBridgeHeader(t *
103116 response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once()
104117 response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once()
105118 response.EXPECT().SetMimeType("application/json").Once()
119+ expectAPIResponseHeaders(response)
106120
107121 handler := h.handleAPI(nil, http.MethodPost, "/api/focus-sync", request)
108122 require.NotNil(t, handler)
@@ -131,6 +145,7 @@ func TestSchemeHandler_APIFocusSyncInvokesEditableFocusCallback(t *testing.T) {
131145 response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
132146 response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()
133147 response.EXPECT().SetMimeType("application/json").Once()
148+ expectAPIResponseHeaders(response)
134149
135150 handler := h.handleAPI(browser, http.MethodPost, "/api/focus-sync", request)
136151 require.NotNil(t, handler)
@@ -140,3 +155,59 @@ func TestSchemeHandler_APIFocusSyncInvokesEditableFocusCallback(t *testing.T) {
140155 require.Positive(t, responseLength)
141156 require.Same(t, browser, focused)
142157 }
158+
159+func TestSchemeHandler_Create_ConceptualAPIRequestBypassesRedirect(t *testing.T) {
160+ oldNewResourceHandler := cefNewResourceHandler
161+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
162+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
163+
164+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
165+ require.NoError(t, err)
166+
167+ var copied string
168+ h.onClipboardSet = func(text string) { copied = text }
169+
170+ request := cefmocks.NewMockRequest(t)
171+ request.EXPECT().GetURL().Return("dumb://api/clipboard-set").Once()
172+ request.EXPECT().GetMethod().Return(http.MethodPost).Once()
173+ encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from create"}`))
174+ request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()
175+
176+ response := cefmocks.NewMockResponse(t)
177+ response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
178+ response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()
179+ response.EXPECT().SetMimeType("application/json").Once()
180+ expectAPIResponseHeaders(response)
181+
182+ handler := h.Create(nil, nil, "", request)
183+ require.NotNil(t, handler)
184+
185+ var responseLength int64
186+ handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0)
187+ require.Positive(t, responseLength)
188+ require.Equal(t, "copied from create", copied)
189+}
190+
191+func TestSchemeHandler_APIOptionsReturnsCORSPreflightHeaders(t *testing.T) {
192+ oldNewResourceHandler := cefNewResourceHandler
193+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
194+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
195+
196+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
197+ require.NoError(t, err)
198+
199+ request := cefmocks.NewMockRequest(t)
200+ response := cefmocks.NewMockResponse(t)
201+ response.EXPECT().SetStatus(int32(http.StatusNoContent)).Once()
202+ response.EXPECT().SetStatusText(http.StatusText(http.StatusNoContent)).Once()
203+ response.EXPECT().SetMimeType("text/plain").Once()
204+ response.EXPECT().SetCharset("utf-8").Once()
205+ expectAPIResponseHeaders(response)
206+
207+ handler := h.handleAPI(nil, http.MethodOptions, "/api/clipboard-set", request)
208+ require.NotNil(t, handler)
209+
210+ var responseLength int64
211+ handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0)
212+ require.Zero(t, responseLength)
213+}
@@ -13,6 +13,18 @@ import (
13 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
14 )14 )
15 15
16+func expectAPIResponseHeaders(response *cefmocks.MockResponse) {
17+ response.EXPECT().SetHeaderByName("Access-Control-Allow-Origin", "*", int32(1)).Once()
18+ response.EXPECT().SetHeaderByName("Access-Control-Allow-Methods", "GET, POST, OPTIONS", int32(1)).Once()
19+ response.EXPECT().SetHeaderByName(
20+ "Access-Control-Allow-Headers",
21+ "Content-Type, X-Dumber-Body, X-Dumber-Bridge-Action",
22+ int32(1),
23+ ).Once()
24+ response.EXPECT().SetHeaderByName("Access-Control-Max-Age", "86400", int32(1)).Once()
25+ response.EXPECT().SetHeaderByName("Cache-Control", "no-store", int32(1)).Once()
26+}
27+
16 func TestResolveConfigPayload_UsesInjectedBuilder(t *testing.T) {28 func TestResolveConfigPayload_UsesInjectedBuilder(t *testing.T) {
17 data, err := resolveConfigPayload(func() ([]byte, error) {29 data, err := resolveConfigPayload(func() ([]byte, error) {
18 return []byte(`{"engine_type":"cef"}`), nil30 return []byte(`{"engine_type":"cef"}`), nil
@@ -78,6 +90,7 @@ func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) {
78 response.EXPECT().SetStatus(int32(http.StatusOK)).Once()90 response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
79 response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()91 response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()
80 response.EXPECT().SetMimeType("application/json").Once()92 response.EXPECT().SetMimeType("application/json").Once()
93+ expectAPIResponseHeaders(response)
81 94
82 handler := h.handleAPI(nil, http.MethodPost, "/api/clipboard-set", request)95 handler := h.handleAPI(nil, http.MethodPost, "/api/clipboard-set", request)
83 require.NotNil(t, handler)96 require.NotNil(t, handler)
@@ -103,6 +116,7 @@ func TestSchemeHandler_APIFocusSyncRejectsRequestsWithoutTrustedBridgeHeader(t *
103 response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once()116 response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once()
104 response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once()117 response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once()
105 response.EXPECT().SetMimeType("application/json").Once()118 response.EXPECT().SetMimeType("application/json").Once()
119+ expectAPIResponseHeaders(response)
106 120
107 handler := h.handleAPI(nil, http.MethodPost, "/api/focus-sync", request)121 handler := h.handleAPI(nil, http.MethodPost, "/api/focus-sync", request)
108 require.NotNil(t, handler)122 require.NotNil(t, handler)
@@ -131,6 +145,7 @@ func TestSchemeHandler_APIFocusSyncInvokesEditableFocusCallback(t *testing.T) {
131 response.EXPECT().SetStatus(int32(http.StatusOK)).Once()145 response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
132 response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()146 response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()
133 response.EXPECT().SetMimeType("application/json").Once()147 response.EXPECT().SetMimeType("application/json").Once()
148+ expectAPIResponseHeaders(response)
134 149
135 handler := h.handleAPI(browser, http.MethodPost, "/api/focus-sync", request)150 handler := h.handleAPI(browser, http.MethodPost, "/api/focus-sync", request)
136 require.NotNil(t, handler)151 require.NotNil(t, handler)
@@ -140,3 +155,59 @@ func TestSchemeHandler_APIFocusSyncInvokesEditableFocusCallback(t *testing.T) {
140 require.Positive(t, responseLength)155 require.Positive(t, responseLength)
141 require.Same(t, browser, focused)156 require.Same(t, browser, focused)
142 }157 }
158+
159+func TestSchemeHandler_Create_ConceptualAPIRequestBypassesRedirect(t *testing.T) {
160+ oldNewResourceHandler := cefNewResourceHandler
161+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
162+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
163+
164+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
165+ require.NoError(t, err)
166+
167+ var copied string
168+ h.onClipboardSet = func(text string) { copied = text }
169+
170+ request := cefmocks.NewMockRequest(t)
171+ request.EXPECT().GetURL().Return("dumb://api/clipboard-set").Once()
172+ request.EXPECT().GetMethod().Return(http.MethodPost).Once()
173+ encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from create"}`))
174+ request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()
175+
176+ response := cefmocks.NewMockResponse(t)
177+ response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
178+ response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()
179+ response.EXPECT().SetMimeType("application/json").Once()
180+ expectAPIResponseHeaders(response)
181+
182+ handler := h.Create(nil, nil, "", request)
183+ require.NotNil(t, handler)
184+
185+ var responseLength int64
186+ handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0)
187+ require.Positive(t, responseLength)
188+ require.Equal(t, "copied from create", copied)
189+}
190+
191+func TestSchemeHandler_APIOptionsReturnsCORSPreflightHeaders(t *testing.T) {
192+ oldNewResourceHandler := cefNewResourceHandler
193+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
194+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
195+
196+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
197+ require.NoError(t, err)
198+
199+ request := cefmocks.NewMockRequest(t)
200+ response := cefmocks.NewMockResponse(t)
201+ response.EXPECT().SetStatus(int32(http.StatusNoContent)).Once()
202+ response.EXPECT().SetStatusText(http.StatusText(http.StatusNoContent)).Once()
203+ response.EXPECT().SetMimeType("text/plain").Once()
204+ response.EXPECT().SetCharset("utf-8").Once()
205+ expectAPIResponseHeaders(response)
206+
207+ handler := h.handleAPI(nil, http.MethodOptions, "/api/clipboard-set", request)
208+ require.NotNil(t, handler)
209+
210+ var responseLength int64
211+ handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0)
212+ require.Zero(t, responseLength)
213+}
M internal/infrastructure/cef/webview_handlers.go +0 -2
@@ -15,7 +15,6 @@ import (
1515 const (
1616 consoleMarkerVideoDiag = "[VIDEO-DIAG]"
1717 consoleMarkerRedditVideoPatch = "[REDDIT-VIDEO-PATCH]"
18- consoleMarkerClipboardBridge = "[CLIPBOARD-BRIDGE]"
1918 consoleMarkerAutoCopy = "[AUTO-COPY]"
2019 )
2120
@@ -469,7 +468,6 @@ func (h *handlerSet) OnConsoleMessage(
469468 if h.wv != nil && h.wv.ctx != nil &&
470469 (strings.Contains(message, consoleMarkerVideoDiag) ||
471470 strings.Contains(message, consoleMarkerRedditVideoPatch) ||
472- strings.Contains(message, consoleMarkerClipboardBridge) ||
473471 strings.Contains(message, consoleMarkerAutoCopy)) {
474472 log := logging.FromContext(h.wv.ctx).With().
475473 Str("component", "cef-console").
@@ -15,7 +15,6 @@ import (
15 const (15 const (
16 consoleMarkerVideoDiag = "[VIDEO-DIAG]"16 consoleMarkerVideoDiag = "[VIDEO-DIAG]"
17 consoleMarkerRedditVideoPatch = "[REDDIT-VIDEO-PATCH]"17 consoleMarkerRedditVideoPatch = "[REDDIT-VIDEO-PATCH]"
18- consoleMarkerClipboardBridge = "[CLIPBOARD-BRIDGE]"
19 consoleMarkerAutoCopy = "[AUTO-COPY]"18 consoleMarkerAutoCopy = "[AUTO-COPY]"
20 )19 )
21 20
@@ -469,7 +468,6 @@ func (h *handlerSet) OnConsoleMessage(
469 if h.wv != nil && h.wv.ctx != nil &&468 if h.wv != nil && h.wv.ctx != nil &&
470 (strings.Contains(message, consoleMarkerVideoDiag) ||469 (strings.Contains(message, consoleMarkerVideoDiag) ||
471 strings.Contains(message, consoleMarkerRedditVideoPatch) ||470 strings.Contains(message, consoleMarkerRedditVideoPatch) ||
472- strings.Contains(message, consoleMarkerClipboardBridge) ||
473 strings.Contains(message, consoleMarkerAutoCopy)) {471 strings.Contains(message, consoleMarkerAutoCopy)) {
474 log := logging.FromContext(h.wv.ctx).With().472 log := logging.FromContext(h.wv.ctx).With().
475 Str("component", "cef-console").473 Str("component", "cef-console").

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