Skip to content

Commit aba3526

Browse files
fix: wire UI handlers for CSP compliance and strengthen policy tests
1 parent aceb5c1 commit aba3526

10 files changed

Lines changed: 88 additions & 34 deletions

File tree

app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
"img-src 'self' data:",
2626
"connect-src 'self'",
2727
"font-src 'self'",
28+
"base-uri 'self'",
29+
"frame-ancestors 'none'",
2830
]
2931
)
3032

docs/architecture.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,10 @@ No bundler step — modern browsers load modules directly. Frontend unit tests u
125125
| `img-src` | `'self'`, `data:` | Session images and data URLs |
126126
| `connect-src` | `'self'` | API `fetch` calls to same origin |
127127
| `font-src` | `'self'` | Local fonts only |
128+
| `base-uri` | `'self'` | Restrict `<base>` tag injection |
129+
| `frame-ancestors` | `'none'` | Prevent clickjacking via iframes |
128130

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'`.
131+
**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'`. Navbar and route UI handlers use `addEventListener` instead of inline `onclick` attributes for the same reason.
130132

131133
## Continuous integration
132134

static/index.html

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,15 @@
3636
<!-- Navbar -->
3737
<nav class="navbar">
3838
<div class="navbar-inner">
39-
<button class="hamburger" id="hamburger-btn" onclick="toggleSidebar()" aria-label="Toggle sidebar" style="display:none">
39+
<button class="hamburger" id="hamburger-btn" aria-label="Toggle sidebar" style="display:none">
4040
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
4141
</button>
42-
<a href="#" onclick="showProjects();return false;" class="navbar-brand">Claude Code Chat Browser</a>
42+
<a href="#" id="navbar-brand" class="navbar-brand">Claude Code Chat Browser</a>
4343
<div class="navbar-actions">
44-
<a href="#search" onclick="showSearchPage();return false;" class="nav-link" title="Search">
44+
<a href="#search" id="nav-search-link" class="nav-link" title="Search">
4545
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
4646
</a>
47-
<button id="theme-toggle" class="nav-link" title="Toggle theme" onclick="toggleTheme()">
47+
<button id="theme-toggle" class="nav-link" title="Toggle theme" type="button">
4848
<svg id="icon-moon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
4949
<svg id="icon-sun" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
5050
</button>

static/js/app.js

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
1-
// Claude Code Chat Browser — Entry module (router + theme + window registrations).
1+
// Claude Code Chat Browser — Entry module (router + theme + navbar wiring).
22
// Route modules live in sessions.js, projects.js, search.js, export.js.
33
// Shared helpers live in shared/utils.js, shared/markdown.js, shared/theme.js.
44

55
import { state } from './shared/state.js';
66
import { toggleSidebar, closeSidebar, loadingBar } from './shared/utils.js';
77
import { HLJS_THEME_SHEETS, applyHljsTheme, applyTheme, toggleTheme, setWorkspaceMode } from './shared/theme.js';
88
import { showProjects } from './projects.js';
9-
import { showWorkspace, loadSession, selectSession, copyAll } from './sessions.js';
10-
import { showSearchPage, doSearch } from './search.js';
11-
import { bulkExport, downloadSession } from './export.js';
9+
import { showWorkspace, loadSession } from './sessions.js';
10+
import { showSearchPage } from './search.js';
1211

1312
// ==================== Router ====================
1413

@@ -69,22 +68,18 @@ document.addEventListener('DOMContentLoaded', () => {
6968
topBtn.addEventListener('click', () => window.scrollTo({ top: 0, behavior: 'smooth' }));
7069
document.body.appendChild(topBtn);
7170
window.addEventListener('scroll', () => topBtn.classList.toggle('show', window.scrollY > 400));
72-
});
73-
74-
// ==================== Window registrations for inline HTML handlers ====================
75-
// Functions listed here are called from onclick="..." attributes in index.html or
76-
// in HTML strings generated by route modules. ES module scope does not expose
77-
// identifiers to the global scope automatically, so they must be registered on window.
7871

79-
window.showProjects = showProjects;
80-
window.showSearchPage = showSearchPage;
81-
window.toggleTheme = toggleTheme;
82-
window.toggleSidebar = toggleSidebar;
83-
window.selectSession = selectSession;
84-
window.copyAll = copyAll;
85-
window.downloadSession = downloadSession;
86-
window.bulkExport = bulkExport;
87-
window.doSearch = doSearch;
72+
document.getElementById('hamburger-btn')?.addEventListener('click', toggleSidebar);
73+
document.getElementById('navbar-brand')?.addEventListener('click', (e) => {
74+
e.preventDefault();
75+
showProjects();
76+
});
77+
document.getElementById('nav-search-link')?.addEventListener('click', (e) => {
78+
e.preventDefault();
79+
showSearchPage();
80+
});
81+
document.getElementById('theme-toggle')?.addEventListener('click', toggleTheme);
82+
});
8883

8984
// Keep HLJS_THEME_SHEETS accessible for test_hljs_theme_consistency.py (source-level check)
9085
export { HLJS_THEME_SHEETS };

static/js/hljs-theme-init.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
if (t !== 'light') return;
66
var link = document.getElementById('hljs-theme');
77
if (!link) return;
8+
// Set integrity before href — browser reads integrity at fetch time (see applyHljsTheme).
89
link.integrity = 'sha512-0aPQyyeZrWj9sCA46UlmWgKOP0mUipLQ6OZXu8l4IcAmD2u31EPEy9VcIMvl7SoAaKe8bLXZhYoMaE/in+gcgA==';
910
link.href = 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css';
1011
})();

static/js/projects.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,15 @@
33
import { state } from './shared/state.js';
44
import { esc, formatDate, smoothSet, loadingBar, setHamburgerVisible } from './shared/utils.js';
55
import { setWorkspaceMode } from './shared/theme.js';
6+
import { bulkExport } from './export.js';
67

78
// ==================== Projects (home) ====================
89

10+
function bindProjectsExportButtons(root) {
11+
root.querySelector('#btn-export-since')?.addEventListener('click', () => bulkExport('incremental'));
12+
root.querySelector('#btn-export-all')?.addEventListener('click', () => bulkExport('all'));
13+
}
14+
915
export async function showProjects() {
1016
state.currentProject = null;
1117
setHamburgerVisible(false);
@@ -69,7 +75,7 @@ export async function showProjects() {
6975
}
7076

7177
const sinceBtnHtml = hasPreviousExport
72-
? `<button class="btn btn-primary btn-sm" id="btn-export-since" onclick="bulkExport('incremental')">
78+
? `<button type="button" class="btn btn-primary btn-sm" id="btn-export-since">
7379
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
7480
Export new since last
7581
</button>`
@@ -80,7 +86,7 @@ export async function showProjects() {
8086
<h1>Projects</h1>
8187
<div class="btn-group">
8288
${sinceBtnHtml}
83-
<button class="btn btn-outline btn-sm" id="btn-export-all" onclick="bulkExport('all')">
89+
<button type="button" class="btn btn-outline btn-sm" id="btn-export-all">
8490
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
8591
Export all
8692
</button>
@@ -140,6 +146,7 @@ export async function showProjects() {
140146

141147
loadingBar.done();
142148
smoothSet(content, html);
149+
bindProjectsExportButtons(content);
143150
} catch (e) {
144151
loadingBar.done();
145152
smoothSet(content, `<div class="loading"><p class="text-danger">Failed to load projects.</p></div>`);

static/js/search.js

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { esc, smoothSet, setHamburgerVisible } from './shared/utils.js';
44
import { setWorkspaceMode } from './shared/theme.js';
5+
import { showProjects } from './projects.js';
56

67
// ==================== Search ====================
78

@@ -14,19 +15,26 @@ export function showSearchPage() {
1415
const content = document.getElementById('content');
1516
content.innerHTML = `
1617
<div class="search-page">
17-
<a class="back-link" href="#" onclick="showProjects();return false;">
18+
<a class="back-link" href="#" id="search-back-link">
1819
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
1920
Back to Projects
2021
</a>
2122
<br><br>
2223
<h1>Search</h1>
2324
<div class="search-bar">
24-
<input class="input" type="text" id="search-input" placeholder="Search conversations..." autofocus
25-
onkeydown="if(event.key==='Enter') doSearch()">
26-
<button class="btn btn-primary" onclick="doSearch()">Search</button>
25+
<input class="input" type="text" id="search-input" placeholder="Search conversations..." autofocus>
26+
<button type="button" class="btn btn-primary" id="search-submit-btn">Search</button>
2727
</div>
2828
<div id="search-results"></div>
2929
</div>`;
30+
document.getElementById('search-back-link')?.addEventListener('click', (e) => {
31+
e.preventDefault();
32+
showProjects();
33+
});
34+
document.getElementById('search-submit-btn')?.addEventListener('click', doSearch);
35+
document.getElementById('search-input')?.addEventListener('keydown', (e) => {
36+
if (e.key === 'Enter') doSearch();
37+
});
3038
document.getElementById('search-results').addEventListener('click', (e) => {
3139
if (!(e.target instanceof Element)) return;
3240
const result = e.target.closest('.search-result[data-project]');

static/js/sessions.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { esc, formatDate, formatTs, smoothSet, loadingBar, showToast, closeSideb
55
import { renderMarkdown, cleanContent } from './shared/markdown.js';
66
import { setWorkspaceMode } from './shared/theme.js';
77
import { downloadSession } from './export.js';
8+
import { showProjects } from './projects.js';
89
import { renderToolUse, renderToolResult, toolResultHasBody } from './render/registry.js';
910

1011
// ==================== Workspace (split layout) ====================
@@ -66,7 +67,7 @@ export async function showWorkspace(projectName, selectedSessionId) {
6667
sidebar += '</div>';
6768

6869
let html = `<div class="workspace-top-bar">
69-
<a class="btn btn-ghost btn-sm back-link" href="#" onclick="showProjects();return false;">
70+
<a class="btn btn-ghost btn-sm back-link" href="#" id="ws-back-link">
7071
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
7172
Back to Projects
7273
</a>
@@ -84,6 +85,10 @@ export async function showWorkspace(projectName, selectedSessionId) {
8485
</div>`;
8586
smoothSet(content, html);
8687
bindSidebarSessionClicks();
88+
content.querySelector('#ws-back-link')?.addEventListener('click', (e) => {
89+
e.preventDefault();
90+
showProjects();
91+
});
8792
loadingBar.done();
8893

8994
if (selectedSessionId) {
@@ -175,7 +180,7 @@ export async function loadSession(projectName, sessionId) {
175180
const wsActions = document.getElementById('ws-actions');
176181
if (wsActions) {
177182
wsActions.innerHTML = `<div class="btn-group">
178-
<button class="btn btn-outline btn-sm" onclick="copyAll()">
183+
<button type="button" class="btn btn-outline btn-sm" id="btn-copy-all">
179184
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
180185
Copy All
181186
</button>
@@ -185,6 +190,7 @@ export async function loadSession(projectName, sessionId) {
185190
</button>
186191
</div>`;
187192
bindWorkspaceDownloadClick(wsActions);
193+
wsActions.querySelector('#btn-copy-all')?.addEventListener('click', copyAll);
188194
}
189195

190196
html += `<div class="card">

tests/test_api_integration.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,16 @@
99

1010
from __future__ import annotations
1111

12+
from app import CSP_POLICY
1213
from tests.conftest import assert_error_response as _assert_error_shape
1314

1415
# --- / (SPA shell) ---
1516

1617

1718
def test_root_sets_csp_header(client):
1819
resp = client.get("/")
19-
assert "Content-Security-Policy" in resp.headers
20-
assert "default-src 'self'" in resp.headers["Content-Security-Policy"]
20+
assert resp.status_code == 200
21+
assert resp.headers.get("Content-Security-Policy") == CSP_POLICY
2122

2223

2324
# --- /api/projects ---

tests/test_hljs_theme_consistency.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
REPO_ROOT = Path(__file__).resolve().parent.parent
1818
INDEX_HTML = REPO_ROOT / "static" / "index.html"
19+
HLJS_THEME_INIT_JS = REPO_ROOT / "static" / "js" / "hljs-theme-init.js"
1920
# HLJS_THEME_SHEETS was extracted to shared/theme.js (Day 4 module split).
2021
# app.js re-exports it, but the canonical source is theme.js.
2122
APP_JS = REPO_ROOT / "static" / "js" / "shared" / "theme.js"
@@ -47,6 +48,19 @@ def _js_theme_entry(js: str, theme: str) -> dict:
4748
return out
4849

4950

51+
def _js_string_assignments(js: str, keys: tuple[str, ...]) -> dict[str, str]:
52+
"""Return string literal assignments like ``link.href = '...'`` from classic JS."""
53+
out: dict[str, str] = {}
54+
for key in keys:
55+
m = re.search(
56+
r"link\." + re.escape(key) + r"\s*=\s*['\"]([^'\"]+)['\"]",
57+
js,
58+
)
59+
assert m, f"hljs-theme-init.js has no link.{key} assignment"
60+
out[key] = m.group(1)
61+
return out
62+
63+
5064
def test_dark_theme_url_and_hash_match_between_html_and_js():
5165
html = INDEX_HTML.read_text(encoding="utf-8")
5266
js = APP_JS.read_text(encoding="utf-8")
@@ -65,3 +79,21 @@ def test_dark_theme_url_and_hash_match_between_html_and_js():
6579
f"html={html_integrity!r}, "
6680
f"app.js HLJS_THEME_SHEETS.dark={js_dark['integrity']!r}."
6781
)
82+
83+
84+
def test_light_theme_url_and_hash_match_between_hljs_init_and_theme_js():
85+
init_js = HLJS_THEME_INIT_JS.read_text(encoding="utf-8")
86+
theme_js = APP_JS.read_text(encoding="utf-8")
87+
88+
init = _js_string_assignments(init_js, ("integrity", "href"))
89+
js_light = _js_theme_entry(theme_js, "light")
90+
91+
assert init["href"] == js_light["href"], (
92+
"highlight.js light theme URL drifted between hljs-theme-init.js and theme.js — "
93+
f"init={init['href']!r}, theme.js HLJS_THEME_SHEETS.light={js_light['href']!r}."
94+
)
95+
assert init["integrity"] == js_light["integrity"], (
96+
"highlight.js light theme SRI hash drifted between hljs-theme-init.js and theme.js — "
97+
f"init={init['integrity']!r}, "
98+
f"theme.js HLJS_THEME_SHEETS.light={js_light['integrity']!r}."
99+
)

0 commit comments

Comments
 (0)