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); + }); });