mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
103
src/main.ts
103
src/main.ts
@@ -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
160
tests/csp-filter.test.mjs
Normal 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));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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] ?? '';
|
||||
|
||||
Reference in New Issue
Block a user