diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index c1b273798..972c53e6e 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -11,6 +11,7 @@ import { authLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; import { parseUserAgent } from "../../utils/user-agent-parser.js"; +import { isDesktopAppOrigin } from "../../utils/cors-config.js"; import { deleteUserAndRelatedData } from "./delete-user-data.js"; import { allowsDesktopAutoSession, @@ -744,7 +745,11 @@ router.get("/setup-required", async (req, res) => { */ router.post("/internal/auto-session", async (req, res) => { try { - if (!allowsDesktopAutoSession() || !isLoopbackRequest(req)) { + if ( + !allowsDesktopAutoSession() || + !isLoopbackRequest(req) || + !isDesktopAppOrigin(req.headers.origin) + ) { authLogger.warn( "Rejected non-loopback attempt to access auto-session endpoint", { source: req.ip }, diff --git a/src/backend/tests/utils/cors-config.test.ts b/src/backend/tests/utils/cors-config.test.ts index e511120c9..1812ca66e 100644 --- a/src/backend/tests/utils/cors-config.test.ts +++ b/src/backend/tests/utils/cors-config.test.ts @@ -1,6 +1,9 @@ import { afterEach, describe, expect, it } from "vitest"; import type { Request } from "express"; -import { isCorsOriginAllowed } from "../../utils/cors-config.js"; +import { + isCorsOriginAllowed, + isDesktopAppOrigin, +} from "../../utils/cors-config.js"; function request(headers: Record = {}): Request { return { @@ -11,6 +14,7 @@ function request(headers: Record = {}): Request { afterEach(() => { delete process.env.CORS_ALLOWED_ORIGINS; + delete process.env.ELECTRON_EMBEDDED; }); describe("isCorsOriginAllowed", () => { @@ -41,4 +45,41 @@ describe("isCorsOriginAllowed", () => { const req = request({ host: "termix.example" }); expect(isCorsOriginAllowed(req, "https://attacker.example")).toBe(false); }); + + describe("embedded desktop backend", () => { + it("rejects other websites even with no allowlist", () => { + process.env.ELECTRON_EMBEDDED = "true"; + const req = request({ host: "127.0.0.1:30001" }); + expect(isCorsOriginAllowed(req, "https://evil.example")).toBe(false); + }); + + it("still allows the desktop app and the dev server", () => { + process.env.ELECTRON_EMBEDDED = "true"; + const req = request({ host: "127.0.0.1:30001" }); + expect(isCorsOriginAllowed(req, "file://")).toBe(true); + expect(isCorsOriginAllowed(req, "http://localhost:5173")).toBe(true); + expect(isCorsOriginAllowed(req, undefined)).toBe(true); + }); + + it("allows an explicitly configured origin", () => { + process.env.ELECTRON_EMBEDDED = "true"; + process.env.CORS_ALLOWED_ORIGINS = "https://portal.example"; + expect(isCorsOriginAllowed(request(), "https://portal.example")).toBe( + true, + ); + }); + }); +}); + +describe("isDesktopAppOrigin", () => { + it("accepts no origin, file:// and the dev server", () => { + expect(isDesktopAppOrigin(undefined)).toBe(true); + expect(isDesktopAppOrigin("file://")).toBe(true); + expect(isDesktopAppOrigin("http://127.0.0.1:5173")).toBe(true); + }); + + it("rejects websites", () => { + expect(isDesktopAppOrigin("https://evil.example")).toBe(false); + expect(isDesktopAppOrigin("null")).toBe(false); + }); }); diff --git a/src/backend/utils/cors-config.ts b/src/backend/utils/cors-config.ts index a5c7a1d78..979991cf4 100644 --- a/src/backend/utils/cors-config.ts +++ b/src/backend/utils/cors-config.ts @@ -14,15 +14,28 @@ function getAllowedOrigins(): string[] { .filter(Boolean); } +/** + * Origins allowed to drive the embedded desktop backend: the desktop app + * itself and the dev server. Any website open in a browser on the same + * machine can reach loopback too, so nothing else gets in. + */ +export function isDesktopAppOrigin(origin: string | undefined): boolean { + if (!origin) return true; + return ( + DEV_ORIGINS.includes(origin) || origin.startsWith(ELECTRON_FILE_ORIGIN) + ); +} + export function isCorsOriginAllowed( req: Request, origin: string | undefined, ): boolean { - if (!origin) return true; - if (DEV_ORIGINS.includes(origin)) return true; - if (origin.startsWith(ELECTRON_FILE_ORIGIN)) return true; + if (isDesktopAppOrigin(origin)) return true; const configured = getAllowedOrigins(); + if (process.env.ELECTRON_EMBEDDED === "true") { + return configured.includes(origin) || origin === getRequestOrigin(req); + } if (configured.length === 0) return true; if (configured.includes(origin)) return true;