From e38bbba353c0f2f9bc96f720e7d781acffc3c506 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 18 Oct 2026 20:36:16 -0500 Subject: [PATCH 1/2] fix(desktop): compare custom renderer scheme and authority explicitly The main-window will-navigate guard compared URL.origin. Node's URL parser does not know t3code:// and t3code-dev:// are registered as standard schemes, so both origins serialize to "null", the same as file:, data: and any other custom-scheme URL. The guard treated all of those as same-origin and let them through. Compare scheme and authority instead, and require the application URL to have an authority. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/window/DesktopWindow.test.ts | 39 +++++++++++++++++++ apps/desktop/src/window/DesktopWindow.ts | 11 +++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index cebdcbb779728..43e01cbfe1f9d 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -631,6 +631,45 @@ describe("DesktopWindow", () => { ); }); + it("rejects renderer navigations whose origin only serializes as null", () => { + for (const applicationUrl of ["t3code://app/", "t3code-dev://app/"]) { + assert.isTrue( + DesktopWindow.isSameOriginRendererNavigation({ + applicationUrl, + navigationUrl: `${applicationUrl}threads?id=1#latest`, + }), + applicationUrl, + ); + } + for (const navigationUrl of [ + "t3code://other/", + "t3code://app.example/", + "t3code://app:8080/", + "t3code://user@other/", + "t3code:app", + "t3code-dev://app/", + "file:///etc/passwd", + "data:text/html,

hi

", + "javascript:alert(1)", + "about:blank", + "blob:t3code://app/0b5e2c4a", + ]) { + assert.isFalse( + DesktopWindow.isSameOriginRendererNavigation({ + applicationUrl: "t3code://app/", + navigationUrl, + }), + navigationUrl, + ); + } + assert.isFalse( + DesktopWindow.isSameOriginRendererNavigation({ + applicationUrl: "data:text/html,app", + navigationUrl: "data:text/html,other", + }), + ); + }); + it.effect("does not open a development window until the backend is ready", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 1d76107425ed5..6236c9fbb2e02 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -188,7 +188,16 @@ export function isSameOriginRendererNavigation(input: { readonly navigationUrl: string; }): boolean { try { - return new URL(input.applicationUrl).origin === new URL(input.navigationUrl).origin; + const applicationUrl = new URL(input.applicationUrl); + const navigationUrl = new URL(input.navigationUrl); + // Node does not know the app's custom schemes are standard, so their + // `origin` is "null", the same as data:, file: and other custom URLs. + // Compare scheme and authority instead. + return ( + applicationUrl.host !== "" && + navigationUrl.protocol === applicationUrl.protocol && + navigationUrl.host === applicationUrl.host + ); } catch { return false; } From 89b04d7e94b050824692e60934b4b7022713a46e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 04:42:31 +0000 Subject: [PATCH 2/2] test(desktop): cover custom-scheme navigation handling --- apps/desktop/src/window/DesktopWindow.test.ts | 11 +++++++++++ apps/desktop/src/window/DesktopWindow.ts | 4 +--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index ce3e5f81a6c3d..16d345f97e4b6 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -1012,6 +1012,17 @@ describe("DesktopWindow", () => { if (!willNavigate) { return yield* Effect.die("will-navigate listener was not registered"); } + for (const [url, expectedPrevented] of [ + ["t3code-dev://app/threads?id=1#latest", false], + ["t3code-dev://other/", true], + ["t3code://app/", true], + ] as const) { + const preventDefault = vi.fn(); + willNavigate({ preventDefault }, url); + assert.equal(preventDefault.mock.calls.length, expectedPrevented ? 1 : 0, url); + } + assert.deepEqual(openedExternalUrls, []); + let prevented = false; willNavigate( { diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 58eccb4ec5547..2a6353cfa9479 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -189,9 +189,7 @@ export function isSameOriginRendererNavigation(input: { try { const applicationUrl = new URL(input.applicationUrl); const navigationUrl = new URL(input.navigationUrl); - // Node does not know the app's custom schemes are standard, so their - // `origin` is "null", the same as data:, file: and other custom URLs. - // Compare scheme and authority instead. + // Node serializes custom-scheme origins as "null", even for different hosts and schemes. return ( applicationUrl.host !== "" && navigationUrl.protocol === applicationUrl.protocol &&