Skip to content

Commit ad4cfc2

Browse files
gr2mclaude
andauthored
fix(security): add URL validation to prevent SSRF in download functions (#13085)
## Background The `downloadBlob` function (`@ai-sdk/provider-utils`) and `download` function (`ai`) fetch user-provided URLs without any validation. When users pass image URLs to `generateImage()`, strings starting with "http" flow directly to `fetch()` via `downloadBlob(file.url)` in OpenAI, OpenAI-compatible, and DeepInfra image models. This enables blind SSRF — attackers can make the server request internal resources (localhost, cloud metadata endpoints like `169.254.169.254`, private IPs). ## Summary Added a shared `validateDownloadUrl` utility in `@ai-sdk/provider-utils` that both `downloadBlob` and `download` call before `fetch`. It throws `DownloadError` if the URL is unsafe: - **Protocol** — only `http:` and `https:` allowed - **Hostname** — blocks `localhost`, `*.local`, `*.localhost`, empty hostname - **IPv4** — blocks private ranges: `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, `0.0.0.0/8` - **IPv6** — blocks `::1`, `fc00::/7`, `fe80::/10`, `::`, and IPv4-mapped addresses (`::ffff:x.x.x.x`) No DNS resolution checks (not available in edge runtimes). Reuses `DownloadError` for rejected URLs. ## Manual Verification - Ran `pnpm vitest run src/validate-download-url.test.ts` in `packages/provider-utils` — 29 tests pass - Ran `pnpm vitest run src/download-blob.test.ts` in `packages/provider-utils` — 13 tests pass - Ran `pnpm vitest run src/util/download/download.test.ts` in `packages/ai` — 7 tests pass ## Future Work - DNS rebinding protection could be added if a DNS resolution API becomes available in edge runtimes - Consider making the blocklist configurable for users who need to access private networks intentionally Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4f7ec7f commit ad4cfc2

File tree

8 files changed

+396
-0
lines changed

8 files changed

+396
-0
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@ai-sdk/provider-utils': patch
3+
'ai': patch
4+
---
5+
6+
Add URL validation to `downloadBlob` and `download` to prevent blind SSRF attacks. Private/internal IP addresses, localhost, and non-HTTP protocols are now rejected before fetching.

packages/ai/src/util/download/download.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,26 @@ const server = createTestServer({
88
'http://example.com/large': {},
99
});
1010

11+
describe('download SSRF protection', () => {
12+
it('should reject private IPv4 addresses', async () => {
13+
await expect(
14+
download({ url: new URL('http://127.0.0.1/file') }),
15+
).rejects.toThrow(DownloadError);
16+
await expect(
17+
download({ url: new URL('http://10.0.0.1/file') }),
18+
).rejects.toThrow(DownloadError);
19+
await expect(
20+
download({ url: new URL('http://169.254.169.254/latest/meta-data/') }),
21+
).rejects.toThrow(DownloadError);
22+
});
23+
24+
it('should reject localhost', async () => {
25+
await expect(
26+
download({ url: new URL('http://localhost/file') }),
27+
).rejects.toThrow(DownloadError);
28+
});
29+
});
30+
1131
describe('download', () => {
1232
it('should download data successfully and match expected bytes', async () => {
1333
const expectedBytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);

packages/ai/src/util/download/download.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
DownloadError,
33
readResponseWithSizeLimit,
44
DEFAULT_MAX_DOWNLOAD_SIZE,
5+
validateDownloadUrl,
56
} from '@ai-sdk/provider-utils';
67
import {
78
withUserAgentSuffix,
@@ -29,6 +30,7 @@ export const download = async ({
2930
abortSignal?: AbortSignal;
3031
}) => {
3132
const urlText = url.toString();
33+
validateDownloadUrl(urlText);
3234
try {
3335
const response = await fetch(urlText, {
3436
headers: withUserAgentSuffix(

packages/provider-utils/src/download-blob.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,32 @@ describe('downloadBlob()', () => {
180180
});
181181
});
182182

183+
describe('downloadBlob() SSRF protection', () => {
184+
it('should reject private IPv4 addresses', async () => {
185+
await expect(downloadBlob('http://127.0.0.1/file')).rejects.toThrow(
186+
DownloadError,
187+
);
188+
await expect(downloadBlob('http://10.0.0.1/file')).rejects.toThrow(
189+
DownloadError,
190+
);
191+
await expect(
192+
downloadBlob('http://169.254.169.254/latest/meta-data/'),
193+
).rejects.toThrow(DownloadError);
194+
});
195+
196+
it('should reject localhost', async () => {
197+
await expect(downloadBlob('http://localhost/file')).rejects.toThrow(
198+
DownloadError,
199+
);
200+
});
201+
202+
it('should reject non-http protocols', async () => {
203+
await expect(downloadBlob('file:///etc/passwd')).rejects.toThrow(
204+
DownloadError,
205+
);
206+
});
207+
});
208+
183209
describe('DownloadError', () => {
184210
it('should create error with status code and text', () => {
185211
const error = new DownloadError({

packages/provider-utils/src/download-blob.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
readResponseWithSizeLimit,
44
DEFAULT_MAX_DOWNLOAD_SIZE,
55
} from './read-response-with-size-limit';
6+
import { validateDownloadUrl } from './validate-download-url';
67

78
/**
89
* Download a file from a URL and return it as a Blob.
@@ -19,6 +20,7 @@ export async function downloadBlob(
1920
url: string,
2021
options?: { maxBytes?: number; abortSignal?: AbortSignal },
2122
): Promise<Blob> {
23+
validateDownloadUrl(url);
2224
try {
2325
const response = await fetch(url, {
2426
signal: options?.abortSignal,

packages/provider-utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export {
5656
} from './schema';
5757
export { stripFileExtension } from './strip-file-extension';
5858
export * from './uint8-utils';
59+
export { validateDownloadUrl } from './validate-download-url';
5960
export * from './validate-types';
6061
export { VERSION } from './version';
6162
export { withUserAgentSuffix } from './with-user-agent-suffix';
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { validateDownloadUrl } from './validate-download-url';
3+
import { DownloadError } from './download-error';
4+
5+
describe('validateDownloadUrl', () => {
6+
describe('allowed URLs', () => {
7+
it('should allow https URLs', () => {
8+
expect(() =>
9+
validateDownloadUrl('https://example.com/image.png'),
10+
).not.toThrow();
11+
});
12+
13+
it('should allow http URLs', () => {
14+
expect(() =>
15+
validateDownloadUrl('http://example.com/image.png'),
16+
).not.toThrow();
17+
});
18+
19+
it('should allow public IP addresses', () => {
20+
expect(() =>
21+
validateDownloadUrl('https://203.0.113.1/file'),
22+
).not.toThrow();
23+
});
24+
25+
it('should allow URLs with ports', () => {
26+
expect(() =>
27+
validateDownloadUrl('https://example.com:8080/file'),
28+
).not.toThrow();
29+
});
30+
});
31+
32+
describe('blocked protocols', () => {
33+
it('should block file:// URLs', () => {
34+
expect(() => validateDownloadUrl('file:///etc/passwd')).toThrow(
35+
DownloadError,
36+
);
37+
});
38+
39+
it('should block ftp:// URLs', () => {
40+
expect(() => validateDownloadUrl('ftp://example.com/file')).toThrow(
41+
DownloadError,
42+
);
43+
});
44+
45+
it('should block javascript: URLs', () => {
46+
expect(() => validateDownloadUrl('javascript:alert(1)')).toThrow(
47+
DownloadError,
48+
);
49+
});
50+
51+
it('should block data: URLs', () => {
52+
expect(() => validateDownloadUrl('data:text/plain,hello')).toThrow(
53+
DownloadError,
54+
);
55+
});
56+
});
57+
58+
describe('malformed URLs', () => {
59+
it('should block invalid URLs', () => {
60+
expect(() => validateDownloadUrl('not-a-url')).toThrow(DownloadError);
61+
});
62+
});
63+
64+
describe('blocked hostnames', () => {
65+
it('should block localhost', () => {
66+
expect(() => validateDownloadUrl('http://localhost/file')).toThrow(
67+
DownloadError,
68+
);
69+
});
70+
71+
it('should block localhost with port', () => {
72+
expect(() => validateDownloadUrl('http://localhost:3000/file')).toThrow(
73+
DownloadError,
74+
);
75+
});
76+
77+
it('should block .local domains', () => {
78+
expect(() => validateDownloadUrl('http://myhost.local/file')).toThrow(
79+
DownloadError,
80+
);
81+
});
82+
83+
it('should block .localhost domains', () => {
84+
expect(() => validateDownloadUrl('http://app.localhost/file')).toThrow(
85+
DownloadError,
86+
);
87+
});
88+
});
89+
90+
describe('blocked IPv4 addresses', () => {
91+
it('should block 127.0.0.1 (loopback)', () => {
92+
expect(() => validateDownloadUrl('http://127.0.0.1/file')).toThrow(
93+
DownloadError,
94+
);
95+
});
96+
97+
it('should block 127.x.x.x range', () => {
98+
expect(() => validateDownloadUrl('http://127.255.0.1/file')).toThrow(
99+
DownloadError,
100+
);
101+
});
102+
103+
it('should block 10.x.x.x (private)', () => {
104+
expect(() => validateDownloadUrl('http://10.0.0.1/file')).toThrow(
105+
DownloadError,
106+
);
107+
});
108+
109+
it('should block 172.16.x.x - 172.31.x.x (private)', () => {
110+
expect(() => validateDownloadUrl('http://172.16.0.1/file')).toThrow(
111+
DownloadError,
112+
);
113+
expect(() => validateDownloadUrl('http://172.31.255.255/file')).toThrow(
114+
DownloadError,
115+
);
116+
});
117+
118+
it('should allow 172.15.x.x and 172.32.x.x (public)', () => {
119+
expect(() => validateDownloadUrl('http://172.15.0.1/file')).not.toThrow();
120+
expect(() => validateDownloadUrl('http://172.32.0.1/file')).not.toThrow();
121+
});
122+
123+
it('should block 192.168.x.x (private)', () => {
124+
expect(() => validateDownloadUrl('http://192.168.1.1/file')).toThrow(
125+
DownloadError,
126+
);
127+
});
128+
129+
it('should block 169.254.x.x (link-local / cloud metadata)', () => {
130+
expect(() =>
131+
validateDownloadUrl('http://169.254.169.254/latest/meta-data/'),
132+
).toThrow(DownloadError);
133+
});
134+
135+
it('should block 0.0.0.0', () => {
136+
expect(() => validateDownloadUrl('http://0.0.0.0/file')).toThrow(
137+
DownloadError,
138+
);
139+
});
140+
});
141+
142+
describe('blocked IPv6 addresses', () => {
143+
it('should block ::1 (loopback)', () => {
144+
expect(() => validateDownloadUrl('http://[::1]/file')).toThrow(
145+
DownloadError,
146+
);
147+
});
148+
149+
it('should block :: (unspecified)', () => {
150+
expect(() => validateDownloadUrl('http://[::]/file')).toThrow(
151+
DownloadError,
152+
);
153+
});
154+
155+
it('should block fc00::/7 (unique local)', () => {
156+
expect(() => validateDownloadUrl('http://[fc00::1]/file')).toThrow(
157+
DownloadError,
158+
);
159+
expect(() => validateDownloadUrl('http://[fd12::1]/file')).toThrow(
160+
DownloadError,
161+
);
162+
});
163+
164+
it('should block fe80::/10 (link-local)', () => {
165+
expect(() => validateDownloadUrl('http://[fe80::1]/file')).toThrow(
166+
DownloadError,
167+
);
168+
});
169+
});
170+
171+
describe('IPv4-mapped IPv6 addresses', () => {
172+
it('should block ::ffff:127.0.0.1', () => {
173+
expect(() =>
174+
validateDownloadUrl('http://[::ffff:127.0.0.1]/file'),
175+
).toThrow(DownloadError);
176+
});
177+
178+
it('should block ::ffff:10.0.0.1', () => {
179+
expect(() =>
180+
validateDownloadUrl('http://[::ffff:10.0.0.1]/file'),
181+
).toThrow(DownloadError);
182+
});
183+
184+
it('should block ::ffff:169.254.169.254', () => {
185+
expect(() =>
186+
validateDownloadUrl('http://[::ffff:169.254.169.254]/file'),
187+
).toThrow(DownloadError);
188+
});
189+
190+
it('should allow ::ffff: with public IP', () => {
191+
expect(() =>
192+
validateDownloadUrl('http://[::ffff:203.0.113.1]/file'),
193+
).not.toThrow();
194+
});
195+
});
196+
});

0 commit comments

Comments
 (0)