diff --git a/electron/main.cjs b/electron/main.cjs index 2efe350f..13963207 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1397,7 +1397,7 @@ ipcMain.handle( server.once("error", fail); - server.listen(callbackPort, "127.0.0.1", async () => { + server.listen(callbackPort, "localhost", async () => { try { await shell.openExternal(authUrl); } catch (error) { diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 068f2b50..9823b3de 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -18,6 +18,10 @@ import { getRequestBasePath, getRequestBaseUrlWithForceHTTPS, } from "../../utils/request-origin.js"; +import { + getDesktopOidcCallbackUrl, + isOidcTokenCallback, +} from "../../utils/oidc-desktop-callback.js"; import { deleteUserAndRelatedData } from "./delete-user-data.js"; import { getOIDCConfigFromEnv, @@ -654,7 +658,10 @@ router.get("/oidc/authorize", async (req, res) => { const referer = req.get("Referer"); let frontendOrigin; if (desktopCallbackPort) { - frontendOrigin = `http://127.0.0.1:${desktopCallbackPort}/oidc-callback`; + frontendOrigin = getDesktopOidcCallbackUrl(desktopCallbackPort); + if (!frontendOrigin) { + return res.status(400).json({ error: "Invalid desktop callback port" }); + } } else if (typeof appCallbackUrl === "string" && appCallbackUrl) { let callbackUrl: URL; try { @@ -1001,9 +1008,7 @@ router.get("/oidc/callback", async (req, res) => { }); const ghRedirectUrl = new URL(frontendOrigin); ghRedirectUrl.searchParams.set("success", "true"); - const ghIsTokenCallback = - frontendOrigin.startsWith("http://127.0.0.1:") || - frontendOrigin.startsWith("termix-mobile:"); + const ghIsTokenCallback = isOidcTokenCallback(frontendOrigin); const ghMaxAge = deviceInfo.type === "desktop" || deviceInfo.type === "mobile" ? 30 * 24 * 60 * 60 * 1000 @@ -1487,9 +1492,7 @@ router.get("/oidc/callback", async (req, res) => { const redirectUrl = new URL(frontendOrigin); redirectUrl.searchParams.set("success", "true"); - const isTokenCallback = - frontendOrigin.startsWith("http://127.0.0.1:") || - frontendOrigin.startsWith("termix-mobile:"); + const isTokenCallback = isOidcTokenCallback(frontendOrigin); const maxAge = deviceInfo.type === "desktop" || deviceInfo.type === "mobile" diff --git a/src/backend/utils/oidc-desktop-callback.test.ts b/src/backend/utils/oidc-desktop-callback.test.ts new file mode 100644 index 00000000..09d1e485 --- /dev/null +++ b/src/backend/utils/oidc-desktop-callback.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + getDesktopOidcCallbackUrl, + isOidcTokenCallback, +} from "./oidc-desktop-callback"; + +describe("getDesktopOidcCallbackUrl", () => { + it("uses localhost so browsers do not upgrade the loopback callback", () => { + expect(getDesktopOidcCallbackUrl("17850")).toBe( + "http://localhost:17850/oidc-callback", + ); + }); + + it.each(["", "0", "65536", "17850/path", ["17850"]])( + "rejects invalid callback port %j", + (port) => { + expect(getDesktopOidcCallbackUrl(port)).toBeNull(); + }, + ); +}); + +describe("isOidcTokenCallback", () => { + it.each([ + "http://localhost:17850/oidc-callback", + "http://127.0.0.1:17850/oidc-callback", + "termix-mobile://oidc-callback", + ])("recognizes app callback %s", (url) => { + expect(isOidcTokenCallback(url)).toBe(true); + }); + + it.each([ + "https://localhost:17850/oidc-callback", + "http://example.com:17850/oidc-callback", + "http://localhost:17850/other", + ])("rejects non-app callback %s", (url) => { + expect(isOidcTokenCallback(url)).toBe(false); + }); +}); diff --git a/src/backend/utils/oidc-desktop-callback.ts b/src/backend/utils/oidc-desktop-callback.ts new file mode 100644 index 00000000..898431fe --- /dev/null +++ b/src/backend/utils/oidc-desktop-callback.ts @@ -0,0 +1,25 @@ +const CALLBACK_PATH = "/oidc-callback"; + +export function getDesktopOidcCallbackUrl(value: unknown): string | null { + if (typeof value !== "string" || !/^\d+$/.test(value)) return null; + + const port = Number(value); + if (!Number.isInteger(port) || port < 1 || port > 65535) return null; + + return `http://localhost:${port}${CALLBACK_PATH}`; +} + +export function isOidcTokenCallback(value: string): boolean { + if (value.startsWith("termix-mobile:")) return true; + + try { + const url = new URL(value); + return ( + url.protocol === "http:" && + (url.hostname === "localhost" || url.hostname === "127.0.0.1") && + url.pathname === CALLBACK_PATH + ); + } catch { + return false; + } +}