03
Findings
CriticalVerified in codeC1
Any logged-in user can grant themselves a paid subscription with no payment
Three separate routes provision an active subscription without ever charging a card:
- stripe/create-subscription/route.ts:44 — writes
status:"active" with a fabricated stripeSubscriptionId.
- debug/create-subscription/route.ts:19 — the same bypass, with the caller choosing plan and status.
- debug/test-webhook/route.ts:5 — calls the real provisioning handler with mock data, skipping Stripe signature verification.
ImpactDirect revenue loss — the entire paywall is optional.
RemediationDelete all three paths. Subscriptions may be created only by the signature-verified Stripe webhook.
CriticalVerified in codeC2
A debug and test API surface of ~12 endpoints ships live, several unauthenticated
- api/debug/magnets/route.ts — no authentication; returns every user's magnets including owner emails, surfaced on a public page.
- api/test-env/route.ts, api/test-db/route.ts — report which secrets are set, the environment, and database schema to anonymous callers.
ImpactCross-tenant data and PII exposure, plus infrastructure reconnaissance. None are gated by environment.
RemediationDelete the entire debug/* and test-* surface, or gate it behind a non-production check and an admin role.
CriticalVerified in codeC3
Stored XSS in the embeddable widget — around 56 sinks, no escaping
The 6,843-line widget.js/route.ts interpolates magnet.title, description and field.label straight into innerHTML in roughly 56 places. A search for any escaping or sanitisation helper returns nothing.
AttackA magnet titled with an onerror image runs attacker JavaScript on every third-party site that embeds the widget — persistent, and hitting every visitor.
RemediationEscape every interpolated value (or use textContent / DOMPurify); add a Content-Security-Policy header on the widget response.
CriticalVerified in codeC4
Lead capture allows identity spoofing and an unbounded table scan
- api/leads/route.ts:30 —
userId is read from the request body and trusted, so leads can be attributed to any user.
- api/leads/route.ts:337 — the update fallback runs
findMany() with no filter, loading the whole table into memory.
- The public endpoint has wildcard CORS, no rate limit and no validation, and auto-creates rows.
ImpactCross-tenant lead poisoning, plus a denial-of-service risk as the table grows.
RemediationDerive the owner from the magnet, never the body; replace the scan with a filtered query; add validation and rate limiting.
CriticalVerified in codeC5
Plan quotas are cosmetic — every plan is effectively unlimited
canCaptureLeads() is defined at lib/subscription.ts:45 and is never called anywhere, so lead capture is unlimited on every plan. Magnet limits are enforced on only some paths, and read a plan the user can set on themselves.
RemediationEnforce the quota checks at every creation path, and collapse to a single source of truth for plan limits.
HighVerified in codeH1
Raw eval() in the public widget
widget.js/route.ts:6421 and :6441 run eval() on owner- and AI-authored strings. The React renderer uses mathjs and is largely safe — the genuine code-execution risk is the widget's raw eval.
RemediationReplace with a restricted mathjs instance; pass inputs through a scope object, not string substitution.
HighVerified in codeH2
Middleware runs but enforces nothing
middleware.ts is simply clerkMiddleware() with no auth.protect(). Every route is public unless it individually calls auth() — trivial to forget, and the root cause behind several exposures above.
RemediationProtect authenticated route groups in the middleware rather than relying on per-route checks.
HighH3
No rate limit or prompt cap on the OpenAI routes
The generate and customise routes only check authentication. A single leaked token means unbounded OpenAI spend, and the AI response is only shallowly validated before being stored and later evaluated.
RemediationAdd per-user rate limiting and prompt-length caps, and validate AI output against a schema before persisting.
HighVerified in codeH4
Subscription schema drift — unique constraints silently dropped
Migration 20250904084932 drops all three unique indexes on the Subscription table despite an unrelated name, so Stripe retries create duplicate rows and the schema disagrees with the migration history.
RemediationRestore the unique key on stripeSubscriptionId; make webhook handlers idempotent by event id; reconcile schema against migrations.
HighVerified in codeH5
Deploys never apply database migrations
scripts/vercel-build.js runs generate and build but never prisma migrate deploy, so the production database drifts from the schema and a runtime "repair" script papers over the gap.
RemediationAdd prisma migrate deploy to the build step and delete the runtime repair script.
HighVerified in codeH6
The build ships with type and lint checks turned off
next.config.ts sets ignoreBuildErrors and ignoreDuringBuilds, on top of 263 any usages, so type errors reach runtime silently. It also sets an invalid minifier flag and a build id that breaks caching.
RemediationRe-enable both checks, resolve the errors they surface, and remove the invalid options.
HighH7
Conflicting plan definitions and an unauthenticated magnet read
- Two files disagree on plan limits (5 vs 20 magnets); enforcement and checkout can diverge, and the checkout price is hardcoded.
- api/magnets/[id]/route.ts — an unauthenticated read returns full content plus owner email for any public magnet.
RemediationUse one plan-limits module, drive prices from Stripe price ids, and strip owner email from public responses.
MediumM1 – M8
Robustness and correctness gaps
PII in logs. 1,510 console.* calls; the leads route logs full bodies, emails and lead records into host logs — a GDPR exposure.
Error-detail leakage. Fifteen routes return internal error messages and stack detail to the client.
Expiry ignored. The active-subscription check never verifies the current period end, so lapsed subscriptions stay active.
No webhook idempotency by event id — Stripe retries duplicate state.
Model duplication. Magnet and UserMagnet overlap; different routes treat different models as the source of truth.
Deletion & indexes. Missing foreign-key indexes and no cascade — user deletion is blocked and data orphans.
Dead auth surface. A User.password field persists despite Clerk being the provider.
Cross-tenant admin write. An admin can change visibility on any user's private template.
Low · HygieneLOW
Cleanup and maintainability
No tests and no CI; four (prisma as any) casts where the client is out of sync; leftover template boilerplate and unused heavy dependencies; an invalid package version and a mismatched start port; flat route folders patched by rewrites instead of route groups; and a duplicated, deprecated server-packages declaration.