Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/vs/workbench/contrib/cortexide/browser/toolsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,63 @@ const checkIfIsFolder = (uriStr: string) => {
return false
}


/**
* Reject URLs whose hostname is a loopback / private / link-local literal.
* Blocks the most common SSRF vectors without doing DNS resolution:
* - localhost / *.localhost
* - IPv4 0.0.0.0, 127/8, 10/8, 172.16/12, 192.168/16, 169.254/16 (incl. cloud metadata)
* - IPv6 ::, ::1, fc00::/7, fe80::/10, and IPv4-mapped equivalents
*
* DNS-based bypasses (hostname that resolves to a private IP) are not caught here —
* that needs an async preflight and is queued as a follow-up.
*/
export const assertNotSSRF = (url: string) => {
let parsed: URL
try { parsed = new URL(url) } catch { return } // malformed URLs are rejected elsewhere
let host = parsed.hostname.toLowerCase()
if (!host) throw new Error(`Blocked: URL has no hostname.`)

// localhost variants
if (host === 'localhost' || host.endsWith('.localhost')) {
throw new Error(`Blocked: ${host} is a loopback hostname. browse_url cannot target local/private network resources.`)
}

// IPv6 literals are bracketed in URL.hostname only for the [::1]-style form;
// URL strips the brackets, so host is the bare IPv6 string here.
if (host.includes(':')) {
// IPv4-mapped IPv6: ::ffff:127.0.0.1 — extract the trailing IPv4 and re-check
const v4MappedMatch = host.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/)
if (v4MappedMatch) { host = v4MappedMatch[1] /* fall through to IPv4 checks below */ }
else {
const compact = host.replace(/^\[|\]$/g, '')
if (compact === '::' || compact === '::1') {
throw new Error(`Blocked: ${parsed.hostname} is an IPv6 loopback/unspecified address.`)
}
// fe80::/10 — link-local
if (/^fe[89ab][0-9a-f]?:/i.test(compact)) {
throw new Error(`Blocked: ${parsed.hostname} is an IPv6 link-local address.`)
}
// fc00::/7 — unique-local (fc.. and fd..)
if (/^f[cd][0-9a-f]{2}:/i.test(compact)) {
throw new Error(`Blocked: ${parsed.hostname} is an IPv6 unique-local address.`)
}
return // other IPv6 — assume public
}
}

// IPv4 literal checks
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
if (v4) {
const [a, b] = [Number(v4[1]), Number(v4[2])]
if (a === 0 || a === 127) throw new Error(`Blocked: ${host} is in the loopback/unspecified range.`)
if (a === 10) throw new Error(`Blocked: ${host} is in the 10.0.0.0/8 private range.`)
if (a === 192 && b === 168) throw new Error(`Blocked: ${host} is in the 192.168.0.0/16 private range.`)
if (a === 172 && b >= 16 && b <= 31) throw new Error(`Blocked: ${host} is in the 172.16.0.0/12 private range.`)
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).`)
}
}

export interface IToolsService {
readonly _serviceBrand: undefined;
validateParams: ValidateBuiltinParams;
Expand Down Expand Up @@ -473,6 +530,7 @@ export class ToolsService implements IToolsService {
} catch (e) {
throw new Error(`Invalid URL format: ${url}. Error: ${e}`);
}
assertNotSSRF(url);
let refresh = false;
if (refreshUnknown && typeof refreshUnknown === 'string') {
refresh = refreshUnknown.toLowerCase() === 'true';
Expand Down Expand Up @@ -1509,6 +1567,10 @@ export class ToolsService implements IToolsService {
},

browse_url: async ({ url, refresh }) => {
// Re-check at the impl boundary so redirect re-entry (which skips the validator)
// and any future internal callers don't bypass the SSRF guard.
assertNotSSRF(url);

// Check offline/privacy mode (centralized gate)
this._offlineGate.ensureNotOfflineOrPrivacy('URL browsing', false);

Expand Down
76 changes: 76 additions & 0 deletions src/vs/workbench/contrib/cortexide/test/common/ssrfGuard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/

import * as assert from 'assert';
import { assertNotSSRF } from '../../browser/toolsService.js';

suite('SSRF guard for browse_url', () => {

const expectBlocked = (url: string) => {
assert.throws(() => assertNotSSRF(url), /Blocked:/, `expected ${url} to be blocked`);
};

const expectAllowed = (url: string) => {
assert.doesNotThrow(() => assertNotSSRF(url), `expected ${url} to be allowed`);
};

test('blocks localhost variants', () => {
expectBlocked('http://localhost');
expectBlocked('http://localhost:8080/foo');
expectBlocked('https://api.localhost/v1');
});

test('blocks IPv4 loopback', () => {
expectBlocked('http://127.0.0.1');
expectBlocked('http://127.1.2.3:9000/path');
expectBlocked('http://0.0.0.0');
});

test('blocks IPv4 private ranges', () => {
expectBlocked('http://10.0.0.1');
expectBlocked('http://10.255.255.255');
expectBlocked('http://192.168.1.1');
expectBlocked('http://172.16.0.1');
expectBlocked('http://172.31.255.255');
});

test('blocks IPv4 link-local including cloud metadata service', () => {
expectBlocked('http://169.254.169.254/latest/meta-data/'); // AWS / GCP metadata
expectBlocked('http://169.254.0.1');
});

test('blocks IPv6 loopback and unspecified', () => {
expectBlocked('http://[::1]/');
expectBlocked('http://[::]/');
});

test('blocks IPv6 link-local and unique-local', () => {
expectBlocked('http://[fe80::1]/');
expectBlocked('http://[fc00::1]/');
expectBlocked('http://[fd12:3456:789a::1]/');
});

test('blocks IPv4-mapped IPv6 forms of loopback / private', () => {
expectBlocked('http://[::ffff:127.0.0.1]/');
expectBlocked('http://[::ffff:10.0.0.1]/');
expectBlocked('http://[::ffff:169.254.169.254]/');
});

test('allows ordinary public IPv4 / IPv6 / hostnames', () => {
expectAllowed('https://example.com');
expectAllowed('https://api.github.com/repos/foo/bar');
expectAllowed('http://8.8.8.8');
expectAllowed('http://172.15.0.1'); // just outside 172.16/12
expectAllowed('http://172.32.0.1'); // just outside 172.16/12
expectAllowed('http://192.169.0.1'); // just outside 192.168/16
expectAllowed('https://[2606:4700:4700::1111]/'); // Cloudflare DNS
});

test('passes through malformed URLs (handled by the URL-format check elsewhere)', () => {
// assertNotSSRF returns silently on URL.parse failure; the existing
// "URL must start with http(s)://" check rejects these earlier.
expectAllowed('not a url');
});
});
Loading