mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
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:
1 parent
13f7aa95a3
commit
8c3f816407
4 files changed
+112
-4
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
<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;
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user