From df547eefc2973d31d54a1e492a6b1345a71dfe03 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Tue, 14 Jul 2026 15:13:51 -0400 Subject: [PATCH] Jul 14 - Using CDN - Fix web CSP --- CLAUDE.md | 1 + app/__init__.py | 14 +++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9035f83..555ab61 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1396,6 +1396,7 @@ timeout = 30 - Backward-compatible: empty URL arrays (older server) → resolver builds the `/static/` URL as before. **Operator cutover runbook (after 4b ships + Phase 3 sync exits clean):** +- [ ] **CSP:** the web `img-src` must allow the R2 host or the browser blocks presigned image loads. Handled in `app/__init__.py` `set_security_headers` — the R2 endpoint host is derived from `R2_ENDPOINT_URL` and appended to `img-src` automatically when configured (local backend unaffected). If you use a custom R2 domain for presigned URLs, add that host too. - [ ] Confirm `python scripts/migrate_photos_to_r2.py` prints "✅ SAFE" (0 mismatches) and verified count ≥ Phase 0 baseline present-count. - [ ] Maintenance window: run the sync once more (delta) → set `STORAGE_BACKEND=s3` in `.env` → `systemctl restart janitorial-qc`. - [ ] Smoke test: existing web issue/inspection photos load; existing iPad issue photos load; a **new** upload from web and from iPad lands in R2 and renders; generate an inspection PDF + issue PDF with photos. diff --git a/app/__init__.py b/app/__init__.py index 2408f03..d16329a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -234,6 +234,18 @@ def create_app(config_name='default'): # ── Security response headers ───────────────────────────────────────── # Applied to every response. Blocks clickjacking, MIME sniffing, and # obvious XSS vectors without breaking Bootstrap CDN / Google Fonts. + # Allow R2 presigned photo URLs in the CSP img-src when the s3 storage + # backend is configured. Derived from R2_ENDPOINT_URL (the presigned URL + # host is the same R2 account endpoint), so nothing is hardcoded and the + # local backend is unaffected. + _r2_img_src = '' + _r2_endpoint = app.config.get('R2_ENDPOINT_URL') + if _r2_endpoint: + from urllib.parse import urlparse + _r2_host = urlparse(_r2_endpoint).netloc + if _r2_host: + _r2_img_src = f' https://{_r2_host}' + @app.after_request def set_security_headers(response): response.headers.setdefault('X-Content-Type-Options', 'nosniff') @@ -245,7 +257,7 @@ def create_app(config_name='default'): "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com; " "font-src 'self' data: https://fonts.gstatic.com https://cdn.jsdelivr.net; " - "img-src 'self' data: blob: https://maps.gstatic.com https://maps.googleapis.com; " + f"img-src 'self' data: blob: https://maps.gstatic.com https://maps.googleapis.com{_r2_img_src}; " "connect-src 'self' https://cdn.jsdelivr.net; " "frame-src https://maps.google.com https://www.google.com; " "frame-ancestors 'none';"