diff --git a/server/env.ts b/server/env.ts index bba2e35bdab0..0506a214bd8d 100644 --- a/server/env.ts +++ b/server/env.ts @@ -536,6 +536,22 @@ export class Environment { @IsOptional() public DEFAULT_EMAIL_DOMAIN = environment.DEFAULT_EMAIL_DOMAIN ?? "askii.ai"; + /** + * Root domain of the foss-server-bundle deployment (set by + * `foss-server-bundle/platform.sh`). All app subdomains hang off this + * (e.g. `docs.${PLATFORM_DOMAIN}`, `pm.${PLATFORM_DOMAIN}`). + * + * Used by `/auth/portal-logout?next=…` as the redirect allowlist: the + * endpoint will follow a `next` URL whose host matches `PLATFORM_DOMAIN` + * or any of its subdomains, and reject everything else. Unset → + * endpoint still clears cookies, just won't follow any redirect. + */ + @IsOptional() + public PLATFORM_DOMAIN = (environment.PLATFORM_DOMAIN ?? "") + .toLowerCase() + .replace(/^\.+/, "") + .trim(); + /** * A boolean switch to toggle the rate limiter at application web server. */ diff --git a/server/routes/auth/index.test.ts b/server/routes/auth/index.test.ts index 9f29fc5a97d1..1e87471d97c7 100644 --- a/server/routes/auth/index.test.ts +++ b/server/routes/auth/index.test.ts @@ -1,3 +1,4 @@ +import env from "@server/env"; import { buildUser, buildCollection } from "@server/test/factories"; import { getTestServer } from "@server/test/support"; import { JWT_COOKIE_TTL_DAYS } from "@server/utils/authentication"; @@ -81,3 +82,151 @@ describe("auth/redirect", () => { expect(ageMs).toBeLessThan(expectedMs + 60_000); }); }); + +describe("auth/portal-logout", () => { + let originalPlatformDomain: string; + + beforeEach(() => { + originalPlatformDomain = env.PLATFORM_DOMAIN; + }); + + afterEach(() => { + env.PLATFORM_DOMAIN = originalPlatformDomain; + }); + + const setPlatformDomain = (domain: string) => { + env.PLATFORM_DOMAIN = domain; + }; + + /** + * Matches `=;` followed (anywhere up to the next cookie boundary) + * by `path=/`. Regression guard for the path-scoping bug: without + * `path=/`, Set-Cookie defaults to `/auth/portal-logout`, which fails + * to shadow the original `path=/` cookie — the browser keeps the JWT + * and "logout" silently does nothing. + */ + const cookieClearedAtRoot = (name: string) => + new RegExp(`${name}=;[^,]*\\bpath=/(?:[;,]|$)`, "i"); + + it("should clear accessToken and lastSignedIn cookies at path=/ on every call", async () => { + setPlatformDomain(""); + const res = await server.get("/auth/portal-logout", { redirect: "manual" }); + expect(res.status).toEqual(200); + const setCookie = res.headers.get("set-cookie") ?? ""; + // Both cookies cleared AND scoped to path=/ so they actually shadow + // the original login cookies in the browser. + expect(setCookie).toMatch(cookieClearedAtRoot("accessToken")); + expect(setCookie).toMatch(cookieClearedAtRoot("lastSignedIn")); + }); + + it("should 302 to next when host is in the allowlist", async () => { + setPlatformDomain("foss.arbisoft.com"); + const target = "https://pm.foss.arbisoft.com/auth/portal-sign-out/"; + const res = await server.get( + `/auth/portal-logout?next=${encodeURIComponent(target)}`, + { redirect: "manual" } + ); + expect(res.status).toEqual(302); + expect(res.headers.get("location")).toEqual(target); + // cookies still cleared at path=/ + const setCookie = res.headers.get("set-cookie") ?? ""; + expect(setCookie).toMatch(cookieClearedAtRoot("accessToken")); + }); + + it("should match subdomains of allowlist entries", async () => { + setPlatformDomain("foss.arbisoft.com"); + const target = "https://docs.foss.arbisoft.com/done"; + const res = await server.get( + `/auth/portal-logout?next=${encodeURIComponent(target)}`, + { redirect: "manual" } + ); + expect(res.status).toEqual(302); + expect(res.headers.get("location")).toEqual(target); + }); + + it("should reject next on a host outside the allowlist", async () => { + setPlatformDomain("foss.arbisoft.com"); + const target = "https://evil.example/steal"; + const res = await server.get( + `/auth/portal-logout?next=${encodeURIComponent(target)}`, + { redirect: "manual" } + ); + expect(res.status).toEqual(200); + // still clears cookies at path=/ — non-redirect rejection isn't an error path + const setCookie = res.headers.get("set-cookie") ?? ""; + expect(setCookie).toMatch(cookieClearedAtRoot("accessToken")); + }); + + it("should enforce dot-boundary on suffix matches", async () => { + // "foss.arbisoft.com.evil" must NOT match "foss.arbisoft.com". + setPlatformDomain("foss.arbisoft.com"); + const target = "https://foss.arbisoft.com.evil.example/x"; + const res = await server.get( + `/auth/portal-logout?next=${encodeURIComponent(target)}`, + { redirect: "manual" } + ); + expect(res.status).toEqual(200); + }); + + it("should reject every next when PLATFORM_DOMAIN is unset", async () => { + setPlatformDomain(""); + const res = await server.get( + "/auth/portal-logout?next=https%3A%2F%2Ffoss.arbisoft.com%2F", + { redirect: "manual" } + ); + expect(res.status).toEqual(200); + }); + + it("should reject malformed next values", async () => { + setPlatformDomain("foss.arbisoft.com"); + const res = await server.get( + "/auth/portal-logout?next=not-a-url", + { redirect: "manual" } + ); + expect(res.status).toEqual(200); + }); + + it("should reject next with non-http(s) schemes", async () => { + // javascript:, data:, file:, etc. parse fine via new URL() but must + // never be a valid next-hop — `` style would + // execute in the user's browser if we 302'd to it. + setPlatformDomain("foss.arbisoft.com"); + for (const target of [ + "javascript:alert(1)", + "data:text/html,", + "file:///etc/passwd", + ]) { + const res = await server.get( + `/auth/portal-logout?next=${encodeURIComponent(target)}`, + { redirect: "manual" } + ); + expect(res.status).toEqual(200); + } + }); + + it("should accept both http and https for allowlisted hosts", async () => { + // http allowed for local-dev / localhost flows; https for production. + // Beyond the two are rejected by the scheme gate. + setPlatformDomain("foss.arbisoft.com"); + for (const target of [ + "https://docs.foss.arbisoft.com/x", + "http://docs.foss.arbisoft.com/x", + ]) { + const res = await server.get( + `/auth/portal-logout?next=${encodeURIComponent(target)}`, + { redirect: "manual" } + ); + expect(res.status).toEqual(302); + expect(res.headers.get("location")).toEqual(target); + } + }); + + it("should not 302 when next is missing", async () => { + setPlatformDomain("foss.arbisoft.com"); + const res = await server.get("/auth/portal-logout", { redirect: "manual" }); + expect(res.status).toEqual(200); + // cookies still cleared at path=/ + const setCookie = res.headers.get("set-cookie") ?? ""; + expect(setCookie).toMatch(cookieClearedAtRoot("accessToken")); + }); +}); diff --git a/server/routes/auth/index.ts b/server/routes/auth/index.ts index d0d6be6c52b1..bcbc2c034d17 100644 --- a/server/routes/auth/index.ts +++ b/server/routes/auth/index.ts @@ -3,6 +3,7 @@ import { addDays } from "date-fns"; import Koa from "koa"; import bodyParser from "koa-body"; import Router from "koa-router"; +import env from "@server/env"; import { AuthenticationError } from "@server/errors"; import authMiddleware from "@server/middlewares/authentication"; import coalesceBody from "@server/middlewares/coaleseBody"; @@ -93,6 +94,92 @@ router.get("/redirect", authMiddleware(), async (ctx: APIContext) => { ); }); +/** + * Returns true iff `url` is a safe redirect target: + * - scheme is http or https (rejects javascript:, data:, etc.) + * - hostname is `PLATFORM_DOMAIN` itself or a subdomain of it + * + * Suffix match enforces a dot boundary: `foss.arbisoft.com` matches + * `foss.arbisoft.com` and `*.foss.arbisoft.com` but NOT + * `foss.arbisoft.com.evil.example`. Closes the obvious open-redirect + * surface the endpoint would otherwise expose. + * + * When `PLATFORM_DOMAIN` is unset, every `next` is rejected — the + * endpoint still clears the cookie, it just won't follow a redirect. + */ +function isAllowedSignOutNext(url: string): boolean { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + // Reject non-http(s) schemes outright — javascript: and data: parse fine + // but should never be a valid next-hop. https is required in production; + // http is allowed for dev/localhost flows. + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + return false; + } + const host = parsed.hostname.toLowerCase(); + if (!host) { + return false; + } + const platformDomain = env.PLATFORM_DOMAIN; + if (!platformDomain) { + return false; + } + return host === platformDomain || host.endsWith("." + platformDomain); +} + +/** + * GET /auth/portal-logout?next= + * + * Clears the accessToken + lastSignedIn cookies and 302-redirects to + * `next`. Designed for the foss-server-bundle portal's "Log out of all + * apps" redirect chain — the portal navigates the browser through each + * app's logout URL, each step clearing its own cookies while the browser + * is on that app's own domain (so the Set-Cookie scope is correct). + * + * CSRF-exempt by design: the portal cannot share Outline's CSRF token + * cross-origin, so the existing POST + CSRF flow isn't usable here. The + * residual risk is force-logout (an attacker embeds an `` and the + * victim's session ends). Low impact — the only state lost is the + * cookie, and ForwardAuth re-auths on the next request. + * + * Open-redirect protection: `next` is validated against `PLATFORM_DOMAIN` + * (suffix-match on a dot boundary). Unset `PLATFORM_DOMAIN` rejects every + * `next` — cookies are still cleared, the endpoint just returns 200 + * instead of 302. + */ +router.get("/portal-logout", async (ctx: APIContext) => { + const epoch = new Date(0); + // path:/ is load-bearing — without it Koa defaults the Set-Cookie path + // to the request URL (`/auth/portal-logout`), which doesn't shadow the + // original `accessToken` cookie's path:/ scope. The browser keeps the + // JWT. Matches the cookie-clear shape used by the auth() catch block + // at server/middlewares/authentication.ts:114-117. + ctx.cookies.set("accessToken", "", { + sameSite: "lax", + expires: epoch, + path: "/", + }); + // lastSignedIn is non-HttpOnly because the frontend reads it. + ctx.cookies.set("lastSignedIn", "", { + httpOnly: false, + sameSite: "lax", + expires: epoch, + path: "/", + }); + + const nextRaw = String(ctx.query.next ?? "").trim(); + if (nextRaw && isAllowedSignOutNext(nextRaw)) { + ctx.redirect(nextRaw); + return; + } + + ctx.body = { ok: true }; +}); + app.use(bodyParser()); app.use(coalesceBody()); app.use(verifyCSRFToken());