Skip to content

Commit c66afc5

Browse files
Backport: Backport: fix(security): validate redirect targets in download functions to prevent SSRF bypass (#13130)
This is an automated backport of #13127 to the release-v5.0 branch. --------- Co-authored-by: vercel-ai-sdk[bot] <225926702+vercel-ai-sdk[bot]@users.noreply.github.com> Co-authored-by: Gregor Martynus <39992+gr2m@users.noreply.github.com>
1 parent 5a844ad commit c66afc5

5 files changed

Lines changed: 151 additions & 42 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'ai': patch
3+
---
4+
5+
fix(security): validate redirect targets in download functions to prevent SSRF bypass
6+
7+
`download` now validates the final URL after following HTTP redirects, preventing attackers from bypassing SSRF protections via open redirects to internal/private addresses.

packages/ai/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@
136136

137137
- 20565b8: security: prevent unbounded memory growth in download functions
138138

139-
The `download()` and `downloadBlob()` functions now enforce a default 2 GiB size limit when downloading from user-provided URLs. Downloads that exceed this limit are aborted with a `DownloadError` instead of consuming unbounded memory and crashing the process. The `abortSignal` parameter is now passed through to `fetch()` in all download call sites.
139+
The `download()` function now enforces a default 2 GiB size limit when downloading from user-provided URLs. Downloads that exceed this limit are aborted with a `DownloadError` instead of consuming unbounded memory and crashing the process. The `abortSignal` parameter is now passed through to `fetch()` in all download call sites.
140140

141141
Added `download` option to `transcribe()` and `experimental_generateVideo()` for providing a custom download function. Use the new `createDownload({ maxBytes })` factory to configure download size limits.
142142

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

Lines changed: 137 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,6 @@
1-
import { createTestServer } from '@ai-sdk/test-server/with-vitest';
2-
import { download } from './download';
31
import { DownloadError } from './download-error';
4-
import { describe, it, expect, vi } from 'vitest';
5-
6-
const server = createTestServer({
7-
'http://example.com/file': {},
8-
'http://example.com/large': {},
9-
});
2+
import { download } from './download';
3+
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
104

115
describe('download SSRF protection', () => {
126
it('should reject private IPv4 addresses', async () => {
@@ -30,17 +24,110 @@ describe('download SSRF protection', () => {
3024
});
3125
});
3226

27+
describe('download SSRF redirect protection', () => {
28+
const originalFetch = globalThis.fetch;
29+
30+
afterEach(() => {
31+
globalThis.fetch = originalFetch;
32+
});
33+
34+
it('should reject redirects to private IP addresses', async () => {
35+
globalThis.fetch = vi.fn().mockResolvedValue({
36+
ok: true,
37+
status: 200,
38+
redirected: true,
39+
url: 'http://169.254.169.254/latest/meta-data/',
40+
headers: new Headers({ 'content-type': 'text/plain' }),
41+
body: new ReadableStream({
42+
start(controller) {
43+
controller.enqueue(new TextEncoder().encode('secret'));
44+
controller.close();
45+
},
46+
}),
47+
} as unknown as Response);
48+
49+
try {
50+
await download({ url: new URL('https://evil.com/redirect') });
51+
expect.fail('Expected download to throw');
52+
} catch (error) {
53+
expect(DownloadError.isInstance(error)).toBe(true);
54+
}
55+
});
56+
57+
it('should reject redirects to localhost', async () => {
58+
globalThis.fetch = vi.fn().mockResolvedValue({
59+
ok: true,
60+
status: 200,
61+
redirected: true,
62+
url: 'http://localhost:8080/admin',
63+
headers: new Headers({ 'content-type': 'text/plain' }),
64+
body: new ReadableStream({
65+
start(controller) {
66+
controller.enqueue(new TextEncoder().encode('secret'));
67+
controller.close();
68+
},
69+
}),
70+
} as unknown as Response);
71+
72+
try {
73+
await download({ url: new URL('https://evil.com/redirect') });
74+
expect.fail('Expected download to throw');
75+
} catch (error) {
76+
expect(DownloadError.isInstance(error)).toBe(true);
77+
}
78+
});
79+
80+
it('should allow redirects to safe URLs', async () => {
81+
const content = new Uint8Array([1, 2, 3]);
82+
globalThis.fetch = vi.fn().mockResolvedValue({
83+
ok: true,
84+
status: 200,
85+
redirected: true,
86+
url: 'https://cdn.example.com/image.png',
87+
headers: new Headers({ 'content-type': 'image/png' }),
88+
body: new ReadableStream({
89+
start(controller) {
90+
controller.enqueue(content);
91+
controller.close();
92+
},
93+
}),
94+
} as unknown as Response);
95+
96+
const result = await download({
97+
url: new URL('https://example.com/image.png'),
98+
});
99+
expect(result.data).toEqual(content);
100+
expect(result.mediaType).toBe('image/png');
101+
});
102+
});
103+
33104
describe('download', () => {
105+
const originalFetch = globalThis.fetch;
106+
107+
beforeEach(() => {
108+
vi.resetAllMocks();
109+
});
110+
111+
afterEach(() => {
112+
globalThis.fetch = originalFetch;
113+
});
114+
34115
it('should download data successfully and match expected bytes', async () => {
35116
const expectedBytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
36117

37-
server.urls['http://example.com/file'].response = {
38-
type: 'binary',
39-
headers: {
118+
globalThis.fetch = vi.fn().mockResolvedValue({
119+
ok: true,
120+
status: 200,
121+
headers: new Headers({
40122
'content-type': 'application/octet-stream',
41-
},
42-
body: Buffer.from(expectedBytes),
43-
};
123+
}),
124+
body: new ReadableStream({
125+
start(controller) {
126+
controller.enqueue(expectedBytes);
127+
controller.close();
128+
},
129+
}),
130+
} as unknown as Response);
44131

45132
const result = await download({
46133
url: new URL('http://example.com/file'),
@@ -50,16 +137,21 @@ describe('download', () => {
50137
expect(result!.data).toEqual(expectedBytes);
51138
expect(result!.mediaType).toBe('application/octet-stream');
52139

53-
// UA header assertion
54-
expect(server.calls[0].requestUserAgent).toContain('ai-sdk/');
140+
expect(fetch).toHaveBeenCalledWith(
141+
'http://example.com/file',
142+
expect.objectContaining({
143+
headers: expect.any(Object),
144+
}),
145+
);
55146
});
56147

57148
it('should throw DownloadError when response is not ok', async () => {
58-
server.urls['http://example.com/file'].response = {
59-
type: 'error',
149+
globalThis.fetch = vi.fn().mockResolvedValue({
150+
ok: false,
60151
status: 404,
61-
body: 'Not Found',
62-
};
152+
statusText: 'Not Found',
153+
headers: new Headers(),
154+
} as unknown as Response);
63155

64156
try {
65157
await download({
@@ -74,11 +166,7 @@ describe('download', () => {
74166
});
75167

76168
it('should throw DownloadError when fetch throws an error', async () => {
77-
server.urls['http://example.com/file'].response = {
78-
type: 'error',
79-
status: 500,
80-
body: 'Network error',
81-
};
169+
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
82170

83171
try {
84172
await download({
@@ -91,15 +179,20 @@ describe('download', () => {
91179
});
92180

93181
it('should abort when response exceeds default size limit', async () => {
94-
// Create a response that claims to be larger than 2 GiB
95-
server.urls['http://example.com/large'].response = {
96-
type: 'binary',
97-
headers: {
182+
globalThis.fetch = vi.fn().mockResolvedValue({
183+
ok: true,
184+
status: 200,
185+
headers: new Headers({
98186
'content-type': 'application/octet-stream',
99187
'content-length': `${3 * 1024 * 1024 * 1024}`,
100-
},
101-
body: Buffer.from(new Uint8Array(10)),
102-
};
188+
}),
189+
body: new ReadableStream({
190+
start(controller) {
191+
controller.enqueue(new Uint8Array(10));
192+
controller.close();
193+
},
194+
}),
195+
} as unknown as Response);
103196

104197
try {
105198
await download({
@@ -118,13 +211,11 @@ describe('download', () => {
118211
const controller = new AbortController();
119212
controller.abort();
120213

121-
server.urls['http://example.com/file'].response = {
122-
type: 'binary',
123-
headers: {
124-
'content-type': 'application/octet-stream',
125-
},
126-
body: Buffer.from(new Uint8Array([1, 2, 3])),
127-
};
214+
globalThis.fetch = vi
215+
.fn()
216+
.mockRejectedValue(
217+
new DOMException('The operation was aborted.', 'AbortError'),
218+
);
128219

129220
try {
130221
await download({
@@ -133,8 +224,14 @@ describe('download', () => {
133224
});
134225
expect.fail('Expected download to throw');
135226
} catch (error: unknown) {
136-
// The fetch should be aborted, resulting in a DownloadError wrapping an AbortError
137227
expect(DownloadError.isInstance(error)).toBe(true);
138228
}
229+
230+
expect(fetch).toHaveBeenCalledWith(
231+
'http://example.com/file',
232+
expect.objectContaining({
233+
signal: controller.signal,
234+
}),
235+
);
139236
});
140237
});

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ export const download = async ({
4141
signal: abortSignal,
4242
});
4343

44+
// Validate final URL after redirects to prevent SSRF via open redirect
45+
if (response.redirected) {
46+
validateDownloadUrl(response.url);
47+
}
48+
4449
if (!response.ok) {
4550
throw new DownloadError({
4651
url: urlText,

packages/provider-utils/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
- 20565b8: security: prevent unbounded memory growth in download functions
1515

16-
The `download()` and `downloadBlob()` functions now enforce a default 2 GiB size limit when downloading from user-provided URLs. Downloads that exceed this limit are aborted with a `DownloadError` instead of consuming unbounded memory and crashing the process. The `abortSignal` parameter is now passed through to `fetch()` in all download call sites.
16+
The `download()` function now enforces a default 2 GiB size limit when downloading from user-provided URLs. Downloads that exceed this limit are aborted with a `DownloadError` instead of consuming unbounded memory and crashing the process. The `abortSignal` parameter is now passed through to `fetch()` in all download call sites.
1717

1818
Added `download` option to `transcribe()` and `experimental_generateVideo()` for providing a custom download function. Use the new `createDownload({ maxBytes })` factory to configure download size limits.
1919

0 commit comments

Comments
 (0)