Skip to content

Commit aceb5c1

Browse files
feat: add Content-Security-Policy header and externalize theme-init
1 parent 0435b2d commit aceb5c1

6 files changed

Lines changed: 63 additions & 19 deletions

File tree

app.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,21 @@
1313
from api.sessions import sessions_bp
1414
from utils.exclusion_rules import load_rules, resolve_exclusion_rules_path
1515

16+
# Content-Security-Policy for all Flask responses. 'unsafe-inline' in style-src is
17+
# required because highlight.js themes apply inline styles; can be tightened with
18+
# nonces later. script-src lists cdnjs only — keep in sync with SRI <script>/<link>
19+
# sources in static/index.html.
20+
CSP_POLICY = "; ".join(
21+
[
22+
"default-src 'self'",
23+
"script-src 'self' https://cdnjs.cloudflare.com",
24+
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com",
25+
"img-src 'self' data:",
26+
"connect-src 'self'",
27+
"font-src 'self'",
28+
]
29+
)
30+
1631

1732
def _normalize_bind_host(host: str) -> str:
1833
"""Lowercase host for checks; strip optional IPv6 brackets (e.g. ``[::1]`` → ``::1``)."""
@@ -83,6 +98,11 @@ def create_app(
8398
app.register_blueprint(search_bp)
8499
app.register_blueprint(export_bp)
85100

101+
@app.after_request
102+
def set_security_headers(response):
103+
response.headers.setdefault("Content-Security-Policy", CSP_POLICY)
104+
return response
105+
86106
@app.route("/")
87107
def index():
88108
return app.send_static_file("index.html")

docs/architecture.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,21 @@ The UI is a **hash-routed** SPA with ES modules under `static/js/`:
113113

114114
No bundler step — modern browsers load modules directly. Frontend unit tests use **vitest** + **jsdom** (`npm test`), including `static/js/render/registry.test.js` for registry wiring and renderer escaping.
115115

116+
## Content-Security-Policy
117+
118+
`create_app()` registers an `@app.after_request` hook that sets a `Content-Security-Policy` header on every Flask response. The policy is defined as `CSP_POLICY` in `app.py`:
119+
120+
| Directive | Sources | Notes |
121+
|-----------|---------|-------|
122+
| `default-src` | `'self'` | Fallback for unspecified fetch types |
123+
| `script-src` | `'self'`, `https://cdnjs.cloudflare.com` | Self-hosted JS (e.g. `theme-init.js`, ES modules) plus SRI-pinned CDN scripts in `index.html` |
124+
| `style-src` | `'self'`, `'unsafe-inline'`, `https://cdnjs.cloudflare.com` | `'unsafe-inline'` needed for highlight.js theme inline styles; tighten with nonces later |
125+
| `img-src` | `'self'`, `data:` | Session images and data URLs |
126+
| `connect-src` | `'self'` | API `fetch` calls to same origin |
127+
| `font-src` | `'self'` | Local fonts only |
128+
129+
**Keeping CDN sources in sync:** when adding or bumping a CDN asset in `static/index.html`, update both the SRI `integrity` hash and `CSP_POLICY` if the origin changes (today all CDN assets use `cdnjs.cloudflare.com`). Theme-init scripts were externalized to `static/js/theme-init.js` and `static/js/hljs-theme-init.js` so `script-src` does not require `'unsafe-inline'`.
130+
116131
## Continuous integration
117132

118133
[`.github/workflows/ci.yml`](../.github/workflows/ci.yml) runs on push/PR:

static/index.html

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,7 @@
44
<meta charset="UTF-8">
55
<meta name="viewport" content="width=device-width, initial-scale=1.0">
66
<title>Claude Code Chat Browser</title>
7-
<script>
8-
/* Apply saved theme before first paint (avoids dark flash in light mode).
9-
hljs URLs/integrity must match HLJS_THEME_SHEETS in static/js/shared/theme.js */
10-
(function () {
11-
var t = localStorage.getItem('theme');
12-
if (t !== 'light' && t !== 'dark') t = 'dark';
13-
document.documentElement.setAttribute('data-theme', t);
14-
})();
15-
</script>
7+
<script src="/static/js/theme-init.js"></script>
168
<link rel="stylesheet" href="/static/css/style.css">
179
<!-- SRI hashes pin each CDN asset to a specific known-good payload; the
1810
browser refuses anything whose hash does not match (issue #19).
@@ -26,16 +18,7 @@
2618
integrity="sha512-mtXspRdOWHCYp+f4c7CkWGYPPRAhq9X+xCvJMUBVAb6pqA4U8pxhT3RWT3LP3bKbiolYL2CkL1bSKZZO4eeTew=="
2719
crossorigin="anonymous"
2820
id="hljs-theme">
29-
<script>
30-
(function () {
31-
var t = document.documentElement.getAttribute('data-theme') || 'dark';
32-
if (t !== 'light') return;
33-
var link = document.getElementById('hljs-theme');
34-
if (!link) return;
35-
link.integrity = 'sha512-0aPQyyeZrWgKOP0mUipLQ6OZXu8l4IcAmD2u31EPEy9VcIMvl7SoAaKe8bLXZhYoMaE/in+gcgA==';
36-
link.href = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css';
37-
})();
38-
</script>
21+
<script src="/static/js/hljs-theme-init.js"></script>
3922
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"
4023
integrity="sha512-D9gUyxqja7hBtkWpPWGt9wfbfaMGVt9gnyCvYa+jojwwPHLCzUm5i8rpk7vD7wNee9bA35eYIjobYPaQuKS1MQ=="
4124
crossorigin="anonymous"></script>

static/js/hljs-theme-init.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/* Initial hljs stylesheet for light mode — must match HLJS_THEME_SHEETS.light
2+
in static/js/shared/theme.js (runtime swaps use applyHljsTheme). */
3+
(function () {
4+
var t = document.documentElement.getAttribute('data-theme') || 'dark';
5+
if (t !== 'light') return;
6+
var link = document.getElementById('hljs-theme');
7+
if (!link) return;
8+
link.integrity = 'sha512-0aPQyyeZrWj9sCA46UlmWgKOP0mUipLQ6OZXu8l4IcAmD2u31EPEy9VcIMvl7SoAaKe8bLXZhYoMaE/in+gcgA==';
9+
link.href = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css';
10+
})();

static/js/theme-init.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/* Apply saved theme before first paint (avoids dark flash in light mode).
2+
hljs URLs/integrity must match HLJS_THEME_SHEETS in static/js/shared/theme.js */
3+
(function () {
4+
var t = localStorage.getItem('theme');
5+
if (t !== 'light' && t !== 'dark') t = 'dark';
6+
document.documentElement.setAttribute('data-theme', t);
7+
})();

tests/test_api_integration.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@
1111

1212
from tests.conftest import assert_error_response as _assert_error_shape
1313

14+
# --- / (SPA shell) ---
15+
16+
17+
def test_root_sets_csp_header(client):
18+
resp = client.get("/")
19+
assert "Content-Security-Policy" in resp.headers
20+
assert "default-src 'self'" in resp.headers["Content-Security-Policy"]
21+
22+
1423
# --- /api/projects ---
1524

1625

0 commit comments

Comments
 (0)