diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f27b3c9..721b9da 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -147,7 +147,7 @@ The keeper chooses `deposit_address`. A stolen keeper key could redirect at most | `GET /api/vault` | session | `get_state()` + `ft_balance_of` + next run | | `GET /api/tokens` | public | Cached token list | | `POST /api/fund/quote` | session, rate-limited | `{originAsset, amount, refundTo, dry}` → preview or FundingAttempt | -| `POST /api/fund/:id/submit-tx` · `GET /api/fund/:id/status` | session | | +| `POST /api/fund/:id/submit-tx` · `GET /api/fund/:id/status` | session | `:id` must be a UUID, else 404 (`uuidParam`, K13.8) | | `POST /api/plan/preview` | session | Dry buy quote → expected cost % for a buy size | | `GET /api/storage-check?account=` | session, rate-limited | Is the account registered on `wrap.near`? A NEAR plan can't be saved until it is (no refund otherwise); the UI offers a one-tap `storage_deposit` (0.00125 NEAR). Demo: always yes | | `GET /api/buys` | session | History | diff --git a/README.md b/README.md index 8e169de..249f988 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ 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. +- **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. - **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. diff --git a/ROADMAP.md b/ROADMAP.md index 2a3a674..349452d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -27,8 +27,8 @@ | 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 | 67% | in progress (pre-launch security and reliability review) | -| **Overall (MVP)** | | **~80%** | | +| 13. Launch review fixes | Nov 8–13 | 78% | in progress (pre-launch security and reliability review) | +| **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.) @@ -217,7 +217,7 @@ From the pre-launch security and reliability review (2026-11-08), ranked with au - [x] **K13.5** [#131](https://github.com/MFrancis-dev/stack/issues/131) Keep preview and development off the production database · 2026-11-08: `scripts/vercel-build.mjs` migrates only on production builds; `databaseUrlFor()` ignores `DATABASE_URL` on Preview/Development (uses `PREVIEW_DATABASE_URL` or refuses to start); `pnpm demo` clears `DATABASE_URL` (checked: Next keeps the empty value over `.env.local`); 3 tests - [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 -- [ ] **K13.8** [#134](https://github.com/MFrancis-dev/stack/issues/134) Bad ids and malformed cookies return 500 +- [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 --- @@ -234,6 +234,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.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 - 2026-11-08 · agent · K13.5 previews and `pnpm demo` can't touch the production database (migrate on production builds only, `databaseUrlFor`, `PREVIEW_DATABASE_URL`); README, ARCHITECTURE, ESSENTIALS, `.env.example` updated · K13.5 diff --git a/app/api/fund/[id]/status/route.ts b/app/api/fund/[id]/status/route.ts index 1eb7caa..7156511 100644 --- a/app/api/fund/[id]/status/route.ts +++ b/app/api/fund/[id]/status/route.ts @@ -6,10 +6,10 @@ import { FUNDING_TERMINAL, fundingStatusFromOneClick, type FundingStatus } from "@/lib/core/status"; import { getFundingAttempt, updateFundingStatus } from "@/lib/db/queries/fundingAttempts"; import { requireUser } from "@/lib/server/context"; -import { handle, HttpError, json } from "@/lib/server/http"; +import { handle, HttpError, json, uuidParam } from "@/lib/server/http"; export const GET = handle(async (req: Request, ctx: { params: Promise<{ id: string }> }) => { - const { id } = await ctx.params; + const id = uuidParam((await ctx.params).id); const { services, user } = await requireUser(req); const attempt = await getFundingAttempt(services.db, id, user.id); if (!attempt) throw new HttpError(404, "Not found"); diff --git a/app/api/fund/[id]/submit-tx/route.ts b/app/api/fund/[id]/submit-tx/route.ts index 3cfd06c..e74f4e8 100644 --- a/app/api/fund/[id]/submit-tx/route.ts +++ b/app/api/fund/[id]/submit-tx/route.ts @@ -6,12 +6,12 @@ import { z } from "zod"; import { getFundingAttempt, updateFundingStatus } from "@/lib/db/queries/fundingAttempts"; import { requireUser } from "@/lib/server/context"; -import { handle, HttpError, json, parseBody } from "@/lib/server/http"; +import { handle, HttpError, json, parseBody, uuidParam } from "@/lib/server/http"; const input = z.object({ txHash: z.string().trim().min(8).max(200) }); export const POST = handle(async (req: Request, ctx: { params: Promise<{ id: string }> }) => { - const { id } = await ctx.params; + const id = uuidParam((await ctx.params).id); const { services, user } = await requireUser(req); const { txHash } = await parseBody(req, input); const attempt = await getFundingAttempt(services.db, id, user.id); diff --git a/lib/server/http.ts b/lib/server/http.ts index b62bfa6..d2b1211 100644 --- a/lib/server/http.ts +++ b/lib/server/http.ts @@ -41,6 +41,14 @@ export async function parseBody(req: Request, schema: S) return r.data; } +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** A path id for a uuid column. Anything else is 404, not a database error (K13.8). */ +export function uuidParam(id: string): string { + if (!UUID.test(id)) throw new HttpError(404, "Not found"); + return id; +} + export function clientIp(req: Request): string { return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip") || "local"; } diff --git a/lib/server/routes.test.ts b/lib/server/routes.test.ts index 7d9841d..0f9fd9e 100644 --- a/lib/server/routes.test.ts +++ b/lib/server/routes.test.ts @@ -150,6 +150,16 @@ describe("session (K4.1)", () => { await expect(signIn()).rejects.toThrow(); }); + it("treats a malformed session cookie as signed out, not a server error (K13.8)", async () => { + expect((await vaultRoute.GET(req("/api/vault", { cookie: "stack_session=%E0%A4%A" }))).status).toBe(401); + }); + + it("answers 404, not 500, for funding ids that aren't UUIDs (K13.8)", async () => { + const cookie = await signIn(); + expect((await fundStatusRoute.GET(req("/api/fund/not-a-uuid/status", { cookie }), params("not-a-uuid"))).status).toBe(404); + expect((await submitTxRoute.POST(req("/api/fund/1;drop/submit-tx", { method: "POST", cookie, body: { txHash: "5xYzTxHash123" } }), params("1;drop"))).status).toBe(404); + }); + it("requires a session for user routes", async () => { expect((await vaultRoute.GET(req("/api/vault"))).status).toBe(401); expect((await onboardRoute.POST(req("/api/onboard", { method: "POST", cookie: "stack_session=forged.token" }))).status).toBe(401); diff --git a/lib/server/session.ts b/lib/server/session.ts index 5464bdd..79aa215 100644 --- a/lib/server/session.ts +++ b/lib/server/session.ts @@ -70,7 +70,12 @@ export function readCookie(req: Request, name: string): string | undefined { const header = req.headers.get("cookie") ?? ""; for (const part of header.split(";")) { const [k, ...v] = part.trim().split("="); - if (k === name) return decodeURIComponent(v.join("=")); + if (k !== name) continue; + try { + return decodeURIComponent(v.join("=")); + } catch { + return undefined; // a malformed cookie reads as signed out, not as a server error (K13.8) + } } return undefined; }