fix(browse): case-insensitive exfiltration-domain blocklist matching

urlBlocklistFilter compared URLs against the exfiltration blocklist with
case-sensitive substring checks, so an uppercased sink (https://WEBHOOK.SITE/x)
bypassed the guard and reached the scoped browser agent. Normalize the page URL
and extracted content URLs to lowercase before comparing, and make URL
extraction scheme-insensitive so HTTPS:// links are still caught.

Fixes #2190

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
t
2026-07-10 14:39:32 -07:00
parent 7c9df1c568
commit 279fda4dc5
2 changed files with 51 additions and 4 deletions
+13 -4
View File
@@ -338,19 +338,28 @@ const BLOCKLIST_DOMAINS = [
export function urlBlocklistFilter(content: string, url: string, _command: string): ContentFilterResult { export function urlBlocklistFilter(content: string, url: string, _command: string): ContentFilterResult {
const warnings: string[] = []; 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 // Check page URL
const normalizedUrl = url.toLowerCase();
for (const domain of BLOCKLIST_DOMAINS) { for (const domain of BLOCKLIST_DOMAINS) {
if (url.includes(domain)) { if (normalizedUrl.includes(domain)) {
warnings.push(`Page URL matches blocklisted domain: ${domain}`); warnings.push(`Page URL matches blocklisted domain: ${domain}`);
} }
} }
// Check for blocklisted URLs in content (links, form actions) // Check for blocklisted URLs in content (links, form actions). The `i` flag
const urlPattern = /https?:\/\/[^\s"'<>]+/g; // keeps an uppercased scheme (HTTPS://) from evading URL extraction.
const urlPattern = /https?:\/\/[^\s"'<>]+/gi;
const contentUrls = content.match(urlPattern) || []; const contentUrls = content.match(urlPattern) || [];
for (const contentUrl of contentUrls) { for (const contentUrl of contentUrls) {
const normalizedContentUrl = contentUrl.toLowerCase();
for (const domain of BLOCKLIST_DOMAINS) { for (const domain of BLOCKLIST_DOMAINS) {
if (contentUrl.includes(domain)) { if (normalizedContentUrl.includes(domain)) {
warnings.push(`Content contains blocklisted URL: ${contentUrl.slice(0, 100)}`); warnings.push(`Content contains blocklisted URL: ${contentUrl.slice(0, 100)}`);
break; break;
} }
+38
View File
@@ -151,6 +151,44 @@ describe('Content filter hooks', () => {
expect(result.warnings.length).toBe(0); 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(
'<a href="https://WEBHOOK.SITE/a1b2c3-steal">click</a>',
'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(
'<a href="https://GitHub.com/acme/project">source</a>',
'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', () => { test('custom filter can be registered and runs', () => {
registerContentFilter((content, url, cmd) => { registerContentFilter((content, url, cmd) => {
if (content.includes('SECRET')) { if (content.includes('SECRET')) {