diff --git a/.env.example b/.env.example index b9bfcada0e..a4da8c0296 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,28 @@ RELAY_URL=ws://localhost:3000 # (use `just web` for Vite HMR instead). # BUZZ_WEB_DIR=./web/dist +# ----------------------------------------------------------------------------- +# Admin Dashboard (private moderation surface) +# ----------------------------------------------------------------------------- +# Host name that serves the read-only moderation dashboard and its +# /api/admin/v1 endpoints. Leave unset to keep the admin surface absent. +# Setting it requires one of the two authentication variables below. +# BUZZ_ADMIN_HOST=admin.localhost:3000 +# +# Option A — Bearer token (recommended for public-facing / OSS deployments): +# Exactly 64 hex characters (32 bytes); generate with `openssl rand -hex 32`. +# `just admin` generates a throwaway one per run and prints it. +# BUZZ_ADMIN_TOKEN=<64 hex characters> +# +# Option B — Network-layer auth (for deployments behind a VPN or firewall): +# Set only when the admin API is already protected at the network layer. +# The relay logs a WARN on every startup in this mode. +# Only the exact value "true" is accepted; any other non-empty value fails. +# BUZZ_ADMIN_INSECURE_NO_AUTH=true +# +# Directory holding the built dashboard assets (`pnpm -C admin-web build`). +# BUZZ_ADMIN_WEB_DIR=./admin-web/dist + # Shared Redis-backed admission limits. Defaults shown below; each value must # be a positive integer. # BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=60 diff --git a/CHANGELOG.md b/CHANGELOG.md index 71a4bbd449..e0bf32cbb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## Unreleased + +- **Breaking:** the relay admin moderation API (`/api/admin/v1`) now requires + explicit authentication when `BUZZ_ADMIN_HOST` is set. Choose one mode: + - **Token mode:** set `BUZZ_ADMIN_TOKEN` to exactly 64 hex characters + (`openssl rand -hex 32`). Every API request requires `Authorization: Bearer`. + The dashboard prompts for the token on first load. + - **Network-layer mode:** set `BUZZ_ADMIN_INSECURE_NO_AUTH=true` (exact value + only). Use only when the admin API is already protected by a VPN or private + ingress. The relay logs a `WARN` on every startup. The dashboard skips the + token prompt and renders directly. + - Both set at the same time, or neither, is a startup error. + `Host`/`Origin` matching is retained in both modes as defense-in-depth. + ## v0.5.3 ### Desktop and shared changes diff --git a/Justfile b/Justfile index 64a1f36daf..98d6a1f614 100644 --- a/Justfile +++ b/Justfile @@ -390,7 +390,11 @@ admin: bootstrap _ensure-migrations pnpm -C admin-web build export BUZZ_ADMIN_HOST="${BUZZ_ADMIN_HOST:-admin.localhost:3000}" export BUZZ_ADMIN_WEB_DIR="${BUZZ_ADMIN_WEB_DIR:-{{justfile_directory()}}/admin-web/dist}" + # The relay refuses to start without a token, and never logs one. Mint a + # throwaway per run so no dev secret is ever committed or reused. + export BUZZ_ADMIN_TOKEN="${BUZZ_ADMIN_TOKEN:-$(openssl rand -hex 32)}" echo "Admin dashboard: http://${BUZZ_ADMIN_HOST}/reports" + echo "Admin token (paste into dashboard prompt): ${BUZZ_ADMIN_TOKEN}" cargo run -p buzz-relay # Seed deterministic reports and product feedback for local admin dashboard review @@ -403,7 +407,7 @@ admin-check: fmt-check cargo test -p buzz-relay api::admin cargo test -p buzz-relay router::tests pnpm -C admin-web check - pnpm -C admin-web exec playwright test + pnpm -C admin-web test:e2e # Start the relay server in release mode relay-release: _ensure-migrations diff --git a/admin-web/src/App.tsx b/admin-web/src/App.tsx index f39ecc33c5..6b96c2f83e 100644 --- a/admin-web/src/App.tsx +++ b/admin-web/src/App.tsx @@ -6,7 +6,13 @@ import { useMemo, useState, } from "react"; -import { ApiFailure, request } from "./api"; +import { + ApiFailure, + probeAuthRequired, + request, + requestObjectUrl, +} from "./api"; +import { setToken, useToken } from "./token"; import type { FeedbackDetail, FeedbackSummary, @@ -460,7 +466,7 @@ function FeedbackDetailView({ id }: { id: string }) {
{attachments.map((attachment) => ( ))} @@ -491,7 +497,7 @@ function FeedbackDetailView({ id }: { id: string }) { type FeedbackStatuses = Record; interface FeedbackAttachment { - url: string; + path: string; sourceUrl: string; mimeType: string; hash: string; @@ -551,7 +557,7 @@ function feedbackAttachments( const parsedSize = Number(values.get("size")); return [ { - url: `/api/admin/v1/feedback/${encodeURIComponent(feedbackId)}/attachments/${hash}`, + path: `/feedback/${encodeURIComponent(feedbackId)}/attachments/${hash}`, sourceUrl: safeUrl, mimeType, hash, @@ -596,7 +602,9 @@ function stripAttachmentMarkdown( } function Attachment({ attachment }: { attachment: FeedbackAttachment }) { - const url = attachment.url; + const [objectUrl, setObjectUrl] = useState(); + const [failed, setFailed] = useState(false); + const path = attachment.path; const name = attachment.filename ?? `attachment-${attachment.hash.slice(0, 8)}`; const metadata = [ @@ -607,33 +615,76 @@ function Attachment({ attachment }: { attachment: FeedbackAttachment }) { .filter(Boolean) .join(" · "); + useEffect(() => { + // The API requires an Authorization header, so the bytes are fetched here + // and handed to the DOM as an object URL revoked on replacement/unmount. + let url: string | undefined; + let active = true; + setObjectUrl(undefined); + setFailed(false); + requestObjectUrl(path) + .then((created) => { + if (!active) { + URL.revokeObjectURL(created); + return; + } + url = created; + setObjectUrl(created); + }) + .catch(() => { + if (active) setFailed(true); + }); + return () => { + active = false; + if (url) URL.revokeObjectURL(url); + }; + }, [path]); + + const detail = failed ? "Could not load attachment" : metadata; + if (attachment.mimeType.startsWith("image/")) { return (
- - {name} - + {objectUrl ? ( + + {name} + + ) : ( +
+ {failed ? "Unavailable" : "Loading…"} +
+ )}
{name} - {metadata} + {detail}
); } + const label = ( + <> + + + {name} + {detail} + + + ); + + // Until the bytes are fetched there is nothing a link could point at: the + // API path itself would open unauthenticated in a new tab. + if (!objectUrl) return
{label}
; + return ( - - - {name} - {metadata} - + {label} ); @@ -797,10 +848,81 @@ function ArrowIcon() { ); } +/// The whole dashboard is behind the credential: with no token in session +/// storage there is nothing worth rendering, and every API call would 401. +function TokenPrompt({ rejected }: { rejected: boolean }) { + const [value, setValue] = useState(""); + return ( +
+
{ + event.preventDefault(); + const token = value.trim(); + if (token) setToken(token); + }} + > +

Admin token required

+

+ {rejected + ? "That token was rejected. Enter the operator token for this deployment." + : "Enter the operator token for this deployment. It is kept for this browser session only."} +

+ + +
+
+ ); +} + export function App() { const { path } = usePath(); + const token = useToken(); + // Distinguishes the first visit from a token the relay just rejected. + const [everHadToken, setEverHadToken] = useState(token !== null); + useEffect(() => { + if (token !== null) setEverHadToken(true); + }, [token]); + + // When no token is stored, probe the relay to find out whether it requires + // one. In insecure_no_auth mode the probe returns 200 and the dashboard + // renders without a credential; in token mode it returns 401 and the prompt + // is shown. `null` means the probe is still in flight. + const [authRequired, setAuthRequired] = useState( + token !== null ? false : null, + ); + useEffect(() => { + if (token !== null) { + setAuthRequired(false); + return; + } + let active = true; + probeAuthRequired().then((required) => { + if (active) setAuthRequired(required); + }); + return () => { + active = false; + }; + }, [token]); + const report = path.match(/^\/reports\/([^/]+)$/); const feedback = path.match(/^\/feedback\/([^/]+)$/); + + // Probe still in flight — render nothing to avoid a visible flash. + if (authRequired === null) return null; + + if (authRequired && !token) return ; + const content = report ? ( ) : feedback ? ( diff --git a/admin-web/src/api.ts b/admin-web/src/api.ts index 9e5aa7569f..6f05dfe605 100644 --- a/admin-web/src/api.ts +++ b/admin-web/src/api.ts @@ -1,3 +1,5 @@ +import { clearToken, getToken } from "./token"; + const PREFIX = "/api/admin/v1"; export class ApiFailure extends Error { @@ -9,11 +11,24 @@ export class ApiFailure extends Error { } } -export async function request(path: string): Promise { +/// Every admin API call goes through here so the operator token is attached +/// exactly once and a rejected credential is cleared in exactly one place. +/// When no token is present the request is sent without an Authorization +/// header; in insecure_no_auth mode the relay returns 200, while in token +/// mode it returns 401 and the caller discovers that auth is required. +async function send(path: string, accept: string): Promise { + const token = getToken(); + const headers: Record = { accept }; + if (token) headers.authorization = `Bearer ${token}`; const response = await fetch(`${PREFIX}${path}`, { credentials: "same-origin", - headers: { accept: "application/json" }, + headers, }); + if (response.status === 401) { + // Idempotent: concurrent 401s collapse into a single re-prompt. + clearToken(); + throw new ApiFailure(401, "The admin token was rejected."); + } if (!response.ok) { const envelope = await response.json().catch(() => null); throw new ApiFailure( @@ -21,5 +36,40 @@ export async function request(path: string): Promise { envelope?.error?.message ?? `Request failed (${response.status})`, ); } + return response; +} + +export async function request(path: string): Promise { + const response = await send(path, "application/json"); return response.json() as Promise; } + +/// Probe whether the relay requires a bearer token. Issues one unauthenticated +/// request and returns `true` if auth is required (token prompt should be shown) +/// or `false` if the relay returned 200 (insecure_no_auth mode, no token needed). +/// Used by the App shell to skip the token prompt for deployments that rely on +/// network-layer access control. +export async function probeAuthRequired(): Promise { + try { + const response = await fetch(`${PREFIX}/reports`, { + credentials: "same-origin", + headers: { accept: "application/json" }, + }); + // 200 → insecure_no_auth mode, no token needed. + // Anything else — including 401, 403, 404, 5xx — means auth is required + // or the deployment is misconfigured. Show the prompt rather than silently + // skipping it. + return response.status !== 200; + } catch { + // Network error — treat as auth-required so the prompt is shown. + return true; + } +} + +/// Attachments cannot be fetched by `` or `` because those +/// carry no Authorization header. Callers render the object URL and must +/// revoke it when it is replaced or unmounted. +export async function requestObjectUrl(path: string): Promise { + const response = await send(path, "*/*"); + return URL.createObjectURL(await response.blob()); +} diff --git a/admin-web/src/styles.css b/admin-web/src/styles.css index 93ebceeb14..357fef0a0b 100644 --- a/admin-web/src/styles.css +++ b/admin-web/src/styles.css @@ -667,6 +667,26 @@ dd { color: #9f2424; } +.token-prompt input { + width: min(22rem, 70vw); + padding: 0.65rem 0.9rem; + border: 1px solid rgb(35 30 30 / 16%); + border-radius: 0.75rem; + font: inherit; +} + +.token-prompt button { + margin-top: 1rem; +} + +.attachment-placeholder { + min-height: 8rem; + display: grid; + place-content: center; + color: rgb(35 30 30 / 48%); + font-size: 0.8rem; +} + @media (max-width: 720px) { .app-header { width: min(100% - 2rem, 1120px); diff --git a/admin-web/src/token.ts b/admin-web/src/token.ts new file mode 100644 index 0000000000..7d5f4ef3c1 --- /dev/null +++ b/admin-web/src/token.ts @@ -0,0 +1,56 @@ +import { useSyncExternalStore } from "react"; + +/// Session-scoped so the credential dies with the browser tab. It is never +/// written to localStorage or a cookie, and never placed in a URL. +const STORAGE_KEY = "buzz-admin-token"; + +const listeners = new Set<() => void>(); +let token = read(); + +function read(): string | null { + try { + return sessionStorage.getItem(STORAGE_KEY); + } catch { + return null; + } +} + +function publish(next: string | null) { + if (token === next) return; + token = next; + for (const listener of listeners) listener(); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function getToken() { + return token; +} + +export function setToken(next: string) { + try { + sessionStorage.setItem(STORAGE_KEY, next); + } catch { + // The token still works for this page load if storage is blocked. + } + publish(next); +} + +/// Idempotent so concurrent 401s from parallel requests re-prompt once. +export function clearToken() { + try { + sessionStorage.removeItem(STORAGE_KEY); + } catch { + // Nothing was stored; the in-memory token is authoritative. + } + publish(null); +} + +export function useToken() { + return useSyncExternalStore(subscribe, getToken, getToken); +} diff --git a/admin-web/tests/auth.spec.ts b/admin-web/tests/auth.spec.ts new file mode 100644 index 0000000000..3a3f9f8aa8 --- /dev/null +++ b/admin-web/tests/auth.spec.ts @@ -0,0 +1,448 @@ +import { expect, type Page, test } from "@playwright/test"; + +const TOKEN = + "5f0e1d2c3b4a59687786958493a2b1c0decadebeefcafe0123456789abcdef01"; +const STORAGE_KEY = "buzz-admin-token"; + +async function seedToken(page: Page, token = TOKEN) { + await page.addInitScript( + ([key, value]) => { + sessionStorage.setItem(key, value); + }, + [STORAGE_KEY, token], + ); +} + +/// Records the Authorization header of every admin API call the SPA makes. +async function recordAuthorization(page: Page, body: unknown = []) { + const seen: (string | undefined)[] = []; + await page.route("**/api/admin/v1/**", async (route) => { + const authorization = route.request().headers().authorization; + seen.push(authorization); + // Simulate token-mode relay: 401 without a credential, 200 with one. + if (!authorization) { + await route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ + error: { code: "unauthorized", message: "token required" }, + }), + }); + } else { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify(body), + }); + } + }); + return seen; +} + +test("the dashboard prompts for a token before any api call", async ({ + page, +}) => { + const seen = await recordAuthorization(page); + await page.goto("/reports"); + await expect( + page.getByRole("heading", { name: "Admin token required" }), + ).toBeVisible(); + // The probe fires unauthenticated (no Authorization header), but no + // bearer-credentialed call has been made yet. + expect(seen.filter(Boolean)).toHaveLength(0); + + await page.getByPlaceholder("Admin token").fill(TOKEN); + await page.getByRole("button", { name: "Continue" }).click(); + await expect( + page.getByRole("heading", { name: "Open reports" }), + ).toBeVisible(); + expect(seen).toContain(`Bearer ${TOKEN}`); +}); + +test("api calls carry the stored token", async ({ page }) => { + await seedToken(page); + const seen = await recordAuthorization(page); + await page.goto("/reports"); + await expect( + page.getByRole("heading", { name: "Open reports" }), + ).toBeVisible(); + expect(seen).toEqual([`Bearer ${TOKEN}`]); +}); + +test("the token survives a reload within the session", async ({ page }) => { + // Simulate token mode: 401 for unauthenticated, 200 for authenticated. + await page.route("**/api/admin/v1/**", (route) => { + const authorization = route.request().headers().authorization; + if (!authorization) { + route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ + error: { code: "unauthorized", message: "token required" }, + }), + }); + } else { + route.fulfill({ contentType: "application/json", body: "[]" }); + } + }); + await page.goto("/reports"); + await page.getByPlaceholder("Admin token").fill(TOKEN); + await page.getByRole("button", { name: "Continue" }).click(); + await expect( + page.getByRole("heading", { name: "Open reports" }), + ).toBeVisible(); + + await page.reload(); + await expect( + page.getByRole("heading", { name: "Open reports" }), + ).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Admin token required" }), + ).toHaveCount(0); +}); + +test("a rejected token clears storage and re-prompts once", async ({ + page, +}) => { + await seedToken(page, "f".repeat(64)); + await page.route("**/api/admin/v1/**", (route) => + route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ + error: { code: "unauthorized", message: "rejected" }, + }), + }), + ); + + await page.goto("/reports"); + const prompt = page.getByRole("heading", { name: "Admin token required" }); + await expect(prompt).toHaveCount(1); + await expect(page.getByText("That token was rejected.")).toBeVisible(); + expect( + await page.evaluate((key) => sessionStorage.getItem(key), STORAGE_KEY), + ).toBeNull(); +}); + +test("attachments are fetched with the token and rendered from blob urls", async ({ + page, +}) => { + const id = "feedback-with-attachments"; + const imageHash = "a".repeat(64); + const fileHash = "b".repeat(64); + const imageUrl = `https://design.buzz.xyz/media/${imageHash}.png`; + const fileUrl = `https://design.buzz.xyz/media/${fileHash}.txt`; + await seedToken(page); + + const attachmentRequests: { path: string; authorization?: string }[] = []; + await page.route(`**/api/admin/v1/feedback/${id}/attachments/**`, (route) => { + attachmentRequests.push({ + path: new URL(route.request().url()).pathname, + authorization: route.request().headers().authorization, + }); + route.fulfill({ contentType: "application/octet-stream", body: "bytes" }); + }); + await page.route(`**/api/admin/v1/feedback/${id}`, (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + id, + communityId: "one", + communityHost: "design.buzz.xyz", + eventId: "31".repeat(32), + submitterPubkey: "21".repeat(32), + category: "bug", + body: "Composer froze.", + tags: [ + [ + "imeta", + `url ${imageUrl}`, + "m image/png", + `x ${imageHash}`, + "filename screenshot.png", + ], + [ + "imeta", + `url ${fileUrl}`, + "m text/plain", + `x ${fileHash}`, + "filename diagnostics.txt", + ], + ], + eventCreatedAt: "2026-07-17T17:25:00Z", + receivedAt: "2026-07-17T17:30:00Z", + }), + }), + ); + + await page.goto(`/feedback/${id}`); + await expect( + page.getByRole("img", { name: "screenshot.png" }), + ).toHaveAttribute("src", /^blob:/); + await expect( + page.getByRole("link", { name: /diagnostics.txt/ }), + ).toHaveAttribute("href", /^blob:/); + + expect(attachmentRequests.map((request) => request.path).sort()).toEqual( + [ + `/api/admin/v1/feedback/${id}/attachments/${imageHash}`, + `/api/admin/v1/feedback/${id}/attachments/${fileHash}`, + ].sort(), + ); + for (const request of attachmentRequests) { + expect(request.authorization).toBe(`Bearer ${TOKEN}`); + } +}); + +interface ObjectUrlLog { + created: string[]; + revoked: string[]; +} + +declare global { + interface Window { + objectUrlLog: ObjectUrlLog; + clearedCount: number; + } +} + +/// Records every object URL the SPA creates and revokes, so a test can prove a +/// blob handed to the DOM is released rather than merely replaced. +async function instrumentObjectUrls(page: Page) { + await page.addInitScript(() => { + const log: ObjectUrlLog = { created: [], revoked: [] }; + window.objectUrlLog = log; + const create = URL.createObjectURL.bind(URL); + const revoke = URL.revokeObjectURL.bind(URL); + URL.createObjectURL = (source: Blob | MediaSource) => { + const url = create(source); + log.created.push(url); + return url; + }; + URL.revokeObjectURL = (url: string) => { + log.revoked.push(url); + revoke(url); + }; + }); +} + +const FEEDBACK_ID = "feedback-with-attachments"; +const IMAGE_HASH = "a".repeat(64); +const FILE_HASH = "b".repeat(64); + +/// A feedback detail carrying one image and one non-image attachment. +async function routeFeedbackDetail(page: Page) { + const host = "design.buzz.xyz"; + await page.route(`**/api/admin/v1/feedback?**`, (route) => + route.fulfill({ contentType: "application/json", body: "[]" }), + ); + await page.route(`**/api/admin/v1/feedback/${FEEDBACK_ID}`, (route) => + route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + id: FEEDBACK_ID, + communityId: "one", + communityHost: host, + eventId: "31".repeat(32), + submitterPubkey: "21".repeat(32), + category: "bug", + body: "Composer froze.", + tags: [ + [ + "imeta", + `url https://${host}/media/${IMAGE_HASH}.png`, + "m image/png", + `x ${IMAGE_HASH}`, + "filename screenshot.png", + ], + [ + "imeta", + `url https://${host}/media/${FILE_HASH}.txt`, + "m text/plain", + `x ${FILE_HASH}`, + "filename diagnostics.txt", + ], + ], + eventCreatedAt: "2026-07-17T17:25:00Z", + receivedAt: "2026-07-17T17:30:00Z", + }), + }), + ); +} + +test("attachment object urls are revoked when the view is left", async ({ + page, +}) => { + await seedToken(page); + await instrumentObjectUrls(page); + await routeFeedbackDetail(page); + await page.route( + `**/api/admin/v1/feedback/${FEEDBACK_ID}/attachments/**`, + (route) => + route.fulfill({ contentType: "application/octet-stream", body: "bytes" }), + ); + + await page.goto(`/feedback/${FEEDBACK_ID}`); + const imageUrl = await page + .getByRole("img", { name: "screenshot.png" }) + .getAttribute("src"); + const fileUrl = await page + .getByRole("link", { name: /diagnostics.txt/ }) + .getAttribute("href"); + expect(imageUrl).toMatch(/^blob:/); + expect(fileUrl).toMatch(/^blob:/); + expect(await page.evaluate(() => window.objectUrlLog.revoked)).toEqual([]); + + await page.getByRole("link", { name: "Back to feedback" }).click(); + await expect(page.getByRole("heading", { name: "Feedback" })).toBeVisible(); + + await expect + .poll(() => page.evaluate(() => window.objectUrlLog.revoked)) + .toEqual(expect.arrayContaining([imageUrl, fileUrl])); +}); + +test("an attachment that arrives after the view is left is revoked immediately", async ({ + page, +}) => { + await seedToken(page); + await instrumentObjectUrls(page); + await routeFeedbackDetail(page); + let release = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + await page.route( + `**/api/admin/v1/feedback/${FEEDBACK_ID}/attachments/**`, + async (route) => { + await held; + await route.fulfill({ + contentType: "application/octet-stream", + body: "bytes", + }); + }, + ); + + await page.goto(`/feedback/${FEEDBACK_ID}`); + // Both fetches are held, so no blob exists yet. + await expect(page.getByText("Loading…")).toBeVisible(); + expect(await page.evaluate(() => window.objectUrlLog.created)).toEqual([]); + + // Leave before either fetch resolves, then let both complete. + await page.getByRole("link", { name: "Back to feedback" }).click(); + await expect(page.getByRole("heading", { name: "Feedback" })).toBeVisible(); + release(); + + await expect + .poll(() => page.evaluate(() => window.objectUrlLog.revoked.length)) + .toBe(2); + const log = await page.evaluate(() => window.objectUrlLog); + expect(log.revoked.sort()).toEqual(log.created.sort()); + // Nothing was ever handed to the DOM: the blobs outlived their view. + await expect(page.getByRole("img", { name: "screenshot.png" })).toHaveCount( + 0, + ); +}); + +test("concurrent rejected requests re-prompt exactly once", async ({ + page, +}) => { + await seedToken(page, "f".repeat(64)); + await routeFeedbackDetail(page); + // Counts how many rejections reached the centralized clearing path, so the + // test can distinguish "two 401s collapsed into one prompt" from "only one + // request ever failed". + await page.addInitScript(() => { + let cleared = 0; + const remove = sessionStorage.removeItem.bind(sessionStorage); + sessionStorage.removeItem = (key: string) => { + cleared += 1; + remove(key); + }; + Object.defineProperty(window, "clearedCount", { get: () => cleared }); + }); + + // Both attachment requests are held until the second arrives, so the two + // 401s are in flight at the same time. + let secondArrived = () => {}; + const bothInFlight = new Promise((resolve) => { + secondArrived = resolve; + }); + let pending = 2; + await page.route( + `**/api/admin/v1/feedback/${FEEDBACK_ID}/attachments/**`, + async (route) => { + pending -= 1; + if (pending === 0) secondArrived(); + await bothInFlight; + await route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ + error: { code: "unauthorized", message: "rejected" }, + }), + }); + }, + ); + + await page.goto(`/feedback/${FEEDBACK_ID}`); + await expect( + page.getByRole("heading", { name: "Admin token required" }), + ).toHaveCount(1); + await expect(page.getByText("That token was rejected.")).toBeVisible(); + expect( + await page.evaluate((key) => sessionStorage.getItem(key), STORAGE_KEY), + ).toBeNull(); + expect(pending).toBe(0); + await expect + .poll(() => page.evaluate(() => window.clearedCount)) + .toBeGreaterThanOrEqual(2); + await expect( + page.getByRole("heading", { name: "Admin token required" }), + ).toHaveCount(1); +}); + +test("probe: insecure_no_auth mode skips the token prompt when probe returns 200", async ({ + page, +}) => { + // No token in storage. The probe to /api/admin/v1/reports returns 200, + // indicating the relay runs in insecure_no_auth mode. The dashboard must + // render directly without showing the token prompt. + await page.route("**/api/admin/v1/reports**", (route) => + route.fulfill({ contentType: "application/json", body: "[]" }), + ); + + await page.goto("/reports"); + + await expect( + page.getByRole("heading", { name: "Open reports" }), + ).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Admin token required" }), + ).toHaveCount(0); +}); + +test("probe: token mode shows the prompt when probe returns 401", async ({ + page, +}) => { + // No token in storage. The probe to /api/admin/v1/reports returns 401, + // indicating the relay requires a bearer token. The prompt must be shown. + await page.route("**/api/admin/v1/**", (route) => + route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ + error: { code: "unauthorized", message: "token required" }, + }), + }), + ); + + await page.goto("/reports"); + + await expect( + page.getByRole("heading", { name: "Admin token required" }), + ).toBeVisible(); + // The prompt must not say "rejected" on a fresh first visit. + await expect(page.getByText("That token was rejected.")).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Open reports" })).toHaveCount( + 0, + ); +}); diff --git a/admin-web/tests/csp.spec.ts b/admin-web/tests/csp.spec.ts new file mode 100644 index 0000000000..343862ad33 --- /dev/null +++ b/admin-web/tests/csp.spec.ts @@ -0,0 +1,109 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "@playwright/test"; + +const ROUTER_RS = fileURLToPath( + new URL("../../crates/buzz-relay/src/router.rs", import.meta.url), +); + +/// The exact policy the relay serves on admin SPA documents. Read from the +/// relay source rather than copied, so this test can never pass against a +/// policy operators do not actually get. The preview server used here serves +/// no CSP of its own, so it is injected below. +function adminCsp() { + const source = readFileSync(ROUTER_RS, "utf8"); + const match = source.match(/const ADMIN_CSP: &str = "([^"]+)";/); + if (!match) throw new Error(`ADMIN_CSP not found in ${ROUTER_RS}`); + return match[1]; +} + +const TOKEN = + "5f0e1d2c3b4a59687786958493a2b1c0decadebeefcafe0123456789abcdef01"; + +test("the relay admin csp does not break the built dashboard", async ({ + page, +}) => { + const csp = adminCsp(); + expect(csp).toContain("frame-ancestors 'none'"); + expect(csp).not.toContain("unsafe-inline"); + + await page.addInitScript((token) => { + sessionStorage.setItem("buzz-admin-token", token); + }, TOKEN); + await page.route("**/api/admin/v1/**", (route) => + route.fulfill({ contentType: "application/json", body: "[]" }), + ); + // Only the document request: the API call to /reports carries a query string. + await page.route( + (url) => url.pathname === "/reports" && url.search === "", + async (route) => { + const response = await route.fetch(); + await route.fulfill({ + response, + headers: { ...response.headers(), "content-security-policy": csp }, + }); + }, + ); + + const violations: string[] = []; + page.on("console", (message) => { + if (message.text().includes("Content Security Policy")) + violations.push(message.text()); + }); + + await page.goto("/reports"); + + // Rendering at all proves the bundle's script and stylesheet loaded, and the + // empty state proves the fetch to /api/admin/v1 survived `connect-src 'self'`. + await expect( + page.getByRole("heading", { name: "Open reports" }), + ).toBeVisible(); + await expect(page.getByText("No records.")).toBeVisible(); + expect(violations).toEqual([]); +}); + +test("the linked favicon loads under the admin csp", async ({ page }) => { + const csp = adminCsp(); + + await page.route( + (url) => url.pathname === "/" && url.search === "", + async (route) => { + const response = await route.fetch(); + await route.fulfill({ + response, + headers: { ...response.headers(), "content-security-policy": csp }, + }); + }, + ); + + const violations: string[] = []; + page.on("console", (message) => { + if (message.text().includes("Content Security Policy")) + violations.push(message.text()); + }); + + await page.goto("/"); + + const href = await page.locator("link[rel=icon]").getAttribute("href"); + expect(href).toBe("/favicon.svg"); + + // Headless Chromium never issues the `` request itself, so + // load the same file the same way the policy sees it: an image fetch under + // `img-src 'self'`. A blocked fetch rejects `decode()`; a successful one + // proves the icon renders, and that the SVG's own inline