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
vercel/ai-chatbot
70–89 fix first
<70 not ready
This is the Vercel ai-chatbot template (Next.js 16 App Router, AI SDK 7 via AI Gateway, NextAuth v5 with guest + credentials, Drizzle/Postgres, Vercel Blob, Redis rate limiting). The API routes are mostly well guarded, but two server actions bypass authorization entirely: `getSuggestions` lets any logged-in (or guest) user read another user's document suggestions by ID, and `generateTitleFromUserMessage` is an exported server action that triggers an unmetered LLM call. Beyond that, the main risks are abuse/cost (unlimited guest account creation resets the per-user quota, IP limit silently disabled without Redis), a missing UNIQUE constraint on User.email, upload content-type spoofing, CI tests pointed at the production database, and zero non-PK indexes on tables that are scanned on every chat request. Fix the two server actions and the guest/rate-limit gaps before launch.
172 files reviewed · claude-fable-5-1 · deep audit
Findings (17)
`getSuggestions({ documentId })` is an exported function in a `"use server"` module, imported by the client in `artifacts/text/client.tsx` (line 122), so its action ID ships to the browser. It calls `getSuggestionsByDocumentId` directly with no `auth()` call and no `userId` comparison. The sibling API route `app/(chat)/api/suggestions/route.ts` does check `suggestion.userId !== session.user.id`, but this action skips all of that.
Any user (including a throwaway guest) can call the action with any document UUID and receive every suggestion row for it, including `originalText` and `suggestedText`, which are verbatim sentences from another user's private document.
Add a session check and ownership check, mirroring the API route: ```ts export async function getSuggestions({ documentId }: { documentId: string }) { const session = await auth(); if (!session?.user?.id) throw new Error("Unauthorized"); const suggestions = await getSuggestionsByDocumentId({ documentId }); if (suggestions.length > 0 && suggestions[0].userId !== session.user.id) { throw new Error("Forbidden"); } return suggestions; } ``` Or better: have the client fetch `/api/suggestions?documentId=` (already authorized) and delete this action.
In artifacts/actions.ts, the exported server action `getSuggestions` has no authentication or authorization. Import `auth` from "@/app/(auth)/auth", call `const session = await auth()` at the top, throw `new Error("Unauthorized")` if `!session?.user?.id`, then after fetching suggestions, if `suggestions.length > 0 && suggestions[0].userId !== session.user.id` throw `new Error("Forbidden")`. Return an empty array if there are no suggestions. This mirrors the checks already in app/(chat)/api/suggestions/route.ts. Reason: exported functions in "use server" files are public endpoints and this one currently leaks another user's document text via suggestion rows.`app/(chat)/actions.ts` starts with `"use server"`, so every exported async function is registered as a server action endpoint. `generateTitleFromUserMessage` (line 23) calls `generateText` with a client-controlled `message` and has no `auth()`, no rate limit, and no length cap on `getTextFromMessage(message)`. It is only meant to be called internally from `app/(chat)/api/chat/route.ts` line 132. Its action ID is not referenced from a client component, so an attacker must obtain the ID, but Next.js explicitly treats all exports of "use server" modules as public endpoints. `saveChatModelAsCookie` (line 18) is likewise public but harmless.
Anyone who obtains the action ID (leaked bundle, RSC payload, or future refactor that imports it client-side) can run unlimited AI Gateway requests with arbitrarily long prompts on your bill, bypassing the per-user message quota and BotID (which only protects POST /api/chat).
Move `generateTitleFromUserMessage` out of the "use server" module into a plain server-only file (e.g. `lib/ai/title.ts` with `import "server-only"`), and import it from the chat route. Keep only real client-callable actions (`deleteTrailingMessages`, `updateChatVisibility`) in `actions.ts`. Also cap the prompt: `getTextFromMessage(message).slice(0, 2000)`.
In app/(chat)/actions.ts, the function `generateTitleFromUserMessage` is exported from a "use server" module, which makes it a publicly callable server action that performs an LLM call with no auth or rate limit. Move `generateTitleFromUserMessage` (and its imports: generateText, titleModel, titlePrompt, getTitleModel, getTextFromMessage) to a new file lib/ai/title.ts that begins with `import "server-only";` and is NOT marked "use server". Update app/(chat)/api/chat/route.ts to import it from "@/lib/ai/title" instead of "../../actions". Also truncate the prompt with `.slice(0, 2000)` on the result of getTextFromMessage. Do the same for `saveChatModelAsCookie` if it is not used from a client component. Reason: every export of a "use server" file is an HTTP endpoint; paid LLM calls must never be exposed that way.
Quick wins
- · Add auth + ownership check to artifacts/actions.ts getSuggestions (5 lines) or replace it with the already-authorized /api/suggestions route.
- · Move generateTitleFromUserMessage out of the "use server" file into lib/ai/title.ts with `import "server-only"`.
- · Restrict filePartSchema.url in app/(chat)/api/chat/schema.ts to your Vercel Blob hostname.
- · Add `if (!document) return not_found` to the DELETE handler in app/(chat)/api/document/route.ts.
- · Add a UNIQUE index on User.email and lowercase emails on register/login.
- · Add composite indexes on Message_v2(chatId, createdAt), Chat(userId, createdAt) and the other FK columns via one Drizzle migration.
- · Point .github/workflows/playwright.yml at a test database/Blob store instead of production secrets.
- · Lower guest maxMessagesPerHour and log (instead of silently skipping) when the Redis rate limiter is unavailable in production.
- · Await delete fetches in the sidebar before showing 'deleted' toasts.
- · Replace template branding: metadataBase, page title, 'Deploy with Vercel' button, 'Powered by AI Gateway'.
What's already good
- · Every API route (/api/chat, /api/document, /api/vote, /api/history, /api/suggestions, /api/files/upload) and the two client-used server actions (deleteTrailingMessages, updateChatVisibility) check both session and resource ownership; AI tools (editDocument, updateDocument, requestSuggestions) also compare document.userId to the session.
- · Request bodies are validated with Zod before use (chat POST, document POST, vote PATCH, upload), and the selected model is checked against an allow-list server-side rather than trusting the client.
- · Credentials auth uses bcrypt with a dummy-hash compare to avoid user-enumeration timing differences; no secrets are hard-coded and .env files are gitignored.
- · Layered abuse controls exist in the chat route: BotID, per-IP Redis rate limit, and a per-user hourly message quota computed from the database.
- · Error handling is centralized in ChatbotError with per-surface visibility so database errors are logged, not returned to clients; unhandled chat errors are caught and logged with the Vercel request id.
- · Public/private chat visibility is enforced server-side in /api/messages, and the guest redirect validates redirectUrl to prevent open redirects.
- · Markdown rendering goes through Streamdown (sanitized) rather than raw dangerouslySetInnerHTML; the only inline script is a static theme-color snippet.
Do this first
- Fix the two server-action exposures: add auth/ownership to getSuggestions (F1) and move generateTitleFromUserMessage into a server-only module (F2).
- Close the abuse/cost gaps: restrict attachment URLs to your blob host (F4), rate-limit guest creation and auth actions, and make the IP limiter fail loudly in production (F5, F7).
- Ship one Drizzle migration that adds the UNIQUE(email) constraint and the missing indexes (F6, F9), then handle the unique-violation in register.
- Harden uploads with magic-byte checks, explicit contentType and per-user paths (F3).
- Point CI at a non-production database (F8) and wrap chat deletes in transactions (F11); fix the /api/document DELETE crash (F12) and vote scoping (F10).
- Raise maxDuration / lower step count and add timeouts to nested streamText calls (F13); decide whether to implement or remove resumable streams (F16).
- Tighten the tool-approval part merge (F14), await delete requests in the UI (F15), and replace template branding (F17) before announcing.
Fixed things? Re-audit.
Run a new scan on the updated repo. Use a 5-pack key or pay per audit.