From 89217e52d593ea07c6cfdfdaa794a3446119464c Mon Sep 17 00:00:00 2001 From: Tajudeen Date: Sun, 31 May 2026 14:14:58 +0100 Subject: [PATCH] feat(tools): SSRF guard on browse_url for loopback / private / link-local targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../contrib/cortexide/browser/toolsService.ts | 62 +++++++++++++++ .../cortexide/test/common/ssrfGuard.test.ts | 76 +++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 src/vs/workbench/contrib/cortexide/test/common/ssrfGuard.test.ts diff --git a/src/vs/workbench/contrib/cortexide/browser/toolsService.ts b/src/vs/workbench/contrib/cortexide/browser/toolsService.ts index bdcfad0f2e2..24b0df3c838 100644 --- a/src/vs/workbench/contrib/cortexide/browser/toolsService.ts +++ b/src/vs/workbench/contrib/cortexide/browser/toolsService.ts @@ -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; @@ -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'; @@ -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); diff --git a/src/vs/workbench/contrib/cortexide/test/common/ssrfGuard.test.ts b/src/vs/workbench/contrib/cortexide/test/common/ssrfGuard.test.ts new file mode 100644 index 00000000000..98d1942ad6a --- /dev/null +++ b/src/vs/workbench/contrib/cortexide/test/common/ssrfGuard.test.ts @@ -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'); + }); +});