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.
This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-10-14 08:12:53 +08:00
1 parent 13f7aa95a3
commit 8c3f816407
4 files changed
+112 -4

No files matched your search

+14 -4
View File
@@ -73,11 +73,21 @@ export function ElectronLoginForm({
); );
// postMessage from server Auth.tsx after the backend has set the HttpOnly cookie. // 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(() => { useEffect(() => {
let serverOrigin: string;
try {
serverOrigin = new URL(serverUrl).origin;
} catch {
return;
}
const handleMessage = async (event: MessageEvent) => { const handleMessage = async (event: MessageEvent) => {
try { try {
if (event.source !== iframeRef.current?.contentWindow) return; if (event.source !== iframeRef.current?.contentWindow) return;
if (event.origin !== serverOrigin) return;
if (!event.data || typeof event.data !== "object") return; if (!event.data || typeof event.data !== "object") return;
const { const {
type, type,
@@ -115,7 +125,7 @@ export function ElectronLoginForm({
providerId, providerId,
...result, ...result,
}, },
"*", serverOrigin,
); );
}; };
const electronAPI = ( const electronAPI = (
@@ -181,14 +191,14 @@ export function ElectronLoginForm({
success: false, success: false,
error, error,
}, },
"*", serverOrigin,
); );
} }
}; };
window.addEventListener("message", handleMessage); window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage); return () => window.removeEventListener("message", handleMessage);
}, [handleAuthSuccess, t]); }, [handleAuthSuccess, serverUrl, t]);
useEffect(() => { useEffect(() => {
const iframe = iframeRef.current; const iframe = iframeRef.current;
+5
View File
@@ -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 { export function isInElectronWebView(): boolean {
if (isInMobileWebView()) return false; if (isInMobileWebView()) return false;
if ((window as EmbeddedFrameWindow).IS_ELECTRON_WEBVIEW) return true; if ((window as EmbeddedFrameWindow).IS_ELECTRON_WEBVIEW) return true;
if (!/Termix-Desktop\//.test(navigator.userAgent)) return false;
try { try {
if (window.self !== window.top) return true; if (window.self !== window.top) return true;
} catch { } catch {
@@ -43,6 +43,7 @@ describe("ElectronLoginForm", () => {
window.dispatchEvent( window.dispatchEvent(
new MessageEvent("message", { new MessageEvent("message", {
source: frame.contentWindow, source: frame.contentWindow,
origin: "https://termix.example.com",
data: { data: {
type: "AUTH_SUCCESS", type: "AUTH_SUCCESS",
platform: "desktop", platform: "desktop",
@@ -53,4 +54,87 @@ describe("ElectronLoginForm", () => {
); );
await waitFor(() => expect(onAuthSuccess).toHaveBeenCalledWith("jwt-1")); 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(
<ElectronLoginForm
serverUrl="https://termix.example.com/"
onAuthSuccess={onAuthSuccess}
onChangeServer={vi.fn()}
/>,
);
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(
<ElectronLoginForm
serverUrl="https://termix.example.com/"
onAuthSuccess={vi.fn()}
onChangeServer={vi.fn()}
/>,
);
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;
});
}); });
+9
View File
@@ -40,4 +40,13 @@ describe("embedded frame detection", () => {
expect(isInElectronWebView()).toBe(true); expect(isInElectronWebView()).toBe(true);
expect(isEmbeddedAuthFrame()).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);
});
}); });