Skip to content

Commit 289c0da

Browse files
authored
fix: pin CDN scripts + theme CSS with SRI integrity hashes (closes #19) (#20)
* fix: pin CDN scripts + theme CSS with SRI integrity hashes (closes #19) The dashboard loads three cross-origin assets without integrity attributes: highlight.js/11.9.0/styles/vs2015.min.css highlight.js/11.9.0/highlight.min.js marked/12.0.1/marked.min.js Marked.js parses raw markdown and highlight.js runs on every code block — a swapped CDN payload would execute in our origin with full DOM access. Subresource Integrity (sha512 hash + crossorigin="anonymous") makes the browser refuse anything whose hash does not match a known-good value. Wrinkle: static/js/app.js used to swap `hljsLink.href` between the dark (vs2015) and light (github) stylesheets at runtime via a plain ternary. Adding integrity to the static <link> tag would have made the runtime swap break — the browser reads the (now-stale) integrity at fetch time and refuses the new sheet. Fix: introduced HLJS_THEME_SHEETS const map keyed by theme name, each entry carrying { href, integrity }. New applyHljsTheme(theme) helper sets integrity FIRST then href so the browser sees the right hash when it triggers the new fetch. Both inline ternary call sites (applyTheme and setWorkspaceMode) now call the helper instead of duplicating the logic. All four SHA-512 hashes verified two ways: 1. cdnjs SRI API gh api 'https://api.cdnjs.com/libraries/<name>/<version>?fields=sri' 2. Re-derived from actual CDN content curl -sL <url> | openssl dgst -sha512 -binary | base64 → all four match. pytest 75/75 OK (no behaviour change in Python code). Live HTTP smoke: served HTML carries integrity + crossorigin on all three tags; manual browser verification path documented in PR body. * ci: empty retrigger to see if any workflow runs on PR #20 * test: assert hljs dark-theme URL+hash stay aligned across html and js Per review feedback on PR #20 — vs2015.min.css URL and SRI hash live in both static/index.html (initial load) and static/js/app.js (HLJS_THEME_SHEETS.dark, runtime swap). On a highlight.js version bump both must update together; the cross-referenced comments warn about it but couldn't enforce it. This test extracts the href + integrity from each file and asserts both pairs match, turning the drift hazard into a checked invariant.
1 parent e1bfb0a commit 289c0da

3 files changed

Lines changed: 114 additions & 17 deletions

File tree

static/index.html

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,24 @@
55
<meta name="viewport" content="width=device-width, initial-scale=1.0">
66
<title>Claude Code Chat Browser</title>
77
<link rel="stylesheet" href="/static/css/style.css">
8-
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/vs2015.min.css" id="hljs-theme">
9-
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
10-
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script>
8+
<!-- SRI hashes pin each CDN asset to a specific known-good payload; the
9+
browser refuses anything whose hash does not match (issue #19).
10+
crossorigin="anonymous" is required for the browser to enforce SRI
11+
on cross-origin requests. Hashes verified against cdnjs's SRI API:
12+
curl https://api.cdnjs.com/libraries/<name>/<version>?fields=sri
13+
NOTE: the runtime theme swap in static/js/app.js MUST also swap the
14+
integrity attribute when it changes href — see HLJS_THEME_SHEETS. -->
15+
<link rel="stylesheet"
16+
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/vs2015.min.css"
17+
integrity="sha512-mtXspRdOWHCYp+f4c7CkWGYPPRAhq9X+xCvJMUBVAb6pqA4U8pxhT3RWT3LP3bKbiolYL2CkL1bSKZZO4eeTew=="
18+
crossorigin="anonymous"
19+
id="hljs-theme">
20+
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"
21+
integrity="sha512-D9gUyxqja7hBtkWpPWGt9wfbfaMGVt9gnyCvYa+jojwwPHLCzUm5i8rpk7vD7wNee9bA35eYIjobYPaQuKS1MQ=="
22+
crossorigin="anonymous"></script>
23+
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"
24+
integrity="sha512-pSeTnZAQF/RHxb0ysMoYQI/BRZsa5XuklcrgFfU3YZIdnD3LvkkqzrIeHxzFi6gKtI8Cpq2DEWdZjMTcNVhUYA=="
25+
crossorigin="anonymous"></script>
1126
</head>
1227
<body>
1328
<!-- Navbar -->

static/js/app.js

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,33 @@
11
// Claude Code Chat Browser — Main JS
22

3+
// Highlight.js theme stylesheets, keyed by theme name. Both `href` and
4+
// `integrity` MUST be assigned together when swapping at runtime —
5+
// changing `href` while leaving a stale `integrity` would make the
6+
// browser refuse the new stylesheet and break the UI (issue #19).
7+
// Hashes verified against cdnjs's SRI API. The corresponding static
8+
// tag in static/index.html carries crossorigin="anonymous" which
9+
// persists across runtime href swaps.
10+
const HLJS_THEME_SHEETS = {
11+
dark: {
12+
href: 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/vs2015.min.css',
13+
integrity: 'sha512-mtXspRdOWHCYp+f4c7CkWGYPPRAhq9X+xCvJMUBVAb6pqA4U8pxhT3RWT3LP3bKbiolYL2CkL1bSKZZO4eeTew==',
14+
},
15+
light: {
16+
href: 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css',
17+
integrity: 'sha512-0aPQyyeZrWj9sCA46UlmWgKOP0mUipLQ6OZXu8l4IcAmD2u31EPEy9VcIMvl7SoAaKe8bLXZhYoMaE/in+gcgA==',
18+
},
19+
};
20+
21+
function applyHljsTheme(themeName) {
22+
const link = document.getElementById('hljs-theme');
23+
if (!link) return;
24+
const sheet = HLJS_THEME_SHEETS[themeName] || HLJS_THEME_SHEETS.dark;
25+
// Set integrity FIRST, then href — the browser reads the current
26+
// integrity at fetch time, and href change is what triggers the fetch.
27+
link.integrity = sheet.integrity;
28+
link.href = sheet.href;
29+
}
30+
331
function showToast(message, type = 'info') {
432
const icons = { success: '\u2713', error: '\u2717', info: '\u2139' };
533
const toast = document.createElement('div');
@@ -122,14 +150,8 @@ function setHamburgerVisible(visible) {
122150
function setWorkspaceMode(active) {
123151
// No container class change needed — workspace lives inside the standard container
124152
document.body.classList.toggle('workspace-mode', active);
125-
// Switch highlight.js theme
126-
const hljsLink = document.getElementById('hljs-theme');
127-
if (hljsLink) {
128-
const theme = localStorage.getItem('theme') || 'dark';
129-
hljsLink.href = theme === 'dark'
130-
? 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/vs2015.min.css'
131-
: 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css';
132-
}
153+
// Switch highlight.js theme — helper updates href + integrity together (issue #19).
154+
applyHljsTheme(localStorage.getItem('theme') || 'dark');
133155
}
134156

135157
let _navInProgress = false;
@@ -836,12 +858,7 @@ function applyTheme(theme) {
836858
moon.style.display = theme === 'dark' ? 'block' : 'none';
837859
sun.style.display = theme === 'light' ? 'block' : 'none';
838860
}
839-
const hljsLink = document.getElementById('hljs-theme');
840-
if (hljsLink) {
841-
hljsLink.href = theme === 'dark'
842-
? 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/vs2015.min.css'
843-
: 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css';
844-
}
861+
applyHljsTheme(theme); // href + integrity swapped together (issue #19)
845862
}
846863

847864
function toggleTheme() {
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Regression test for highlight.js theme URL+hash drift between
2+
static/index.html (initial load) and static/js/app.js (runtime swap).
3+
4+
Both files carry the dark-theme stylesheet URL and its SRI hash. On a
5+
highlight.js version bump both must update together — if they drift, either
6+
the initial load breaks (HTML stale, mismatched hash) or the runtime theme
7+
swap breaks (JS stale). This test fails fast when they diverge so the
8+
"MUST also swap the integrity attribute" comments in both files become a
9+
checked invariant rather than a hope.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import re
15+
from pathlib import Path
16+
17+
REPO_ROOT = Path(__file__).resolve().parent.parent
18+
INDEX_HTML = REPO_ROOT / "static" / "index.html"
19+
APP_JS = REPO_ROOT / "static" / "js" / "app.js"
20+
21+
22+
def _link_attr(html: str, link_id: str, attr: str) -> str:
23+
"""Return the value of `attr` on the <link> tag with id=`link_id`."""
24+
tag_re = re.compile(
25+
r'<link\b[^>]*\bid\s*=\s*"' + re.escape(link_id) + r'"[^>]*>',
26+
re.DOTALL,
27+
)
28+
m = tag_re.search(html)
29+
assert m, f'No <link id="{link_id}"> found in index.html'
30+
attr_m = re.search(re.escape(attr) + r'\s*=\s*"([^"]*)"', m.group(0))
31+
assert attr_m, f'<link id="{link_id}"> has no {attr!r} attribute'
32+
return attr_m.group(1)
33+
34+
35+
def _js_theme_entry(js: str, theme: str) -> dict:
36+
"""Return {'href': ..., 'integrity': ...} from HLJS_THEME_SHEETS.<theme>."""
37+
block = re.search(re.escape(theme) + r"\s*:\s*\{([^}]*)\}", js, re.DOTALL)
38+
assert block, f"HLJS_THEME_SHEETS.{theme} entry not found in app.js"
39+
body = block.group(1)
40+
out = {}
41+
for key in ("href", "integrity"):
42+
m = re.search(key + r"\s*:\s*['\"]([^'\"]+)['\"]", body)
43+
assert m, f"HLJS_THEME_SHEETS.{theme} has no {key!r} key"
44+
out[key] = m.group(1)
45+
return out
46+
47+
48+
def test_dark_theme_url_and_hash_match_between_html_and_js():
49+
html = INDEX_HTML.read_text(encoding="utf-8")
50+
js = APP_JS.read_text(encoding="utf-8")
51+
52+
html_href = _link_attr(html, "hljs-theme", "href")
53+
html_integrity = _link_attr(html, "hljs-theme", "integrity")
54+
js_dark = _js_theme_entry(js, "dark")
55+
56+
assert html_href == js_dark["href"], (
57+
"highlight.js theme URL drifted between index.html and app.js — "
58+
f"html={html_href!r}, app.js HLJS_THEME_SHEETS.dark={js_dark['href']!r}. "
59+
"On a version bump both must update together (issue #19)."
60+
)
61+
assert html_integrity == js_dark["integrity"], (
62+
"highlight.js theme SRI hash drifted between index.html and app.js — "
63+
f"html={html_integrity!r}, "
64+
f"app.js HLJS_THEME_SHEETS.dark={js_dark['integrity']!r}."
65+
)

0 commit comments

Comments
 (0)