VibeAudit · Claude Fable 5.1 reads a whole repo and grades it for launch. Free quick scan, $19 deep audit. Scan your own repo →
DEEP AUDIT · 2026-09-02
shadcn-ui/taxonomy
70–89 fix first
<70 not ready
Taxonomy is a Next.js 13 App Router SaaS starter (shadcn/taxonomy): NextAuth login, a Prisma/MySQL post editor, and a Stripe PRO subscription that lifts a 3-post limit. The core flows are wired sensibly, but there is one outright authorization hole: an unauthenticated request can DELETE or PATCH any post by ID because the ownership check passes `undefined` as the author filter and Prisma drops it. Beyond that, the app is pinned to an April-2023 Next.js canary with many unpatched CVEs, the Stripe checkout flow can double-subscribe a user, and the site still ships demo copy ('you won't be charged', lorem-ipsum Terms/Privacy, shadcn branding). Fix the post route authz and the framework version before anything else.
167 files reviewed · claude-fable-5-1 · deep audit
Findings (15)
`verifyCurrentUserHasAccessToPost` runs `db.post.count({ where: { id: postId, authorId: session?.user.id } })` without first checking that a session exists. When there is no session (the middleware matcher in middleware.ts:45 does not cover /api/*), `session?.user.id` evaluates to `undefined`. Prisma treats `undefined` in a `where` clause as 'field not set', so the query becomes `WHERE id = ?` and returns count 1 for any existing post. Both DELETE (line 23) and PATCH (line 53) then proceed against `params.postId` with no owner filter.
Anyone who learns a post ID (they appear in /editor/<id> URLs, browser history, referrers, logs, screenshots) can delete or overwrite that user's post with a single curl request and no cookie. Same hole applies to a logged-in user attacking another user's posts. This is destructive, unauthenticated write access to customer data.
Require a session up front and fail closed, then scope the write itself by author instead of relying on a separate count. Example: const session = await getServerSession(authOptions) if (!session?.user?.id) return new Response(null, { status: 401 }) const result = await db.post.deleteMany({ where: { id: params.postId, authorId: session.user.id } }) if (result.count === 0) return new Response(null, { status: 404 }) Do the same for PATCH with `updateMany`. As a general rule in this codebase, never pass a possibly-undefined value into a Prisma `where`.
In app/api/posts/[postId]/route.ts, fix an authorization bypass. `verifyCurrentUserHasAccessToPost` calls `db.post.count({ where: { id: postId, authorId: session?.user.id } })`; when there is no session `authorId` is `undefined`, Prisma drops that filter, and the count succeeds for any post, so unauthenticated users can DELETE/PATCH any post. Rewrite both handlers: (1) call `getServerSession(authOptions)` first and return 401 if `!session?.user?.id`; (2) remove the separate count check and instead perform the mutation scoped to the owner using `db.post.deleteMany({ where: { id: params.postId, authorId: session.user.id } })` and `db.post.updateMany({ where: { id: params.postId, authorId: session.user.id }, data: {...} })`; (3) return 404 when `result.count === 0`. Delete the `verifyCurrentUserHasAccessToPost` helper. Reason: Prisma ignores `undefined` in where clauses, so the owner check must never depend on a nullable value.`next` is pinned to `13.3.2-canary.13` with `experimental.appDir` (next.config.mjs:12), and the README explicitly says the project is archived and 'not recommended for use in production'. Next.js 13.x releases before 13.5.9 are affected by CVE-2025-29927 (middleware bypass via `x-middleware-subrequest`) plus several image-optimizer DoS and cache-poisoning advisories. `next-auth` 4.22.1 and `@vercel/og` 0.0.21 are similarly old.
Known, public exploits against the framework itself. In this app the middleware bypass mostly skips redirects (pages and route handlers re-check the session), but the other advisories (DoS, cache poisoning, SSRF) apply to any deployment. You also cannot get security patches for a canary build, and App Router APIs used here (e.g. `headers()` sync usage) have since changed.
Upgrade to a supported release line (Next.js 14.2.x or 15.x, next-auth 4.24.x or Auth.js v5), remove `experimental.appDir`, run `next lint`/`tsc`, and fix the small API drift (route handler `context` typing, `headers()` becoming async in 15, `themeColor` moving to `viewport`). Then add Dependabot/Renovate so this does not drift again.
Upgrade this Next.js app off the canary build. In package.json change `next` from `13.3.2-canary.13` to the latest 14.2.x (or 15.x) release, bump `next-auth` to the latest 4.x, `@vercel/og` to a current version, and `eslint-config-next` to match. In next.config.mjs remove `experimental.appDir` (it is stable now) and keep `serverComponentsExternalPackages`. Fix any resulting type/runtime errors: route handler `context` params typing in app/api/posts/[postId]/route.ts and app/api/users/[userId]/route.ts, move `themeColor` from `metadata` to `export const viewport` in app/layout.tsx, and if targeting Next 15 make `headers()` awaited in app/api/webhooks/stripe/route.ts. Reason: the pinned canary has multiple published CVEs (including the middleware bypass CVE-2025-29927) and receives no patches; the README itself says the code is not production-ready.
The checkout branch is taken whenever `!(isPro && stripeCustomerId)`. It creates a session with `customer_email` only (line 41) and never passes `customer: subscriptionPlan.stripeCustomerId`, so a lapsed or expired user gets a brand-new Stripe customer each time. There is no check at checkout-completion time either: the webhook (app/api/webhooks/stripe/route.ts:35) blindly overwrites `stripeSubscriptionId`/`stripeCustomerId`. A user with two tabs open, or who upgrades while the previous webhook has not landed (`isPro` still false), can complete two Checkout sessions and have two live subscriptions in Stripe while the DB only remembers the last one.
Customers get charged twice per month with no way to see or cancel the orphaned subscription from the app's billing portal (it points at a different customer). Refund tickets, chargebacks, and manual Stripe cleanup. Duplicate customer records also break the Billing Portal for lapsed users.
Reuse the existing customer and short-circuit if a subscription is already active in Stripe: if (subscriptionPlan.stripeCustomerId) { const subs = await stripe.subscriptions.list({ customer: subscriptionPlan.stripeCustomerId, status: 'active', limit: 1 }) if (subs.data.length) { /* return billing portal URL */ } } const stripeSession = await stripe.checkout.sessions.create({ ...(subscriptionPlan.stripeCustomerId ? { customer: subscriptionPlan.stripeCustomerId } : { customer_email: session.user.email }), client_reference_id: session.user.id, ... }) Also create the Stripe customer once and store `stripeCustomerId` before the first checkout so every checkout is bound to it.
In app/api/users/stripe/route.ts, prevent duplicate Stripe customers and double subscriptions. Change the flow to: (1) if `subscriptionPlan.stripeCustomerId` exists, call `stripe.subscriptions.list({ customer, status: 'active', limit: 1 })` and if any active subscription exists return a Billing Portal session URL instead of a Checkout URL (regardless of the DB `isPro` flag); (2) when creating the Checkout session, pass `customer: subscriptionPlan.stripeCustomerId` when it exists instead of `customer_email`, and always set `client_reference_id: session.user.id` in addition to `metadata.userId`; (3) if the user has no stripeCustomerId yet, create one with `stripe.customers.create({ email, metadata: { userId } })`, persist it to `db.user` first, then use it in the Checkout session. Keep the existing 403 for missing session. Reason: today a lapsed user or a user with two tabs can create a second Stripe customer/subscription and be double-charged while the DB only stores the last one.Quick wins
- · Return 401 (not 403) for missing sessions in app/api/posts/route.ts and app/api/users/stripe/route.ts so clients can distinguish 'log in' from 'forbidden'.
- · Stop spreading `...subscriptionPlan` (includes stripeCustomerId/stripeSubscriptionId) into the client BillingForm in app/(dashboard)/dashboard/billing/page.tsx; pass only name, description, isPro, isCanceled, stripeCurrentPeriodEnd.
- · Add `.max(64)` to `type` and use `.max(200)` on `heading` in lib/validations/og.ts so /api/og cannot be fed arbitrary-length strings.
- · Delete the dead duplicate app/api/auth/[...nextauth]/_route.ts to avoid confusion about which NextAuth handler is live (pages/api/auth/[...nextauth].ts is).
- · Add `take: 100` (with pagination later) to the dashboard `db.post.findMany` for PRO users with unlimited posts.
- · Set `allowDangerousEmailAccountLinking: true` on GitHubProvider only if you want email-then-GitHub users to link automatically; otherwise add copy on the OAuthAccountNotLinked error page.
- · Add Dependabot/Renovate so next, next-auth, stripe, and prisma stop drifting years behind.
What's already good
- · Stripe webhook verifies signatures against the raw request body (`req.text()` + `constructEvent`) and reads the secret from validated env, which is the part most vibe-coded apps get wrong.
- · Environment variables are schema-validated at build time via @t3-oss/env-nextjs, so a missing STRIPE_WEBHOOK_SECRET or NEXTAUTH_SECRET fails the deploy instead of failing at runtime.
- · Secrets stay server-side: Stripe and Postmark clients live in lib/*, only NEXT_PUBLIC_APP_URL is exposed to the client, and .env is gitignored.
- · Dashboard and editor pages scope queries by `authorId: user.id`, and PATCH /api/users/[userId] correctly compares the path param to the session user before writing.
- · Request bodies are validated with zod on every API route, and Zod errors return 422 rather than leaking stack traces.
- · NextAuth's default redirect callback constrains the `from` callbackUrl, so the login redirect is not an open redirect.
Do this first
- Fix F1 today: require a session in app/api/posts/[postId]/route.ts and scope DELETE/PATCH with deleteMany/updateMany on { id, authorId } — this is an unauthenticated data-destruction hole.
- Upgrade off the Next.js canary to a supported 14.2.x/15.x and bump next-auth/@vercel/og (F2); re-run the app end-to-end.
- Fix the Stripe flow: reuse stripeCustomerId, block a second active subscription, switch /api/users/stripe to POST, and make the webhook order-tolerant with subscription.updated/deleted handling (F3, F4).
- Remove all demo/placeholder copy and branding and write real Terms/Privacy (F6) — do not take a payment while the pricing page says users won't be charged.
- Add rate limiting to magic-link sends and Stripe session creation (F5), then rewrite the jwt callback to stop hitting the DB per request (F7).
- Sweep the low-severity items in one pass: atomic post limit (F8), typed/size-capped content (F9), @updatedAt (F10), relationMode (F11), client error handling (F12), guarded billing page (F13), secureCookie in middleware (F14), LinkTool (F15).
Fixed things? Re-audit.
Run a new scan on the updated repo. Use a 5-pack key or pay per audit.