VibeAudit

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

Download .md
68/100
NOT READY
90+ ship it
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.

Next.js 16 (App Router, cacheComponents, proxy.ts middleware)React 19 + React CompilerAI SDK 7 (ai, @ai-sdk/react) via Vercel AI GatewayNextAuth 5 beta (Credentials + guest provider, JWT sessions)Drizzle ORM + postgres-js (Neon Postgres)Vercel Blob (file uploads)Redis (node-redis) for IP rate limiting, resumable-streamVercel BotIDTailwind CSS 4 + shadcn/radix-uiProseMirror / CodeMirror / react-data-grid artifactsPyodide (client-side Python execution via CDN)Playwright e2e

172 files reviewed · claude-fable-5-1 · deep audit

Findings (17)

What's wrong

`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.

Impact

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.

Fix

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.

Paste into Cursor / Claude Code
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.
What's wrong

`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.

Impact

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).

Fix

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)`.

Paste into Cursor / Claude Code
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

  1. Fix the two server-action exposures: add auth/ownership to getSuggestions (F1) and move generateTitleFromUserMessage into a server-only module (F2).
  2. 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).
  3. Ship one Drizzle migration that adds the UNIQUE(email) constraint and the missing indexes (F6, F9), then handle the unique-violation in register.
  4. Harden uploads with magic-byte checks, explicit contentType and per-user paths (F3).
  5. 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).
  6. Raise maxDuration / lower step count and add timeouts to nested streamText calls (F13); decide whether to implement or remove resumable streams (F16).
  7. Tighten the tool-approval part merge (F14), await delete requests in the UI (F15), and replace template branding (F17) before announcing.
VibeAudit badge
Add the badge to your README
[![VibeAudit](https://vibeaudit.sh/api/badge/fx03aichatbot)](https://vibeaudit.sh/a/fx03aichatbot)

Fixed things? Re-audit.

Run a new scan on the updated repo. Use a 5-pack key or pay per audit.

Public repo URL, no signup. Private repo? Sign in with GitHub and pick it — read-only, nothing stored except the report.