detached 5a06542

fix(security): harden clipboard bridge trust

commit
5a0654206e296727eae734288be58697a5739343
author
brice · · 2026-04-18 14:33 UTC
parent
b4e5347
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +277 -44
M internal/infrastructure/cef/content_injector.go +65 -16
@@ -388,25 +388,60 @@ const redditDirectVideoJS = `(function(){
388388 // clipboardSelectionFetchBridgeJS keeps the lightweight fetch bridge enabled
389389 // while the native renderer bridge remains disabled due to an OSR startup
390390 // regression. It mirrors explicit clipboard text from DOM selections,
391-// input/textarea selections, and async Clipboard API writes, and also
391+// safe input/textarea selections, and async Clipboard API writes, and also
392392 // reasserts browser focus when an editable element gains DOM focus.
393393 const clipboardSelectionFetchBridgeJS = `(function(){
394- if (window.__dumberClipboardBridge) return;
395- window.__dumberClipboardBridge = true;
394+ var bridgeNonce = '__DUMBER_BRIDGE_NONCE__';
395+ if (window.__dumberClipboardBridge && typeof window.__dumberClipboardBridge.setBridgeNonce === 'function') {
396+ window.__dumberClipboardBridge.setBridgeNonce(bridgeNonce);
397+ return;
398+ }
399+ window.__dumberClipboardBridge = {
400+ setBridgeNonce: function(nextBridgeNonce) {
401+ bridgeNonce = nextBridgeNonce == null ? '' : String(nextBridgeNonce);
402+ }
403+ };
396404
397405 function encodeBody(body) {
398406 return typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : '';
399407 }
400408
409+ function inputType(node) {
410+ return node && node.type ? String(node.type).toLowerCase() : 'text';
411+ }
412+
413+ function isTextEditableInputType(type) {
414+ switch ((type || 'text').toLowerCase()) {
415+ case 'button':
416+ case 'submit':
417+ case 'reset':
418+ case 'checkbox':
419+ case 'radio':
420+ case 'range':
421+ case 'color':
422+ case 'file':
423+ case 'image':
424+ case 'hidden':
425+ return false;
426+ default:
427+ return true;
428+ }
429+ }
430+
431+ function isSelectionReadableInputType(type) {
432+ return isTextEditableInputType(type) && type !== 'password';
433+ }
434+
401435 function getActiveElementSelection() {
402436 var el = document.activeElement;
403- if (!el) return '';
437+ if (!el || el.nodeType !== 1 || typeof el.value !== 'string') return '';
404438 var tag = el.tagName;
405- if ((tag === 'INPUT' || tag === 'TEXTAREA') && typeof el.value === 'string') {
406- var start = typeof el.selectionStart === 'number' ? el.selectionStart : 0;
407- var end = typeof el.selectionEnd === 'number' ? el.selectionEnd : 0;
408- if (end > start) return el.value.slice(start, end);
409- }
439+ if (el.disabled || el.readOnly) return '';
440+ if (tag === 'INPUT' && !isSelectionReadableInputType(inputType(el))) return '';
441+ if (tag !== 'INPUT' && tag !== 'TEXTAREA') return '';
442+ var start = typeof el.selectionStart === 'number' ? el.selectionStart : 0;
443+ var end = typeof el.selectionEnd === 'number' ? el.selectionEnd : 0;
444+ if (end > start) return el.value.slice(start, end);
410445 return '';
411446 }
412447
@@ -421,8 +456,10 @@ const clipboardSelectionFetchBridgeJS = `(function(){
421456 if (!node || node.nodeType !== 1) return false;
422457 if (node.isContentEditable) return true;
423458 var tag = node.tagName;
424- if (tag !== 'INPUT' && tag !== 'TEXTAREA') return false;
425- return !node.disabled && !node.readOnly;
459+ if (tag === 'TEXTAREA') return !node.disabled && !node.readOnly;
460+ if (tag !== 'INPUT') return false;
461+ if (node.disabled || node.readOnly) return false;
462+ return isTextEditableInputType(inputType(node));
426463 }
427464
428465 function sendToClipboard(text) {
@@ -432,14 +469,20 @@ const clipboardSelectionFetchBridgeJS = `(function(){
432469 if (!encoded) return;
433470 fetch('dumb:///api/clipboard-set', {
434471 method: 'POST',
435- headers: {'X-Dumber-Body': encoded}
472+ headers: {
473+ 'X-Dumber-Body': encoded,
474+ 'X-Dumber-Bridge-Nonce': bridgeNonce
475+ }
436476 }).catch(function(){});
437477 }
438478
439479 function sendFocusSync() {
440480 fetch('dumb:///api/focus-sync', {
441481 method: 'POST',
442- headers: {'X-Dumber-Bridge-Action': 'focus-sync'}
482+ headers: {
483+ 'X-Dumber-Bridge-Action': 'focus-sync',
484+ 'X-Dumber-Bridge-Nonce': bridgeNonce
485+ }
443486 }).catch(function(){});
444487 }
445488
@@ -461,11 +504,13 @@ const clipboardSelectionFetchBridgeJS = `(function(){
461504 } catch (_) {}
462505 }
463506
464- document.addEventListener('copy', function() {
507+ document.addEventListener('copy', function(event) {
508+ if (event && event.isTrusted === false) return;
465509 var text = getSelectedText();
466510 if (text) sendToClipboard(text);
467511 });
468- document.addEventListener('cut', function() {
512+ document.addEventListener('cut', function(event) {
513+ if (event && event.isTrusted === false) return;
469514 var text = getSelectedText();
470515 if (text) sendToClipboard(text);
471516 });
@@ -581,6 +626,10 @@ const clipboardSelectionFetchBridgeJS = `(function(){
581626 if (isEditable(document.activeElement)) sendFocusSync();
582627 })();`
583628
629+func buildClipboardSelectionFetchBridgeJS(bridgeNonce string) string {
630+ return strings.ReplaceAll(clipboardSelectionFetchBridgeJS, "__DUMBER_BRIDGE_NONCE__", bridgeNonce)
631+}
632+
584633 // contentInjector implements port.ContentInjector for the CEF engine.
585634 // It stores CSS strings and injects them into webviews via ExecuteJavaScript.
586635 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd
@@ -703,7 +752,7 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
703752
704753 // Clipboard copy/cut and editable focus sync still need a JS bridge in OSR
705754 // mode while the native renderer bridge remains disabled.
706- wv.RunJavaScript(context.Background(), clipboardSelectionFetchBridgeJS)
755+ wv.RunJavaScript(context.Background(), buildClipboardSelectionFetchBridgeJS(wv.rotateBridgeNonce()))
707756
708757 // Video playback diagnostic — logs video element state changes.
709758 // Gated behind DUMBER_VIDEO_DIAG=1 environment variable.
@@ -388,25 +388,60 @@ const redditDirectVideoJS = `(function(){
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 DOM selections,390 // regression. It mirrors explicit clipboard text from DOM selections,
391-// input/textarea selections, and async Clipboard API writes, and also391+// safe input/textarea selections, and async Clipboard API writes, and also
392 // reasserts browser focus when an editable 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+ var bridgeNonce = '__DUMBER_BRIDGE_NONCE__';
395- window.__dumberClipboardBridge = true;395+ if (window.__dumberClipboardBridge && typeof window.__dumberClipboardBridge.setBridgeNonce === 'function') {
396+ window.__dumberClipboardBridge.setBridgeNonce(bridgeNonce);
397+ return;
398+ }
399+ window.__dumberClipboardBridge = {
400+ setBridgeNonce: function(nextBridgeNonce) {
401+ bridgeNonce = nextBridgeNonce == null ? '' : String(nextBridgeNonce);
402+ }
403+ };
396 404
397 function encodeBody(body) {405 function encodeBody(body) {
398 return typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : '';406 return typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : '';
399 }407 }
400 408
409+ function inputType(node) {
410+ return node && node.type ? String(node.type).toLowerCase() : 'text';
411+ }
412+
413+ function isTextEditableInputType(type) {
414+ switch ((type || 'text').toLowerCase()) {
415+ case 'button':
416+ case 'submit':
417+ case 'reset':
418+ case 'checkbox':
419+ case 'radio':
420+ case 'range':
421+ case 'color':
422+ case 'file':
423+ case 'image':
424+ case 'hidden':
425+ return false;
426+ default:
427+ return true;
428+ }
429+ }
430+
431+ function isSelectionReadableInputType(type) {
432+ return isTextEditableInputType(type) && type !== 'password';
433+ }
434+
401 function getActiveElementSelection() {435 function getActiveElementSelection() {
402 var el = document.activeElement;436 var el = document.activeElement;
403- if (!el) return '';437+ if (!el || el.nodeType !== 1 || typeof el.value !== 'string') return '';
404 var tag = el.tagName;438 var tag = el.tagName;
405- if ((tag === 'INPUT' || tag === 'TEXTAREA') && typeof el.value === 'string') {439+ if (el.disabled || el.readOnly) return '';
406- var start = typeof el.selectionStart === 'number' ? el.selectionStart : 0;440+ if (tag === 'INPUT' && !isSelectionReadableInputType(inputType(el))) return '';
407- var end = typeof el.selectionEnd === 'number' ? el.selectionEnd : 0;441+ if (tag !== 'INPUT' && tag !== 'TEXTAREA') return '';
408- if (end > start) return el.value.slice(start, end);442+ var start = typeof el.selectionStart === 'number' ? el.selectionStart : 0;
409- }443+ var end = typeof el.selectionEnd === 'number' ? el.selectionEnd : 0;
444+ if (end > start) return el.value.slice(start, end);
410 return '';445 return '';
411 }446 }
412 447
@@ -421,8 +456,10 @@ const clipboardSelectionFetchBridgeJS = `(function(){
421 if (!node || node.nodeType !== 1) return false;456 if (!node || node.nodeType !== 1) return false;
422 if (node.isContentEditable) return true;457 if (node.isContentEditable) return true;
423 var tag = node.tagName;458 var tag = node.tagName;
424- if (tag !== 'INPUT' && tag !== 'TEXTAREA') return false;459+ if (tag === 'TEXTAREA') return !node.disabled && !node.readOnly;
425- return !node.disabled && !node.readOnly;460+ if (tag !== 'INPUT') return false;
461+ if (node.disabled || node.readOnly) return false;
462+ return isTextEditableInputType(inputType(node));
426 }463 }
427 464
428 function sendToClipboard(text) {465 function sendToClipboard(text) {
@@ -432,14 +469,20 @@ const clipboardSelectionFetchBridgeJS = `(function(){
432 if (!encoded) return;469 if (!encoded) return;
433 fetch('dumb:///api/clipboard-set', {470 fetch('dumb:///api/clipboard-set', {
434 method: 'POST',471 method: 'POST',
435- headers: {'X-Dumber-Body': encoded}472+ headers: {
473+ 'X-Dumber-Body': encoded,
474+ 'X-Dumber-Bridge-Nonce': bridgeNonce
475+ }
436 }).catch(function(){});476 }).catch(function(){});
437 }477 }
438 478
439 function sendFocusSync() {479 function sendFocusSync() {
440 fetch('dumb:///api/focus-sync', {480 fetch('dumb:///api/focus-sync', {
441 method: 'POST',481 method: 'POST',
442- headers: {'X-Dumber-Bridge-Action': 'focus-sync'}482+ headers: {
483+ 'X-Dumber-Bridge-Action': 'focus-sync',
484+ 'X-Dumber-Bridge-Nonce': bridgeNonce
485+ }
443 }).catch(function(){});486 }).catch(function(){});
444 }487 }
445 488
@@ -461,11 +504,13 @@ const clipboardSelectionFetchBridgeJS = `(function(){
461 } catch (_) {}504 } catch (_) {}
462 }505 }
463 506
464- document.addEventListener('copy', function() {507+ document.addEventListener('copy', function(event) {
508+ if (event && event.isTrusted === false) return;
465 var text = getSelectedText();509 var text = getSelectedText();
466 if (text) sendToClipboard(text);510 if (text) sendToClipboard(text);
467 });511 });
468- document.addEventListener('cut', function() {512+ document.addEventListener('cut', function(event) {
513+ if (event && event.isTrusted === false) return;
469 var text = getSelectedText();514 var text = getSelectedText();
470 if (text) sendToClipboard(text);515 if (text) sendToClipboard(text);
471 });516 });
@@ -581,6 +626,10 @@ const clipboardSelectionFetchBridgeJS = `(function(){
581 if (isEditable(document.activeElement)) sendFocusSync();626 if (isEditable(document.activeElement)) sendFocusSync();
582 })();`627 })();`
583 628
629+func buildClipboardSelectionFetchBridgeJS(bridgeNonce string) string {
630+ return strings.ReplaceAll(clipboardSelectionFetchBridgeJS, "__DUMBER_BRIDGE_NONCE__", bridgeNonce)
631+}
632+
584 // contentInjector implements port.ContentInjector for the CEF engine.633 // contentInjector implements port.ContentInjector for the CEF engine.
585 // It stores CSS strings and injects them into webviews via ExecuteJavaScript.634 // It stores CSS strings and injects them into webviews via ExecuteJavaScript.
586 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd635 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd
@@ -703,7 +752,7 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
703 752
704 // Clipboard copy/cut and editable focus sync still need a JS bridge in OSR753 // Clipboard copy/cut and editable focus sync still need a JS bridge in OSR
705 // mode while the native renderer bridge remains disabled.754 // mode while the native renderer bridge remains disabled.
706- wv.RunJavaScript(context.Background(), clipboardSelectionFetchBridgeJS)755+ wv.RunJavaScript(context.Background(), buildClipboardSelectionFetchBridgeJS(wv.rotateBridgeNonce()))
707 756
708 // Video playback diagnostic — logs video element state changes.757 // Video playback diagnostic — logs video element state changes.
709 // Gated behind DUMBER_VIDEO_DIAG=1 environment variable.758 // Gated behind DUMBER_VIDEO_DIAG=1 environment variable.
M internal/infrastructure/cef/content_injector_test.go +45 -20
@@ -6,31 +6,56 @@ import (
66 "github.com/stretchr/testify/require"
77 )
88
9+func testClipboardSelectionFetchBridgeJS() string {
10+ return buildClipboardSelectionFetchBridgeJS("test-bridge-nonce")
11+}
12+
913 func TestClipboardSelectionFetchBridgeJS_CapturesInputAndTextareaSelections(t *testing.T) {
10- require.Contains(t, clipboardSelectionFetchBridgeJS, "document.activeElement")
11- require.Contains(t, clipboardSelectionFetchBridgeJS, "selectionStart")
12- require.Contains(t, clipboardSelectionFetchBridgeJS, "selectionEnd")
13- require.Contains(t, clipboardSelectionFetchBridgeJS, "window.getSelection()")
14+ script := testClipboardSelectionFetchBridgeJS()
15+
16+ require.Contains(t, script, "document.activeElement")
17+ require.Contains(t, script, "selectionStart")
18+ require.Contains(t, script, "selectionEnd")
19+ require.Contains(t, script, "window.getSelection()")
20+ require.Contains(t, script, "type !== 'password'")
1421 }
1522
1623 func TestClipboardSelectionFetchBridgeJS_PostsTrustedFocusSyncRequests(t *testing.T) {
17- require.Contains(t, clipboardSelectionFetchBridgeJS, "dumb:///api/focus-sync")
18- require.Contains(t, clipboardSelectionFetchBridgeJS, "X-Dumber-Bridge-Action")
19- require.Contains(t, clipboardSelectionFetchBridgeJS, "focus-sync")
20- require.Contains(t, clipboardSelectionFetchBridgeJS, "focusin")
21- require.Contains(t, clipboardSelectionFetchBridgeJS, "event.isTrusted === false")
22- require.Contains(t, clipboardSelectionFetchBridgeJS, "event && event.target")
23- require.Contains(t, clipboardSelectionFetchBridgeJS, "document.activeElement")
24- require.Contains(t, clipboardSelectionFetchBridgeJS, "isEditable(document.activeElement)")
25- require.Contains(t, clipboardSelectionFetchBridgeJS, "sendFocusSync()")
24+ script := testClipboardSelectionFetchBridgeJS()
25+
26+ require.Contains(t, script, "dumb:///api/focus-sync")
27+ require.Contains(t, script, "X-Dumber-Bridge-Action")
28+ require.Contains(t, script, "X-Dumber-Bridge-Nonce")
29+ require.Contains(t, script, "focus-sync")
30+ require.Contains(t, script, "focusin")
31+ require.Contains(t, script, "event.isTrusted === false")
32+ require.Contains(t, script, "event && event.target")
33+ require.Contains(t, script, "document.activeElement")
34+ require.Contains(t, script, "isEditable(document.activeElement)")
35+ require.Contains(t, script, "sendFocusSync()")
36+}
37+
38+func TestClipboardSelectionFetchBridgeJS_HandlesTrustedClipboardEvents(t *testing.T) {
39+ script := testClipboardSelectionFetchBridgeJS()
40+
41+ require.Contains(t, script, "dumb:///api/clipboard-set")
42+ require.Contains(t, script, "X-Dumber-Body")
43+ require.Contains(t, script, "X-Dumber-Bridge-Nonce")
44+ require.Contains(t, script, "document.addEventListener('copy', function(event)")
45+ require.Contains(t, script, "document.addEventListener('cut', function(event)")
46+ require.Contains(t, script, "event.isTrusted === false")
2647 }
2748
2849 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)")
50+ script := testClipboardSelectionFetchBridgeJS()
51+
52+ require.Contains(t, script, "navigator && navigator.clipboard")
53+ require.Contains(t, script, "clipboardProto.writeText")
54+ require.Contains(t, script, "clipboardObj.writeText")
55+ require.Contains(t, script, "clipboardProto.write")
56+ require.Contains(t, script, "clipboardObj.write")
57+ require.Contains(t, script, "sendToClipboard(normalized)")
58+ require.Contains(t, script, "mirrorClipboardItems(items)")
59+ require.NotContains(t, script, "__DUMBER_BRIDGE_NONCE__")
60+ require.Contains(t, script, "test-bridge-nonce")
3661 }
@@ -6,31 +6,56 @@ import (
6 "github.com/stretchr/testify/require"6 "github.com/stretchr/testify/require"
7 )7 )
8 8
9+func testClipboardSelectionFetchBridgeJS() string {
10+ return buildClipboardSelectionFetchBridgeJS("test-bridge-nonce")
11+}
12+
9 func TestClipboardSelectionFetchBridgeJS_CapturesInputAndTextareaSelections(t *testing.T) {13 func TestClipboardSelectionFetchBridgeJS_CapturesInputAndTextareaSelections(t *testing.T) {
10- require.Contains(t, clipboardSelectionFetchBridgeJS, "document.activeElement")14+ script := testClipboardSelectionFetchBridgeJS()
11- require.Contains(t, clipboardSelectionFetchBridgeJS, "selectionStart")15+
12- require.Contains(t, clipboardSelectionFetchBridgeJS, "selectionEnd")16+ require.Contains(t, script, "document.activeElement")
13- require.Contains(t, clipboardSelectionFetchBridgeJS, "window.getSelection()")17+ require.Contains(t, script, "selectionStart")
18+ require.Contains(t, script, "selectionEnd")
19+ require.Contains(t, script, "window.getSelection()")
20+ require.Contains(t, script, "type !== 'password'")
14 }21 }
15 22
16 func TestClipboardSelectionFetchBridgeJS_PostsTrustedFocusSyncRequests(t *testing.T) {23 func TestClipboardSelectionFetchBridgeJS_PostsTrustedFocusSyncRequests(t *testing.T) {
17- require.Contains(t, clipboardSelectionFetchBridgeJS, "dumb:///api/focus-sync")24+ script := testClipboardSelectionFetchBridgeJS()
18- require.Contains(t, clipboardSelectionFetchBridgeJS, "X-Dumber-Bridge-Action")25+
19- require.Contains(t, clipboardSelectionFetchBridgeJS, "focus-sync")26+ require.Contains(t, script, "dumb:///api/focus-sync")
20- require.Contains(t, clipboardSelectionFetchBridgeJS, "focusin")27+ require.Contains(t, script, "X-Dumber-Bridge-Action")
21- require.Contains(t, clipboardSelectionFetchBridgeJS, "event.isTrusted === false")28+ require.Contains(t, script, "X-Dumber-Bridge-Nonce")
22- require.Contains(t, clipboardSelectionFetchBridgeJS, "event && event.target")29+ require.Contains(t, script, "focus-sync")
23- require.Contains(t, clipboardSelectionFetchBridgeJS, "document.activeElement")30+ require.Contains(t, script, "focusin")
24- require.Contains(t, clipboardSelectionFetchBridgeJS, "isEditable(document.activeElement)")31+ require.Contains(t, script, "event.isTrusted === false")
25- require.Contains(t, clipboardSelectionFetchBridgeJS, "sendFocusSync()")32+ require.Contains(t, script, "event && event.target")
33+ require.Contains(t, script, "document.activeElement")
34+ require.Contains(t, script, "isEditable(document.activeElement)")
35+ require.Contains(t, script, "sendFocusSync()")
36+}
37+
38+func TestClipboardSelectionFetchBridgeJS_HandlesTrustedClipboardEvents(t *testing.T) {
39+ script := testClipboardSelectionFetchBridgeJS()
40+
41+ require.Contains(t, script, "dumb:///api/clipboard-set")
42+ require.Contains(t, script, "X-Dumber-Body")
43+ require.Contains(t, script, "X-Dumber-Bridge-Nonce")
44+ require.Contains(t, script, "document.addEventListener('copy', function(event)")
45+ require.Contains(t, script, "document.addEventListener('cut', function(event)")
46+ require.Contains(t, script, "event.isTrusted === false")
26 }47 }
27 48
28 func TestClipboardSelectionFetchBridgeJS_PatchesAsyncClipboardAPIs(t *testing.T) {49 func TestClipboardSelectionFetchBridgeJS_PatchesAsyncClipboardAPIs(t *testing.T) {
29- require.Contains(t, clipboardSelectionFetchBridgeJS, "navigator && navigator.clipboard")50+ script := testClipboardSelectionFetchBridgeJS()
30- require.Contains(t, clipboardSelectionFetchBridgeJS, "clipboardProto.writeText")51+
31- require.Contains(t, clipboardSelectionFetchBridgeJS, "clipboardObj.writeText")52+ require.Contains(t, script, "navigator && navigator.clipboard")
32- require.Contains(t, clipboardSelectionFetchBridgeJS, "clipboardProto.write")53+ require.Contains(t, script, "clipboardProto.writeText")
33- require.Contains(t, clipboardSelectionFetchBridgeJS, "clipboardObj.write")54+ require.Contains(t, script, "clipboardObj.writeText")
34- require.Contains(t, clipboardSelectionFetchBridgeJS, "sendToClipboard(normalized)")55+ require.Contains(t, script, "clipboardProto.write")
35- require.Contains(t, clipboardSelectionFetchBridgeJS, "mirrorClipboardItems(items)")56+ require.Contains(t, script, "clipboardObj.write")
57+ require.Contains(t, script, "sendToClipboard(normalized)")
58+ require.Contains(t, script, "mirrorClipboardItems(items)")
59+ require.NotContains(t, script, "__DUMBER_BRIDGE_NONCE__")
60+ require.Contains(t, script, "test-bridge-nonce")
36 }61 }
M internal/infrastructure/cef/engine.go +30 -0
@@ -2,6 +2,7 @@ package cef
22
33 import (
44 "context"
5+ "crypto/subtle"
56 "sync"
67 "sync/atomic"
78 "time"
@@ -417,6 +418,35 @@ func (e *Engine) currentContext() context.Context {
417418 return e.ctx
418419 }
419420
421+func (e *Engine) validateBridgeRequest(browser purecef.Browser, bridgeNonce string) bool {
422+ if e == nil || browser == nil || bridgeNonce == "" {
423+ return false
424+ }
425+
426+ browserID := browser.GetIdentifier()
427+ valid := false
428+ e.activeWebViews.Range(func(_, value any) bool {
429+ wv, ok := value.(*WebView)
430+ if !ok || wv == nil {
431+ return true
432+ }
433+
434+ wv.mu.RLock()
435+ wvBrowser := wv.browser
436+ wvBridgeNonce := wv.bridgeNonce
437+ wv.mu.RUnlock()
438+ if wvBrowser == nil || wvBridgeNonce == "" || wvBrowser.GetIdentifier() != browserID {
439+ return true
440+ }
441+ if subtle.ConstantTimeCompare([]byte(wvBridgeNonce), []byte(bridgeNonce)) == 1 {
442+ valid = true
443+ return false
444+ }
445+ return true
446+ })
447+ return valid
448+}
449+
420450 func (e *Engine) logTranscoderStartupState() {
421451 if e == nil || !e.transcoderStateLogged.CompareAndSwap(false, true) {
422452 return
@@ -2,6 +2,7 @@ package cef
2 2
3 import (3 import (
4 "context"4 "context"
5+ "crypto/subtle"
5 "sync"6 "sync"
6 "sync/atomic"7 "sync/atomic"
7 "time"8 "time"
@@ -417,6 +418,35 @@ func (e *Engine) currentContext() context.Context {
417 return e.ctx418 return e.ctx
418 }419 }
419 420
421+func (e *Engine) validateBridgeRequest(browser purecef.Browser, bridgeNonce string) bool {
422+ if e == nil || browser == nil || bridgeNonce == "" {
423+ return false
424+ }
425+
426+ browserID := browser.GetIdentifier()
427+ valid := false
428+ e.activeWebViews.Range(func(_, value any) bool {
429+ wv, ok := value.(*WebView)
430+ if !ok || wv == nil {
431+ return true
432+ }
433+
434+ wv.mu.RLock()
435+ wvBrowser := wv.browser
436+ wvBridgeNonce := wv.bridgeNonce
437+ wv.mu.RUnlock()
438+ if wvBrowser == nil || wvBridgeNonce == "" || wvBrowser.GetIdentifier() != browserID {
439+ return true
440+ }
441+ if subtle.ConstantTimeCompare([]byte(wvBridgeNonce), []byte(bridgeNonce)) == 1 {
442+ valid = true
443+ return false
444+ }
445+ return true
446+ })
447+ return valid
448+}
449+
420 func (e *Engine) logTranscoderStartupState() {450 func (e *Engine) logTranscoderStartupState() {
421 if e == nil || !e.transcoderStateLogged.CompareAndSwap(false, true) {451 if e == nil || !e.transcoderStateLogged.CompareAndSwap(false, true) {
422 return452 return
M internal/infrastructure/cef/engine_init.go +1 -0
@@ -260,6 +260,7 @@ func wireEngine(
260260 }
261261
262262 schemeHandler.onEditableFocus = eng.handleEditableFocusBridge
263+ schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest
263264
264265 schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)
265266
@@ -260,6 +260,7 @@ func wireEngine(
260 }260 }
261 261
262 schemeHandler.onEditableFocus = eng.handleEditableFocusBridge262 schemeHandler.onEditableFocus = eng.handleEditableFocusBridge
263+ schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest
263 264
264 schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)265 schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)
265 266
M internal/infrastructure/cef/scheme_handler.go +28 -2
@@ -65,6 +65,10 @@ type dumbSchemeHandler struct {
6565 // onEditableFocus is called when JS reports that an editable element gained
6666 // DOM focus. The engine uses this to reassert CEF browser focus in OSR mode.
6767 onEditableFocus func(browser purecef.Browser)
68+
69+ // bridgeNonceValidator checks whether a bridge nonce belongs to the active
70+ // browser/navigation context that issued the request.
71+ bridgeNonceValidator func(browser purecef.Browser, bridgeNonce string) bool
6872 }
6973
7074 // newDumbSchemeHandler creates a handler for internal CEF pages.
@@ -175,7 +179,7 @@ func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path stri
175179 return h.handleTranscodeAPI(request)
176180
177181 case path == "/api/clipboard-set" && strings.EqualFold(method, "POST"):
178- return h.handleClipboardSet(request)
182+ return h.handleClipboardSet(request, browser)
179183
180184 case path == "/api/focus-sync" && strings.EqualFold(method, "POST"):
181185 return h.handleFocusSync(request, browser)
@@ -259,12 +263,18 @@ const (
259263 maxClipboardBytes = 10 << 20 // 10 MB
260264 dumberBodyHeaderName = "X-Dumber-Body"
261265 dumberBridgeActionHeaderName = "X-Dumber-Bridge-Action"
266+ dumberBridgeNonceHeaderName = "X-Dumber-Bridge-Nonce"
262267 dumberBridgeActionFocusSync = "focus-sync"
263268 )
264269
265-func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.ResourceHandler {
270+func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
266271 h.logger.Debug().Msg("cef: /api/clipboard-set request received")
267272
273+ if !h.hasTrustedBridgeNonce(request, browser) {
274+ h.logger.Warn().Msg("cef: clipboard-set — rejected request without valid bridge nonce")
275+ return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
276+ }
277+
268278 body := readBodyFromHeader(request)
269279 if body == nil {
270280 h.logger.Debug().Msg("cef: clipboard-set — empty body (no X-Dumber-Body header)")
@@ -307,6 +317,10 @@ func (h *dumbSchemeHandler) handleFocusSync(request purecef.Request, browser pur
307317 h.logger.Debug().Msg("cef: focus-sync — browser unavailable")
308318 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})
309319 }
320+ if !h.hasTrustedBridgeNonce(request, browser) {
321+ h.logger.Warn().Msg("cef: focus-sync — rejected request without valid bridge nonce")
322+ return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
323+ }
310324
311325 if h.onEditableFocus != nil {
312326 h.onEditableFocus(browser)
@@ -317,6 +331,17 @@ func (h *dumbSchemeHandler) handleFocusSync(request purecef.Request, browser pur
317331 return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
318332 }
319333
334+func (h *dumbSchemeHandler) hasTrustedBridgeNonce(request purecef.Request, browser purecef.Browser) bool {
335+ if browser == nil || h == nil || h.bridgeNonceValidator == nil || request == nil {
336+ return false
337+ }
338+ bridgeNonce := strings.TrimSpace(request.GetHeaderByName(dumberBridgeNonceHeaderName))
339+ if bridgeNonce == "" {
340+ return false
341+ }
342+ return h.bridgeNonceValidator(browser, bridgeNonce)
343+}
344+
320345 func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler {
321346 return newStaticResourceHandler(status, "text/plain; charset=utf-8", nil, map[string]string{
322347 "Location": location,
@@ -486,6 +511,7 @@ func apiResponseHeaders(extra map[string]string) map[string]string {
486511 "Content-Type",
487512 "X-Dumber-Body",
488513 "X-Dumber-Bridge-Action",
514+ "X-Dumber-Bridge-Nonce",
489515 }, ", "),
490516 "Access-Control-Max-Age": "86400",
491517 "Cache-Control": "no-store",
@@ -65,6 +65,10 @@ type dumbSchemeHandler struct {
65 // onEditableFocus is called when JS reports that an editable element gained65 // onEditableFocus is called when JS reports that an editable element gained
66 // DOM focus. The engine uses this to reassert CEF browser focus in OSR mode.66 // DOM focus. The engine uses this to reassert CEF browser focus in OSR mode.
67 onEditableFocus func(browser purecef.Browser)67 onEditableFocus func(browser purecef.Browser)
68+
69+ // bridgeNonceValidator checks whether a bridge nonce belongs to the active
70+ // browser/navigation context that issued the request.
71+ bridgeNonceValidator func(browser purecef.Browser, bridgeNonce string) bool
68 }72 }
69 73
70 // newDumbSchemeHandler creates a handler for internal CEF pages.74 // newDumbSchemeHandler creates a handler for internal CEF pages.
@@ -175,7 +179,7 @@ func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path stri
175 return h.handleTranscodeAPI(request)179 return h.handleTranscodeAPI(request)
176 180
177 case path == "/api/clipboard-set" && strings.EqualFold(method, "POST"):181 case path == "/api/clipboard-set" && strings.EqualFold(method, "POST"):
178- return h.handleClipboardSet(request)182+ return h.handleClipboardSet(request, browser)
179 183
180 case path == "/api/focus-sync" && strings.EqualFold(method, "POST"):184 case path == "/api/focus-sync" && strings.EqualFold(method, "POST"):
181 return h.handleFocusSync(request, browser)185 return h.handleFocusSync(request, browser)
@@ -259,12 +263,18 @@ const (
259 maxClipboardBytes = 10 << 20 // 10 MB263 maxClipboardBytes = 10 << 20 // 10 MB
260 dumberBodyHeaderName = "X-Dumber-Body"264 dumberBodyHeaderName = "X-Dumber-Body"
261 dumberBridgeActionHeaderName = "X-Dumber-Bridge-Action"265 dumberBridgeActionHeaderName = "X-Dumber-Bridge-Action"
266+ dumberBridgeNonceHeaderName = "X-Dumber-Bridge-Nonce"
262 dumberBridgeActionFocusSync = "focus-sync"267 dumberBridgeActionFocusSync = "focus-sync"
263 )268 )
264 269
265-func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.ResourceHandler {270+func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
266 h.logger.Debug().Msg("cef: /api/clipboard-set request received")271 h.logger.Debug().Msg("cef: /api/clipboard-set request received")
267 272
273+ if !h.hasTrustedBridgeNonce(request, browser) {
274+ h.logger.Warn().Msg("cef: clipboard-set — rejected request without valid bridge nonce")
275+ return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
276+ }
277+
268 body := readBodyFromHeader(request)278 body := readBodyFromHeader(request)
269 if body == nil {279 if body == nil {
270 h.logger.Debug().Msg("cef: clipboard-set — empty body (no X-Dumber-Body header)")280 h.logger.Debug().Msg("cef: clipboard-set — empty body (no X-Dumber-Body header)")
@@ -307,6 +317,10 @@ func (h *dumbSchemeHandler) handleFocusSync(request purecef.Request, browser pur
307 h.logger.Debug().Msg("cef: focus-sync — browser unavailable")317 h.logger.Debug().Msg("cef: focus-sync — browser unavailable")
308 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})318 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})
309 }319 }
320+ if !h.hasTrustedBridgeNonce(request, browser) {
321+ h.logger.Warn().Msg("cef: focus-sync — rejected request without valid bridge nonce")
322+ return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
323+ }
310 324
311 if h.onEditableFocus != nil {325 if h.onEditableFocus != nil {
312 h.onEditableFocus(browser)326 h.onEditableFocus(browser)
@@ -317,6 +331,17 @@ func (h *dumbSchemeHandler) handleFocusSync(request purecef.Request, browser pur
317 return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})331 return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
318 }332 }
319 333
334+func (h *dumbSchemeHandler) hasTrustedBridgeNonce(request purecef.Request, browser purecef.Browser) bool {
335+ if browser == nil || h == nil || h.bridgeNonceValidator == nil || request == nil {
336+ return false
337+ }
338+ bridgeNonce := strings.TrimSpace(request.GetHeaderByName(dumberBridgeNonceHeaderName))
339+ if bridgeNonce == "" {
340+ return false
341+ }
342+ return h.bridgeNonceValidator(browser, bridgeNonce)
343+}
344+
320 func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler {345 func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler {
321 return newStaticResourceHandler(status, "text/plain; charset=utf-8", nil, map[string]string{346 return newStaticResourceHandler(status, "text/plain; charset=utf-8", nil, map[string]string{
322 "Location": location,347 "Location": location,
@@ -486,6 +511,7 @@ func apiResponseHeaders(extra map[string]string) map[string]string {
486 "Content-Type",511 "Content-Type",
487 "X-Dumber-Body",512 "X-Dumber-Body",
488 "X-Dumber-Bridge-Action",513 "X-Dumber-Bridge-Action",
514+ "X-Dumber-Bridge-Nonce",
489 }, ", "),515 }, ", "),
490 "Access-Control-Max-Age": "86400",516 "Access-Control-Max-Age": "86400",
491 "Cache-Control": "no-store",517 "Cache-Control": "no-store",
M internal/infrastructure/cef/scheme_handler_test.go +51 -3
@@ -18,7 +18,7 @@ func expectAPIResponseHeaders(response *cefmocks.MockResponse) {
1818 response.EXPECT().SetHeaderByName("Access-Control-Allow-Methods", "GET, POST, OPTIONS", int32(1)).Once()
1919 response.EXPECT().SetHeaderByName(
2020 "Access-Control-Allow-Headers",
21- "Content-Type, X-Dumber-Body, X-Dumber-Bridge-Action",
21+ "Content-Type, X-Dumber-Body, X-Dumber-Bridge-Action, X-Dumber-Bridge-Nonce",
2222 int32(1),
2323 ).Once()
2424 response.EXPECT().SetHeaderByName("Access-Control-Max-Age", "86400", int32(1)).Once()
@@ -79,11 +79,18 @@ func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) {
7979 h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
8080 require.NoError(t, err)
8181
82+ const bridgeNonce = "bridge-nonce"
83+ browser := cefmocks.NewMockBrowser(t)
84+ h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool {
85+ return got == browser && gotNonce == bridgeNonce
86+ }
87+
8288 var copied string
8389 h.onClipboardSet = func(text string) { copied = text }
8490
8591 request := cefmocks.NewMockRequest(t)
8692 encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`))
93+ request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once()
8794 request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()
8895
8996 response := cefmocks.NewMockResponse(t)
@@ -92,7 +99,7 @@ func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) {
9299 response.EXPECT().SetMimeType("application/json").Once()
93100 expectAPIResponseHeaders(response)
94101
95- handler := h.handleAPI(nil, http.MethodPost, "/api/clipboard-set", request)
102+ handler := h.handleAPI(browser, http.MethodPost, "/api/clipboard-set", request)
96103 require.NotNil(t, handler)
97104
98105 var responseLength int64
@@ -134,12 +141,18 @@ func TestSchemeHandler_APIFocusSyncInvokesEditableFocusCallback(t *testing.T) {
134141 h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
135142 require.NoError(t, err)
136143
144+ const bridgeNonce = "bridge-nonce"
137145 browser := cefmocks.NewMockBrowser(t)
146+ h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool {
147+ return got == browser && gotNonce == bridgeNonce
148+ }
149+
138150 var focused purecef.Browser
139151 h.onEditableFocus = func(got purecef.Browser) { focused = got }
140152
141153 request := cefmocks.NewMockRequest(t)
142154 request.EXPECT().GetHeaderByName(dumberBridgeActionHeaderName).Return(dumberBridgeActionFocusSync).Once()
155+ request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once()
143156
144157 response := cefmocks.NewMockResponse(t)
145158 response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
@@ -164,12 +177,19 @@ func TestSchemeHandler_Create_ConceptualAPIRequestBypassesRedirect(t *testing.T)
164177 h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
165178 require.NoError(t, err)
166179
180+ const bridgeNonce = "bridge-nonce"
181+ browser := cefmocks.NewMockBrowser(t)
182+ h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool {
183+ return got == browser && gotNonce == bridgeNonce
184+ }
185+
167186 var copied string
168187 h.onClipboardSet = func(text string) { copied = text }
169188
170189 request := cefmocks.NewMockRequest(t)
171190 request.EXPECT().GetURL().Return("dumb://api/clipboard-set").Once()
172191 request.EXPECT().GetMethod().Return(http.MethodPost).Once()
192+ request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once()
173193 encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from create"}`))
174194 request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()
175195
@@ -179,7 +199,7 @@ func TestSchemeHandler_Create_ConceptualAPIRequestBypassesRedirect(t *testing.T)
179199 response.EXPECT().SetMimeType("application/json").Once()
180200 expectAPIResponseHeaders(response)
181201
182- handler := h.Create(nil, nil, "", request)
202+ handler := h.Create(browser, nil, "", request)
183203 require.NotNil(t, handler)
184204
185205 var responseLength int64
@@ -188,6 +208,34 @@ func TestSchemeHandler_Create_ConceptualAPIRequestBypassesRedirect(t *testing.T)
188208 require.Equal(t, "copied from create", copied)
189209 }
190210
211+func TestSchemeHandler_APIClipboardSetRejectsInvalidBridgeNonce(t *testing.T) {
212+ oldNewResourceHandler := cefNewResourceHandler
213+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
214+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
215+
216+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
217+ require.NoError(t, err)
218+
219+ browser := cefmocks.NewMockBrowser(t)
220+ h.bridgeNonceValidator = func(_ purecef.Browser, _ string) bool { return false }
221+
222+ request := cefmocks.NewMockRequest(t)
223+ request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return("invalid").Once()
224+
225+ response := cefmocks.NewMockResponse(t)
226+ response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once()
227+ response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once()
228+ response.EXPECT().SetMimeType("application/json").Once()
229+ expectAPIResponseHeaders(response)
230+
231+ handler := h.handleAPI(browser, http.MethodPost, "/api/clipboard-set", request)
232+ require.NotNil(t, handler)
233+
234+ var responseLength int64
235+ handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0)
236+ require.Positive(t, responseLength)
237+}
238+
191239 func TestSchemeHandler_APIOptionsReturnsCORSPreflightHeaders(t *testing.T) {
192240 oldNewResourceHandler := cefNewResourceHandler
193241 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
@@ -18,7 +18,7 @@ func expectAPIResponseHeaders(response *cefmocks.MockResponse) {
18 response.EXPECT().SetHeaderByName("Access-Control-Allow-Methods", "GET, POST, OPTIONS", int32(1)).Once()18 response.EXPECT().SetHeaderByName("Access-Control-Allow-Methods", "GET, POST, OPTIONS", int32(1)).Once()
19 response.EXPECT().SetHeaderByName(19 response.EXPECT().SetHeaderByName(
20 "Access-Control-Allow-Headers",20 "Access-Control-Allow-Headers",
21- "Content-Type, X-Dumber-Body, X-Dumber-Bridge-Action",21+ "Content-Type, X-Dumber-Body, X-Dumber-Bridge-Action, X-Dumber-Bridge-Nonce",
22 int32(1),22 int32(1),
23 ).Once()23 ).Once()
24 response.EXPECT().SetHeaderByName("Access-Control-Max-Age", "86400", int32(1)).Once()24 response.EXPECT().SetHeaderByName("Access-Control-Max-Age", "86400", int32(1)).Once()
@@ -79,11 +79,18 @@ func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) {
79 h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })79 h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
80 require.NoError(t, err)80 require.NoError(t, err)
81 81
82+ const bridgeNonce = "bridge-nonce"
83+ browser := cefmocks.NewMockBrowser(t)
84+ h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool {
85+ return got == browser && gotNonce == bridgeNonce
86+ }
87+
82 var copied string88 var copied string
83 h.onClipboardSet = func(text string) { copied = text }89 h.onClipboardSet = func(text string) { copied = text }
84 90
85 request := cefmocks.NewMockRequest(t)91 request := cefmocks.NewMockRequest(t)
86 encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`))92 encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`))
93+ request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once()
87 request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()94 request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()
88 95
89 response := cefmocks.NewMockResponse(t)96 response := cefmocks.NewMockResponse(t)
@@ -92,7 +99,7 @@ func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) {
92 response.EXPECT().SetMimeType("application/json").Once()99 response.EXPECT().SetMimeType("application/json").Once()
93 expectAPIResponseHeaders(response)100 expectAPIResponseHeaders(response)
94 101
95- handler := h.handleAPI(nil, http.MethodPost, "/api/clipboard-set", request)102+ handler := h.handleAPI(browser, http.MethodPost, "/api/clipboard-set", request)
96 require.NotNil(t, handler)103 require.NotNil(t, handler)
97 104
98 var responseLength int64105 var responseLength int64
@@ -134,12 +141,18 @@ func TestSchemeHandler_APIFocusSyncInvokesEditableFocusCallback(t *testing.T) {
134 h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })141 h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
135 require.NoError(t, err)142 require.NoError(t, err)
136 143
144+ const bridgeNonce = "bridge-nonce"
137 browser := cefmocks.NewMockBrowser(t)145 browser := cefmocks.NewMockBrowser(t)
146+ h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool {
147+ return got == browser && gotNonce == bridgeNonce
148+ }
149+
138 var focused purecef.Browser150 var focused purecef.Browser
139 h.onEditableFocus = func(got purecef.Browser) { focused = got }151 h.onEditableFocus = func(got purecef.Browser) { focused = got }
140 152
141 request := cefmocks.NewMockRequest(t)153 request := cefmocks.NewMockRequest(t)
142 request.EXPECT().GetHeaderByName(dumberBridgeActionHeaderName).Return(dumberBridgeActionFocusSync).Once()154 request.EXPECT().GetHeaderByName(dumberBridgeActionHeaderName).Return(dumberBridgeActionFocusSync).Once()
155+ request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once()
143 156
144 response := cefmocks.NewMockResponse(t)157 response := cefmocks.NewMockResponse(t)
145 response.EXPECT().SetStatus(int32(http.StatusOK)).Once()158 response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
@@ -164,12 +177,19 @@ func TestSchemeHandler_Create_ConceptualAPIRequestBypassesRedirect(t *testing.T)
164 h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })177 h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
165 require.NoError(t, err)178 require.NoError(t, err)
166 179
180+ const bridgeNonce = "bridge-nonce"
181+ browser := cefmocks.NewMockBrowser(t)
182+ h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool {
183+ return got == browser && gotNonce == bridgeNonce
184+ }
185+
167 var copied string186 var copied string
168 h.onClipboardSet = func(text string) { copied = text }187 h.onClipboardSet = func(text string) { copied = text }
169 188
170 request := cefmocks.NewMockRequest(t)189 request := cefmocks.NewMockRequest(t)
171 request.EXPECT().GetURL().Return("dumb://api/clipboard-set").Once()190 request.EXPECT().GetURL().Return("dumb://api/clipboard-set").Once()
172 request.EXPECT().GetMethod().Return(http.MethodPost).Once()191 request.EXPECT().GetMethod().Return(http.MethodPost).Once()
192+ request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once()
173 encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from create"}`))193 encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from create"}`))
174 request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()194 request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()
175 195
@@ -179,7 +199,7 @@ func TestSchemeHandler_Create_ConceptualAPIRequestBypassesRedirect(t *testing.T)
179 response.EXPECT().SetMimeType("application/json").Once()199 response.EXPECT().SetMimeType("application/json").Once()
180 expectAPIResponseHeaders(response)200 expectAPIResponseHeaders(response)
181 201
182- handler := h.Create(nil, nil, "", request)202+ handler := h.Create(browser, nil, "", request)
183 require.NotNil(t, handler)203 require.NotNil(t, handler)
184 204
185 var responseLength int64205 var responseLength int64
@@ -188,6 +208,34 @@ func TestSchemeHandler_Create_ConceptualAPIRequestBypassesRedirect(t *testing.T)
188 require.Equal(t, "copied from create", copied)208 require.Equal(t, "copied from create", copied)
189 }209 }
190 210
211+func TestSchemeHandler_APIClipboardSetRejectsInvalidBridgeNonce(t *testing.T) {
212+ oldNewResourceHandler := cefNewResourceHandler
213+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
214+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
215+
216+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
217+ require.NoError(t, err)
218+
219+ browser := cefmocks.NewMockBrowser(t)
220+ h.bridgeNonceValidator = func(_ purecef.Browser, _ string) bool { return false }
221+
222+ request := cefmocks.NewMockRequest(t)
223+ request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return("invalid").Once()
224+
225+ response := cefmocks.NewMockResponse(t)
226+ response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once()
227+ response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once()
228+ response.EXPECT().SetMimeType("application/json").Once()
229+ expectAPIResponseHeaders(response)
230+
231+ handler := h.handleAPI(browser, http.MethodPost, "/api/clipboard-set", request)
232+ require.NotNil(t, handler)
233+
234+ var responseLength int64
235+ handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0)
236+ require.Positive(t, responseLength)
237+}
238+
191 func TestSchemeHandler_APIOptionsReturnsCORSPreflightHeaders(t *testing.T) {239 func TestSchemeHandler_APIOptionsReturnsCORSPreflightHeaders(t *testing.T) {
192 oldNewResourceHandler := cefNewResourceHandler240 oldNewResourceHandler := cefNewResourceHandler
193 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }241 cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
M internal/infrastructure/cef/webview.go +20 -0
@@ -2,10 +2,13 @@ package cef
22
33 import (
44 "context"
5+ "crypto/rand"
56 "encoding/base64"
7+ "encoding/hex"
68 "errors"
79 "fmt"
810 "math"
11+ "strconv"
912 "strings"
1013 "sync"
1114 "sync/atomic"
@@ -101,6 +104,7 @@ type WebView struct {
101104 isLoading bool
102105 selectedText string
103106 focusedEditable bool
107+ bridgeNonce string
104108 selectionDebounceTimer stoppableTimer
105109 selectionDebounceSeq uint64
106110 selectionDebounceDelay *time.Duration
@@ -485,6 +489,22 @@ func (wv *WebView) RunJavaScript(_ context.Context, script string) {
485489 // colorScale converts a 0.0–1.0 color component to an 8-bit integer.
486490 const colorScale = 255
487491
492+func newBridgeNonce() string {
493+ buf := make([]byte, 16)
494+ if _, err := rand.Read(buf); err != nil {
495+ return strconv.FormatInt(time.Now().UnixNano(), 16)
496+ }
497+ return hex.EncodeToString(buf)
498+}
499+
500+func (wv *WebView) rotateBridgeNonce() string {
501+ nonce := newBridgeNonce()
502+ wv.mu.Lock()
503+ wv.bridgeNonce = nonce
504+ wv.mu.Unlock()
505+ return nonce
506+}
507+
488508 // SetBackgroundColor sets the background via JS injection (CEF has no runtime API).
489509 func (wv *WebView) SetBackgroundColor(r, g, b, a float64) {
490510 script := fmt.Sprintf(
@@ -2,10 +2,13 @@ package cef
2 2
3 import (3 import (
4 "context"4 "context"
5+ "crypto/rand"
5 "encoding/base64"6 "encoding/base64"
7+ "encoding/hex"
6 "errors"8 "errors"
7 "fmt"9 "fmt"
8 "math"10 "math"
11+ "strconv"
9 "strings"12 "strings"
10 "sync"13 "sync"
11 "sync/atomic"14 "sync/atomic"
@@ -101,6 +104,7 @@ type WebView struct {
101 isLoading bool104 isLoading bool
102 selectedText string105 selectedText string
103 focusedEditable bool106 focusedEditable bool
107+ bridgeNonce string
104 selectionDebounceTimer stoppableTimer108 selectionDebounceTimer stoppableTimer
105 selectionDebounceSeq uint64109 selectionDebounceSeq uint64
106 selectionDebounceDelay *time.Duration110 selectionDebounceDelay *time.Duration
@@ -485,6 +489,22 @@ func (wv *WebView) RunJavaScript(_ context.Context, script string) {
485 // colorScale converts a 0.0–1.0 color component to an 8-bit integer.489 // colorScale converts a 0.0–1.0 color component to an 8-bit integer.
486 const colorScale = 255490 const colorScale = 255
487 491
492+func newBridgeNonce() string {
493+ buf := make([]byte, 16)
494+ if _, err := rand.Read(buf); err != nil {
495+ return strconv.FormatInt(time.Now().UnixNano(), 16)
496+ }
497+ return hex.EncodeToString(buf)
498+}
499+
500+func (wv *WebView) rotateBridgeNonce() string {
501+ nonce := newBridgeNonce()
502+ wv.mu.Lock()
503+ wv.bridgeNonce = nonce
504+ wv.mu.Unlock()
505+ return nonce
506+}
507+
488 // SetBackgroundColor sets the background via JS injection (CEF has no runtime API).508 // SetBackgroundColor sets the background via JS injection (CEF has no runtime API).
489 func (wv *WebView) SetBackgroundColor(r, g, b, a float64) {509 func (wv *WebView) SetBackgroundColor(r, g, b, a float64) {
490 script := fmt.Sprintf(510 script := fmt.Sprintf(
M internal/infrastructure/webkit/injector.go +32 -3
@@ -63,9 +63,31 @@ const autoCopySelectionScript = `(function() {
6363 const explicitCopyScript = `(function() {
6464 var pendingCommand = '';
6565
66+ function isSafeSelectableInput(activeEl) {
67+ if (!activeEl || activeEl.tagName !== 'INPUT') {
68+ return false;
69+ }
70+ switch (String(activeEl.type || '').toLowerCase()) {
71+ case 'password':
72+ case 'button':
73+ case 'checkbox':
74+ case 'color':
75+ case 'file':
76+ case 'hidden':
77+ case 'image':
78+ case 'radio':
79+ case 'range':
80+ case 'reset':
81+ case 'submit':
82+ return false;
83+ default:
84+ return true;
85+ }
86+ }
87+
6688 function selectedText() {
6789 var activeEl = document.activeElement;
68- if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA')) {
90+ if (activeEl && (isSafeSelectableInput(activeEl) || activeEl.tagName === 'TEXTAREA')) {
6991 if (typeof activeEl.selectionStart === 'number' && typeof activeEl.selectionEnd === 'number') {
7092 var start = activeEl.selectionStart;
7193 var end = activeEl.selectionEnd;
@@ -114,10 +136,17 @@ const explicitCopyScript = `(function() {
114136 var originalExecCommand = document.execCommand.bind(document);
115137 document.execCommand = function(commandId, showUI, value) {
116138 var command = String(commandId || '').toLowerCase();
117- if (command === 'copy' || command === 'cut') {
139+ var tracked = command === 'copy' || command === 'cut';
140+ if (tracked) {
118141 pendingCommand = command;
119142 }
120- return originalExecCommand(commandId, showUI, value);
143+ try {
144+ return originalExecCommand(commandId, showUI, value);
145+ } finally {
146+ if (tracked && pendingCommand === command) {
147+ pendingCommand = '';
148+ }
149+ }
121150 };
122151 }
123152
@@ -63,9 +63,31 @@ const autoCopySelectionScript = `(function() {
63 const explicitCopyScript = `(function() {63 const explicitCopyScript = `(function() {
64 var pendingCommand = '';64 var pendingCommand = '';
65 65
66+ function isSafeSelectableInput(activeEl) {
67+ if (!activeEl || activeEl.tagName !== 'INPUT') {
68+ return false;
69+ }
70+ switch (String(activeEl.type || '').toLowerCase()) {
71+ case 'password':
72+ case 'button':
73+ case 'checkbox':
74+ case 'color':
75+ case 'file':
76+ case 'hidden':
77+ case 'image':
78+ case 'radio':
79+ case 'range':
80+ case 'reset':
81+ case 'submit':
82+ return false;
83+ default:
84+ return true;
85+ }
86+ }
87+
66 function selectedText() {88 function selectedText() {
67 var activeEl = document.activeElement;89 var activeEl = document.activeElement;
68- if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA')) {90+ if (activeEl && (isSafeSelectableInput(activeEl) || activeEl.tagName === 'TEXTAREA')) {
69 if (typeof activeEl.selectionStart === 'number' && typeof activeEl.selectionEnd === 'number') {91 if (typeof activeEl.selectionStart === 'number' && typeof activeEl.selectionEnd === 'number') {
70 var start = activeEl.selectionStart;92 var start = activeEl.selectionStart;
71 var end = activeEl.selectionEnd;93 var end = activeEl.selectionEnd;
@@ -114,10 +136,17 @@ const explicitCopyScript = `(function() {
114 var originalExecCommand = document.execCommand.bind(document);136 var originalExecCommand = document.execCommand.bind(document);
115 document.execCommand = function(commandId, showUI, value) {137 document.execCommand = function(commandId, showUI, value) {
116 var command = String(commandId || '').toLowerCase();138 var command = String(commandId || '').toLowerCase();
117- if (command === 'copy' || command === 'cut') {139+ var tracked = command === 'copy' || command === 'cut';
140+ if (tracked) {
118 pendingCommand = command;141 pendingCommand = command;
119 }142 }
120- return originalExecCommand(commandId, showUI, value);143+ try {
144+ return originalExecCommand(commandId, showUI, value);
145+ } finally {
146+ if (tracked && pendingCommand === command) {
147+ pendingCommand = '';
148+ }
149+ }
121 };150 };
122 }151 }
123 152
M internal/infrastructure/webkit/injector_accent_test.go +5 -0
@@ -39,6 +39,9 @@ func TestExplicitCopyScriptCapturesClipboardOperations(t *testing.T) {
3939 assert.Contains(t, script, "document.addEventListener('cut'")
4040 assert.Contains(t, script, "document.execCommand")
4141 assert.Contains(t, script, "navigator.clipboard.writeText")
42+ assert.Contains(t, script, "try {")
43+ assert.Contains(t, script, "finally {")
44+ assert.Contains(t, script, "pendingCommand === command")
4245 }
4346
4447 func TestExplicitCopyScriptReadsInputAndTextareaSelectionFirst(t *testing.T) {
@@ -49,4 +52,6 @@ func TestExplicitCopyScriptReadsInputAndTextareaSelectionFirst(t *testing.T) {
4952 assert.Contains(t, script, "selectionEnd")
5053 assert.Contains(t, script, "INPUT")
5154 assert.Contains(t, script, "TEXTAREA")
55+ assert.Contains(t, script, "type || '').toLowerCase()")
56+ assert.Contains(t, script, "case 'password':")
5257 }
@@ -39,6 +39,9 @@ func TestExplicitCopyScriptCapturesClipboardOperations(t *testing.T) {
39 assert.Contains(t, script, "document.addEventListener('cut'")39 assert.Contains(t, script, "document.addEventListener('cut'")
40 assert.Contains(t, script, "document.execCommand")40 assert.Contains(t, script, "document.execCommand")
41 assert.Contains(t, script, "navigator.clipboard.writeText")41 assert.Contains(t, script, "navigator.clipboard.writeText")
42+ assert.Contains(t, script, "try {")
43+ assert.Contains(t, script, "finally {")
44+ assert.Contains(t, script, "pendingCommand === command")
42 }45 }
43 46
44 func TestExplicitCopyScriptReadsInputAndTextareaSelectionFirst(t *testing.T) {47 func TestExplicitCopyScriptReadsInputAndTextareaSelectionFirst(t *testing.T) {
@@ -49,4 +52,6 @@ func TestExplicitCopyScriptReadsInputAndTextareaSelectionFirst(t *testing.T) {
49 assert.Contains(t, script, "selectionEnd")52 assert.Contains(t, script, "selectionEnd")
50 assert.Contains(t, script, "INPUT")53 assert.Contains(t, script, "INPUT")
51 assert.Contains(t, script, "TEXTAREA")54 assert.Contains(t, script, "TEXTAREA")
55+ assert.Contains(t, script, "type || '').toLowerCase()")
56+ assert.Contains(t, script, "case 'password':")
52 }57 }

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