From 86c9b30c53165c1ad85ef4422ce2c8fdfb861078 Mon Sep 17 00:00:00 2001 From: abrcrmb Date: Thu, 8 Oct 2026 16:01:19 +0100 Subject: [PATCH] test: automated claim-link fragment-leak check (M3-14) The security headers/CSP work already existed; the row's actual Done-when (an automated test confirming the claim-link fragment never appears in any network request) didn't. csp.test.ts only unit-tests the CSP string builder, not real runtime behavior. Added e2e/claim-fragment-privacy.spec.ts: loads /claim/[id] with a real-shaped fragment, captures every outbound request for the page's lifetime, fails if the reference or salt appears anywhere. Configured playwright.config.ts with a webServer and baseURL (neither existed). Verified the test can actually fail: temporarily injected a real leak into useClaim.ts, confirmed the test caught it, reverted (byte- identical diff), confirmed a clean pass on the real code. Not done here: wiring into CI. .github/workflows/e2e.yml is marked DRAFT (ask-first: CI); editing it needs explicit approval. --- ROADMAP.md | 9 ++--- e2e/claim-fragment-privacy.spec.ts | 54 ++++++++++++++++++++++++++++++ playwright.config.ts | 13 ++++++- 3 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 e2e/claim-fragment-privacy.spec.ts diff --git a/ROADMAP.md b/ROADMAP.md index 5b0398e..20208aa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ | | | |---|---| -| **Last updated** | 2026-10-08 (real testnet `create_lock` verified; M3-06 narrowed to the app's own signing path) | +| **Last updated** | 2026-10-08 (M3-14: automated fragment-leak test) | | **Docs baseline** | v0.3, worldwide scope (`PRD.md`, `ARCHITECTURE.md`, `ARCHITECTURE_ESSENTIALS.md`, `AGENTS.md`, `CLAUDE.md`, `project_structure.md`) | | **Current phase** | Phase 0 (Foundations) → starting Phase 1 (M0 Validate) | | **Readiness** | See [§3](#3-progress-snapshot) | @@ -131,7 +131,7 @@ Readiness % = `DONE ÷ (all rows − DEFERRED − DROPPED)`. Conditional rows (P | 1 M0 Validate | 17 | 0 | 1 | 16 | 0 | 0 | 0 | 0% | | 2 Contract + registry | 36 | 16 | 5 | 15 | 0 | 0 | 0 | 44% | | 3 SDK + indexer | 19 | 1 | 1 | 17 | 0 | 0 | 0 | 5% | -| 4 App | 26 | 13 | 6 | 7 | 0 | 0 | 0 | 50% | +| 4 App | 26 | 14 | 5 | 7 | 0 | 0 | 0 | 54% | | 5 Testnet pilot | 7 | 0 | 0 | 7 | 0 | 0 | 0 | 0% | | 6 Pre-mainnet features | 10 | 0 | 0 | 10 | 0 | 0 | 0 | 0% | | 7 Hardening | 26 | 0 | 0 | 26 | 0 | 0 | 0 | 0% | @@ -139,7 +139,7 @@ Readiness % = `DONE ÷ (all rows − DEFERRED − DROPPED)`. Conditional rows (P | 9 Conditional ramp | 9 | 0 | 0 | 0 | 0 | 9 | 0 | n/a | | 10 Wave + community | 8 | 1 | 0 | 7 | 0 | 0 | 0 | 13% | | 11 Deferred parking lot | 16 | 0 | 0 | 0 | 0 | 16 | 0 | n/a | -| **All** | **201** | **43** | **15** | **118** | **0** | **25** | **0** | **24%** | +| **All** | **201** | **44** | **14** | **118** | **0** | **25** | **0** | **25%** | --- @@ -303,7 +303,7 @@ Readiness % = `DONE ÷ (all rows − DEFERRED − DROPPED)`. Conditional rows (P | M3-11 | `/payee` dashboard (lists via indexer) | app | P0 | DONE | M2-12 | Filter by status and reference | | M3-12 | `/attester` tooling: read-only account checks (trustline, authorization, XLM float) | app | P1 | TODO | M1-26 | Checks match the checklist script | | M3-13 | `/r/[txHash]/[eventIndex]` receipt page and `/verify` | app | P0 | DONE | M2-06 | Shows "Payment to verified payee"; Valid or Not valid | -| M3-14 | Security headers, strict CSP, `Referrer-Policy`, no third-party scripts on `/claim/*` | app | P0 | IN PROGRESS | M3-09 | Automated test confirms fragment never appears in any network request | +| M3-14 | Security headers, strict CSP, `Referrer-Policy`, no third-party scripts on `/claim/*` | app | P0 | DONE | M3-09 | Automated test confirms fragment never appears in any network request | | M3-15 | `AssetLabel` (code and issuer), `AmountDisplay`, UTC+local time components | app | P0 | IN PROGRESS | M3-01 | Used everywhere money or time is shown | | M3-16 | Choose and implement the `RateProvider` for indicative local-currency equivalents across currencies, with USD-only fallback and disclosure | app | P0 | TODO | M0-05 | Source documented; labeled indicative in UI | | M3-17 | Accessibility pass (keyboard, labels, contrast) | app | P1 | IN PROGRESS | M3-13 | Audit checklist passed | @@ -525,6 +525,7 @@ Newest first. One entry per PR. Required for every contribution (see §2). | Date | PR / ref | Repo | Rows touched | Summary | |---|---|---|---|---| +| 2026-10-08 | `feat/m3-14-fragment-leak-test` | app | DONE: M3-14 | The security headers, strict CSP, and no-third-party-scripts work was already built (`proxy.ts`, `lib/security/csp.ts`); the row's actual "Done when" (an automated test confirming the claim-link fragment never appears in any network request) didn't exist, and `lib/security/csp.test.ts` only unit-tests the CSP string builder, not real runtime behavior. Added `e2e/claim-fragment-privacy.spec.ts`: loads `/claim/11` (a real, already-existing testnet lock) with a real-shaped fragment, captures every outbound request for the page's lifetime (URL, body, headers), and fails if the reference or salt appears anywhere. Configured `playwright.config.ts` with a `webServer` (`pnpm build && pnpm start`, since `/claim/*`'s CSP differs between dev and production) and a `baseURL`, neither of which existed before. Verified the test can actually fail, not just trivially pass: temporarily injected a real leak into `useClaim.ts` (a stray `fetch` echoing the reference and salt), confirmed the test caught it with a clear diagnostic, then reverted (`git diff` confirms byte-identical to before) and confirmed a clean pass on the real code. Also caught and fixed two test-design bugs along the way: an invalid (non-`generateSalt()`-shaped) test salt silently made `parseClaimLink` throw before any of the code under test ran, and `waitForLoadState("networkidle")` never resolves on this page (it polls), so switched to a fixed wait. Verified: `pnpm lint`, `pnpm typecheck`, `pnpm test` (108/108), `pnpm build`, and the E2E test itself, run manually multiple times. **Not done here**: wiring this into CI. `.github/workflows/e2e.yml` is marked `DRAFT (ask-first: CI)` and currently just echoes a placeholder; actually running `pnpm e2e` there needs a human to approve editing the workflow file, per `AGENTS.md`'s CI/CD ask-first rule. No new secrets needed (all required env vars are the existing public `NEXT_PUBLIC_*` testnet config) | | 2026-10-08 | `docs/m3-06-testnet-verification` | app | M3-06 (status unchanged, `IN PROGRESS`) | Attempted the owner's ask to verify `/send` by driving the live app with a real testnet wallet. Got substantially further than "can't automate a browser extension": `@creit.tech/stellar-wallets-kit`'s Albedo option is web-based (no extension) and exposes "provide secret key directly," reachable via Playwright against the real `kinlock-app.vercel.app/send` all the way to the secret-entry field. Writing the secret into any file or process for that automation was blocked outright by the harness's credential-materialization check, including after the owner pasted it directly into the conversation; did not attempt to work around that. Fell back to proving the layer underneath instead: called the deployed contract's `create_lock` directly via `stellar contract invoke` (using a CLI-keystore identity by name, so no secret was ever materialized by this agent), from a freshly generated, friendbot-funded sender account with a real Circle testnet-USDC trustline and balance (owner completed the faucet's human-verification step). Simulated first, then submitted for real: lock id `11` created on the live testnet contract (tx `0b65ec01b50052bac645242c9d375151c4a2b7517856340504d5b7aa46d2042a`, verifiable on stellar.expert), 2 USDC transferred to the contract, correct payee/payout snapshot. Confirmed the indexer picked it up correctly (`/locks/11` matches the submitted transaction exactly) and that the live app's own `/locks/11` and `/claim/11` pages render it (`200`, real chain data). This proves the contract and SDK path End to end on testnet, the thing `/send`'s own code depends on, but does **not** prove `/send`'s own `useSendForm.ts` → wallet-kit → `createLock()` code path specifically, since the actual signing step was bypassed via direct CLI rather than driven through the app. Narrowing what's left for `M3-06`: a human completing the Albedo "provide secret key" step themselves in their own browser against the live site, or a CI-sanctioned signing approach for `M3-19` (still an open, deferred decision) | | 2026-10-08 | `feat/m3-21-indexer-lag-notice` | app | DONE: M3-21 | Audited each of the row's five named states against the actual message keys and error-mapping code (not assumed): **trustline failure** (claim/release side: `claim-logic.ts`'s `CONTRACT_ERRORS` plus `errorTx`'s "can't receive this asset yet" phrasing; send side: `preflight.payout_trustline_authorized.*`, already specific per failure mode), **expired** (`claim.errorExpired`, `lock.refundAvailable.Expired`), **revoked** and **suspended** (`claim.errorPayeeNotActive` via the contract's `PayeeNotActive` error; `preflight.payee_active.revoked`/`.suspended`; `lock.refundAvailable.Revoked`/`.SuspendedTimeout`) were all already clear and, per the SDK's own preflight doc comment, chain-backed where it matters (`payee_active` and `payout_trustline_authorized` are blocking chain checks, never indexer-sourced, so none of this is at risk from indexer lag). **Indexer lag** was the one genuinely missing piece: added `indexerLagging()` to `lib/indexer/index.ts` (reads the indexer's own `/health` status, defaulting to "possibly stale" on any failure so a broken health check can't silently hide a real lag) and a shared `` component, shown on `/send`, `/request`, and `/payee` when the indexer reports anything other than `ok`. Purely informational, never blocking: every actual money decision already re-reads chain state regardless. Verified: `pnpm lint`, `pnpm typecheck`, `pnpm test` (108/108, 5 new in `lib/indexer/index.test.ts`), `pnpm build`; confirmed against the real indexer (currently healthy, `lagLedgers: 1`, correctly shows no notice) that the three pages still render and list payees correctly with the added parallel health fetch | | 2026-10-08 | `feat/payee-dashboard` | app | DONE: M3-11 | Built `/payee`, replacing its `PageStub`. Connect a wallet; `matchingPayeeIds` (new, `payee-logic.ts`) finds every registered payee this wallet is the payout address for, by matching against the already-fetched indexer payee list (no new chain or indexer call needed for that step). For each match, lists its locks via a new `listLocks(payeeId, state?)` in `lib/indexer/index.ts`, filterable by status (server-side, re-queries the indexer per change) and by one specific payment: pasting a claim link runs the exact same `parseClaimLink` + `computeRefHash` check `/claim/[id]` already uses, entirely client-side (hard rule 5: the reference and salt never leave the browser; only `ref_hash` is ever compared). `listLocks` is callable from the browser too (same-origin, via `next.config.ts`'s existing `/locks` rewrite), unlike `listPayees` which is server-only, since the dashboard doesn't know which payeeId to ask for until a wallet connects client-side. Each row links to `/claim/[id]` for the payee's actual release/decline actions; nothing here moves money. Verified: `pnpm lint`, `pnpm typecheck`, `pnpm test` (103/103, 9 new in `payee-logic.test.ts`), `pnpm build` all green; confirmed the `/locks` rewrite returns real, already-created testnet locks for a known payeeId with the exact shape `listLocks` expects (`curl` against a local build with the real indexer URL); visually verified the pre-connect page renders cleanly with no console errors. Not independently verified: the post-wallet-connect render with real data, since driving a real wallet extension isn't automatable (same limitation already accepted for `/send`'s wallet step this session) | diff --git a/e2e/claim-fragment-privacy.spec.ts b/e2e/claim-fragment-privacy.spec.ts new file mode 100644 index 0000000..4b0a1ee --- /dev/null +++ b/e2e/claim-fragment-privacy.spec.ts @@ -0,0 +1,54 @@ +/** + * [sec] The claim-link fragment (#r=&s=) must never reach a server, log, + * analytics tool, error tracker, or third-party script (hard rule 5, AGENTS.md §3). This is the + * automated check roadmap M3-14 asks for: load /claim/[id] with a real fragment, capture every + * outbound network request the page makes for its whole lifetime, and fail if the reference or + * salt shows up anywhere in a URL, request body, or request header. + * + * Browsers never send the fragment in the initial navigation request by design; the real risk + * this guards against is a later JS-initiated request (an RPC call, an indexer call, a third-party + * script, an error-tracker payload) echoing `window.location.href` or similar. The request + * listener below is attached before navigation, so it also covers the initial request. + * + * Lock 11 is a real lock on the testnet contract (created 2026-10-08, verifying roadmap M3-06); + * any Open lock id would do, since this test never checks whether the reference is valid. + */ +import { buildClaimLink, generateSalt } from "@kinlock/sdk"; +import { expect, test } from "@playwright/test"; + +const SECRET_REFERENCE = "e2e-fragment-privacy-check-reference"; +// Must be a real generateSalt()-shaped value: parseClaimLink (and so the app's own fragment +// handling) rejects anything else before ever reaching the code this test is trying to exercise. +const SECRET_SALT = generateSalt(); +const LOCK_ID = "11"; + +test("the claim-link fragment never appears in any network request", async ({ page }) => { + const leaks: string[] = []; + const containsSecret = (text: string) => + text.includes(SECRET_REFERENCE) || text.includes(SECRET_SALT); + + page.on("request", (request) => { + const url = request.url(); + if (containsSecret(url)) leaks.push(`URL: ${url}`); + + const postData = request.postData(); + if (postData && containsSecret(postData)) leaks.push(`BODY (${url}): ${postData}`); + + for (const [key, value] of Object.entries(request.headers())) { + if (containsSecret(value)) leaks.push(`HEADER ${key} (${url}): ${value}`); + } + }); + + const link = buildClaimLink("http://localhost:3000", { + lockId: BigInt(LOCK_ID), + reference: SECRET_REFERENCE, + salt: SECRET_SALT, + }); + await page.goto(link.replace("http://localhost:3000", "")); + await page.waitForSelector("main dl", { timeout: 10_000 }); + // A fixed window rather than networkidle: the page polls (wallet/RPC checks), so network + // activity never truly goes idle; this still gives any delayed request time to fire. + await page.waitForTimeout(2000); + + expect(leaks).toEqual([]); +}); diff --git a/playwright.config.ts b/playwright.config.ts index efa03ba..a5826b8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,3 +1,14 @@ import { defineConfig } from "@playwright/test"; -export default defineConfig({ testDir: "./e2e" }); +/** Tests run against a real production build, since /claim/*'s CSP differs by NODE_ENV + * (proxy.ts allows 'unsafe-eval' only in dev); a dev-mode run wouldn't test the real policy. */ +export default defineConfig({ + testDir: "./e2e", + webServer: { + command: "pnpm build && pnpm start", + url: "http://localhost:3000", + reuseExistingServer: !process.env.CI, + timeout: 180_000, + }, + use: { baseURL: "http://localhost:3000" }, +});