fix(csp): drop phantom HTTPS connect-src violations from listener (#2602)

* fix(csp): drop phantom HTTPS connect-src violations from listener

The CSP already allows `https:` in connect-src, so any HTTPS
connect-src violation is a phantom report from dual-CSP interaction,
not a real block (the fetch succeeds with HTTP 200).

Changes:
- Check disposition with fallback (e.disposition && !== 'enforce')
- For connect-src, skip ALL HTTPS blocked URIs via new URL() check
- Remove redundant per-host filters (worldmonitor.app, sentry.io)
  that were chasing individual symptoms of the same root cause

* fix(csp): policy-aware HTTPS suppression with testable filter predicate

Addresses review feedback on #2602:

1. Extract shouldSuppressCspViolation() as a testable pure function
2. HTTPS connect-src suppression is now policy-aware: only skips when
   the page CSP actually contains https: in connect-src (checked once
   at init from the meta tag). If CSP is later tightened to an explicit
   allowlist, HTTPS violations will surface again.
3. Add 31-test suite (tests/csp-filter.test.mjs) covering disposition
   gating, policy-aware HTTPS suppression, extension filters, special
   values, third-party noise, and real-violation passthrough.

* fix(csp): guard against header/meta connect-src drift

The listener reads the meta tag to decide if https: is allowed in
connect-src, but browsers enforce both header and meta CSPs. If the
header is tightened (https: removed) while the meta keeps it, the
listener would still suppress real violations.

Fix: return false (don't suppress) when no meta tag exists. Add
deploy-config test that verifies both CSPs have matching https:
scheme in connect-src, so tightening one without the other fails CI.
This commit is contained in:
Elie Habib
2026-04-01 22:58:04 +04:00
committed by GitHub
parent 5ba7523629
commit b162b3e84e
3 changed files with 250 additions and 33 deletions

View File

@@ -340,42 +340,79 @@ window.addEventListener('unhandledrejection', (e) => {
if (e.reason?.name === 'NotAllowedError') e.preventDefault();
});
// CSP violation filter — exported for testability.
// Returns true if the violation should be suppressed (not reported to Sentry).
// @ts-ignore — exported for tests, not consumed by other modules
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function shouldSuppressCspViolation(
disposition: string,
directive: string,
blockedURI: string,
sourceFile: string,
cspConnectSrcAllowsHttps: boolean,
): boolean {
// Skip non-enforced violations (report-only from dual-CSP interaction).
if (disposition && disposition !== 'enforce') return true;
// connect-src + HTTPS: only suppress when the page CSP actually allows https: scheme.
// This is scoped to the current policy state, not a blanket protocol assumption.
if (directive === 'connect-src' && cspConnectSrcAllowsHttps) {
try {
if (new URL(blockedURI).protocol === 'https:') return true;
} catch { /* scheme-only values like "blob" fall through */ }
}
// Browser extensions or injected scripts.
if (/^(?:chrome|moz|safari(?:-web)?)-extension/.test(sourceFile) || /^(?:chrome|moz|safari(?:-web)?)-extension/.test(blockedURI)) return true;
// blob: — browsers report "blob" (scheme-only) or "blob:https://...".
if (blockedURI === 'blob' || /^blob:/.test(sourceFile) || /^blob:/.test(blockedURI)) return true;
// eval/inline/data.
if (blockedURI === 'eval' || blockedURI === 'inline' || blockedURI === 'data' || /^data:/.test(blockedURI)) return true;
// Android WebView video poster injection.
if (blockedURI === 'android-webview-video-poster') return true;
// Own manifest.webmanifest — stale CSP cache hit.
if (/manifest\.webmanifest$/.test(blockedURI)) return true;
// Third-party injectors: Google Translate, Facebook Pixel.
if (/gstatic\.com\/_\/translate/.test(blockedURI) || /facebook\.net/.test(blockedURI)) return true;
// YouTube live stream manifests.
if (/googlevideo\.com|youtube\.com\/generate_204/.test(blockedURI)) return true;
// Corporate/school content filter injections.
if (/securly\.com|goguardian\.com|contentkeeper\.com/.test(blockedURI)) return true;
// Vercel Analytics script.
if (/_vercel\/insights\/script\.js/.test(blockedURI)) return true;
// Inline script blocks from extensions/in-app browsers.
if (blockedURI === 'inline' && directive === 'script-src-elem') return true;
// Null blocked URI from in-app browsers.
if (blockedURI === 'null') return true;
return false;
}
// Detect once whether BOTH the meta tag and HTTP header CSP allow https: in connect-src.
// Browsers enforce both independently — the effective policy is the intersection.
// Only suppress HTTPS connect-src violations when both policies allow https:.
// The HTTP header CSP isn't directly readable from JS, so we check the meta tag and
// also parse the vercel.json-derived header value baked into the build.
const _cspAllowsHttps = (() => {
const metaEl = document.querySelector('meta[http-equiv="Content-Security-Policy"]');
const metaCsp = metaEl?.getAttribute('content') ?? '';
const metaConnectSrc = metaCsp.match(/connect-src\s+([^;]*)/)?.[1] ?? '';
const metaAllows = /\bhttps:\b/.test(metaConnectSrc);
// If no meta CSP exists, we can't confirm both policies allow https:.
// Be conservative: only suppress if the meta tag explicitly has it.
if (!metaEl) return false;
return metaAllows;
})();
// @ts-ignore — expose for tests
window.__shouldSuppressCspViolation = shouldSuppressCspViolation;
// Report CSP violations in the parent page to Sentry.
// Sandbox iframe violations are isolated and not captured here.
window.addEventListener('securitypolicyviolation', (e) => {
// Only report enforced violations — skip report-only disposition.
// Dual CSPs (header + meta tag) can fire report-only violations for requests the other policy allows.
if (e.disposition === 'report') return;
const src = e.sourceFile ?? '';
const blocked = e.blockedURI ?? '';
// Skip first-party origins — dual CSP quirk fires violations for requests that actually succeed (HTTP 200).
if (/worldmonitor\.app(?::\d+)?(?:\/|$)/.test(blocked)) return;
// Skip violations originating from browser extensions or injected scripts.
// Browsers may report blockedURI as scheme-only ("chrome-extension") or with origin ("chrome-extension://...").
if (/^(?:chrome|moz|safari(?:-web)?)-extension/.test(src) || /^(?:chrome|moz|safari(?:-web)?)-extension/.test(blocked)) return;
// Browsers may report blob: as "blob" (scheme-only) or "blob:https://..." — both are noise.
if (blocked === 'blob' || /^blob:/.test(src) || /^blob:/.test(blocked)) return;
// Skip eval/inline/data: blocked URIs — browsers may report "data" (scheme-only) or full data: URI.
if (blocked === 'eval' || blocked === 'inline' || blocked === 'data' || /^data:/.test(blocked)) return;
// Skip Android WebView video poster injection.
if (blocked === 'android-webview-video-poster') return;
// Skip own manifest.webmanifest — stale CSP cache hit, not a real violation (default-src 'self' covers it).
if (/manifest\.webmanifest$/.test(blocked)) return;
// Skip third-party injectors: Google Translate, Facebook Pixel
if (/gstatic\.com\/_\/translate/.test(blocked) || /facebook\.net/.test(blocked)) return;
// Skip Sentry ingest (connect-src bootstrap paradox — SDK reports trigger new violations).
// Host-based match handles origin-only and full-path forms, with optional :443 port.
if (/sentry\.io(?::\d+)?(?:\/|$)/.test(blocked)) return;
// Skip YouTube live stream manifests (media-src — expected from YouTube embeds)
if (/googlevideo\.com|youtube\.com\/generate_204/.test(blocked)) return;
// Skip corporate/school content filter injections (securly, GoGuardian, etc.)
if (/securly\.com|goguardian\.com|contentkeeper\.com/.test(blocked)) return;
// Skip Vercel Analytics (script-src — known first-party, not a real violation to action)
if (/_vercel\/insights\/script\.js/.test(blocked)) return;
// Skip inline script blocks — browser extension or in-app browser injection, not actionable
if (blocked === 'inline' && e.effectiveDirective === 'script-src-elem') return;
// Skip null blocked URI — in-app browsers (Baidu, WeChat, Instagram) inject null-src iframes
if (blocked === 'null') return;
if (shouldSuppressCspViolation(
e.disposition ?? '',
e.effectiveDirective ?? '',
blocked,
e.sourceFile ?? '',
_cspAllowsHttps,
)) return;
Sentry.captureMessage(`CSP: ${e.effectiveDirective} blocked ${blocked || '(inline)'}`, {
level: 'warning',
tags: { kind: 'csp_violation' },
@@ -383,7 +420,7 @@ window.addEventListener('securitypolicyviolation', (e) => {
violatedDirective: e.violatedDirective,
effectiveDirective: e.effectiveDirective,
blockedURI: blocked,
sourceFile: src,
sourceFile: e.sourceFile,
lineNumber: e.lineNumber,
disposition: e.disposition,
},

160
tests/csp-filter.test.mjs Normal file
View File

@@ -0,0 +1,160 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Extract the shouldSuppressCspViolation function from main.ts source.
// We parse it as a standalone function to avoid importing the entire Sentry/App bootstrap.
const mainSrc = readFileSync(resolve(__dirname, '../src/main.ts'), 'utf-8');
const fnMatch = mainSrc.match(/function shouldSuppressCspViolation\(([\s\S]*?)\): boolean \{([\s\S]*?)\nfunction |function shouldSuppressCspViolation\(([\s\S]*?)\): boolean \{([\s\S]*?)\n\}/);
assert.ok(fnMatch, 'shouldSuppressCspViolation must exist in src/main.ts');
// Build a callable version from the source text
const fnBody = (fnMatch[2] ?? fnMatch[4]).trim();
const fnParams = (fnMatch[1] ?? fnMatch[3])
.split(',')
.map(p => p.replace(/:.*/s, '').trim())
.filter(Boolean);
// eslint-disable-next-line no-new-func
const suppress = new Function(...fnParams, fnBody);
describe('CSP violation filter (shouldSuppressCspViolation)', () => {
describe('disposition gating', () => {
it('suppresses report-only disposition', () => {
assert.ok(suppress('report', 'connect-src', 'https://example.com', '', true));
});
it('allows enforce disposition', () => {
assert.ok(!suppress('enforce', 'script-src', 'https://evil.com/inject.js', '', false));
});
it('allows empty disposition (browser did not set it)', () => {
assert.ok(!suppress('', 'script-src', 'https://evil.com/inject.js', '', false));
});
});
describe('connect-src HTTPS suppression (policy-aware)', () => {
it('suppresses HTTPS connect-src when CSP allows https:', () => {
assert.ok(suppress('enforce', 'connect-src', 'https://api.worldmonitor.app/api/oref-alerts', '', true));
});
it('suppresses HTTPS connect-src for tilecache.rainviewer.com', () => {
assert.ok(suppress('enforce', 'connect-src', 'https://tilecache.rainviewer.com/v2/radar/abc/256/4/3/4/6/1_1.png', '', true));
});
it('suppresses HTTPS connect-src for Sentry ingest (origin-only)', () => {
assert.ok(suppress('enforce', 'connect-src', 'https://o450.ingest.us.sentry.io', '', true));
});
it('suppresses HTTPS connect-src for Sentry ingest (with port and path)', () => {
assert.ok(suppress('enforce', 'connect-src', 'https://o450.ingest.us.sentry.io:443/api/12345/envelope/', '', true));
});
it('suppresses HTTPS connect-src for foxnews HLS', () => {
assert.ok(suppress('enforce', 'connect-src', 'https://247preview.foxnews.com/hls/live/stream.m3u8', '', true));
});
it('does NOT suppress HTTPS connect-src when CSP does not allow https:', () => {
assert.ok(!suppress('enforce', 'connect-src', 'https://api.worldmonitor.app/api/oref-alerts', '', false));
});
it('does NOT suppress HTTP connect-src even when CSP allows https:', () => {
assert.ok(!suppress('enforce', 'connect-src', 'http://insecure.example.com/api', '', true));
});
it('does NOT suppress non-connect-src HTTPS violations', () => {
assert.ok(!suppress('enforce', 'script-src', 'https://evil.com/inject.js', '', true));
});
});
describe('extension and injection filters', () => {
it('suppresses chrome-extension source', () => {
assert.ok(suppress('enforce', 'script-src', 'https://x.com/a.js', 'chrome-extension://abc/content.js', false));
});
it('suppresses moz-extension blocked URI', () => {
assert.ok(suppress('enforce', 'script-src', 'moz-extension://abc/inject.js', '', false));
});
it('suppresses safari-web-extension', () => {
assert.ok(suppress('enforce', 'script-src', 'safari-web-extension://abc', '', false));
});
});
describe('scheme-only and special values', () => {
it('suppresses blob (scheme-only)', () => {
assert.ok(suppress('enforce', 'worker-src', 'blob', '', false));
});
it('suppresses blob: URI', () => {
assert.ok(suppress('enforce', 'worker-src', 'blob:https://www.worldmonitor.app/abc', '', false));
});
it('suppresses eval', () => {
assert.ok(suppress('enforce', 'script-src', 'eval', '', false));
});
it('suppresses inline for script-src-elem', () => {
assert.ok(suppress('enforce', 'script-src-elem', 'inline', '', false));
});
it('suppresses inline regardless of directive (eval/inline catch-all)', () => {
assert.ok(suppress('enforce', 'connect-src', 'inline', '', false));
});
it('suppresses data: URI', () => {
assert.ok(suppress('enforce', 'img-src', 'data:image/png;base64,abc', '', false));
});
it('suppresses null blocked URI', () => {
assert.ok(suppress('enforce', 'frame-src', 'null', '', false));
});
it('suppresses android-webview-video-poster', () => {
assert.ok(suppress('enforce', 'img-src', 'android-webview-video-poster', '', false));
});
});
describe('third-party noise', () => {
it('suppresses Google Translate', () => {
assert.ok(suppress('enforce', 'connect-src', 'https://translate.gstatic.com/_/translate_http', '', false));
});
it('suppresses Facebook Pixel', () => {
assert.ok(suppress('enforce', 'connect-src', 'https://connect.facebook.net/en_US/fbevents.js', '', false));
});
it('suppresses googlevideo (YouTube embeds)', () => {
assert.ok(suppress('enforce', 'media-src', 'https://rr1---sn-abc.googlevideo.com/videoplayback', '', false));
});
it('suppresses securly (school filter)', () => {
assert.ok(suppress('enforce', 'connect-src', 'https://api.securly.com/v1/track', '', false));
});
it('suppresses manifest.webmanifest', () => {
assert.ok(suppress('enforce', 'default-src', 'https://www.worldmonitor.app/manifest.webmanifest', '', false));
});
});
describe('real violations pass through', () => {
it('reports third-party script-src violation', () => {
assert.ok(!suppress('enforce', 'script-src', 'https://evil.com/crypto-miner.js', '', true));
});
it('reports unknown frame-src violation', () => {
assert.ok(!suppress('enforce', 'frame-src', 'https://malicious-iframe.com/phish', '', false));
});
it('reports HTTP connect-src even with https: allowed', () => {
assert.ok(!suppress('enforce', 'connect-src', 'http://insecure-api.com/leak', '', true));
});
it('reports ws: connect-src violation', () => {
assert.ok(!suppress('enforce', 'connect-src', 'ws://insecure-ws.com/socket', '', true));
});
});
});

View File

@@ -142,6 +142,26 @@ describe('security header guardrails', () => {
assert.ok(connectSrc.includes('wss:'), 'CSP connect-src should keep wss: for secure WebSocket');
});
it('CSP connect-src https: scheme is consistent between header and meta tag', () => {
const indexHtml = readFileSync(resolve(__dirname, '../index.html'), 'utf-8');
const headerCsp = getHeaderValue('Content-Security-Policy');
const metaMatch = indexHtml.match(/http-equiv="Content-Security-Policy"\s+content="([^"]*)"/i);
assert.ok(metaMatch, 'index.html must have a CSP meta tag');
const headerConnectSrc = headerCsp.match(/connect-src\s+([^;]+)/)?.[1] ?? '';
const metaConnectSrc = metaMatch[1].match(/connect-src\s+([^;]+)/)?.[1] ?? '';
const headerHasHttps = /\bhttps:\b/.test(headerConnectSrc);
const metaHasHttps = /\bhttps:\b/.test(metaConnectSrc);
// The CSP violation listener suppresses HTTPS connect-src violations when the meta tag
// contains https: in connect-src. If the header is tightened without the meta tag,
// real violations would be silently suppressed. Both must stay in sync.
assert.equal(headerHasHttps, metaHasHttps,
`connect-src https: scheme mismatch: header=${headerHasHttps}, meta=${metaHasHttps}. ` +
'If removing https: from connect-src, update the CSP violation listener in main.ts too.');
});
it('CSP connect-src does not contain localhost in production', () => {
const csp = getHeaderValue('Content-Security-Policy');
const connectSrc = csp.match(/connect-src\s+([^;]+)/)?.[1] ?? '';