Repository navigation
Make a cross-workspace reference unrepresentable - #23
Conversation
Composite foreign keys on (userId, projectId) and (userId, sessionId) for memories, sessions, and handoffs. The API already refuses these, but only at the edge; nothing stopped a future code path, a script, or a migration writing one. MATCH SIMPLE leaves a null reference unconstrained, so "filed nowhere" stays legal. ON DELETE is no action deliberately: deleteProject already refuses while any record still references the project, so the constraint agrees with the application rather than changing what it does. drizzle-kit ordered the constraints before the unique indexes they target, which Postgres rejects, so the migration is hand-ordered: indexes, then the cleanup, then the constraints. The cleanup nulls references that resolve to nothing or to another workspace, because the constraints cannot be added while any remain. Nulled rather than deleted: the row is the user's content and only its filing is wrong.
The foreign key makes this behaviour load-bearing. If deleteProject stopped checking first, the delete would surface as a 500 rather than a conflict.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
shadcn is a scaffolding CLI with zero imports, and it sat in dependencies, so its whole tree shipped. @modelcontextprotocol/server also had zero imports; the MCP route uses mcp-handler. Moving one and dropping the other takes the production tree from 28 known vulnerabilities to 2. The remaining nanoid advisory is a patch release away, so it is pinned. The last one is an esbuild dev-server issue reachable only through build tooling, which does not run in production; forcing esbuild across three major versions in the tree would risk more than it fixes.
…cret in constant time Every other header was set, so the policy was an omission rather than a decision. Next emits inline bootstrap scripts with no nonce, so 'unsafe-inline' is unavoidable without middleware; the value is in closing external origins, object-src, base-uri and form-action. Verified in a real browser: the landing, sign-in, sign-up and signed-in dashboard pages all render and hydrate with no violations. CRON_SECRET was compared with !==, which is the one place a secret was matched byte by byte. Negligible over HTTP, but timingSafeEqual costs nothing.
|
Pushed the security fixes onto this branch as requested, so #23 now carries both the reference constraints and the hardening. Production dependency tree: 28 vulnerabilities → 1
That left two. Content Security PolicyEvery other security header was already set, so this read as an omission rather than a decision. Next emits inline bootstrap scripts with no nonce, so Verified in a real browser rather than assumed. Headless Brave over CDP, collecting The dashboard check sets the real session cookie and confirms it landed on Constant-time cron secret
No test covered the accepting path, which is exactly what this change touches, so I checked all four cases including same-length-but-wrong, which is the one that actually exercises the comparison: 49/49 tests pass, and all four commits typecheck standalone. Still not auditedThe unauthenticated flows — |
The durable half of #22. That PR stopped the API accepting a bad
projectId; this stops the database storing one, and clears the rows written before the guard existed.What changed
Composite foreign keys on
(userId, projectId)and(userId, sessionId)for memories, sessions, and handoffs. Proof it holds against something that never touches the application:MATCH SIMPLEleaves a null reference unconstrained, so "filed nowhere" stays legal — verified, along with the ordinary same-workspace case.ON DELETE is
no action, on purposedeleteProjectalready refuses while any record still references the project. So the constraint agrees with the application rather than changing it — it is a backstop, not new behaviour. I confirmed the API still answers409and not a500, then200once the project is emptied, and added a test, because the foreign key makes that check load-bearing: ifdeleteProjectever stopped checking first, the failure would surface as a500.Sessions are never hard-deleted anywhere (
complete()sets a status; maintenance prunes infrastructure rows only), so the session references cannot dangle either.The migration needed hand-ordering
drizzle-kitemitted the constraints before the unique indexes they target, which Postgres rejects outright. Reordered to indexes → cleanup → constraints.The cleanup is required: the constraints cannot be added while a single bad row remains. It sets offending references to null rather than deleting rows — the row is the user's content, only its filing is wrong.
Rehearsed on the path production will take
Not just a fresh database. I built one at migration
0012, inserted the exact rows the old code allowed, then migrated:Every memory survived, with only the bad reference cleared, and the one legitimate reference left intact:
Also verified from zero: all five constraints present on a database built from nothing.
49/49 tests pass against the constrained database,
drizzle-kit generatereports no drift, and both commits typecheck standalone.One thing to watch on deploy
This is the first migration to run through the auto-migrate added in #13, and the first that rewrites existing rows. If it fails the build fails and the current version keeps serving, which is the behaviour we want — but it is worth watching the deploy rather than assuming.