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')) {