fix(security): harden clipboard bridge trust
- commit
- 5a0654206e296727eae734288be58697a5739343
- author
- brice · · 2026-04-18 14:33 UTC
- parent
- b4e5347
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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(){ | |||
| 388 | 388 | // clipboardSelectionFetchBridgeJS keeps the lightweight fetch bridge enabled | |
| 389 | 389 | // while the native renderer bridge remains disabled due to an OSR startup | |
| 390 | 390 | // 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 | |
| 392 | 392 | // reasserts browser focus when an editable element gains DOM focus. | |
| 393 | 393 | 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 | + | }; | |
| 396 | 404 | ||
| 397 | 405 | function encodeBody(body) { | |
| 398 | 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 | 435 | function getActiveElementSelection() { | |
| 402 | 436 | var el = document.activeElement; | |
| 403 | - | if (!el) return ''; | |
| 437 | + | if (!el || el.nodeType !== 1 || typeof el.value !== 'string') return ''; | |
| 404 | 438 | 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); | |
| 410 | 445 | return ''; | |
| 411 | 446 | } | |
| 412 | 447 | ||
| @@ -421,8 +456,10 @@ const clipboardSelectionFetchBridgeJS = `(function(){ | |||
| 421 | 456 | if (!node || node.nodeType !== 1) return false; | |
| 422 | 457 | if (node.isContentEditable) return true; | |
| 423 | 458 | 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)); | |
| 426 | 463 | } | |
| 427 | 464 | ||
| 428 | 465 | function sendToClipboard(text) { | |
| @@ -432,14 +469,20 @@ const clipboardSelectionFetchBridgeJS = `(function(){ | |||
| 432 | 469 | if (!encoded) return; | |
| 433 | 470 | fetch('dumb:///api/clipboard-set', { | |
| 434 | 471 | method: 'POST', | |
| 435 | - | headers: {'X-Dumber-Body': encoded} | |
| 472 | + | headers: { | |
| 473 | + | 'X-Dumber-Body': encoded, | |
| 474 | + | 'X-Dumber-Bridge-Nonce': bridgeNonce | |
| 475 | + | } | |
| 436 | 476 | }).catch(function(){}); | |
| 437 | 477 | } | |
| 438 | 478 | ||
| 439 | 479 | function sendFocusSync() { | |
| 440 | 480 | fetch('dumb:///api/focus-sync', { | |
| 441 | 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 | 486 | }).catch(function(){}); | |
| 444 | 487 | } | |
| 445 | 488 | ||
| @@ -461,11 +504,13 @@ const clipboardSelectionFetchBridgeJS = `(function(){ | |||
| 461 | 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 | 509 | var text = getSelectedText(); | |
| 466 | 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 | 514 | var text = getSelectedText(); | |
| 470 | 515 | if (text) sendToClipboard(text); | |
| 471 | 516 | }); | |
| @@ -581,6 +626,10 @@ const clipboardSelectionFetchBridgeJS = `(function(){ | |||
| 581 | 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 | 633 | // contentInjector implements port.ContentInjector for the CEF engine. | |
| 585 | 634 | // It stores CSS strings and injects them into webviews via ExecuteJavaScript. | |
| 586 | 635 | // 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 | 753 | // Clipboard copy/cut and editable focus sync still need a JS bridge in OSR | |
| 705 | 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 | 757 | // Video playback diagnostic — logs video element state changes. | |
| 709 | 758 | // Gated behind DUMBER_VIDEO_DIAG=1 environment variable. | |
| @@ -388,25 +388,60 @@ const redditDirectVideoJS = `(function(){ | |||||
| 388 | // clipboardSelectionFetchBridgeJS keeps the lightweight fetch bridge enabled | 388 | // clipboardSelectionFetchBridgeJS keeps the lightweight fetch bridge enabled | ||
| 389 | // while the native renderer bridge remains disabled due to an OSR startup | 389 | // 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 also | 391 | + | // 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 OnLoadEnd | 635 | // 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 OSR | 753 | // 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 ( | |||
| 6 | 6 | "github.com/stretchr/testify/require" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | + | func testClipboardSelectionFetchBridgeJS() string { | |
| 10 | + | return buildClipboardSelectionFetchBridgeJS("test-bridge-nonce") | |
| 11 | + | } | |
| 12 | + | ||
| 9 | 13 | 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'") | |
| 14 | 21 | } | |
| 15 | 22 | ||
| 16 | 23 | 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") | |
| 26 | 47 | } | |
| 27 | 48 | ||
| 28 | 49 | 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") | |
| 36 | 61 | } | |
| @@ -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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "crypto/subtle" | |
| 5 | 6 | "sync" | |
| 6 | 7 | "sync/atomic" | |
| 7 | 8 | "time" | |
| @@ -417,6 +418,35 @@ func (e *Engine) currentContext() context.Context { | |||
| 417 | 418 | 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 | 450 | func (e *Engine) logTranscoderStartupState() { | |
| 421 | 451 | if e == nil || !e.transcoderStateLogged.CompareAndSwap(false, true) { | |
| 422 | 452 | 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.ctx | 418 | 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 | return | 452 | return | ||
M internal/infrastructure/cef/engine_init.go +1 -0
| @@ -260,6 +260,7 @@ func wireEngine( | |||
| 260 | 260 | } | |
| 261 | 261 | ||
| 262 | 262 | schemeHandler.onEditableFocus = eng.handleEditableFocusBridge | |
| 263 | + | schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest | |
| 263 | 264 | ||
| 264 | 265 | schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler) | |
| 265 | 266 | ||
| @@ -260,6 +260,7 @@ func wireEngine( | |||||
| 260 | } | 260 | } | ||
| 261 | 261 | ||||
| 262 | schemeHandler.onEditableFocus = eng.handleEditableFocusBridge | 262 | 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 { | |||
| 65 | 65 | // onEditableFocus is called when JS reports that an editable element gained | |
| 66 | 66 | // DOM focus. The engine uses this to reassert CEF browser focus in OSR mode. | |
| 67 | 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 | 74 | // newDumbSchemeHandler creates a handler for internal CEF pages. | |
| @@ -175,7 +179,7 @@ func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path stri | |||
| 175 | 179 | return h.handleTranscodeAPI(request) | |
| 176 | 180 | ||
| 177 | 181 | case path == "/api/clipboard-set" && strings.EqualFold(method, "POST"): | |
| 178 | - | return h.handleClipboardSet(request) | |
| 182 | + | return h.handleClipboardSet(request, browser) | |
| 179 | 183 | ||
| 180 | 184 | case path == "/api/focus-sync" && strings.EqualFold(method, "POST"): | |
| 181 | 185 | return h.handleFocusSync(request, browser) | |
| @@ -259,12 +263,18 @@ const ( | |||
| 259 | 263 | maxClipboardBytes = 10 << 20 // 10 MB | |
| 260 | 264 | dumberBodyHeaderName = "X-Dumber-Body" | |
| 261 | 265 | dumberBridgeActionHeaderName = "X-Dumber-Bridge-Action" | |
| 266 | + | dumberBridgeNonceHeaderName = "X-Dumber-Bridge-Nonce" | |
| 262 | 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 | 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 | 278 | body := readBodyFromHeader(request) | |
| 269 | 279 | if body == nil { | |
| 270 | 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 | 317 | h.logger.Debug().Msg("cef: focus-sync — browser unavailable") | |
| 308 | 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 | 325 | if h.onEditableFocus != nil { | |
| 312 | 326 | h.onEditableFocus(browser) | |
| @@ -317,6 +331,17 @@ func (h *dumbSchemeHandler) handleFocusSync(request purecef.Request, browser pur | |||
| 317 | 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 | 345 | func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler { | |
| 321 | 346 | return newStaticResourceHandler(status, "text/plain; charset=utf-8", nil, map[string]string{ | |
| 322 | 347 | "Location": location, | |
| @@ -486,6 +511,7 @@ func apiResponseHeaders(extra map[string]string) map[string]string { | |||
| 486 | 511 | "Content-Type", | |
| 487 | 512 | "X-Dumber-Body", | |
| 488 | 513 | "X-Dumber-Bridge-Action", | |
| 514 | + | "X-Dumber-Bridge-Nonce", | |
| 489 | 515 | }, ", "), | |
| 490 | 516 | "Access-Control-Max-Age": "86400", | |
| 491 | 517 | "Cache-Control": "no-store", | |
| @@ -65,6 +65,10 @@ type dumbSchemeHandler struct { | |||||
| 65 | // onEditableFocus is called when JS reports that an editable element gained | 65 | // 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 MB | 263 | 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) { | |||
| 18 | 18 | response.EXPECT().SetHeaderByName("Access-Control-Allow-Methods", "GET, POST, OPTIONS", int32(1)).Once() | |
| 19 | 19 | response.EXPECT().SetHeaderByName( | |
| 20 | 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 | 22 | int32(1), | |
| 23 | 23 | ).Once() | |
| 24 | 24 | response.EXPECT().SetHeaderByName("Access-Control-Max-Age", "86400", int32(1)).Once() | |
| @@ -79,11 +79,18 @@ func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) { | |||
| 79 | 79 | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 80 | 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 | 88 | var copied string | |
| 83 | 89 | h.onClipboardSet = func(text string) { copied = text } | |
| 84 | 90 | ||
| 85 | 91 | request := cefmocks.NewMockRequest(t) | |
| 86 | 92 | encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`)) | |
| 93 | + | request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once() | |
| 87 | 94 | request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once() | |
| 88 | 95 | ||
| 89 | 96 | response := cefmocks.NewMockResponse(t) | |
| @@ -92,7 +99,7 @@ func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) { | |||
| 92 | 99 | response.EXPECT().SetMimeType("application/json").Once() | |
| 93 | 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 | 103 | require.NotNil(t, handler) | |
| 97 | 104 | ||
| 98 | 105 | var responseLength int64 | |
| @@ -134,12 +141,18 @@ func TestSchemeHandler_APIFocusSyncInvokesEditableFocusCallback(t *testing.T) { | |||
| 134 | 141 | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 135 | 142 | require.NoError(t, err) | |
| 136 | 143 | ||
| 144 | + | const bridgeNonce = "bridge-nonce" | |
| 137 | 145 | browser := cefmocks.NewMockBrowser(t) | |
| 146 | + | h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool { | |
| 147 | + | return got == browser && gotNonce == bridgeNonce | |
| 148 | + | } | |
| 149 | + | ||
| 138 | 150 | var focused purecef.Browser | |
| 139 | 151 | h.onEditableFocus = func(got purecef.Browser) { focused = got } | |
| 140 | 152 | ||
| 141 | 153 | request := cefmocks.NewMockRequest(t) | |
| 142 | 154 | request.EXPECT().GetHeaderByName(dumberBridgeActionHeaderName).Return(dumberBridgeActionFocusSync).Once() | |
| 155 | + | request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once() | |
| 143 | 156 | ||
| 144 | 157 | response := cefmocks.NewMockResponse(t) | |
| 145 | 158 | response.EXPECT().SetStatus(int32(http.StatusOK)).Once() | |
| @@ -164,12 +177,19 @@ func TestSchemeHandler_Create_ConceptualAPIRequestBypassesRedirect(t *testing.T) | |||
| 164 | 177 | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |
| 165 | 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 | 186 | var copied string | |
| 168 | 187 | h.onClipboardSet = func(text string) { copied = text } | |
| 169 | 188 | ||
| 170 | 189 | request := cefmocks.NewMockRequest(t) | |
| 171 | 190 | request.EXPECT().GetURL().Return("dumb://api/clipboard-set").Once() | |
| 172 | 191 | request.EXPECT().GetMethod().Return(http.MethodPost).Once() | |
| 192 | + | request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once() | |
| 173 | 193 | encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from create"}`)) | |
| 174 | 194 | request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once() | |
| 175 | 195 | ||
| @@ -179,7 +199,7 @@ func TestSchemeHandler_Create_ConceptualAPIRequestBypassesRedirect(t *testing.T) | |||
| 179 | 199 | response.EXPECT().SetMimeType("application/json").Once() | |
| 180 | 200 | expectAPIResponseHeaders(response) | |
| 181 | 201 | ||
| 182 | - | handler := h.Create(nil, nil, "", request) | |
| 202 | + | handler := h.Create(browser, nil, "", request) | |
| 183 | 203 | require.NotNil(t, handler) | |
| 184 | 204 | ||
| 185 | 205 | var responseLength int64 | |
| @@ -188,6 +208,34 @@ func TestSchemeHandler_Create_ConceptualAPIRequestBypassesRedirect(t *testing.T) | |||
| 188 | 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 | 239 | func TestSchemeHandler_APIOptionsReturnsCORSPreflightHeaders(t *testing.T) { | |
| 192 | 240 | oldNewResourceHandler := cefNewResourceHandler | |
| 193 | 241 | 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 string | 88 | 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 int64 | 105 | 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.Browser | 150 | 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 string | 186 | 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 int64 | 205 | 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 := cefNewResourceHandler | 240 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "crypto/rand" | |
| 5 | 6 | "encoding/base64" | |
| 7 | + | "encoding/hex" | |
| 6 | 8 | "errors" | |
| 7 | 9 | "fmt" | |
| 8 | 10 | "math" | |
| 11 | + | "strconv" | |
| 9 | 12 | "strings" | |
| 10 | 13 | "sync" | |
| 11 | 14 | "sync/atomic" | |
| @@ -101,6 +104,7 @@ type WebView struct { | |||
| 101 | 104 | isLoading bool | |
| 102 | 105 | selectedText string | |
| 103 | 106 | focusedEditable bool | |
| 107 | + | bridgeNonce string | |
| 104 | 108 | selectionDebounceTimer stoppableTimer | |
| 105 | 109 | selectionDebounceSeq uint64 | |
| 106 | 110 | selectionDebounceDelay *time.Duration | |
| @@ -485,6 +489,22 @@ func (wv *WebView) RunJavaScript(_ context.Context, script string) { | |||
| 485 | 489 | // colorScale converts a 0.0–1.0 color component to an 8-bit integer. | |
| 486 | 490 | 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 | 508 | // SetBackgroundColor sets the background via JS injection (CEF has no runtime API). | |
| 489 | 509 | func (wv *WebView) SetBackgroundColor(r, g, b, a float64) { | |
| 490 | 510 | 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 bool | 104 | isLoading bool | ||
| 102 | selectedText string | 105 | selectedText string | ||
| 103 | focusedEditable bool | 106 | focusedEditable bool | ||
| 107 | + | bridgeNonce string | |||
| 104 | selectionDebounceTimer stoppableTimer | 108 | selectionDebounceTimer stoppableTimer | ||
| 105 | selectionDebounceSeq uint64 | 109 | selectionDebounceSeq uint64 | ||
| 106 | selectionDebounceDelay *time.Duration | 110 | 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 = 255 | 490 | 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() { | |||
| 63 | 63 | const explicitCopyScript = `(function() { | |
| 64 | 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 | 88 | function selectedText() { | |
| 67 | 89 | var activeEl = document.activeElement; | |
| 68 | - | if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA')) { | |
| 90 | + | if (activeEl && (isSafeSelectableInput(activeEl) || activeEl.tagName === 'TEXTAREA')) { | |
| 69 | 91 | if (typeof activeEl.selectionStart === 'number' && typeof activeEl.selectionEnd === 'number') { | |
| 70 | 92 | var start = activeEl.selectionStart; | |
| 71 | 93 | var end = activeEl.selectionEnd; | |
| @@ -114,10 +136,17 @@ const explicitCopyScript = `(function() { | |||
| 114 | 136 | var originalExecCommand = document.execCommand.bind(document); | |
| 115 | 137 | document.execCommand = function(commandId, showUI, value) { | |
| 116 | 138 | var command = String(commandId || '').toLowerCase(); | |
| 117 | - | if (command === 'copy' || command === 'cut') { | |
| 139 | + | var tracked = command === 'copy' || command === 'cut'; | |
| 140 | + | if (tracked) { | |
| 118 | 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 | ||
| @@ -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) { | |||
| 39 | 39 | assert.Contains(t, script, "document.addEventListener('cut'") | |
| 40 | 40 | assert.Contains(t, script, "document.execCommand") | |
| 41 | 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 | 47 | func TestExplicitCopyScriptReadsInputAndTextareaSelectionFirst(t *testing.T) { | |
| @@ -49,4 +52,6 @@ func TestExplicitCopyScriptReadsInputAndTextareaSelectionFirst(t *testing.T) { | |||
| 49 | 52 | assert.Contains(t, script, "selectionEnd") | |
| 50 | 53 | assert.Contains(t, script, "INPUT") | |
| 51 | 54 | assert.Contains(t, script, "TEXTAREA") | |
| 55 | + | assert.Contains(t, script, "type || '').toLowerCase()") | |
| 56 | + | assert.Contains(t, script, "case 'password':") | |
| 52 | 57 | } | |
| @@ -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 | } | ||