From 8c3f8164070fd0972878482b056b41cb8cecdccd Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Wed, 14 Oct 2026 08:12:53 +0800 Subject: [PATCH] fix: only trust the server's origin in the desktop login frame (#1624) The desktop sign-in iframe accepted AUTH_SUCCESS and system browser requests from whatever page it had navigated to, and posted replies (including 2FA temp tokens) to any origin. Messages must now come from the server's origin and replies are posted to it. The web UI also treated any framing page as the desktop app and posted the JWT to it. It now also requires the Termix-Desktop user agent. --- src/ui/auth/ElectronLoginForm.tsx | 18 ++++- src/ui/lib/embedded-frame.ts | 5 ++ src/ui/tests/auth/ElectronLoginForm.test.tsx | 84 ++++++++++++++++++++ src/ui/tests/lib/embedded-frame.test.ts | 9 +++ 4 files changed, 112 insertions(+), 4 deletions(-) diff --git a/src/ui/auth/ElectronLoginForm.tsx b/src/ui/auth/ElectronLoginForm.tsx index dee0fa511..e53be08a0 100644 --- a/src/ui/auth/ElectronLoginForm.tsx +++ b/src/ui/auth/ElectronLoginForm.tsx @@ -73,11 +73,21 @@ export function ElectronLoginForm({ ); // postMessage from server Auth.tsx after the backend has set the HttpOnly cookie. - // Uses '*' as target origin because the iframe may be cross-origin (e.g. remote Docker server). + // Only the server's own origin is trusted: the iframe can navigate away + // (identity provider, redirects), and those pages must not hand us a token, + // start a system browser login, or receive the replies. useEffect(() => { + let serverOrigin: string; + try { + serverOrigin = new URL(serverUrl).origin; + } catch { + return; + } + const handleMessage = async (event: MessageEvent) => { try { if (event.source !== iframeRef.current?.contentWindow) return; + if (event.origin !== serverOrigin) return; if (!event.data || typeof event.data !== "object") return; const { type, @@ -115,7 +125,7 @@ export function ElectronLoginForm({ providerId, ...result, }, - "*", + serverOrigin, ); }; const electronAPI = ( @@ -181,14 +191,14 @@ export function ElectronLoginForm({ success: false, error, }, - "*", + serverOrigin, ); } }; window.addEventListener("message", handleMessage); return () => window.removeEventListener("message", handleMessage); - }, [handleAuthSuccess, t]); + }, [handleAuthSuccess, serverUrl, t]); useEffect(() => { const iframe = iframeRef.current; diff --git a/src/ui/lib/embedded-frame.ts b/src/ui/lib/embedded-frame.ts index b5aab312a..af6879fbe 100644 --- a/src/ui/lib/embedded-frame.ts +++ b/src/ui/lib/embedded-frame.ts @@ -16,9 +16,14 @@ export function isInMobileWebView(): boolean { ); } +// Being framed alone is not enough: any site could frame this page and +// receive the token posted to window.parent. The desktop app tags every +// request and frame with its own user agent, which a web page cannot fake +// in someone else's browser. export function isInElectronWebView(): boolean { if (isInMobileWebView()) return false; if ((window as EmbeddedFrameWindow).IS_ELECTRON_WEBVIEW) return true; + if (!/Termix-Desktop\//.test(navigator.userAgent)) return false; try { if (window.self !== window.top) return true; } catch { diff --git a/src/ui/tests/auth/ElectronLoginForm.test.tsx b/src/ui/tests/auth/ElectronLoginForm.test.tsx index 6de5c0a1a..5b78f3b00 100644 --- a/src/ui/tests/auth/ElectronLoginForm.test.tsx +++ b/src/ui/tests/auth/ElectronLoginForm.test.tsx @@ -43,6 +43,7 @@ describe("ElectronLoginForm", () => { window.dispatchEvent( new MessageEvent("message", { source: frame.contentWindow, + origin: "https://termix.example.com", data: { type: "AUTH_SUCCESS", platform: "desktop", @@ -53,4 +54,87 @@ describe("ElectronLoginForm", () => { ); await waitFor(() => expect(onAuthSuccess).toHaveBeenCalledWith("jwt-1")); }); + + it("ignores messages once the frame has left the server's origin", async () => { + const onAuthSuccess = vi.fn(); + const externalBrowserLogin = vi.fn(); + (window as unknown as { electronAPI?: unknown }).electronAPI = { + externalBrowserLogin, + }; + render( + , + ); + const frame = screen.getByTitle( + "auth.serverAuthentication", + ) as HTMLIFrameElement; + for (const data of [ + { + type: "AUTH_SUCCESS", + platform: "desktop", + source: "auth_component", + token: "attacker-jwt", + }, + { + type: "OIDC_SYSTEM_BROWSER_AUTH", + authUrl: "https://evil.example/", + callbackPort: 17832, + }, + ]) { + window.dispatchEvent( + new MessageEvent("message", { + source: frame.contentWindow, + origin: "https://evil.example", + data, + }), + ); + } + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(onAuthSuccess).not.toHaveBeenCalled(); + expect(externalBrowserLogin).not.toHaveBeenCalled(); + delete (window as unknown as { electronAPI?: unknown }).electronAPI; + }); + + it("replies to the server's origin only", async () => { + (window as unknown as { electronAPI?: unknown }).electronAPI = { + externalBrowserLogin: vi.fn().mockResolvedValue({ + success: false, + secondFactor: true, + tempToken: "temp-1", + }), + }; + render( + , + ); + const frame = screen.getByTitle( + "auth.serverAuthentication", + ) as HTMLIFrameElement; + const postMessage = vi.spyOn(frame.contentWindow!, "postMessage"); + window.dispatchEvent( + new MessageEvent("message", { + source: frame.contentWindow, + origin: "https://termix.example.com", + data: { + type: "OIDC_SYSTEM_BROWSER_AUTH", + authUrl: "https://idp.example.com/authorize", + callbackPort: 17832, + providerId: -1, + }, + }), + ); + await waitFor(() => + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ tempToken: "temp-1" }), + "https://termix.example.com", + ), + ); + delete (window as unknown as { electronAPI?: unknown }).electronAPI; + }); }); diff --git a/src/ui/tests/lib/embedded-frame.test.ts b/src/ui/tests/lib/embedded-frame.test.ts index 0ed596678..fb696af67 100644 --- a/src/ui/tests/lib/embedded-frame.test.ts +++ b/src/ui/tests/lib/embedded-frame.test.ts @@ -40,4 +40,13 @@ describe("embedded frame detection", () => { expect(isInElectronWebView()).toBe(true); expect(isEmbeddedAuthFrame()).toBe(true); }); + + it("only treats a frame as the desktop link window inside the app", () => { + vi.spyOn(window, "top", "get").mockReturnValue({} as Window); + expect(isInElectronWebView()).toBe(false); + vi.spyOn(navigator, "userAgent", "get").mockReturnValue( + "Termix-Desktop/26.10.2 (win32; Electron/38.0.0)", + ); + expect(isInElectronWebView()).toBe(true); + }); });