Vercel AI SDK Security Scan
Defensive scan for code using the Vercel AI SDK (ai, @ai-sdk/*). Reports findings using the shared scoring schema.
Scope
- Files importing
ai, @ai-sdk/openai, @ai-sdk/anthropic, @ai-sdk/google, etc.
- Calls to
streamText, generateText, streamObject, generateObject, tool, convertToCoreMessages
- Route handlers backing
useChat / useCompletion / useObject
Rules
| ID |
Severity |
Detection |
Fix |
| VAI-KEY-001 |
critical |
Provider client (createOpenAI, createAnthropic, etc.) instantiated with API key in a file marked "use client" or under app/ Client Component path |
Initialize providers in route handlers / server actions only |
| VAI-AUTH-001 |
high |
Route handler backing useChat (POST /api/chat) has no auth check before calling streamText |
Verify session/JWT at top of handler |
| VAI-TOOL-001 |
critical |
tool({ ... execute: async (args) => { /* shell/sql/fs from args without validation */ } }) |
Define parameters: z.object(...) and re-validate inside execute; allowlist values |
| VAI-TOOL-002 |
high |
Tool registered but parameters is z.any() / z.object({}).passthrough() |
Define explicit zod schema with .strict() |
| VAI-OBJ-001 |
medium |
generateObject / streamObject called with a loose schema (z.record, z.any) |
Use specific zod schema; rely on the SDK's enforcement |
| VAI-OUT-001 |
high |
streamText output piped to dangerouslySetInnerHTML (or markdown renderer with HTML enabled) on the client |
Render as text or use a sanitizing markdown renderer (rehype-sanitize) |
| VAI-OUT-002 |
critical |
Server-side use of model output as code (new Function, eval, shell exec) |
Never; parse into schema first |
| VAI-ATT-001 |
high |
experimental_attachments accepted from client without size / MIME / count limits |
Validate on the server before forwarding to model |
| VAI-ATT-002 |
medium |
Attachments forwarded to model with URLs originating from arbitrary user input |
Allowlist hosts; or download/validate server-side |
| VAI-INJ-001 |
high |
system prompt built by string-concatenating request data (e.g., user-provided role/persona) |
Use parameterized templates with allowlisted values |
| VAI-INJ-002 |
medium |
RAG context passed inline without instruction-vs-data delimiters |
Wrap retrieved chunks in tagged blocks; instruct model to treat as data |
| VAI-LOG-001 |
medium |
onFinish({ text, usage, ... }) callback ships text to remote telemetry / 3rd-party logger |
Log usage/finishReason only; redact text |
| VAI-RATE-001 |
medium |
/api/chat exposed without rate limiting |
Apply per-user/IP limiter (Upstash / Vercel KV) |
| VAI-EDGE-001 |
medium |
Route uses runtime: 'edge' and reads Node-only secrets via process.env (works today but risk of build-time leakage if moved to client) |
Mark runtime = 'nodejs' explicitly when using server-only secrets |
Wrong vs. right
VAI-TOOL-001 (unvalidated tool execute)
// ❌ Args go straight to shell
tools: {
run: tool({
description: 'Run a command',
parameters: z.object({ cmd: z.string() }),
execute: async ({ cmd }) => {
const { stdout } = await execAsync(cmd);
return stdout;
},
}),
}
// ✅ Allowlisted enum + dispatcher
tools: {
run: tool({
description: 'Run an allowlisted task',
parameters: z.object({ task: z.enum(['build', 'test', 'lint']) }).strict(),
execute: async ({ task }) => runAllowlisted(task),
}),
}
VAI-OUT-001 (HTML rendering)
// ❌ Prompt-injection → XSS
<div dangerouslySetInnerHTML={{ __html: message.content }} />
// ✅ Plain text or sanitized markdown
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSanitize]}>
{message.content}
</ReactMarkdown>
VAI-AUTH-001 (no auth on chat endpoint)
// ❌ Anyone with the URL gets to spend your tokens + access tools
export async function POST(req: Request) {
const { messages } = await req.json();
return streamText({ model, messages, tools }).toDataStreamResponse();
}
// ✅ Auth + per-tenant rate limit
export async function POST(req: Request) {
const session = await auth();
if (!session?.user) return new Response('Unauthorized', { status: 401 });
const { success } = await ratelimit.limit(session.user.id);
if (!success) return new Response('Too many requests', { status: 429 });
const { messages } = await req.json();
return streamText({ model, messages, tools, system: SYSTEM_PROMPT })
.toDataStreamResponse();
}
References
1---2name: vercel-ai-sdk-security-scan3description: Defensive security scan for the Vercel AI SDK (`ai` package). Detects unbounded experimental_attachments, streamText output rendered as HTML, tool execute handlers running shell from args, useChat endpoints without auth, generateObject schemas missing strict mode, and onFinish leaking content to telemetry. Invoke when the user asks to "review", "audit", or "scan" code using the AI SDK.4---56# Vercel AI SDK Security Scan78Defensive scan for code using the Vercel AI SDK (`ai`, `@ai-sdk/*`). Reports findings using the [shared scoring schema](../../../SCORING.md).910## Scope1112- Files importing `ai`, `@ai-sdk/openai`, `@ai-sdk/anthropic`, `@ai-sdk/google`, etc.13- Calls to `streamText`, `generateText`, `streamObject`, `generateObject`, `tool`, `convertToCoreMessages`14- Route handlers backing `useChat` / `useCompletion` / `useObject`1516## Rules1718| ID | Severity | Detection | Fix |19|----|----------|-----------|-----|20| VAI-KEY-001 | critical | Provider client (`createOpenAI`, `createAnthropic`, etc.) instantiated with API key in a file marked `"use client"` or under `app/` Client Component path | Initialize providers in route handlers / server actions only |21| VAI-AUTH-001 | high | Route handler backing `useChat` (`POST /api/chat`) has no auth check before calling `streamText` | Verify session/JWT at top of handler |22| VAI-TOOL-001 | critical | `tool({ ... execute: async (args) => { /* shell/sql/fs from args without validation */ } })` | Define `parameters: z.object(...)` and re-validate inside `execute`; allowlist values |23| VAI-TOOL-002 | high | Tool registered but `parameters` is `z.any()` / `z.object({}).passthrough()` | Define explicit zod schema with `.strict()` |24| VAI-OBJ-001 | medium | `generateObject` / `streamObject` called with a loose schema (`z.record`, `z.any`) | Use specific zod schema; rely on the SDK's enforcement |25| VAI-OUT-001 | high | `streamText` output piped to `dangerouslySetInnerHTML` (or markdown renderer with HTML enabled) on the client | Render as text or use a sanitizing markdown renderer (rehype-sanitize) |26| VAI-OUT-002 | critical | Server-side use of model output as code (`new Function`, `eval`, shell exec) | Never; parse into schema first |27| VAI-ATT-001 | high | `experimental_attachments` accepted from client without size / MIME / count limits | Validate on the server before forwarding to model |28| VAI-ATT-002 | medium | Attachments forwarded to model with URLs originating from arbitrary user input | Allowlist hosts; or download/validate server-side |29| VAI-INJ-001 | high | `system` prompt built by string-concatenating request data (e.g., user-provided role/persona) | Use parameterized templates with allowlisted values |30| VAI-INJ-002 | medium | RAG context passed inline without instruction-vs-data delimiters | Wrap retrieved chunks in tagged blocks; instruct model to treat as data |31| VAI-LOG-001 | medium | `onFinish({ text, usage, ... })` callback ships `text` to remote telemetry / 3rd-party logger | Log `usage`/`finishReason` only; redact text |32| VAI-RATE-001 | medium | `/api/chat` exposed without rate limiting | Apply per-user/IP limiter (Upstash / Vercel KV) |33| VAI-EDGE-001 | medium | Route uses `runtime: 'edge'` and reads Node-only secrets via `process.env` (works today but risk of build-time leakage if moved to client) | Mark `runtime = 'nodejs'` explicitly when using server-only secrets |3435## Wrong vs. right3637### VAI-TOOL-001 (unvalidated tool execute)3839```ts40// ❌ Args go straight to shell41tools: {42 run: tool({43 description: 'Run a command',44 parameters: z.object({ cmd: z.string() }),45 execute: async ({ cmd }) => {46 const { stdout } = await execAsync(cmd);47 return stdout;48 },49 }),50}51```5253```ts54// ✅ Allowlisted enum + dispatcher55tools: {56 run: tool({57 description: 'Run an allowlisted task',58 parameters: z.object({ task: z.enum(['build', 'test', 'lint']) }).strict(),59 execute: async ({ task }) => runAllowlisted(task),60 }),61}62```6364### VAI-OUT-001 (HTML rendering)6566```tsx67// ❌ Prompt-injection → XSS68<div dangerouslySetInnerHTML={{ __html: message.content }} />69```7071```tsx72// ✅ Plain text or sanitized markdown73<ReactMarkdown74 remarkPlugins={[remarkGfm]}75 rehypePlugins={[rehypeSanitize]}>76 {message.content}77</ReactMarkdown>78```7980### VAI-AUTH-001 (no auth on chat endpoint)8182```ts83// ❌ Anyone with the URL gets to spend your tokens + access tools84export async function POST(req: Request) {85 const { messages } = await req.json();86 return streamText({ model, messages, tools }).toDataStreamResponse();87}88```8990```ts91// ✅ Auth + per-tenant rate limit92export async function POST(req: Request) {93 const session = await auth();94 if (!session?.user) return new Response('Unauthorized', { status: 401 });95 const { success } = await ratelimit.limit(session.user.id);96 if (!success) return new Response('Too many requests', { status: 429 });97 const { messages } = await req.json();98 return streamText({ model, messages, tools, system: SYSTEM_PROMPT })99 .toDataStreamResponse();100}101```102103## References104105- Vercel AI SDK: https://sdk.vercel.ai/docs106- Tools and tool calling: https://sdk.vercel.ai/docs/foundations/tools107- Structured object generation: https://sdk.vercel.ai/docs/ai-sdk-core/generating-structured-data