diff --git a/electron/external-login.cjs b/electron/external-login.cjs new file mode 100644 index 000000000..9b4d18d70 --- /dev/null +++ b/electron/external-login.cjs @@ -0,0 +1,20 @@ +/** + * The login URL a server hands the desktop app goes to shell.openExternal, + * so only web pages pass: any other scheme (file:, smb:, ms-msdt:, ...) + * would launch whatever OS handler is registered for it. + */ +function isExternalLoginUrl(value) { + try { + const { protocol } = new URL(value); + return protocol === "https:" || protocol === "http:"; + } catch { + return false; + } +} + +/** A non-privileged port for the loopback callback listener. */ +function isCallbackPort(value) { + return Number.isInteger(value) && value >= 1024 && value <= 65535; +} + +module.exports = { isExternalLoginUrl, isCallbackPort }; diff --git a/electron/main.cjs b/electron/main.cjs index bf781b319..1a20f6bc2 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -36,6 +36,7 @@ const { quitApp } = require("./app-quit.cjs"); const { selectLinuxPasswordStore } = require("./linux-password-store.cjs"); const { resolveLocalShell, repairSpawnHelpers } = require("./local-shell.cjs"); const { registerLocalFileHandlers } = require("./local-files.cjs"); +const { isExternalLoginUrl, isCallbackPort } = require("./external-login.cjs"); const localTerminalSessions = new Map(); @@ -1663,6 +1664,12 @@ ipcMain.handle("get-embedded-server-status", () => { ipcMain.handle( "external-browser-login", async (_event, authUrl, callbackPort) => { + if (!isExternalLoginUrl(authUrl)) { + return { success: false, error: "Invalid authentication URL" }; + } + if (!isCallbackPort(callbackPort)) { + return { success: false, error: "Invalid callback port" }; + } const http = require("http"); return new Promise((resolve) => { diff --git a/scripts/electron-external-login.test.ts b/scripts/electron-external-login.test.ts new file mode 100644 index 000000000..c143efe3d --- /dev/null +++ b/scripts/electron-external-login.test.ts @@ -0,0 +1,41 @@ +import { createRequire } from "node:module"; +import { describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const { isExternalLoginUrl, isCallbackPort } = + require("../electron/external-login.cjs") as { + isExternalLoginUrl: (value: unknown) => boolean; + isCallbackPort: (value: unknown) => boolean; + }; + +describe("Electron external login", () => { + it.each([ + "https://idp.example.com/authorize?client_id=termix", + "http://keycloak.lan:8080/realms/home/protocol/openid-connect/auth", + ])("opens web login URLs: %s", (url) => { + expect(isExternalLoginUrl(url)).toBe(true); + }); + + it.each([ + "file:///C:/Windows/System32/calc.exe", + "smb://attacker/share", + "ms-msdt:/id PCWDiagnostic", + "javascript:alert(1)", + "not a url", + undefined, + ])("refuses other schemes: %s", (url) => { + expect(isExternalLoginUrl(url)).toBe(false); + }); + + it("accepts the ports the login screen picks", () => { + expect(isCallbackPort(17832)).toBe(true); + expect(isCallbackPort(17931)).toBe(true); + }); + + it.each([0, 80, 65536, 17832.5, "17832", NaN, undefined])( + "refuses callback port %s", + (port) => { + expect(isCallbackPort(port)).toBe(false); + }, + ); +});