Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions server/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Comment on lines +551 to +553

/**
* A boolean switch to toggle the rate limiter at application web server.
*/
Expand Down
149 changes: 149 additions & 0 deletions server/routes/auth/index.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 `<name>=;` 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 — `<a href="javascript:…">` 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,<script>alert(1)</script>",
"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"));
});
});
87 changes: 87 additions & 0 deletions server/routes/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
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";
Expand Down Expand Up @@ -93,6 +94,92 @@
);
});

/**
* 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;
}
Comment on lines +117 to +130
return host === platformDomain || host.endsWith("." + platformDomain);
}

/**
* GET /auth/portal-logout?next=<absolute_url>
*
* 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 `<img>` 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: "/",
});
Comment on lines +167 to +172

const nextRaw = String(ctx.query.next ?? "").trim();
if (nextRaw && isAllowedSignOutNext(nextRaw)) {
ctx.redirect(nextRaw);

Check warning

Code scanning / CodeQL

Server-side URL redirect Medium

Untrusted URL redirection depends on a
user-provided value
.
return;
}

ctx.body = { ok: true };
});

app.use(bodyParser());
app.use(coalesceBody());
app.use(verifyCSRFToken());
Expand Down
Loading