Boring Docs
Security — IDOR + OWASP-10 Lockdown
Ownership bindings, allowlists, verification, headers, deps.
In this guide
- A01 Broken Access Control: the IDOR you'll write by accident
- A02/A07 Crypto & auth: covered in 04-auth, enforced here
- A03 Injection: one sink, fully lit
- A04/A05 Design & misconfig: webhooks, redirects, rate limits, headers
- A06/A08/A09/A10: deps, integrity, logging
- A11 Agent-proof data layer (the PocketOS lesson)
Security
Threat model first, because everything below follows from one sentence: a solo-founder VPS behind nginx, PSP webhooks exposed to the internet, SQLite by default. Deny by default. Now put on the black hat with me — for each control below I'll show you the attack first, then the lock. Attackers don't read your architecture docs; they poke the places you forgot. Let's make sure you forgot nothing.
A01 Broken Access Control: the IDOR you'll write by accident
The attack. You're logged in as yourself. You open devtools, change boardId in a request to someone else's board ID, and the server happily mutates it — because the route checked authentication (you're logged in!) but never authorization (is it yours?). This is IDOR, OWASP's #1, and every founder writes it at least once. It's not exotic; it's the default shape of a missing check.
The locks (all wired). Mutating routes scope by session: board by {_id, userId}, posts via board ownership, products owner-or-admin with 404 masking (note: 404, not 403 — confirming "this exists but isn't yours" leaks information; pretending it's not there leaks nothing). assertActorIntegrity(session, body) in libs/security.js automatically verifies client-provided IDs (userId, authorId, sellerId) match session.user.id — call it before trusting a single ID from the body. Subscription cancel binds subscriptionId → order buyer/customer or admin (403 else). Claim-repo binds to the authenticated user with a githubUsername regex. And purchases merge id+email orders with dedupe — never trust client-supplied owner ids, ever.
Your habit. Every new mutating route gets the question before the code: "whose row is this, and how does the server know?" If the answer is "the client told me," stop and bind it to the session. Write the attack first (curl with a foreign ID), watch it 403/404, then ship.
A02/A07 Crypto & auth: covered in 04-auth, enforced here
NextAuth JWT + per-DB adapters, NEXTAUTH_SECRET required (missing in prod throws MissingSecret — loud, see 03-config-env), dev login double-gated, stale sessions redirect without loops, ADMIN_EMAILS fail-closed. Nothing new to configure; just don't weaken it. The day you think "let me relax auth for this internal tool" is the day it gets indexed. Leave the fences up.
A03 Injection: one sink, fully lit
The attack. SQL injection is the classic, and it's already dead here: prepared statements only (node:sqlite) — no string-built queries anywhere, so there's nothing to inject into. The one live XSS sink is markdown → HTML rendered via dangerouslySetInnerHTML. Today that input is repo-owned (your .md files), so the blast radius is you. The day you let users submit markdown (comments with formatting, bios, anything), sanitize first — the sanitize helper exists in libs/blog.js, use it on user content before it touches the renderer.
Your habit. grep dangerouslySetInnerHTML quarterly. Every hit should have a comment naming why its input is trusted. Un-commented hit = audit it now.
A04/A05 Design & misconfig: webhooks, redirects, rate limits, headers
Webhooks verify signatures (Stripe SDK, Dodo Standard Webhooks + replay window, LemonSqueezy HMAC) and return proper 4xx/500 so providers retry correctly. A 200-on-error webhook tells the provider "all good" while money silently misroutes — always return the truth. Covered deeply in 06-payments; the security angle is just: unsigned payloads never touch your database. No exceptions, no "temporary debug bypass" (temporary bypasses have a way of becoming permanent — I've seen the commit messages).
Redirects must be relative and same-origin: auth callbackUrl, portal returnUrl, checkout successUrl/cancelUrl. All pass through safeUrl(url, fallback) — javascript: schemes, //evil.com protocol-relative tricks, and backslash bypasses all collapse to the fallback. When you add a new redirect parameter anywhere, route it through safeUrl. No homegrown regex; yours will miss the backslash variant. Everyone's does.
Rate limits are two layers: proxy.js guards /api/auth + /api at the edge with X-RateLimit-* headers, nginx rate-limits behind it, and rateLimit(key, limit, windowMs) in libs/security.js gives thin routes their own in-memory sliding window (see claim-repo, 10/minute by IP — free GitHub invites are exactly what bot farms harvest). New public endpoint with a cost (email sends, invites, uploads)? Add a rateLimit line before anything else. It costs one line and saves the abuse story.
Headers come from next.config.js + nginx: DENY framing, nosniff, referrer policy, permissions policy — and nginx denies dotfiles, env files, and raw .db downloads. That last one matters more than it sounds: an exposed app.db download is your entire user table over HTTP. Verify it yourself: curl https://yourdomain.com/db/app.db must 403/404. Do this after every nginx change.
A06/A08/A09/A10: deps, integrity, logging
Deps: npm audit, upgrade what's flagged (the axios/form-data/eslint chain bites periodically), retest. No new packages without a need you can state in one sentence — every dependency is attack surface you didn't write. This repo's zero-dependency religion is a security posture, not aesthetics.
Integrity: prices from the product row, webhooks dedupe on providerPaymentRef (unique/sparse everywhere). Covered in 06-payments; from the security seat, these are fraud controls, not billing features.
Logging & response: scream(message, context) in libs/telemetry.js fires Telegram/Discord alerts with a 60-second circuit breaker (so an error storm sends one alert, not six hundred). Wire it into every money path and auth failure — you already have it on webhooks. The circuit breaker is the part people forget: alerting without one trains you to mute the channel, and a muted channel is the same as no channel.
SSRF: only fixed-host fetches (GitHub API, PSPs); repo from env, usernames encoded. When you add a fetch to a user-supplied URL (avatar proxying, link previews, anything), stop and sandbox it: allowlist hosts, timeout aggressively, never forward internal headers. SSRF is how "fetch this avatar" becomes "read our cloud metadata." Not today, but the day you add it, remember this paragraph.
A11 Agent-proof data layer (the PocketOS lesson)
Threat model addition, and it's a weird one: an AI agent with production access that disobeys every instruction — rules quoted back, then violated. Happened April 2026: nine seconds, prod and backups gone. Defense is structural, never instructional:
- Least-privilege agent handles. SQLite agents inspect via
getAgentDb()(OS-level readonly open + the runtime SQL firewall, which additionally bansATTACH/DETACH— cross-file exfil and overwrite vectors). Supabase agents get theapp_agentrole (SELECT/INSERT/UPDATE only; DELETE/DROP/TRUNCATE revoked at the database). Mongo agents getappAgent(find/insert/update only). Service-role keys are never mounted into agent sessions. The database enforces what the prompt merely suggested. - Backups agents can't reach.
npm run backup:offsiteuploads fresh atomic snapshots under separateBACKUP_*credentials (never agent-mounted), with optional Object Lock immutability. Same-reach backups are decoration — PocketOS proved it. - Human-gated prod migrations. Staging by default;
--target prodneeds--human+ TTY +MIGRATE_PROD_KEY(lives in.env.prodonly). Destructive statements scanned pre-execution. - SQLite file-layer doctrine (no GRANT system exists — files ARE the permissions): prod
app.dbowned by the deploy user (0600), agents never pointSQLITE_PATHat prod (staging seed copy instead), read-only handles see committed snapshots (can't WAL-checkpoint — correct for inspection, and one more reason agents never migrate).
Verify the gates, not the obedience: npm run db:migrate -- --check providers/db/supabase/schema.sql (exit 0), attempt a DROP through getAgentDb() (must throw), npm run backup:offsite -- --dry-run. Quarterly. Put it in the calendar next to the dangerouslySetInnerHTML grep. Paranoia with a schedule is just operations.