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