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