Threat model¶
What this answers: what's worth defending, who'd attack, where the attack surfaces are, and what defences match each. The companion to Reference → Security baseline (concrete controls list) and RFC-0006 (decisions still needed from MASTER).
This page describes the threat model for the target architecture (RFC-0001 leading recommendation: Cloudflare Pages + Workers via the @opennextjs/cloudflare adapter; RFC-0002 leading: pg-boss; RFC-0004 leading: shadcn/ui). It is not generic OWASP advice — it is for THIS stack on Cloudflare with Postgres on Neon and a future Auth.js v5 layer (#11).
What this doc does NOT do: live exploit walkthroughs, code-level audits beyond the /api/go/* route, dependency-CVE tracking. Those belong to a future security-review cadence (#43 observability + alerting).
Asset register¶
What's worth defending, ranked by impact-on-loss:
| Asset | Where it lives | Confidentiality | Integrity | Availability | Notes |
|---|---|---|---|---|---|
| Cloudflare account credentials | MASTER | Critical | Critical | Critical | Compromise = total system compromise. 2FA mandatory. |
| GitHub account (owns OAuth apps + repo) | MASTER | Critical | Critical | High | Same as above for the OAuth side. 2FA mandatory. |
Postgres DATABASE_URL (Neon) |
Cloudflare Workers Secrets | Critical | Critical | High | Read access = exfil all user data; write access = data poisoning. |
AUTH_SECRET (Auth.js JWE signing) |
Workers Secrets (M2) | Critical | Critical | n/a | Compromise = forge any session. Rotation invalidates all sessions (the brute revocation lever). |
BUILD_SESSION_SECRET (ADR-0003) |
Workers Secrets (M2) | High | High | n/a | Compromise = forge anonymous build cookies. Lower blast radius than AUTH_SECRET. |
IP_HASH_SECRET (click salt) |
Workers Secrets | Medium | n/a | n/a | Currently defaults to literal dev-secret-rotate-in-prod (env-vars.md § IP_HASH_SECRET). Compromise → IPv4-space brute-force reverses any ipHash. Mandatory rotation pre-prod. |
| OAuth client secrets (Google, GitHub) | Workers Secrets (M2) | High | High | n/a | Compromise lets attacker impersonate 961tech to the provider. |
RESEND_API_KEY (M2) |
Workers Secrets | High | n/a | High | Compromise = email send abuse from our domain → reputation damage. |
ANTHROPIC_API_KEY (M2) |
Workers Secrets | Medium | n/a | High | Compromise = burn quota / dollars; no PII risk. |
User PII (User.email, build state, subscriptions) |
Postgres (M2) | High | High | Medium | Legal exposure if leaked (#40 data-breach obligations). |
Affiliate revenue data (Conversion, M2 #17) |
Postgres (M2) | Medium | High | Medium | Integrity attacks (click spoofing) directly steal money once CPC payouts attach. |
Scraped catalog (Listing, ListingPrice, Product) |
Postgres | Low | High | High | The moat. Public anyway, but integrity attacks (price poisoning) embarrass us and break the value prop. High availability target. |
audit_log (M2) |
Postgres | Medium | Critical | Medium | Repudiation defence — must be append-only and tamper-evident. |
| Cloudflare Pages app source | Public on GitHub | n/a | High | n/a | Public repo. Integrity guaranteed by Git + signed commits if we ever enable them. |
Anonymous-build state is explicitly low-impact (ADR-0003) — losing a Visitor's PC build is annoying, not damaging. The cookie-claim transaction matters more for product correctness than for security.
Adversary register¶
| Adversary | Capability | Motivation | Likelihood | Realised threat |
|---|---|---|---|---|
| Generic web attacker | Automated scanning, OWASP-top-10 fuzz, opportunistic | Take whatever's exposed | High | Probes, credential stuffing if a password layer exists, SQLi (closed by Prisma), XSS attempts. |
| Competitive scraper | Scripted crawls of our catalog | Steal the moat (the catalog) | High | Aggressive scraping → DDoS-shaped traffic + data exfil of the public catalog (low-secrecy → primary impact is bandwidth cost). |
| Affiliate-fraud clicker | Bot farms generating clicks | Inflate metrics or skim CPC payouts (when #17 lands) | Medium | Spoofed click traffic → corrupt analytics today, theft tomorrow. |
| Account-takeover attacker | Email reconnaissance + OAuth abuse | Hijack a user's account | Medium-Low | Email-collision merge attack (S2 in surface C), session-cookie exfil via XSS. |
| Compromised retailer | Full control of a retailer site we scrape | Various: phishing reputation laundering, price poisoning, SSRF us into our own infra | Low | Malicious image/title/link, price-poison sales, SSRF the M2 image-fetch into Neon's VPC. |
| Supply-chain attack | Compromised npm dep | Code execution in Workers | Low (but freshness-band ↑) | Five recently-major deps (tech-stack.md § Cross-cutting risks) — single compromise cascades. |
| Cloud / OAuth-provider compromise | Out-of-our-control adversary at Google/GitHub/Cloudflare/Neon/Resend | Whatever they want | Very low | Mint valid sessions, exfil DB, etc. Mitigated only by least-privilege scopes + rotation playbook. |
| Insider | Hypothetical co-admin (M2+) | Dispute, malice, accident | Low (single-operator at M1) | Unaudited admin actions; audit_log tamper. |
| Lebanese-network adversary | ISP-level interception | Surveillance, censorship | Low | Mitigated by HTTPS-only + HSTS at edge. Out of scope for application controls. |
Surface map¶
flowchart LR
subgraph A["Surface A — Web app request path"]
A1["Route handlers + server actions"]
A2["Anonymous build_session cookie"]
A3["CSP + headers"]
A4["/api/health"]
end
subgraph B["Surface B — Click redirect + affiliate"]
B1["/api/go/r/X/p/Y"]
B2["Click tokens (#17 future)"]
B3["IP_HASH_SECRET"]
end
subgraph C["Surface C — Auth.js + admin"]
C1["OAuth callback"]
C2["Account merge"]
C3["/admin/* (#16)"]
C4["audit_log"]
end
subgraph D["Surface D — Scraper + alert + LLM"]
D1["undici fetch + cheerio"]
D2["Image fetcher (M2 R2)"]
D3["Email alerts (Resend)"]
D4["LLM extraction (Anthropic)"]
end
GW["Generic web attacker"] --> A
SC["Competitive scraper"] --> A
SC --> D1
AF["Affiliate-fraud clicker"] --> B
AT["Account-takeover attacker"] --> C
CR["Compromised retailer"] --> D
OP["OAuth-provider compromise"] --> C
Each surface gets its own STRIDE table below. Mitigations roll up into Reference → Security baseline — every cell here cites the baseline-row IDs (SB-NNN) that close it.
Surface A — Web app request path¶
Scope. Every Next.js route handler other than /api/go/*, every server action (incl. the future /api/build/save per ADR-0003), the anonymous build_session cookie, HTTP security headers + CSP, the edge surface visible via Cloudflare Workers, /api/health.
| # | STRIDE | Threat | Mitigation refs |
|---|---|---|---|
| A-S1 | Spoofing | Forged build_session cookie if BUILD_SESSION_SECRET weak / leaked |
SB-001, SB-013 |
| A-S2 | Spoofing | Pre-claim cookie planted on shared browser; victim signs in and inherits attacker's items | SB-013; accepted residual (low-value asset) |
| A-S3 | Spoofing | Auth.js session cookie forgery via weak AUTH_SECRET |
SB-001, SB-014 (M2) |
| A-T1 | Tampering | Server-action argument tampering — caller bypasses TS types | SB-006 (Zod on every action) |
| A-T2 | Tampering | .bind() server-action args are not encrypted by Next 16 (Next.js security guide) — caller controls them |
SB-006, SB-007 (closure-over-bind convention) |
| A-T3 | Tampering | build_session cookie bit-flip / re-sign with leaked key |
SB-013, SB-002 (key rotation) |
| A-R1 | Repudiation | No correlation ID on requests; can't reconstruct what a session did | SB-019 (request-id in middleware) |
| A-R2 | Repudiation | No audit row for "who claimed which anon build into which account" | SB-018 (M2) |
| A-I1 | Info disclosure | /api/health leaks git SHA, DB state, queue depth → fingerprint |
SB-008 (minimal health) |
| A-I2 | Info disclosure | RSC payload leaks server-only data via Client Component prop drilling — explicitly flagged in the Next.js security guide | SB-009 (Data Access Layer pattern), SB-010 (taint APIs) |
| A-I3 | Info disclosure | Verbose dev-mode error pages leaking stack traces | SB-011 (prod-mode CI gate) |
| A-I4 | Info disclosure | Permissive CSP lets a future XSS exfiltrate cookies | SB-003 (CSP), SB-004 (security headers) |
| A-D1 | DoS | Server-action flood (POST same action 10 k req/min) → Worker CPU + Neon pool burn | SB-016 (CF Rate Limiting Rules), SB-017 (in-app token bucket) |
| A-D2 | DoS | Oversized build_session cookie stuffing |
SB-013 (4 KB cap, server-side reject) |
| A-D3 | DoS | Hyperdrive connection-pool exhaustion via unauthenticated POSTs | SB-016, SB-005 (Hyperdrive bounded fan-in) |
| A-E1 | EoP | Server-action authorisation regression — exported "use server" callable as anonymous public |
SB-007 (in-action authz; never trust UI) |
| A-E2 | EoP | TOCTOU between cookie read and account write during claim | SB-013 (single-transaction claim) |
| A-E3 | EoP | Missing authz check inside server action (UI-gated only) | SB-007 |
Specifics worth landing in code-review checklists:
- CSP shape. The starting directive set is in
SB-003. The shadcn/Radixstyle-srcposture is an open question for RFC-0006 — Radix@floating-uiinjects inlinestyle="..."for Popover / Tooltip / Dialog positioning, which forces either'unsafe-inline'for styles or a per-render style nonce.script-srcstays nonce-locked regardless; XSS-via-style is bounded compared to XSS-via-script. - Headers delivery on Cloudflare Pages. The
_headersfile applies only to static assets, not to Worker-rendered responses (Cloudflare Pages headers docs). With@opennextjs/cloudflareserving every dynamic route via the Worker,_headersis effectively useless for dynamic responses. Set headers innext.config.tsheaders()config. - Server-action defences (Next 16). Built-in: action-ID hashing, closure-arg encryption via
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, Origin-vs-Host CSRF check. Not built-in: argument validation (always Zod), in-action authorisation (alwaysauth()check at top of body). /api/healthminimisation. Return{ "status": "ok" }and HTTP 200, nothing else. If a deeper liveness check is needed, expose/api/health/deepbehind aX-Health-Tokenheader secret or a Cloudflare Access policy.
Surface B — Click redirect + affiliate¶
Scope. GET /api/go/r/[retailerId]/p/[listingId]/route.ts (source), the Click table, click-token replay surface, the IP_HASH_SECRET salt, and the future S2S postback flow (#17).
| # | STRIDE | Threat | Mitigation refs |
|---|---|---|---|
| B-S1 | Spoofing | Forged X-Forwarded-For to hide source. Current code reads x-forwarded-for; on Cloudflare Workers the trustworthy header is CF-Connecting-IP (CF docs). |
SB-021 (switch to CF-Connecting-IP) |
| B-S2 | Spoofing | Click-token guessing ahead of #17 postback | SB-022 (single-use redemption + short TTL) |
| B-S3 | Spoofing | Bot/headless click farms inflating retailer counts | SB-016 (CF Rate Limiting Rules), SB-026 (Bot Fight Mode), SB-027 (Turnstile if abuse observed) |
| B-T1 | Tampering | Open-redirect (future state). Once admin tooling (#16) lets a non-trusted actor write Listing.url, new URL(listing.url) becomes a phishing surface laundered through 961tech.com. |
SB-023 (URL host allow-list at write and read), SB-024 (https: scheme enforcement) |
| B-T2 | Tampering | source querystring written verbatim → log injection / dashboard XSS if a future admin page renders it raw |
SB-025 (source enum allow-list) |
| B-T3 | Tampering | URL-param pollution on listing.url if logic ever moves from searchParams.set to append/concat |
SB-024 (defensive build) |
| B-R1 | Repudiation | Click written before redirect succeeds → counted clicks that never reached retailer; affiliate reconciliation under-counts | SB-028 (two-phase click record: initiated → landed) |
| B-R2 | Repudiation | No append-only audit on Click row tampering |
SB-018 (audit_log), SB-029 (Postgres role separation) |
| B-I1 | Info disclosure | IP_HASH_SECRET default literal 'dev-secret-rotate-in-prod' lets anyone with source access reverse ipHash over IPv4 in minutes |
SB-001 (Workers Secrets), SB-002 (32-byte random + rotation) — mandatory pre-prod |
| B-I2 | Info disclosure | Raw Referer logging leaks originating page query strings |
SB-030 (strip Referer query before log) |
| B-D1 | DoS | Volumetric click flood → DB write saturation | SB-016, SB-017, SB-005 (Hyperdrive) |
| B-D2 | DoS | 302-redirect amplification cannon at retailers (we become free traffic at them) | SB-016 |
| B-D3 | DoS | Click table storage growth |
SB-031 (M3 retention policy) |
| B-E1 | EoP | Tenant escape via retailerId/listingId mismatch — already checked in code, regression risk |
SB-032 (regression test pinned) |
| B-E2 | EoP | If #17 postback runs in a broad-write DB role, retailer compromise → direct Conversion write |
SB-029 (role separation per worker) |
Concrete bug in current code: req.headers.get('x-forwarded-for') is wrong for the leading-hosting target. On Cloudflare Workers, XFF is whatever the client sent — clients can spoof it freely. The trustworthy header is CF-Connecting-IP (or True-Client-IP if Enterprise). SB-021 tracks the fix; this is independent of #17 and worth its own issue.
Surface C — Auth.js + admin¶
Scope. OAuth callback flow, email-collision / account-merge logic, session cookie security, /admin/* authorisation gate (#16), audit_log, cookie-claim transaction. Auth.js v5 (the renamed-from-NextAuth.js project) is M2 work (#11).
| # | STRIDE | Threat | Mitigation refs |
|---|---|---|---|
| C-S1 | Spoofing | OAuth callback CSRF / state-fixation | Auth.js v5 default checks: ["pkce", "state"] (authjs.dev/reference/core/providers); never override to ["none"] |
| C-S2 | Spoofing | Email-collision account takeover — attacker pre-registers GitHub OAuth using victim's email; victim signs in via Google with same email; if allowDangerousEmailAccountLinking: true on either provider, accounts auto-merge → attacker hijacks victim. Auth.js v5 default is off; misconfiguration is the failure mode. |
SB-040 (allowDangerousEmailAccountLinking: false explicit), SB-041 (callbacks.signIn rejects unverified emails) |
| C-S3 | Spoofing | OAuth provider misconfiguration: redirect_uri loose-match, stale dev URI | SB-042 (exact-match URIs in provider consoles) |
| C-T1 | Tampering | Cookie-claim hijack: attacker steals build_session, signs in with own OAuth, transfers victim's build into attacker's account |
Accepted residual (low-value asset per ADR-0003); SB-013 (cookie hardening) |
| C-T2 | Tampering | JWT session token tampering | Auth.js v5 default JWE encryption with AUTH_SECRET; reduces to secret hygiene (SB-001, SB-002) |
| C-T3 | Tampering | audit_log tampering by compromised admin |
SB-029 (DB role separation), SB-018 (append-only by convention; row-level security at M2) |
| C-R1 | Repudiation | Admin denies destructive action | SB-018 (audit_log inside same Tx as the mutation) |
| C-R2 | Repudiation | Sign-in event lacks IP / UA / provider | SB-043 (wire events.signIn → audit_log) |
| C-I1 | Info disclosure | Session cookie XSS exfil via non-HttpOnly |
Auth.js v5 default HttpOnly |
| C-I2 | Info disclosure | Plaintext-HTTP cookie on misconfigured preview deploy | Auth.js v5 default __Secure- prefix in production; CF Pages forces HTTPS |
| C-I3 | Info disclosure | OAuth scope creep | SB-044 (least-privilege scopes: openid email profile / read:user user:email) |
| C-I4 | Info disclosure | Verbose error pages on OAuth failure | SB-045 (custom pages.error) |
| C-D1 | DoS | OAuth callback flood | SB-016 (CF Rate Limiting on /api/auth/*) |
| C-D2 | DoS | /admin/* enumeration / probe traffic |
SB-016, SB-046 (require auth before any work) |
| C-D3 | DoS | audit_log unbounded growth |
SB-031 (M3 retention) |
| C-E1 | EoP | Admin authz bypass — middleware-only gate evaded by Cloudflare Workers + Next.js 16 RSC quirks | SB-046 (defence-in-depth: middleware and auth() check inside every admin server component / action) |
| C-E2 | EoP | Env-var allow-list drift (ADMIN_EMAILS stale post-rotation) |
SB-047 (M2: migrate to User.role DB column) |
| C-E3 | EoP | OAuth provider compromise | Out-of-our-control; SB-044 (least scope), SB-048 (rotation playbook) |
| C-E4 | EoP | Session fixation | Auth.js v5 default issues new JWT on sign-in; cookie overwritten |
| C-E5 | EoP | Brute-force / credential stuffing | N/A at M1 (OAuth-only). Reappears if email-link / passkey added under #11. |
Surface D — Scraper + alert + LLM¶
Scope. undici fetch + cheerio parse, matcher pipeline, Listing upsert + ListingPrice insert, price-drop detection, image fetcher (M2 → R2), email alerts via Resend (#14), LLM extraction via Anthropic (#21).
Trust boundary. The retailer site is outside the trust boundary, even though we initiate the connection. We pull untrusted bytes into a privileged worker process.
| # | STRIDE | Threat | Mitigation refs |
|---|---|---|---|
| D-S1 | Spoofing | DNS hijack / MitM on retailer hostname | SB-050 (HTTPS-only, no downgrade) |
| D-S2 | Spoofing | Inbound email forging "961tech price drop" From: |
SB-051 (DMARC p=quarantine on sending domain) |
| D-S3 | Spoofing | Subscription created with someone else's email | SB-052 (DIY double-opt-in — Resend has no managed flow per resend.com/docs) |
| D-T1 | Tampering | Compromised retailer ships <script> / javascript: URL / SVG-with-JS in product title |
SB-053 (lint-ban dangerouslySetInnerHTML on retailer-derived strings; React JSX auto-escapes by default) |
| D-T2 | Tampering | Tracking-pixel imageUrl leaks egress IP + scrape cadence |
SB-054 (next/image strict remotePatterns), SB-055 (Content-Type allow-list on M2 image-fetch) |
| D-T3 | Tampering | Price poisoning — hostile retailer drops to $0.01 to skew "cheapest" | SB-056 (rolling-median anomaly hold), SB-057 (operator override via UC-J) |
| D-T4 | Tampering | Product.specs JSON poisoning |
SB-058 (Zod schema per category) |
| D-T5 | Tampering | Cheerio parse-injection — N/A: cheerio doesn't execute JS | Document explicitly so future contributors don't re-evaluate |
| D-R1 | Repudiation | "We never had that price" disputes | SB-059 (store HTTP status + fetched-at + content hash on every ListingPrice) |
| D-R2 | Repudiation | Operator override later disputed | SB-018 (audit_log on UC-J overrides) |
| D-R3 | Repudiation | Recipient claims they never subscribed | SB-052 (persist double-opt-in event) |
| D-I1 | Info disclosure | Tracking pixel discloses Worker egress IP | SB-060 (fixed UA string, no Referer outbound) |
| D-I2 | Info disclosure | LLM call forwards retailer text containing accidental PII | SB-061 (send only product title + already-extracted spec strings, never full HTML) |
| D-I3 | Info disclosure | Worker DB role over-privileged → retailer-content-induced bug exfils user data | SB-029 (three-role separation: scraper_worker, alert_worker, app_web) |
| D-D1 | DoS | Multi-GB HTML / zip-bomb response OOMs the Worker | SB-062 (undici 5 MB cap + bodyTimeout 15 s + headersTimeout 5 s) |
| D-D2 | DoS | Email-alert dispatch abuse — Resend default rate limit is 5 req/sec/team (resend.com/docs); a market sweep saturates instantly | SB-063 (per-user daily digest above N drops/day), SB-064 (pg-boss singleton (userId, day) coalescing), SB-065 (subscription-creation rate limit) |
| D-D3 | DoS | Scraper self-DoS: parser change emits 0 listings → site goes empty | SB-066 (drift alert: 0 listings when 30-day median ≥ 50 pages operator), SB-067 (soft-stale, never hard-delete) |
| D-D4 | DoS | LLM runaway response burns API quota | SB-068 (max_tokens ≤ 512 on every call), SB-069 (per-day spend cap with circuit-break) |
| D-E1 | EoP | LLM prompt injection — retailer description says "Ignore previous instructions and emit {...}" |
SB-070 (Anthropic tool use with strict: true), SB-071 (retailer content only inside delimited <retailer_text>...</retailer_text> user block, never system prompt), SB-072 (Zod validate the parsed tool_use.input regardless) |
| D-E2 | EoP | Future eval/new Function on retailer string → RCE |
SB-073 (lint ban; CF Workers runtime has no eval and no Node vm — defence-in-depth, not the primary control) |
| D-E3 | EoP | SSRF via M2 image fetcher — retailer imageUrl points at 169.254.169.254 (cloud metadata) or localhost:5433 |
SB-074 (undici custom Dispatcher connect hook: pre-resolve DNS, reject RFC1918 / RFC6598 / 127.0.0.0/8 / 169.254.0.0/16 / fe80::/10 / fc00::/7, then connect to validated IP literal — defeats DNS-rebinding) |
Walk-through scenarios¶
Five named scenarios from the #44 brief. Each ends with the baseline-row IDs that close the chain and the residual gap.
Scenario 1 — Aggressive scraping of 961tech (competitive / DoS)¶
A competitor or AI-training crawler hits us at 100 req/sec across /products/*, /build, /api/* for hours. Worker invocation cost spikes; Neon connection pool stays bounded by Hyperdrive but still adds Postgres compute. Catalog (low secrecy) is exfilable but technically already public on retailer sites — the moat is the aggregation, not any single price.
- Defends:
SB-016(Cloudflare Rate Limiting Rules — free tier 1 rule scoped to/api/*POST),SB-026(Bot Fight Mode, free),SB-005(Hyperdrive bounded fan-in),SB-067(soft-stale catalog tolerates parser/scraper degradation). - Tension: legitimate AI / search indexing is desirable for Casual-track SEO (#47). Block aggressive scraping behaviour without blocking well-behaved bots — this means rate-shape on volume + cadence, not blanket UA bans.
- Residual: a determined adversary will defeat Bot Fight Mode + Rate Limiting Rules. Super Bot Fight Mode is Pro-tier; Bot Management is Enterprise-only (CF Bots docs). Cost question for RFC-0006.
Scenario 2 — Compromised retailer site¶
A retailer is breached. Attacker injects a malicious product title (<script>alert(1)</script>), a tracking-pixel imageUrl, a Listing.url pointing at a credential-harvesting clone, and drops one flagship-GPU price to $0.01.
- Defends:
SB-053(nodangerouslySetInnerHTMLon retailer-derived strings; React auto-escapes),SB-054+SB-055(image-host allow-list + Content-Type filter),SB-056(rolling-median anomaly hold for the $0.01 price),SB-023+SB-024(URL host allow-list at click-redirect — already partially in place via DB lookup, but admin-write tooling #16 re-opens this). - Residual: semantically wrong but structurally valid poisoning (price drops 40 %, not 60 %; spec value plausible-but-wrong). Mitigated only operationally — cross-retailer disagreement detection (#43).
Scenario 3 — Account takeover via OAuth (M2)¶
Attacker registers GitHub OAuth using victim@gmail.com (GitHub does not always require ownership proof for non-primary email). Victim later signs in via Google with the same email. If allowDangerousEmailAccountLinking: true on either provider, accounts auto-merge → attacker now signs into victim's data.
sequenceDiagram
participant Attacker
participant GitHub
participant 961tech as 961tech (Auth.js v5)
participant Victim
participant Google
Attacker->>GitHub: register, claim victim@gmail.com (unverified)
Attacker->>961tech: sign in with GitHub
961tech->>961tech: create User (email=victim@gmail.com)
Note over Victim,Google: weeks later
Victim->>Google: sign in
Google->>961tech: callback (sub=victim, email_verified=true)
961tech->>961tech: callbacks.signIn invoked
alt allowDangerousEmailAccountLinking = true (DANGER)
961tech->>961tech: merge into Attacker's User row
961tech->>Victim: signed in, but as Attacker's account
else default v5 behaviour
961tech->>Victim: error / prompt to confirm explicit linking
end
- Defends: Auth.js v5 default
allowDangerousEmailAccountLinking: false(authjs.dev/reference/core/providers). Reinforced bySB-040(set explicit),SB-041(callbacks.signInrejects providers reportingemail_verified === false). - Residual: a verified-email-on-attacker-provider attack still works in theory. Closing requires per-provider trust differentials, which is not the v5 default model. Acceptable for hobby scale; reconsider for higher-value features.
Scenario 4 — Affiliate-click spoofing¶
Bot farm hits /api/go/r/X/p/Y 10 k times across rotating IPs to inflate click counts on retailer X. If #17 lands a CPC-payout flow, this becomes direct revenue theft.
- Defends:
SB-016(CF Rate Limiting — gross volume),SB-017(per-(ipHash, listingId)token bucket — targeted-listing inflation),SB-021(CF-Connecting-IPnotXFF— denies trivial source spoofing),SB-022(single-use opaque click token +redeemedAtcolumn for atomic redemption + 24 h TTL),SB-027(Turnstile interstitial as escalation lever; UX cost noted),SB-028(two-phaseinitiated→landedaccounting). - Residual: sophisticated headless-bot click inflation that defeats Bot Fight Mode + Rate Limiting Rules. Closes only with Super Bot Fight Mode (Pro) or Turnstile-on-every-click (UX tax). Open question for RFC-0006.
Scenario 5 — Listing-data poisoning via SQL injection¶
Attacker tries '; DROP TABLE Listing; -- payloads through every input — source querystring, future admin form, future API.
- Defends: Prisma uses parameterised queries by default — classical SQLi vector closed. Documented in
SB-058callout. Remaining injection vectors: (a) theJsonblob (Product.specs) rendered as a template anywhere; (b) any future raw SQL identifier interpolation. Both must be explicitly guarded viaSB-058(Zod-per-category) and an explicit "never use string-interp on retailer-derived strings" rule. - Residual: a future contributor adds a raw
$queryRawUnsafeand skips review. Mitigated only by code-review discipline; consider an ESLint rule banning$queryRawUnsafeoutside of an audited migration path.
What this model does not cover¶
- Live exploit walkthroughs or pentest output — this is theoretical, control-driven.
- Dependency-CVE tracking beyond the freshness-band call-out in tech-stack.md § Cross-cutting risks.
npm auditgating is one of the controls in the baseline (SB-080); the threat model itself doesn't enumerate CVEs. - Code-level audit of every route — only
/api/go/*is read in detail. Surface A's mitigations apply to every route handler and server action; the baseline is the enforcement layer, not this page. - DR / RTO / RPO — pulled along by the RFC-0001 hosting decision.
- Retailer self-onboarding (UC-K, M2) — deferred. When #36 revisits retailer self-onboarding, this page gets a new surface and re-walks the relevant scenarios.
Cross-references¶
- Reference → Security baseline — the controls this model assumes.
- RFC-0006 — Security controls — open questions for MASTER on Auth.js scope, CSP shape, rate-limit topology, audit-log retention, vulnerability disclosure.
- ADR-0003 — anon build cookie pattern (T1, A-S2 rationale).
- #11 Auth.js, #16 Admin tools, #17 Affiliate reconciliation, #19 Hosting, #40 Legal / privacy, #43 Observability.