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
17 changes: 13 additions & 4 deletions browse/src/content-security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
38 changes: 38 additions & 0 deletions browse/test/content-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<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', () => {
registerContentFilter((content, url, cmd) => {
if (content.includes('SECRET')) {
Expand Down
Loading