From 4a3381711bebf6e7c386b5719a81845ea1a98a80 Mon Sep 17 00:00:00 2001 From: timon0305 Date: Tue, 5 May 2026 18:07:04 +0200 Subject: [PATCH 1/3] fix: pin CDN scripts + theme CSS with SRI integrity hashes (closes #19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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//?fields=sri' 2. Re-derived from actual CDN content curl -sL | 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. --- static/index.html | 21 ++++++++++++++++++--- static/js/app.js | 45 +++++++++++++++++++++++++++++++-------------- 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/static/index.html b/static/index.html index 29c39fe..9f02734 100644 --- a/static/index.html +++ b/static/index.html @@ -5,9 +5,24 @@ Claude Code Chat Browser - - - + + + + diff --git a/static/js/app.js b/static/js/app.js index 262e80e..c3edcd8 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1,5 +1,33 @@ // Claude Code Chat Browser — Main JS +// Highlight.js theme stylesheets, keyed by theme name. Both `href` and +// `integrity` MUST be assigned together when swapping at runtime — +// changing `href` while leaving a stale `integrity` would make the +// browser refuse the new stylesheet and break the UI (issue #19). +// Hashes verified against cdnjs's SRI API. The corresponding static +// tag in static/index.html carries crossorigin="anonymous" which +// persists across runtime href swaps. +const HLJS_THEME_SHEETS = { + dark: { + href: 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/vs2015.min.css', + integrity: 'sha512-mtXspRdOWHCYp+f4c7CkWGYPPRAhq9X+xCvJMUBVAb6pqA4U8pxhT3RWT3LP3bKbiolYL2CkL1bSKZZO4eeTew==', + }, + light: { + href: 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css', + integrity: 'sha512-0aPQyyeZrWj9sCA46UlmWgKOP0mUipLQ6OZXu8l4IcAmD2u31EPEy9VcIMvl7SoAaKe8bLXZhYoMaE/in+gcgA==', + }, +}; + +function applyHljsTheme(themeName) { + const link = document.getElementById('hljs-theme'); + if (!link) return; + const sheet = HLJS_THEME_SHEETS[themeName] || HLJS_THEME_SHEETS.dark; + // Set integrity FIRST, then href — the browser reads the current + // integrity at fetch time, and href change is what triggers the fetch. + link.integrity = sheet.integrity; + link.href = sheet.href; +} + function showToast(message, type = 'info') { const icons = { success: '\u2713', error: '\u2717', info: '\u2139' }; const toast = document.createElement('div'); @@ -122,14 +150,8 @@ function setHamburgerVisible(visible) { function setWorkspaceMode(active) { // No container class change needed — workspace lives inside the standard container document.body.classList.toggle('workspace-mode', active); - // Switch highlight.js theme - const hljsLink = document.getElementById('hljs-theme'); - if (hljsLink) { - const theme = localStorage.getItem('theme') || 'dark'; - hljsLink.href = theme === 'dark' - ? 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/vs2015.min.css' - : 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css'; - } + // Switch highlight.js theme — helper updates href + integrity together (issue #19). + applyHljsTheme(localStorage.getItem('theme') || 'dark'); } let _navInProgress = false; @@ -836,12 +858,7 @@ function applyTheme(theme) { moon.style.display = theme === 'dark' ? 'block' : 'none'; sun.style.display = theme === 'light' ? 'block' : 'none'; } - const hljsLink = document.getElementById('hljs-theme'); - if (hljsLink) { - hljsLink.href = theme === 'dark' - ? 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/vs2015.min.css' - : 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css'; - } + applyHljsTheme(theme); // href + integrity swapped together (issue #19) } function toggleTheme() { From 1f3cc09121b571640365f688d206442da90660e1 Mon Sep 17 00:00:00 2001 From: timon0305 Date: Tue, 5 May 2026 20:24:27 +0200 Subject: [PATCH 2/3] ci: empty retrigger to see if any workflow runs on PR #20 From 7595d8f1b3b0ffc8626bdbf3891d81dea36b7771 Mon Sep 17 00:00:00 2001 From: timon0305 Date: Wed, 6 May 2026 19:01:26 +0200 Subject: [PATCH 3/3] test: assert hljs dark-theme URL+hash stay aligned across html and js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/test_hljs_theme_consistency.py | 65 ++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 tests/test_hljs_theme_consistency.py diff --git a/tests/test_hljs_theme_consistency.py b/tests/test_hljs_theme_consistency.py new file mode 100644 index 0000000..6f55b70 --- /dev/null +++ b/tests/test_hljs_theme_consistency.py @@ -0,0 +1,65 @@ +"""Regression test for highlight.js theme URL+hash drift between +static/index.html (initial load) and static/js/app.js (runtime swap). + +Both files carry the dark-theme stylesheet URL and its SRI hash. On a +highlight.js version bump both must update together — if they drift, either +the initial load breaks (HTML stale, mismatched hash) or the runtime theme +swap breaks (JS stale). This test fails fast when they diverge so the +"MUST also swap the integrity attribute" comments in both files become a +checked invariant rather than a hope. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +INDEX_HTML = REPO_ROOT / "static" / "index.html" +APP_JS = REPO_ROOT / "static" / "js" / "app.js" + + +def _link_attr(html: str, link_id: str, attr: str) -> str: + """Return the value of `attr` on the tag with id=`link_id`.""" + tag_re = re.compile( + r']*\bid\s*=\s*"' + re.escape(link_id) + r'"[^>]*>', + re.DOTALL, + ) + m = tag_re.search(html) + assert m, f'No found in index.html' + attr_m = re.search(re.escape(attr) + r'\s*=\s*"([^"]*)"', m.group(0)) + assert attr_m, f' has no {attr!r} attribute' + return attr_m.group(1) + + +def _js_theme_entry(js: str, theme: str) -> dict: + """Return {'href': ..., 'integrity': ...} from HLJS_THEME_SHEETS..""" + block = re.search(re.escape(theme) + r"\s*:\s*\{([^}]*)\}", js, re.DOTALL) + assert block, f"HLJS_THEME_SHEETS.{theme} entry not found in app.js" + body = block.group(1) + out = {} + for key in ("href", "integrity"): + m = re.search(key + r"\s*:\s*['\"]([^'\"]+)['\"]", body) + assert m, f"HLJS_THEME_SHEETS.{theme} has no {key!r} key" + out[key] = m.group(1) + return out + + +def test_dark_theme_url_and_hash_match_between_html_and_js(): + html = INDEX_HTML.read_text(encoding="utf-8") + js = APP_JS.read_text(encoding="utf-8") + + html_href = _link_attr(html, "hljs-theme", "href") + html_integrity = _link_attr(html, "hljs-theme", "integrity") + js_dark = _js_theme_entry(js, "dark") + + assert html_href == js_dark["href"], ( + "highlight.js theme URL drifted between index.html and app.js — " + f"html={html_href!r}, app.js HLJS_THEME_SHEETS.dark={js_dark['href']!r}. " + "On a version bump both must update together (issue #19)." + ) + assert html_integrity == js_dark["integrity"], ( + "highlight.js theme SRI hash drifted between index.html and app.js — " + f"html={html_integrity!r}, " + f"app.js HLJS_THEME_SHEETS.dark={js_dark['integrity']!r}." + )