|
1 | 1 | import logging |
| 2 | +import time |
2 | 3 |
|
3 | 4 | import requests |
4 | 5 | import secrets |
| 6 | +import sentry_sdk |
| 7 | +from urllib.parse import urlparse |
5 | 8 |
|
6 | 9 | import flask |
7 | 10 | from canonicalwebteam.flask_base.env import get_flask_env |
@@ -84,6 +87,10 @@ def _fetch_google_supported_domains(): |
84 | 87 |
|
85 | 88 | GOOGLE_DOMAINS = _fetch_google_supported_domains() |
86 | 89 |
|
| 90 | +# Same-origin endpoint registered below in init_handlers(); browsers send |
| 91 | +# CSP violation reports here regardless of the page's own connect-src. |
| 92 | +CSP_REPORT_PATH = "/csp-report" |
| 93 | + |
87 | 94 | CSP = { |
88 | 95 | "default-src": ["'self'"], |
89 | 96 | "img-src": [ |
@@ -176,6 +183,9 @@ def _fetch_google_supported_domains(): |
176 | 183 | "secure.livechatinc.com", |
177 | 184 | "web.facebook.com", |
178 | 185 | "www.tfaforms.com", |
| 186 | + # Fallback WASM CDN for homepage Lottie animations, see |
| 187 | + # static/js/homepage/animations.js |
| 188 | + "unpkg.com", |
179 | 189 | ], |
180 | 190 | "frame-src": [ |
181 | 191 | "'self'", |
@@ -210,13 +220,219 @@ def _fetch_google_supported_domains(): |
210 | 220 | "cdn.livechatinc.com", |
211 | 221 | "secure.livechatinc.com", |
212 | 222 | ], |
| 223 | + "form-action": [ |
| 224 | + "'self'", |
| 225 | + "https://pages.ubuntu.com", |
| 226 | + "https://ubuntu.com", |
| 227 | + "https://www.tfaforms.com", |
| 228 | + ], |
| 229 | + "object-src": ["'none'"], |
| 230 | + "base-uri": ["'self'"], |
| 231 | + "worker-src": ["'self'"], |
| 232 | + "report-uri": [CSP_REPORT_PATH], |
| 233 | +} |
| 234 | + |
| 235 | +# These sources seem stale but since marketing tags can be |
| 236 | +# injected at runtime via GTM, outside this repo, we can't |
| 237 | +# be fully sure they're unused from static analysis alone. |
| 238 | +# Put them in a report-only CSP so we can watch Sentry for violations before |
| 239 | +# removing them from the enforced CSP above. |
| 240 | + |
| 241 | +_CSP_REPORT_ONLY_REMOVALS = { |
| 242 | + "script-src-elem": [ |
| 243 | + "script.crazyegg.com", |
| 244 | + "js.zi-scripts.com", |
| 245 | + "snap.licdn.com", |
| 246 | + "buttons.github.io", |
| 247 | + ], |
| 248 | + "connect-src": [ |
| 249 | + "*.crazyegg.com", |
| 250 | + "js.zi-scripts.com", |
| 251 | + "px.ads.linkedin.com", |
| 252 | + "ws.zoominfo.com", |
| 253 | + "www.tfaforms.com", |
| 254 | + ], |
| 255 | + "style-src": ["www.tfaforms.com"], |
| 256 | + "script-src": ["'unsafe-eval'"], |
213 | 257 | } |
214 | 258 |
|
| 259 | + |
| 260 | +def _build_csp_report_only(csp): |
| 261 | + stricter = {directive: list(values) for directive, values in csp.items()} |
| 262 | + for directive, stale_values in _CSP_REPORT_ONLY_REMOVALS.items(): |
| 263 | + stricter[directive] = [ |
| 264 | + value for value in stricter[directive] if value not in stale_values |
| 265 | + ] |
| 266 | + return stricter |
| 267 | + |
| 268 | + |
| 269 | +CSP_REPORT_ONLY = _build_csp_report_only(CSP) |
| 270 | + |
215 | 271 | NONCED_DIRECTIVES = ("script-src", "script-src-elem", "style-src") |
216 | 272 |
|
217 | 273 |
|
| 274 | +# --------------------------------------------------------------------------- |
| 275 | +# CSP violation report throttling |
| 276 | +# --------------------------------------------------------------------------- |
| 277 | +# CSP violation reports arrive on nearly every page load, so forwarding them |
| 278 | +# verbatim would flood Sentry and burn the event budget. Two layers protect us: |
| 279 | +# 1. An ignore-list of hosts already triaged as pure noise; their reports |
| 280 | +# are dropped entirely (no log, no Sentry event). |
| 281 | +# 2. Per-signature de-duplication: for everything else we forward at most one |
| 282 | +# event per (disposition, directive, host) per dedup window, keeping |
| 283 | +# visibility of each distinct violation without the volume. |
| 284 | + |
| 285 | +# Hosts already triaged as noise; their reports are dropped outright. |
| 286 | +CSP_REPORT_IGNORED_HOSTS = frozenset( |
| 287 | + { |
| 288 | + "w.usabilla.com", |
| 289 | + "api.usabilla.com", |
| 290 | + "script.crazyegg.com", |
| 291 | + } |
| 292 | +) |
| 293 | + |
| 294 | +# Forward at most one Sentry event per unique violation signature per window. |
| 295 | +CSP_REPORT_DEDUP_WINDOW = 3600 # seconds (1 hour) |
| 296 | + |
| 297 | + |
| 298 | +def _csp_blocked_host(blocked_uri): |
| 299 | + """ |
| 300 | + Reduce a blocked-uri to a stable host so query strings / cache-busters |
| 301 | + (e.g. ".../ecdf1756070a.js?lv=1") don't explode the cardinality. Non-URL |
| 302 | + tokens such as "inline", "eval" or "data" are returned as-is. |
| 303 | + """ |
| 304 | + if not blocked_uri: |
| 305 | + return "" |
| 306 | + host = urlparse(blocked_uri).hostname |
| 307 | + if host: |
| 308 | + return host |
| 309 | + # Tokens like "inline"/"eval", or bare "data:" - keep the scheme/keyword. |
| 310 | + return blocked_uri.split(":", 1)[0] |
| 311 | + |
| 312 | + |
| 313 | +def _is_ignored_csp_host(host): |
| 314 | + """True if `host` is, or is a subdomain of, an ignored host.""" |
| 315 | + return any( |
| 316 | + host == ignored or host.endswith("." + ignored) |
| 317 | + for ignored in CSP_REPORT_IGNORED_HOSTS |
| 318 | + ) |
| 319 | + |
| 320 | + |
| 321 | +class _CSPReportThrottler: |
| 322 | + """ |
| 323 | + In-memory, per-worker de-duplication for CSP violation reports. |
| 324 | +
|
| 325 | + Remembers the signature of each violation it has forwarded and suppresses |
| 326 | + repeats within `window` seconds. The cache is bounded to `max_entries`; |
| 327 | + when full it evicts stale entries first, then the oldest half as a last |
| 328 | + resort so the expensive sort runs rarely. |
| 329 | + """ |
| 330 | + |
| 331 | + def __init__(self, window, max_entries=1000): |
| 332 | + self._window = window |
| 333 | + self._max_entries = max_entries |
| 334 | + # signature tuple -> monotonic timestamp it was last forwarded. |
| 335 | + self._seen = {} |
| 336 | + |
| 337 | + def should_report(self, signature): |
| 338 | + """Return True only the first time a signature is seen per window.""" |
| 339 | + now = time.monotonic() |
| 340 | + last_sent = self._seen.get(signature) |
| 341 | + if last_sent is not None and now - last_sent < self._window: |
| 342 | + return False |
| 343 | + if len(self._seen) >= self._max_entries: |
| 344 | + self._evict(now) |
| 345 | + self._seen[signature] = now |
| 346 | + return True |
| 347 | + |
| 348 | + def _evict(self, now): |
| 349 | + """Drop entries past the window, then the oldest half if still full.""" |
| 350 | + for signature, last_sent in list(self._seen.items()): |
| 351 | + if now - last_sent >= self._window: |
| 352 | + del self._seen[signature] |
| 353 | + if len(self._seen) >= self._max_entries: |
| 354 | + oldest = sorted(self._seen, key=self._seen.get) |
| 355 | + for signature in oldest[: self._max_entries // 2]: |
| 356 | + del self._seen[signature] |
| 357 | + |
| 358 | + def clear(self): |
| 359 | + """Forget all remembered signatures (used by tests).""" |
| 360 | + self._seen.clear() |
| 361 | + |
| 362 | + |
| 363 | +_csp_throttler = _CSPReportThrottler(CSP_REPORT_DEDUP_WINDOW) |
| 364 | + |
| 365 | + |
| 366 | +def _forward_csp_violation(violation, host, directive, disposition): |
| 367 | + """ |
| 368 | + Log a concise line and send a trimmed payload to Sentry. The raw report |
| 369 | + is dropped on the floor - notably its huge "original-policy" field, which |
| 370 | + is pure noise in the trace. |
| 371 | + """ |
| 372 | + blocked_uri = violation.get("blocked-uri", "") |
| 373 | + document_uri = violation.get("document-uri", "") |
| 374 | + target = host or blocked_uri or "unknown" |
| 375 | + |
| 376 | + logger.warning( |
| 377 | + "CSP [%s] %s blocked %s (%s)", |
| 378 | + disposition, |
| 379 | + directive, |
| 380 | + target, |
| 381 | + document_uri or "unknown", |
| 382 | + ) |
| 383 | + |
| 384 | + with sentry_sdk.new_scope() as scope: |
| 385 | + scope.set_extra( |
| 386 | + "csp-report", |
| 387 | + { |
| 388 | + "disposition": disposition, |
| 389 | + "violated-directive": directive, |
| 390 | + "blocked-uri": blocked_uri, |
| 391 | + "document-uri": document_uri, |
| 392 | + "line-number": violation.get("line-number"), |
| 393 | + "column-number": violation.get("column-number"), |
| 394 | + }, |
| 395 | + ) |
| 396 | + scope.fingerprint = ["csp-violation", directive, host or "unknown"] |
| 397 | + sentry_sdk.capture_message( |
| 398 | + f"CSP violation ({disposition}): {directive} blocked {target}", |
| 399 | + level="warning", |
| 400 | + ) |
| 401 | + |
| 402 | + |
218 | 403 | def init_handlers(app): |
219 | 404 |
|
| 405 | + @app.route(CSP_REPORT_PATH, methods=["POST"]) |
| 406 | + def csp_report(): |
| 407 | + """ |
| 408 | + Browsers POST violations here for both the enforced CSP and the |
| 409 | + Content-Security-Policy-Report-Only header (report bodies include |
| 410 | + a "disposition" field of "enforce" or "report" to tell them apart). |
| 411 | +
|
| 412 | + These reports arrive on nearly every request, so we drop known-noisy |
| 413 | + hosts outright and de-duplicate everything else before forwarding to |
| 414 | + Sentry. We also forward a trimmed payload - the raw report includes |
| 415 | + the full (huge) "original-policy" which is noise in the trace. |
| 416 | + """ |
| 417 | + report = flask.request.get_json(silent=True, force=True) or {} |
| 418 | + violation = report.get("csp-report") |
| 419 | + if not violation: |
| 420 | + return "", 204 |
| 421 | + |
| 422 | + host = _csp_blocked_host(violation.get("blocked-uri", "")) |
| 423 | + directive = violation.get("violated-directive", "unknown") |
| 424 | + disposition = violation.get("disposition", "enforce") |
| 425 | + signature = (disposition, directive, host) |
| 426 | + |
| 427 | + # Drop known-noisy hosts, then de-duplicate everything else. |
| 428 | + if _is_ignored_csp_host(host): |
| 429 | + return "", 204 |
| 430 | + if not _csp_throttler.should_report(signature): |
| 431 | + return "", 204 |
| 432 | + |
| 433 | + _forward_csp_violation(violation, host, directive, disposition) |
| 434 | + return "", 204 |
| 435 | + |
220 | 436 | @app.before_request |
221 | 437 | def set_csp_nonce(): |
222 | 438 | flask.g.csp_nonce = secrets.token_urlsafe(16) |
@@ -257,6 +473,9 @@ def get_csp_as_str(csp={}, nonce=None): |
257 | 473 | response.headers["Content-Security-Policy"] = get_csp_as_str( |
258 | 474 | CSP, nonce=nonce |
259 | 475 | ) |
| 476 | + response.headers["Content-Security-Policy-Report-Only"] = ( |
| 477 | + get_csp_as_str(CSP_REPORT_ONLY, nonce=nonce) |
| 478 | + ) |
260 | 479 |
|
261 | 480 | response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" |
262 | 481 | response.headers["Cross-Origin-Embedder-Policy"] = "unsafe-none" |
|
0 commit comments