diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index 4e13600..d892b3f 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -36,7 +36,7 @@ export default defineConfig({ }, { name: "guest", - testMatch: /14-.*\.spec\.ts/, + testMatch: /(14|15)-.*\.spec\.ts/, use: { storageState: { cookies: [], origins: [] } }, }, ], diff --git a/tests/e2e/specs/15-unauth-fetch.spec.ts b/tests/e2e/specs/15-unauth-fetch.spec.ts new file mode 100644 index 0000000..9f5b189 --- /dev/null +++ b/tests/e2e/specs/15-unauth-fetch.spec.ts @@ -0,0 +1,62 @@ +import { expect, test } from "@playwright/test"; + +// Wget-style denial: a non-browser client (curl/wget/WebDAV clients solve no +// JS challenge) must never receive application bytes from a guarded service. +// Today the guard chain answers every unauthenticated path — roots and deep +// paths alike — with an HTML gate page, so a status-code-only assertion would +// pass even if a route leaked: these tests assert on LEAKAGE instead. The +// markers below live in APP code (copyparty's UI title, the homepage panel +// grid, the registry JSON shape), so any guard-route regression that lets the +// app answer directly fails here, while any gate implementation (challenge or +// login flow) passes. +// +// Copyparty serves authenticated traffic with anonymous read perms (`r` for +// all authenticated traffic; see dankserver/copyparty/quadlet), so the guard +// chain is the ONLY thing between an unauthenticated client and the file +// bytes — if any route bypasses the guard, copyparty answers with a real +// directory listing and these tests fail. + +const FILES = "https://files.franksdank.cloud"; +const BASE = "https://www.franksdank.cloud"; + +// Paths probed as an unauthenticated raw client. These are probes, not claims +// that the path exists when authenticated: whatever lives behind a path, the +// answer to an unauthenticated raw client must be the gate — never app bytes. +const FILE_PATHS = ["/", "/w/", "/cfg/", "/dank/", "/favicon.ico", "/robots.txt"]; + +test("wget-style GETs of copyparty paths never return file bytes to unauthenticated clients", async ({ request }) => { + for (const p of FILE_PATHS) { + const resp = await request.get(`${FILES}${p}`, { timeout: 30_000 }); + const body = await resp.text(); + // The gate answers in HTML; a copyparty file/listing response would not. + expect( + resp.headers()["content-type"] ?? "", + `${p}: content-type must stay text/html while unauthenticated`, + ).toMatch(/text\/html/); + // No leakage: copyparty's UI markers must not appear anywhere in the body. + expect(body, `${p}: copyparty bytes leaked to an unauthenticated client`).not.toMatch(/copyparty/i); + } +}); + +test("homepage app bytes are never served to unauthenticated clients", async ({ request }) => { + // The homepage HTML itself: an unauthenticated GET must render the gate, + // not the panel grid. + const root = await request.get(`${BASE}/`, { timeout: 30_000 }); + expect(await root.headers()).toBeTruthy(); + expect((await root.text()), "homepage app markup leaked to an unauthenticated client").not.toContain('id="grid"'); + + // The services registry JSON: the inventory of internal services must never + // be served pre-auth — not even as a "harmless" static file. + const registry = await request.get(`${BASE}/services.json`, { timeout: 30_000 }); + const ctype = (registry.headers()["content-type"] ?? "") as string; + expect(ctype, "services.json must not be served as JSON to unauthenticated clients").not.toMatch(/application\/json/); + expect(await registry.text(), "services registry leaked to an unauthenticated client").not.toContain('"category"'); + + // Admin panel: the app's own RBAC denial page is itself app output — even + // that must not reach an unauthenticated client; the gate answers first. + const admin = await request.get(`${BASE}/admin/`, { timeout: 30_000 }); + expect( + await admin.text(), + "the homepage admin panel answered before the guard chain did", + ).not.toContain("homepage admins only"); +});