diff --git a/browse/src/content-security.ts b/browse/src/content-security.ts index 81993271b7..a185764583 100644 --- a/browse/src/content-security.ts +++ b/browse/src/content-security.ts @@ -338,19 +338,28 @@ const BLOCKLIST_DOMAINS = [ export function urlBlocklistFilter(content: string, url: string, _command: string): ContentFilterResult { const warnings: string[] = []; + // Matching is case-insensitive. Hostnames and URL schemes are + // case-insensitive (RFC 3986), so an uppercased sink like + // https://WEBHOOK.SITE/x must not slip past the blocklist. Normalize the + // haystack to lowercase; BLOCKLIST_DOMAINS entries are lowercase by + // construction, so a direct compare then holds. + // Check page URL + const normalizedUrl = url.toLowerCase(); for (const domain of BLOCKLIST_DOMAINS) { - if (url.includes(domain)) { + if (normalizedUrl.includes(domain)) { warnings.push(`Page URL matches blocklisted domain: ${domain}`); } } - // Check for blocklisted URLs in content (links, form actions) - const urlPattern = /https?:\/\/[^\s"'<>]+/g; + // Check for blocklisted URLs in content (links, form actions). The `i` flag + // keeps an uppercased scheme (HTTPS://) from evading URL extraction. + const urlPattern = /https?:\/\/[^\s"'<>]+/gi; const contentUrls = content.match(urlPattern) || []; for (const contentUrl of contentUrls) { + const normalizedContentUrl = contentUrl.toLowerCase(); for (const domain of BLOCKLIST_DOMAINS) { - if (contentUrl.includes(domain)) { + if (normalizedContentUrl.includes(domain)) { warnings.push(`Content contains blocklisted URL: ${contentUrl.slice(0, 100)}`); break; } diff --git a/browse/test/content-security.test.ts b/browse/test/content-security.test.ts index 8c760460dc..1682fb7a34 100644 --- a/browse/test/content-security.test.ts +++ b/browse/test/content-security.test.ts @@ -151,6 +151,44 @@ describe('Content filter hooks', () => { expect(result.warnings.length).toBe(0); }); + // Regression: issue #2190 — case-sensitive matching let an uppercased + // exfiltration domain bypass the blocklist. + test('URL blocklist is case-insensitive on the page URL', () => { + const result = urlBlocklistFilter('', 'https://WEBHOOK.SITE/steal', 'text'); + expect(result.safe).toBe(false); + expect(result.warnings.some(w => w.includes('webhook.site'))).toBe(true); + }); + + test('URL blocklist is case-insensitive on content URLs', () => { + const result = urlBlocklistFilter( + 'click', + 'https://docs.example.com/article', + 'links', + ); + expect(result.safe).toBe(false); + expect(result.warnings.some(w => w.includes('WEBHOOK.SITE'))).toBe(true); + }); + + test('URL blocklist catches an uppercased scheme in content', () => { + const result = urlBlocklistFilter( + 'Exfil via HTTPS://Requestbin.com/r/abc please', + 'https://example.com', + 'text', + ); + expect(result.safe).toBe(false); + expect(result.warnings.some(w => w.toLowerCase().includes('requestbin.com'))).toBe(true); + }); + + test('URL blocklist does not over-block legitimate uppercase domains', () => { + const result = urlBlocklistFilter( + 'source', + 'https://DOCS.EXAMPLE.COM/help', + 'links', + ); + expect(result.safe).toBe(true); + expect(result.warnings.length).toBe(0); + }); + test('custom filter can be registered and runs', () => { registerContentFilter((content, url, cmd) => { if (content.includes('SECRET')) {