Skip to content

Commit 8489b7d

Browse files
Merge pull request #2660 from canonical/WD-36642-audit-and-review-csp-headers-on-canonical-com
feat: audit and harden CSP headers, add report-only mode and violation reporting
2 parents 693f550 + 1dcda58 commit 8489b7d

1 file changed

Lines changed: 219 additions & 0 deletions

File tree

webapp/handlers.py

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import logging
2+
import time
23

34
import requests
45
import secrets
6+
import sentry_sdk
7+
from urllib.parse import urlparse
58

69
import flask
710
from canonicalwebteam.flask_base.env import get_flask_env
@@ -84,6 +87,10 @@ def _fetch_google_supported_domains():
8487

8588
GOOGLE_DOMAINS = _fetch_google_supported_domains()
8689

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+
8794
CSP = {
8895
"default-src": ["'self'"],
8996
"img-src": [
@@ -176,6 +183,9 @@ def _fetch_google_supported_domains():
176183
"secure.livechatinc.com",
177184
"web.facebook.com",
178185
"www.tfaforms.com",
186+
# Fallback WASM CDN for homepage Lottie animations, see
187+
# static/js/homepage/animations.js
188+
"unpkg.com",
179189
],
180190
"frame-src": [
181191
"'self'",
@@ -210,13 +220,219 @@ def _fetch_google_supported_domains():
210220
"cdn.livechatinc.com",
211221
"secure.livechatinc.com",
212222
],
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'"],
213257
}
214258

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+
215271
NONCED_DIRECTIVES = ("script-src", "script-src-elem", "style-src")
216272

217273

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+
218403
def init_handlers(app):
219404

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+
220436
@app.before_request
221437
def set_csp_nonce():
222438
flask.g.csp_nonce = secrets.token_urlsafe(16)
@@ -257,6 +473,9 @@ def get_csp_as_str(csp={}, nonce=None):
257473
response.headers["Content-Security-Policy"] = get_csp_as_str(
258474
CSP, nonce=nonce
259475
)
476+
response.headers["Content-Security-Policy-Report-Only"] = (
477+
get_csp_as_str(CSP_REPORT_ONLY, nonce=nonce)
478+
)
260479

261480
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
262481
response.headers["Cross-Origin-Embedder-Policy"] = "unsafe-none"

0 commit comments

Comments
 (0)