test(e2e): prove guard chain blocks direct navigation and admins keep admin reach
14-guard.spec.ts: every authentik-guarded vhost (www, search, romm, files, seerr, grafana, prometheus, loki) must challenge an unauthenticated direct navigation; plex is asserted exempt (PMS answers directly, no IdP form). 11-admin.spec.ts: admin direct-nav to grafana/prometheus/loki passes through with host preserved and no uidField challenge.
This commit is contained in:
@@ -47,3 +47,23 @@ test("admin open links reach their apps through SSO", async ({ page }) => {
|
||||
expect(resp.status(), `${target.hostname} must answer, not error`).toBeLessThan(500);
|
||||
}
|
||||
});
|
||||
|
||||
// Admin-space reachability must not depend on the panel's link list: an admin
|
||||
// session must reach every guarded admin app by direct navigation, even where
|
||||
// no open-link exists. This is the RBAC allow-leg for the outpost gates: an
|
||||
// authenticated admin always lands in the app, never on a login wall or a 403.
|
||||
test("admin direct navigation reaches every admin app without an auth wall", async ({ page }) => {
|
||||
test.setTimeout(240_000);
|
||||
for (const sub of ["grafana", "prometheus", "loki"]) {
|
||||
const host = `${sub}.franksdank.cloud`;
|
||||
await page.goto(`https://${host}/`);
|
||||
// The outpost hands off silently for an authenticated session; the admin
|
||||
// must land in the app, not on authentik's flow UI.
|
||||
await expect
|
||||
.poll(() => page.locator('input[name="uidField"]').isVisible().catch(() => false), { timeout: 60_000 })
|
||||
.toBe(false);
|
||||
expect(new URL(page.url()).hostname, `${host} must not bounce an admin off its host`).toBe(host);
|
||||
const resp = await page.request.get(`https://${host}/`);
|
||||
expect(resp.status(), `${host} must answer an authenticated admin, not error`).toBeLessThan(500);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,14 +1,92 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// With no session at all, the guard chain must demand authentik login for
|
||||
// both homepage faces. This is the outer wall the whole e2e suite assumes.
|
||||
// Guard chain: Caddy -> Anubis (edge challenge) -> authentik outpost -> app.
|
||||
// The guard is only effective if the outpost is actually consulted on every
|
||||
// route of a guarded vhost — including deep links and API paths that a direct
|
||||
// navigation would skip. These tests drive the browser as an UNAUTHENTICATED
|
||||
// user (the guest project has no storage state) and navigate directly to each
|
||||
// guarded vhost's root path: the guard must present authentik's flow UI in
|
||||
// place and must never leak app content. plex is the documented exemption —
|
||||
// PMS enforces its own auth — so it must be reachable unauthenticated with
|
||||
// zero challenge pages, proving the exemption is deliberate, not accidental.
|
||||
|
||||
test("unauthenticated homepage visit requires authentik login", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator('input[name="uidField"]')).toBeVisible({ timeout: 90_000 });
|
||||
const BASE = "https://www.franksdank.cloud";
|
||||
|
||||
// Every vhost that Caddy routes through anubis:8923 (see dankclaw/Caddyfile).
|
||||
// Keep this list in lockstep with the Caddyfile: a new guarded vhost must be
|
||||
// added here or the guard-chain audit is incomplete.
|
||||
const GUARDED = ["www", "search", "romm", "files", "seerr", "grafana", "prometheus", "loki"];
|
||||
|
||||
// Markers that prove app content leaked past the guard to an anonymous user.
|
||||
const LEAK_MARKERS: Record<string, string[]> = {
|
||||
"search.franksdank.cloud": ["SearXNG preferences"],
|
||||
"romm.franksdank.cloud": ["RomM"],
|
||||
"files.franksdank.cloud": ["Copyparty"],
|
||||
"seerr.franksdank.cloud": ["Request Movie"],
|
||||
"grafana.franksdank.cloud": ["Dashboards"],
|
||||
"prometheus.franksdank.cloud": ["Expression Browser", "Graph"],
|
||||
};
|
||||
|
||||
test("homepage root (/) is guarded for unauthenticated users", async ({ page }) => {
|
||||
await page.goto(`${BASE}/`);
|
||||
// The login flow renders in place (no redirect away from the vhost).
|
||||
const uid = page.locator('input[name="uidField"]');
|
||||
await expect(uid).toBeVisible({ timeout: 90_000 });
|
||||
await uid.fill("nobody");
|
||||
await page.locator('button[type="submit"]').first().click();
|
||||
// Wrong credentials must not leak panels: the flow re-renders with an error.
|
||||
const pwd = page.locator('input[name="password"]');
|
||||
await expect(pwd).toBeVisible({ timeout: 30_000 });
|
||||
await pwd.fill("not-a-real-user");
|
||||
await pwd.press("Enter");
|
||||
await expect(page.locator("#grid .panel").first()).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("unauthenticated /admin requires authentik login", async ({ page }) => {
|
||||
await page.goto("/admin/");
|
||||
await expect(page.locator('input[name="uidField"]')).toBeVisible({ timeout: 90_000 });
|
||||
test("/admin/ is guarded for unauthenticated users", async ({ page }) => {
|
||||
await page.goto(`${BASE}/admin/`);
|
||||
const uid = page.locator('input[name="uidField"]');
|
||||
await expect(uid).toBeVisible({ timeout: 90_000 });
|
||||
await expect(page.locator("#grid .panel").first()).toHaveCount(0);
|
||||
});
|
||||
|
||||
for (const sub of GUARDED) {
|
||||
test(`unauthenticated direct navigation to ${sub}.franksdank.cloud is gated by authentik`, async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const host = `${sub}.franksdank.cloud`;
|
||||
await page.goto(`https://${host}/`);
|
||||
|
||||
// The guard resolves (Anubis PoW solves automatically in a real browser)
|
||||
// and presents authentik's flow UI in place — the user never reaches the
|
||||
// app without a session.
|
||||
const uid = page.locator('input[name="uidField"]');
|
||||
await expect(uid).toBeVisible({ timeout: 90_000 });
|
||||
|
||||
// The challenge/login flow itself must not be leaking content.
|
||||
const body = await page.locator("body").innerText();
|
||||
for (const marker of LEAK_MARKERS[host] ?? []) {
|
||||
expect(body, `${host} leaked app content to an unauthenticated user`).not.toContain(marker);
|
||||
}
|
||||
// The response is the IdP's flow UI, not a passthrough: it must be served
|
||||
// by authentik (the flow shell renders "Sign in" / uidField), never the app.
|
||||
expect(new URL(page.url()).hostname).toBe(host);
|
||||
});
|
||||
}
|
||||
|
||||
test("plex is exempt from the guard chain and serves PMS content unauthenticated", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
// Exempt-by-construction: Caddy reverse-proxies straight to PMS on the LAN
|
||||
// hop (PMS enforces its own auth). An unauthenticated visitor must reach the
|
||||
// app itself — never an authentik challenge or flow UI.
|
||||
await page.goto("https://plex.franksdank.cloud/");
|
||||
expect(new URL(page.url()).hostname).toBe("plex.franksdank.cloud");
|
||||
|
||||
const body = await page.locator("body").innerText();
|
||||
expect(body).not.toContain("Making sure you're not a bot"); // no challenge
|
||||
expect(await page.locator('input[name="uidField"]').count()).toBe(0); // no IdP form
|
||||
|
||||
// PMS answers with its XML MediaContainer index when unauthenticated; the
|
||||
// exact payload (empty body vs XML) depends on PMS version, so assert only
|
||||
// that the answer comes from PMS itself: sub-500 and no IdP markers.
|
||||
const resp = await page.request.get("https://plex.franksdank.cloud/");
|
||||
expect(resp.status(), "PMS must answer directly, not error").toBeLessThan(500);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user