From c4a3f4ba1e968d9c65c4768281eb334c8f3cb1b9 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 1 Sep 2026 23:00:28 +0900 Subject: [PATCH] fix: harden Web UX capture profiles --- .gitignore | 1 - tools/web-ux/README.md | 37 +++++--- .../browser-tests/capture_smoke_test.ts | 5 +- tools/web-ux/cli.ts | 26 ++++-- tools/web-ux/scenarios/anonymous-entry.json | 9 ++ .../scenarios/workspace-control-plane.json | 20 +++++ tools/web-ux/src/auth_state.ts | 87 +++++++++++++++++++ tools/web-ux/src/capture.ts | 3 +- tools/web-ux/src/lifecycle.ts | 32 ++++--- tools/web-ux/src/processes.ts | 6 +- tools/web-ux/tests/auth_state_test.ts | 56 ++++++++++++ 11 files changed, 247 insertions(+), 35 deletions(-) create mode 100644 tools/web-ux/src/auth_state.ts create mode 100644 tools/web-ux/tests/auth_state_test.ts diff --git a/.gitignore b/.gitignore index 0b737840..838d45b8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,3 @@ *.local* .env .web-ux/ -artifacts/web-ux/ diff --git a/tools/web-ux/README.md b/tools/web-ux/README.md index cabcf9ef..14a5b147 100644 --- a/tools/web-ux/README.md +++ b/tools/web-ux/README.md @@ -57,7 +57,9 @@ export WORKSPACE_ID='' Authentication state is local sensitive material. `.web-ux/` is gitignored, files are written with mode `0600`, state contents are never copied into a review bundle, and the CLI never prints cookies -or credentials. +or credentials. Each profile has a sidecar binding it to the exact persona and base URL origin with +a 12-hour default expiry. Capture fails explicitly when metadata is missing, the origin differs, or +the profile has expired; it never silently reuses or refreshes that state. For an interactive Passkey/browser login: @@ -78,7 +80,17 @@ without putting its value on the command line: deno task web-ux auth \ --scenario scenarios/workspace-control-plane.json \ --persona owner \ - --import-state /private/path/owner-state.json + --import-state /private/path/owner-state.json \ + --expires-in-hours 8 +``` + +Delete both the profile and its metadata when it is no longer needed: + +```sh +deno task web-ux auth \ + --scenario scenarios/workspace-control-plane.json \ + --persona owner \ + --delete ``` Do not place passwords, bearer tokens, private keys, WebAuthn material, or inline cookies in a @@ -93,7 +105,7 @@ Capture a stable multi-persona bundle: ```sh deno task web-ux capture \ --scenario scenarios/workspace-control-plane.json \ - --output ../../artifacts/web-ux \ + --output ../../target/web-ux \ --run-id before-change ``` @@ -102,7 +114,7 @@ Use filters for a bounded feedback loop: ```sh deno task web-ux capture \ --scenario scenarios/workspace-control-plane.json \ - --output ../../artifacts/web-ux \ + --output ../../target/web-ux \ --run-id ticket-list-after \ --personas owner,non-owner \ --routes tickets \ @@ -127,9 +139,9 @@ the new evidence. Playwright success alone is not visual acceptance. ```sh deno task web-ux compare \ - --before ../../artifacts/web-ux/before-change/review-context.json \ - --after ../../artifacts/web-ux/after-change/review-context.json \ - --output ../../artifacts/web-ux/before-vs-after + --before ../../target/web-ux/before-change/review-context.json \ + --after ../../target/web-ux/after-change/review-context.json \ + --output ../../target/web-ux/before-vs-after ``` `comparison.html` and `comparison.png` show before, after, and pixel diff side by side. @@ -150,13 +162,14 @@ that it did not start. Old complete review bundles can be removed without touching auth state or arbitrary directories: ```sh -deno task web-ux cleanup --output ../../artifacts/web-ux --keep 5 --older-than-days 14 --dry-run -deno task web-ux cleanup --output ../../artifacts/web-ux --keep 5 --older-than-days 14 +deno task web-ux cleanup --output ../../target/web-ux --keep 5 --older-than-days 14 --dry-run +deno task web-ux cleanup --output ../../target/web-ux --keep 5 --older-than-days 14 ``` -Cleanup recognizes only directories containing `review-context.json`. `.web-ux/` and -`artifacts/web-ux/` are ignored by Git. Keep a bundle outside Git or publish it through the approved -immutable artifact channel when durable review evidence is required. +Cleanup recognizes only directories containing `review-context.json`. `.web-ux/` and the repository +`target/` tree are ignored by Git. `capture` defaults to `target/web-ux` when `--output` is omitted. +Keep a bundle outside Git or publish it through the approved immutable artifact channel when durable +review evidence is required. ## Adding a scenario diff --git a/tools/web-ux/browser-tests/capture_smoke_test.ts b/tools/web-ux/browser-tests/capture_smoke_test.ts index 8b655495..cce16b14 100644 --- a/tools/web-ux/browser-tests/capture_smoke_test.ts +++ b/tools/web-ux/browser-tests/capture_smoke_test.ts @@ -1,5 +1,6 @@ import { assertEquals, assertRejects } from "@std/assert"; import { join } from "@std/path"; +import { writeAuthMetadata } from "../src/auth_state.ts"; import { capture } from "../src/capture.ts"; async function freePort(): Promise { @@ -17,8 +18,9 @@ Deno.test("browser smoke captures distinct owner and non-owner evidence and clea const authDirectory = join(directory, "auth"); await Deno.mkdir(authDirectory); for (const persona of ["owner", "non-owner"]) { + const storageState = join(authDirectory, `${persona}.json`); await Deno.writeTextFile( - join(authDirectory, `${persona}.json`), + storageState, JSON.stringify({ cookies: [{ name: "persona", @@ -33,6 +35,7 @@ Deno.test("browser smoke captures distinct owner and non-owner evidence and clea origins: [], }), ); + await writeAuthMetadata(storageState, persona, baseUrl, 1); } const scenarioPath = join(directory, "scenario.json"); await Deno.writeTextFile( diff --git a/tools/web-ux/cli.ts b/tools/web-ux/cli.ts index 6351642a..2c5472f9 100644 --- a/tools/web-ux/cli.ts +++ b/tools/web-ux/cli.ts @@ -1,13 +1,17 @@ #!/usr/bin/env -S deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys +import { dirname, fromFileUrl, resolve } from "@std/path"; import { authenticate, cleanup } from "./src/lifecycle.ts"; import { capture, describeCapture } from "./src/capture.ts"; import { compare } from "./src/compare.ts"; +const DEFAULT_OUTPUT = resolve(dirname(fromFileUrl(import.meta.url)), "../..", "target/web-ux"); + const HELP = `Web UX inspection workbench Usage: - deno task web-ux auth --scenario --persona [--base-url ] [--import-state ] [--headless] - deno task web-ux capture --scenario --output [--base-url ] [--run-id ] [--personas ] [--routes ] [--viewports ] [--headed] + deno task web-ux auth --scenario --persona [--base-url ] [--import-state ] [--expires-in-hours ] [--headless] + deno task web-ux auth --scenario --persona --delete + deno task web-ux capture --scenario [--output ] [--base-url ] [--run-id ] [--personas ] [--routes ] [--viewports ] [--headed] deno task web-ux compare --before --after --output [--threshold <0..1>] deno task web-ux cleanup --output [--keep ] [--older-than-days ] [--dry-run] @@ -81,18 +85,26 @@ export async function main(rawArgs: string[]): Promise { return 0; } if (args.command === "auth") { - rejectUnknown(args, ["scenario", "persona", "base-url", "import-state", "timeout-ms"], [ - "headless", - ]); + rejectUnknown( + args, + ["scenario", "persona", "base-url", "import-state", "timeout-ms", "expires-in-hours"], + ["headless", "delete"], + ); + const deleting = args.flags.has("delete"); + if (deleting && optional(args, "import-state")) { + throw new Error("--delete cannot be combined with --import-state"); + } const path = await authenticate({ scenarioPath: required(args, "scenario"), personaId: required(args, "persona"), baseUrl: optional(args, "base-url"), importState: optional(args, "import-state"), timeoutMs: integer(args, "timeout-ms"), + expiresInHours: integer(args, "expires-in-hours"), + delete: deleting, headless: args.flags.has("headless"), }); - console.log(`auth state saved: ${path}`); + console.log(`auth state ${deleting ? "deleted" : "saved"}: ${path}`); return 0; } if (args.command === "capture") { @@ -105,7 +117,7 @@ export async function main(rawArgs: string[]): Promise { "routes", "viewports", ], ["headed"]); - const outputDirectory = required(args, "output"); + const outputDirectory = optional(args, "output") ?? DEFAULT_OUTPUT; const manifest = await capture({ scenarioPath: required(args, "scenario"), outputDirectory, diff --git a/tools/web-ux/scenarios/anonymous-entry.json b/tools/web-ux/scenarios/anonymous-entry.json index 5eda5fe6..95a329a5 100644 --- a/tools/web-ux/scenarios/anonymous-entry.json +++ b/tools/web-ux/scenarios/anonymous-entry.json @@ -23,6 +23,15 @@ "dataState": "Fresh browser context with no cookies, local storage, or session state.", "ready": { "kind": "network-idle", "timeoutMs": 15000 }, "capturePoints": [{ "id": "initial", "label": "Anonymous entry", "fullPage": true }] + }, + { + "id": "account", + "label": "Account entry", + "path": "/account", + "goal": "Understand current authentication state and the available account action without Workspace-private content.", + "dataState": "Fresh browser context with no cookies, local storage, or session state.", + "ready": { "kind": "network-idle", "timeoutMs": 15000 }, + "capturePoints": [{ "id": "initial", "label": "Anonymous account screen", "fullPage": true }] } ] } diff --git a/tools/web-ux/scenarios/workspace-control-plane.json b/tools/web-ux/scenarios/workspace-control-plane.json index 81da7d09..e23962fc 100644 --- a/tools/web-ux/scenarios/workspace-control-plane.json +++ b/tools/web-ux/scenarios/workspace-control-plane.json @@ -57,6 +57,15 @@ "ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 }, "capturePoints": [{ "id": "initial", "label": "Worker list", "fullPage": true }] }, + { + "id": "settings", + "label": "Workspace settings", + "path": "/w/${WORKSPACE_ID}/settings", + "goal": "Reach the relevant Workspace settings area without presenting owner-only destinations as usable actions to a non-owner.", + "dataState": "Current permission projection for the selected Workspace.", + "ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 }, + "capturePoints": [{ "id": "initial", "label": "Workspace settings", "fullPage": true }] + }, { "id": "repositories", "label": "Repository settings", @@ -65,6 +74,17 @@ "dataState": "Workspace repository catalog projected through current permissions.", "ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 }, "capturePoints": [{ "id": "initial", "label": "Repository settings", "fullPage": true }] + }, + { + "id": "repository-access", + "label": "Repository access", + "path": "/w/${WORKSPACE_ID}/settings/repository-access", + "goal": "Review credential and host-trust bindings as an owner and verify non-owner composition fails closed without secret material.", + "dataState": "Configured repository access bindings projected without credential bytes.", + "ready": { "kind": "selector", "selector": "main", "timeoutMs": 20000 }, + "capturePoints": [ + { "id": "initial", "label": "Repository access settings", "fullPage": true } + ] } ] } diff --git a/tools/web-ux/src/auth_state.ts b/tools/web-ux/src/auth_state.ts new file mode 100644 index 00000000..25a9288e --- /dev/null +++ b/tools/web-ux/src/auth_state.ts @@ -0,0 +1,87 @@ +import { writePrivateJson } from "./artifacts.ts"; + +export type AuthStateMetadata = { + schemaVersion: 1; + personaId: string; + baseOrigin: string; + createdAt: string; + expiresAt: string; +}; + +export function authMetadataPath(storageStatePath: string): string { + return `${storageStatePath}.meta.json`; +} + +function baseOrigin(baseUrl: string): string { + return new URL(baseUrl).origin; +} + +export async function writeAuthMetadata( + storageStatePath: string, + personaId: string, + baseUrl: string, + expiresInHours: number, +): Promise { + if (!Number.isFinite(expiresInHours) || expiresInHours <= 0) { + throw new Error("auth state expiry must be a positive number of hours"); + } + const createdAt = new Date(); + const metadata: AuthStateMetadata = { + schemaVersion: 1, + personaId, + baseOrigin: baseOrigin(baseUrl), + createdAt: createdAt.toISOString(), + expiresAt: new Date(createdAt.getTime() + expiresInHours * 60 * 60 * 1000).toISOString(), + }; + await writePrivateJson(authMetadataPath(storageStatePath), metadata); +} + +export async function validateAuthState( + storageStatePath: string, + personaId: string, + baseUrl: string, + now = new Date(), +): Promise { + await Deno.stat(storageStatePath); + let parsed: unknown; + try { + parsed = JSON.parse(await Deno.readTextFile(authMetadataPath(storageStatePath))); + } catch (error) { + throw new Error( + `auth state metadata is missing or invalid for ${personaId}; run the auth command again: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error(`auth state metadata is invalid for ${personaId}`); + } + const metadata = parsed as Partial; + if (metadata.schemaVersion !== 1 || metadata.personaId !== personaId) { + throw new Error(`auth state metadata does not match persona ${personaId}`); + } + if (metadata.baseOrigin !== baseOrigin(baseUrl)) { + throw new Error( + `auth state for ${personaId} belongs to ${metadata.baseOrigin ?? "an unknown origin"}, not ${ + baseOrigin(baseUrl) + }`, + ); + } + const expiresAt = Date.parse(metadata.expiresAt ?? ""); + if (!Number.isFinite(expiresAt)) throw new Error(`auth state expiry is invalid for ${personaId}`); + if (expiresAt <= now.getTime()) { + throw new Error( + `auth state expired for ${personaId} at ${metadata.expiresAt}; run the auth command again`, + ); + } +} + +export async function deleteAuthState(storageStatePath: string): Promise { + for (const path of [storageStatePath, authMetadataPath(storageStatePath)]) { + try { + await Deno.remove(path); + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) throw error; + } + } +} diff --git a/tools/web-ux/src/capture.ts b/tools/web-ux/src/capture.ts index aed3c52d..4608723e 100644 --- a/tools/web-ux/src/capture.ts +++ b/tools/web-ux/src/capture.ts @@ -1,5 +1,6 @@ import { basename, dirname, join, relative, resolve } from "@std/path"; import { type Browser, chromium, type Page, type Response } from "playwright"; +import { validateAuthState } from "./auth_state.ts"; import { assertBundleIsSecretFree, bounded, @@ -348,7 +349,7 @@ export async function capture(options: CaptureOptions): Promise { const storageState = persona.auth.kind === "storage-state" ? resolveScenarioPath(scenarioPath, persona.auth.path) : undefined; - if (storageState) await Deno.stat(storageState); + if (storageState) await validateAuthState(storageState, persona.id, baseUrl); for (const viewport of viewports) { const context = await browser.newContext({ storageState, diff --git a/tools/web-ux/src/lifecycle.ts b/tools/web-ux/src/lifecycle.ts index bdf0b783..fc6eae4a 100644 --- a/tools/web-ux/src/lifecycle.ts +++ b/tools/web-ux/src/lifecycle.ts @@ -1,6 +1,7 @@ import { dirname, resolve } from "@std/path"; import { chromium } from "playwright"; -import { ensurePrivateDirectory, writePrivateJson } from "./artifacts.ts"; +import { ensurePrivateDirectory, makePrivate, writePrivateJson } from "./artifacts.ts"; +import { deleteAuthState, writeAuthMetadata } from "./auth_state.ts"; import { interpolateEnvironment, loadScenario, @@ -14,6 +15,8 @@ export type AuthOptions = { baseUrl?: string; importState?: string; timeoutMs?: number; + expiresInHours?: number; + delete?: boolean; headless?: boolean; }; @@ -37,22 +40,28 @@ export async function authenticate(options: AuthOptions): Promise { throw new Error(`persona ${persona.id} is anonymous and has no auth state`); } const outputPath = resolveScenarioPath(scenarioPath, persona.auth.path); - await ensurePrivateDirectory(dirname(outputPath)); - if (options.importState) { - const imported = validateStorageState( - JSON.parse(await Deno.readTextFile(resolve(options.importState))), - ); - await writePrivateJson(outputPath, imported); + if (options.delete) { + await deleteAuthState(outputPath); return outputPath; } - if (!persona.login) { - throw new Error(`persona ${persona.id} needs login configuration or --import-state`); - } + await ensurePrivateDirectory(dirname(outputPath)); const baseUrl = validateBaseUrl( interpolateEnvironment( options.baseUrl ?? Deno.env.get("WEB_UX_BASE_URL") ?? scenario.baseUrl ?? "", ), ); + const expiresInHours = options.expiresInHours ?? 12; + if (options.importState) { + const imported = validateStorageState( + JSON.parse(await Deno.readTextFile(resolve(options.importState))), + ); + await writePrivateJson(outputPath, imported); + await writeAuthMetadata(outputPath, persona.id, baseUrl, expiresInHours); + return outputPath; + } + if (!persona.login) { + throw new Error(`persona ${persona.id} needs login configuration or --import-state`); + } const browser = await chromium.launch({ headless: options.headless ?? false }); try { const context = await browser.newContext(); @@ -67,7 +76,8 @@ export async function authenticate(options: AuthOptions): Promise { }); } await context.storageState({ path: outputPath }); - if (Deno.build.os !== "windows") await Deno.chmod(outputPath, 0o600); + await makePrivate(outputPath); + await writeAuthMetadata(outputPath, persona.id, baseUrl, expiresInHours); return outputPath; } finally { await browser.close(); diff --git a/tools/web-ux/src/processes.ts b/tools/web-ux/src/processes.ts index dce99086..7fca0876 100644 --- a/tools/web-ux/src/processes.ts +++ b/tools/web-ux/src/processes.ts @@ -38,8 +38,10 @@ async function waitForReady(url: string, timeoutMs: number): Promise { while (Date.now() < deadline) { try { const response = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(2_000) }); - if (response.status < 500) return; - lastError = `HTTP ${response.status}`; + const status = response.status; + await response.body?.cancel(); + if (status < 500) return; + lastError = `HTTP ${status}`; } catch (error) { lastError = error instanceof Error ? error.message : String(error); } diff --git a/tools/web-ux/tests/auth_state_test.ts b/tools/web-ux/tests/auth_state_test.ts new file mode 100644 index 00000000..d3685fea --- /dev/null +++ b/tools/web-ux/tests/auth_state_test.ts @@ -0,0 +1,56 @@ +import { assertRejects } from "@std/assert"; +import { join } from "@std/path"; +import { + authMetadataPath, + deleteAuthState, + validateAuthState, + writeAuthMetadata, +} from "../src/auth_state.ts"; + +Deno.test("auth state is bound to persona, base origin, and expiry", async () => { + const directory = await Deno.makeTempDir(); + try { + const state = join(directory, "owner.json"); + await Deno.writeTextFile(state, '{"cookies":[],"origins":[]}'); + await writeAuthMetadata(state, "owner", "https://example.test/path", 1); + await validateAuthState(state, "owner", "https://example.test/other"); + await assertRejects( + () => validateAuthState(state, "non-owner", "https://example.test"), + Error, + "does not match persona", + ); + await assertRejects( + () => validateAuthState(state, "owner", "https://other.test"), + Error, + "belongs to https://example.test", + ); + await assertRejects( + () => + validateAuthState( + state, + "owner", + "https://example.test", + new Date(Date.now() + 2 * 60 * 60 * 1000), + ), + Error, + "auth state expired", + ); + } finally { + await Deno.remove(directory, { recursive: true }); + } +}); + +Deno.test("auth state deletion removes state and metadata idempotently", async () => { + const directory = await Deno.makeTempDir(); + try { + const state = join(directory, "owner.json"); + await Deno.writeTextFile(state, "{}"); + await writeAuthMetadata(state, "owner", "http://127.0.0.1:3000", 1); + await deleteAuthState(state); + await deleteAuthState(state); + await assertRejects(() => Deno.stat(state), Deno.errors.NotFound); + await assertRejects(() => Deno.stat(authMetadataPath(state)), Deno.errors.NotFound); + } finally { + await Deno.remove(directory, { recursive: true }); + } +});