Skip to content

Commit 89217e5

Browse files
Tajudeenclaude
andcommitted
feat(tools): SSRF guard on browse_url for loopback / private / link-local targets
Follow-up to TOOLS_AUDIT_2026-05-25 (paired with security review F-03). browse_url previously accepted any well-formed http(s) URL, so the model (or a redirect chain) could be steered at internal resources: - localhost / 127.0.0.1 — local dev servers, admin panels - 10.x / 172.16-31.x / 192.168.x — internal corporate / home network - 169.254.169.254 — AWS/GCP/Azure cloud metadata service (creds leak) - ::1, fe80::/10, fc00::/7 — IPv6 equivalents Add assertNotSSRF() called from two places: 1. The validator, so the model gets a clear error before the request. 2. The impl boundary, so redirect re-entry (callTool.browse_url skips the validator) and any future internal caller can't bypass it. Covers literal hostname bans only. DNS-resolution bypasses (a public-looking hostname that resolves to a private IP) are not caught here — that needs async preflight + IPs-of-redirect checking and is queued as a follow-up. Includes unit tests covering loopback, private ranges, link-local (incl. cloud metadata), IPv6 forms, IPv4-mapped IPv6, and boundary cases just outside the blocked CIDRs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7190206 commit 89217e5

2 files changed

Lines changed: 138 additions & 0 deletions

File tree

‎src/vs/workbench/contrib/cortexide/browser/toolsService.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,63 @@ const checkIfIsFolder = (uriStr: string) => {
179179
return false
180180
}
181181

182+
183+
/**
184+
* Reject URLs whose hostname is a loopback / private / link-local literal.
185+
* Blocks the most common SSRF vectors without doing DNS resolution:
186+
* - localhost / *.localhost
187+
* - IPv4 0.0.0.0, 127/8, 10/8, 172.16/12, 192.168/16, 169.254/16 (incl. cloud metadata)
188+
* - IPv6 ::, ::1, fc00::/7, fe80::/10, and IPv4-mapped equivalents
189+
*
190+
* DNS-based bypasses (hostname that resolves to a private IP) are not caught here —
191+
* that needs an async preflight and is queued as a follow-up.
192+
*/
193+
export const assertNotSSRF = (url: string) => {
194+
let parsed: URL
195+
try { parsed = new URL(url) } catch { return } // malformed URLs are rejected elsewhere
196+
let host = parsed.hostname.toLowerCase()
197+
if (!host) throw new Error(`Blocked: URL has no hostname.`)
198+
199+
// localhost variants
200+
if (host === 'localhost' || host.endsWith('.localhost')) {
201+
throw new Error(`Blocked: ${host} is a loopback hostname. browse_url cannot target local/private network resources.`)
202+
}
203+
204+
// IPv6 literals are bracketed in URL.hostname only for the [::1]-style form;
205+
// URL strips the brackets, so host is the bare IPv6 string here.
206+
if (host.includes(':')) {
207+
// IPv4-mapped IPv6: ::ffff:127.0.0.1 — extract the trailing IPv4 and re-check
208+
const v4MappedMatch = host.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/)
209+
if (v4MappedMatch) { host = v4MappedMatch[1] /* fall through to IPv4 checks below */ }
210+
else {
211+
const compact = host.replace(/^\[|\]$/g, '')
212+
if (compact === '::' || compact === '::1') {
213+
throw new Error(`Blocked: ${parsed.hostname} is an IPv6 loopback/unspecified address.`)
214+
}
215+
// fe80::/10 — link-local
216+
if (/^fe[89ab][0-9a-f]?:/i.test(compact)) {
217+
throw new Error(`Blocked: ${parsed.hostname} is an IPv6 link-local address.`)
218+
}
219+
// fc00::/7 — unique-local (fc.. and fd..)
220+
if (/^f[cd][0-9a-f]{2}:/i.test(compact)) {
221+
throw new Error(`Blocked: ${parsed.hostname} is an IPv6 unique-local address.`)
222+
}
223+
return // other IPv6 — assume public
224+
}
225+
}
226+
227+
// IPv4 literal checks
228+
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
229+
if (v4) {
230+
const [a, b] = [Number(v4[1]), Number(v4[2])]
231+
if (a === 0 || a === 127) throw new Error(`Blocked: ${host} is in the loopback/unspecified range.`)
232+
if (a === 10) throw new Error(`Blocked: ${host} is in the 10.0.0.0/8 private range.`)
233+
if (a === 192 && b === 168) throw new Error(`Blocked: ${host} is in the 192.168.0.0/16 private range.`)
234+
if (a === 172 && b >= 16 && b <= 31) throw new Error(`Blocked: ${host} is in the 172.16.0.0/12 private range.`)
235+
if (a === 169 && b === 254) throw new Error(`Blocked: ${host} is in the 169.254.0.0/16 link-local range (includes cloud metadata services).`)
236+
}
237+
}
238+
182239
export interface IToolsService {
183240
readonly _serviceBrand: undefined;
184241
validateParams: ValidateBuiltinParams;
@@ -473,6 +530,7 @@ export class ToolsService implements IToolsService {
473530
} catch (e) {
474531
throw new Error(`Invalid URL format: ${url}. Error: ${e}`);
475532
}
533+
assertNotSSRF(url);
476534
let refresh = false;
477535
if (refreshUnknown && typeof refreshUnknown === 'string') {
478536
refresh = refreshUnknown.toLowerCase() === 'true';
@@ -1509,6 +1567,10 @@ export class ToolsService implements IToolsService {
15091567
},
15101568

15111569
browse_url: async ({ url, refresh }) => {
1570+
// Re-check at the impl boundary so redirect re-entry (which skips the validator)
1571+
// and any future internal callers don't bypass the SSRF guard.
1572+
assertNotSSRF(url);
1573+
15121574
// Check offline/privacy mode (centralized gate)
15131575
this._offlineGate.ensureNotOfflineOrPrivacy('URL browsing', false);
15141576

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*--------------------------------------------------------------------------------------
2+
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
3+
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
4+
*--------------------------------------------------------------------------------------*/
5+
6+
import * as assert from 'assert';
7+
import { assertNotSSRF } from '../../browser/toolsService.js';
8+
9+
suite('SSRF guard for browse_url', () => {
10+
11+
const expectBlocked = (url: string) => {
12+
assert.throws(() => assertNotSSRF(url), /Blocked:/, `expected ${url} to be blocked`);
13+
};
14+
15+
const expectAllowed = (url: string) => {
16+
assert.doesNotThrow(() => assertNotSSRF(url), `expected ${url} to be allowed`);
17+
};
18+
19+
test('blocks localhost variants', () => {
20+
expectBlocked('http://localhost');
21+
expectBlocked('http://localhost:8080/foo');
22+
expectBlocked('https://api.localhost/v1');
23+
});
24+
25+
test('blocks IPv4 loopback', () => {
26+
expectBlocked('http://127.0.0.1');
27+
expectBlocked('http://127.1.2.3:9000/path');
28+
expectBlocked('http://0.0.0.0');
29+
});
30+
31+
test('blocks IPv4 private ranges', () => {
32+
expectBlocked('http://10.0.0.1');
33+
expectBlocked('http://10.255.255.255');
34+
expectBlocked('http://192.168.1.1');
35+
expectBlocked('http://172.16.0.1');
36+
expectBlocked('http://172.31.255.255');
37+
});
38+
39+
test('blocks IPv4 link-local including cloud metadata service', () => {
40+
expectBlocked('http://169.254.169.254/latest/meta-data/'); // AWS / GCP metadata
41+
expectBlocked('http://169.254.0.1');
42+
});
43+
44+
test('blocks IPv6 loopback and unspecified', () => {
45+
expectBlocked('http://[::1]/');
46+
expectBlocked('http://[::]/');
47+
});
48+
49+
test('blocks IPv6 link-local and unique-local', () => {
50+
expectBlocked('http://[fe80::1]/');
51+
expectBlocked('http://[fc00::1]/');
52+
expectBlocked('http://[fd12:3456:789a::1]/');
53+
});
54+
55+
test('blocks IPv4-mapped IPv6 forms of loopback / private', () => {
56+
expectBlocked('http://[::ffff:127.0.0.1]/');
57+
expectBlocked('http://[::ffff:10.0.0.1]/');
58+
expectBlocked('http://[::ffff:169.254.169.254]/');
59+
});
60+
61+
test('allows ordinary public IPv4 / IPv6 / hostnames', () => {
62+
expectAllowed('https://example.com');
63+
expectAllowed('https://api.github.com/repos/foo/bar');
64+
expectAllowed('http://8.8.8.8');
65+
expectAllowed('http://172.15.0.1'); // just outside 172.16/12
66+
expectAllowed('http://172.32.0.1'); // just outside 172.16/12
67+
expectAllowed('http://192.169.0.1'); // just outside 192.168/16
68+
expectAllowed('https://[2606:4700:4700::1111]/'); // Cloudflare DNS
69+
});
70+
71+
test('passes through malformed URLs (handled by the URL-format check elsewhere)', () => {
72+
// assertNotSSRF returns silently on URL.parse failure; the existing
73+
// "URL must start with http(s)://" check rejects these earlier.
74+
expectAllowed('not a url');
75+
});
76+
});

0 commit comments

Comments
 (0)