From 71c8dcc3b5a4780316a5c15661563628ae0c7d86 Mon Sep 17 00:00:00 2001 From: Allison Muyideen Opeyemi Date: Sun, 8 Nov 2026 14:45:53 +0100 Subject: [PATCH] Add a Content-Security-Policy: two directives enforced, full policy report-only Wallets run in srcdoc sandbox frames that inherit the page's policy, and sign-in can't be tested without a real wallet, so the full policy reports violations instead of blocking until that test is done (K13.10). Closes #135 --- ARCHITECTURE.md | 2 +- ARCHITECTURE_ESSENTIALS.md | 2 +- README.md | 4 ++-- ROADMAP.md | 6 ++++-- lib/server/securityHeaders.test.ts | 17 ++++++++++++++++ next.config.ts | 32 ++++++++++++++++++++++++++++-- 6 files changed, 55 insertions(+), 8 deletions(-) create mode 100644 lib/server/securityHeaders.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 721b9da..d217f33 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -245,7 +245,7 @@ On-chain vault = truth for plan and balance. DB = history and metadata. | Tampered quote | `verifyQuoteSignature()` | | Admin rug | No admin path to funds (I5) | | Secrets | `.env` only; logged errors pass through `redact()` (env secret values, URL credentials, private-key strings) | -| Clickjacking, sniffing, leaky referrers | Security headers on every response: `X-Frame-Options: DENY`, `nosniff`, `strict-origin-when-cross-origin`, restrictive `Permissions-Policy`, `COOP: same-origin-allow-popups`, HSTS in production. No CSP yet (wallet connector iframes) | +| Clickjacking, sniffing, leaky referrers | Security headers on every response: `X-Frame-Options: DENY`, `nosniff`, `strict-origin-when-cross-origin`, restrictive `Permissions-Policy`, `COOP: same-origin-allow-popups`, HSTS in production. CSP (K13.9): enforced `object-src 'none'; frame-ancestors 'none'`; full policy (`default-src 'self'`, scripts/styles self + inline, `connect-src 'self' https: wss:`, `base-uri`/`form-action 'self'`) as `Content-Security-Policy-Report-Only`, because wallets run in `srcdoc` sandbox frames that inherit it; enforce after a real-wallet sign-in shows no violations (K13.10) | | CSRF on state-changing routes | Session cookie is `SameSite=Lax` + HTTP-only, so cross-site POSTs carry no session | | Request floods | Rate limits on quote, plan preview, onboard, storage check and demo start, kept in Postgres (`rate_limits`, one atomic upsert per request) so every serverless instance shares them; counters older than a day removed by the daily cleanup (K13.7) | diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index 1afcd9e..69d5d04 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -82,7 +82,7 @@ On-chain vault = truth for plan + balance. `pollFunding` 5 s · `scheduleBuys` 60 s · `pollBuys` 10 s · `expireQuotes` 60 s · keeper gas alert 10 min · `cleanupDemo` 60 min (demo users > 24 h) - Hosted (serverless): one pass of each loop per `GET /api/cron/tick` every 5 min (`worker/tick.ts`, GitHub workflow, off until `WORKER_ENABLED=true`) - NEAR plans: the destination must be registered on `wrap.near` first (`/api/storage-check`, `wnearRegisterTx`) -- Log errors through `redact()`; security headers live in next.config.ts +- Log errors through `redact()`; security headers live in next.config.ts (CSP: two directives enforced, full policy report-only until a real-wallet test, K13.10) - Every loop is `(deps) => Promise` (worker/deps.ts) — test one tick with fakes - scheduleBuys order: due? → not in flight / not in 15-min backoff → amount = min(per-run, balance) → quote → cost → plan match → **insert row** → execute_buy → submit - Every skip/refusal/failure writes `buys.note`; refusals and failures `log.alert` diff --git a/README.md b/README.md index 249f988..226ce0d 100644 --- a/README.md +++ b/README.md @@ -93,13 +93,13 @@ Other protections: real quotes are refused unless their signature verifies (fail Being honest about what is and isn't live: -- **Pre-launch review (2026-11-08):** a security and reliability review found nine issues; they are fixed one at a time and tracked as ROADMAP Phase 13. Fixed so far: a buy whose deposit never reaches NEAR Intents now expires after 30 minutes instead of blocking the plan forever; when NEAR gives no clear result for a buy (a timeout after sending), it stays in flight until NEAR Intents confirms it, instead of being shown as failed with nothing spent; signing out now ends the session on every device, so a copied session cookie stops working; preview deployments and `pnpm demo` can no longer read or migrate the production database; rate limits now live in Postgres and apply across all serverless instances; demo starts, fast-forwards and resets are capped so a script can't fill the database; malformed ids and cookies get a 404 or 401 instead of a server error. +- **Pre-launch review (2026-11-08):** a security and reliability review found nine issues; they are fixed one at a time and tracked as ROADMAP Phase 13. Fixed so far: a buy whose deposit never reaches NEAR Intents now expires after 30 minutes instead of blocking the plan forever; when NEAR gives no clear result for a buy (a timeout after sending), it stays in flight until NEAR Intents confirms it, instead of being shown as failed with nothing spent; signing out now ends the session on every device, so a copied session cookie stops working; preview deployments and `pnpm demo` can no longer read or migrate the production database; rate limits now live in Postgres and apply across all serverless instances; demo starts, fast-forwards and resets are capped so a script can't fill the database; malformed ids and cookies get a 404 or 401 instead of a server error; a Content-Security-Policy is in place (partly enforced, partly report-only, see below). - **Contracts are built and tested but not deployed yet** (ROADMAP K1.13). Real mode needs deployed contracts, funded keeper/factory accounts and a 1Click partner JWT. - **Quote signatures:** real 1Click quotes work without a partner key, but `verifyQuoteSignature()` in the newest SDK (0.1.26) returns false on dry and real quotes (tested 2026-11-05). Real mode refuses unverified quotes, so real funding and buys stay closed until 1Click confirms the current signing key or hash (ROADMAP K3.5). The demo is not affected. - **Fees without a partner key:** 1Click adds about 0.25%. A real $100 Bitcoin quote came to about 2.0%, against 1.75% in the fee estimates shown in the demo and on the landing page. - **Demo mode** uses live dry quotes when NEAR Intents is reachable and a fee model calibrated on real quotes otherwise; demo deposits complete on a timer. It never moves funds. -- **No Content-Security-Policy yet** — the wallet connector loads wallets in sandboxed iframes. +- **Content-Security-Policy:** `object-src 'none'` and `frame-ancestors 'none'` are enforced; the full policy runs in report-only mode, because wallets run in sandboxed frames that inherit it and wallet sign-in hasn't been tested with a real wallet yet (ROADMAP K13.10). - Rate limits live in Postgres, so all serverless instances share them (quote, plan preview, onboard, storage check, demo start). - **Hosting:** the web app runs on Vercel with a Neon Postgres database; demo cleanup runs as a daily Vercel Cron job. On the hosted app the worker runs as one pass every 5 minutes (`GET /api/cron/tick`, called by the `Worker tick` GitHub workflow). It stays off until real mode is configured: set the repository variable `WORKER_ENABLED=true` and the secret `CRON_SECRET`. diff --git a/ROADMAP.md b/ROADMAP.md index 349452d..1772918 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -27,7 +27,7 @@ | 10. README & public repo | Nov 12–13 | 100% | ✅ done (hosted URL added) | | 11. Launch: video, post, submission | Nov 13–14 | 57% | in progress (video, post and submission need the human) | | 12. After submission | Nov 14–25 | 0% | not started | -| 13. Launch review fixes | Nov 8–13 | 78% | in progress (pre-launch security and reliability review) | +| 13. Launch review fixes | Nov 8–13 | 80% | in progress (K13.3 ⛔ needs a decision; K13.10 needs a real wallet) | | **Overall (MVP)** | | **~81%** | | Phase % = done tasks ÷ non-dropped tasks in that phase. Overall = average of all 15 rows (D + phases 0–13; Phase 13 was added on 2026-11-08, which lowered the overall %). (Earlier entries over-estimated the overall %; recalculated 2026-11-04.) @@ -218,7 +218,8 @@ From the pre-launch security and reliability review (2026-11-08), ranked with au - [x] **K13.6** [#132](https://github.com/MFrancis-dev/stack/issues/132) Cap demo starts and fast-forwards in the database · 2026-11-08: on the shared limiter (K13.7): new demos 10/min and 30/h per IP plus 600/h overall; per demo 12 fast-forwards and 20 resets a day, 60 owner transactions an hour; messages say what to do; the demo banner now shows reset errors too; 2 tests - [x] **K13.7** [#133](https://github.com/MFrancis-dev/stack/issues/133) Rate limits shared across serverless instances · 2026-11-08: `rate_limits` table (migration 0004), one atomic `INSERT … ON CONFLICT` per request, all five limited routes switched; counters older than a day removed by `cleanupDemo`; 5 tests incl. a burst of 8 against a limit of 5 - [x] **K13.8** [#134](https://github.com/MFrancis-dev/stack/issues/134) Bad ids and malformed cookies return 500 · 2026-11-08: `uuidParam` makes non-UUID funding ids a 404; `readCookie` treats a malformed cookie as signed out; 2 tests, both failed before the fix -- [ ] **K13.9** [#135](https://github.com/MFrancis-dev/stack/issues/135) Add a Content-Security-Policy +- [x] **K13.9** [#135](https://github.com/MFrancis-dev/stack/issues/135) Add a Content-Security-Policy · 2026-11-08: enforced `object-src 'none'; frame-ancestors 'none'`; full policy as report-only (wallets run in `srcdoc` sandbox frames that inherit it); production build checked on landing, demo, overview and Add funds: no violations; header test +- [ ] **K13.10** [#143](https://github.com/MFrancis-dev/stack/issues/143) 👤 Enforce the full CSP after a real-wallet sign-in (and one signed transaction) shows no report-only violations --- @@ -234,6 +235,7 @@ From the pre-launch security and reliability review (2026-11-08), ranked with au ## Update log One line per contribution, newest first: `YYYY-MM-DD · who/agent · what changed · tasks` +- 2026-11-08 · agent · K13.9 Content-Security-Policy (2 directives enforced, full policy report-only, no violations on our pages in a production build); new K13.10 to enforce it after a real-wallet test; README, ARCHITECTURE §7, ESSENTIALS updated · K13.9, K13.10 - 2026-11-08 · agent · K13.8 non-UUID ids → 404 and malformed cookies → signed out, instead of 500s; README, ARCHITECTURE §4.1 updated · K13.8 - 2026-11-08 · agent · K13.6 demo actions capped in Postgres (`lib/server/demoLimits.ts`); demo banner shows reset errors; README, ARCHITECTURE §4.1, ESSENTIALS updated · K13.6 - 2026-11-08 · agent · K13.7 rate limits moved from instance memory to Postgres (`rate_limits`, atomic upsert, daily cleanup); README, ARCHITECTURE §5/§7, ESSENTIALS updated · K13.7 diff --git a/lib/server/securityHeaders.test.ts b/lib/server/securityHeaders.test.ts new file mode 100644 index 0000000..9883a55 --- /dev/null +++ b/lib/server/securityHeaders.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import nextConfig from "../../next.config"; + +// Roadmap: K8.7, K13.9 +describe("security headers", () => { + it("enforces the CSP directives that can't break wallets, and reports the full policy", async () => { + const [rule] = await nextConfig.headers!(); + const h = Object.fromEntries(rule!.headers.map((x) => [x.key, x.value])); + expect(h["Content-Security-Policy"]).toBe("object-src 'none'; frame-ancestors 'none'"); + const reportOnly = h["Content-Security-Policy-Report-Only"]!; + for (const d of ["default-src 'self'", "object-src 'none'", "base-uri 'self'", "form-action 'self'", "frame-ancestors 'none'"]) { + expect(reportOnly).toContain(d); + } + expect(h["X-Frame-Options"]).toBe("DENY"); + expect(h["X-Content-Type-Options"]).toBe("nosniff"); + }); +}); diff --git a/next.config.ts b/next.config.ts index 3680c2f..dea5130 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,15 +1,43 @@ import type { NextConfig } from "next"; import path from "node:path"; -/** Baseline security headers (K8.7). No CSP yet: the wallet connector loads wallet scripts in sandboxed iframes. */ +const isProd = process.env.NODE_ENV === "production"; + +/** + * Content-Security-Policy (K13.9). Wallets run in `srcdoc` sandbox frames, which inherit this policy, + * and wallet sign-in can't be tested without a real wallet. So only directives that can't touch + * wallet code are enforced; the full policy is report-only (violations show in the browser console) + * until a real-wallet sign-in shows none, then it moves to the enforced header. + */ +const CSP_ENFORCED = ["object-src 'none'", "frame-ancestors 'none'"].join("; "); +const CSP_REPORT_ONLY = [ + "default-src 'self'", + // Next.js inlines its bootstrap scripts; dev mode also needs eval for fast refresh. + `script-src 'self' 'unsafe-inline'${isProd ? "" : " 'unsafe-eval'"}`, + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self' data:", + "media-src 'self'", + // The wallet connector fetches its wallet list and talks to wallets and NEAR RPC over https/wss. + "connect-src 'self' https: wss:", + "frame-src 'self'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", +].join("; "); + +/** Baseline security headers (K8.7, K13.9). */ const securityHeaders = [ + { key: "Content-Security-Policy", value: CSP_ENFORCED }, + { key: "Content-Security-Policy-Report-Only", value: CSP_REPORT_ONLY }, { key: "X-Content-Type-Options", value: "nosniff" }, { key: "X-Frame-Options", value: "DENY" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), payment=()" }, // Wallet sign-in may use popups that need to talk back to this window. { key: "Cross-Origin-Opener-Policy", value: "same-origin-allow-popups" }, - ...(process.env.NODE_ENV === "production" ? [{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" }] : []), + ...(isProd ? [{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" }] : []), ]; const nextConfig: NextConfig = {