Skip to content

Commit 70cbf3f

Browse files
jpheinclaude
andcommitted
fix: backport Copilot review fixes from PR rboarescu#15 (/viz) to main
Same shape as 152e428 — production at disks runs fork main, so review amendments to the in-flight upstream PR need to ship here too. Eight Copilot comments addressed across main.py and static/viz.html. ## Auth (was: /viz served publicly) main.py: /viz now goes through _check_auth on the same code path as every other endpoint. Accepts the key from either the X-Api-Key header (preferred) or the ?key=… query param (bookmarkable shortcut). _check_auth is a no-op when PALACE_API_KEY is unset, so the zero-config local-dev experience is unchanged. Production deployments with PALACE_API_KEY set will now 401 unauthenticated /viz requests — matches every other endpoint. ## CDN integrity (was: D3 + Mermaid loaded without SRI, version not pinned) static/viz.html: pinned to d3@7.8.5 and mermaid@10.9.1 with SHA-384 SRI hashes via cdn.jsdelivr.net. Added crossorigin="anonymous" (required for SRI on cross-origin scripts) and referrerpolicy="no-referrer". ## Mermaid sanitization static/viz.html: - mermaidSafe() now strips ASCII control chars (\\n, \\r, \\t, etc.) in addition to the existing parser-breaking chars. - mermaid.initialize({ securityLevel: "strict" }) — was "loose" which relaxes label sanitization and enables clickable diagram nodes. The dashboard never needs node click handlers, and our own mermaidSafe() runs before labels reach Mermaid anyway. ## Mermaid render-error fallback (was: <div> appended inside <pre>) static/viz.html: target was <pre id="hierarchy">; appending a <div> inside it produced invalid HTML and inconsistent cross-browser rendering. Surface the error as plain text inside the existing element and tag with class="err" so CSS can style it. ## ?key= leakage warning static/viz.html: comment on the KEY parsing block calls out that ?key=… leaks into browser history, referer headers, and proxy logs. Same caveat in the /viz docstring. ## Docstring drift (was: "cached at module load" but actually lazy) main.py: docstring rewritten to describe the actual lazy-load behaviour ("lazy-loaded on first request and cached in-process thereafter; one disk read per daemon process"). All five fixes are amendments on the corresponding upstream PR rboarescu#15 branch (force-pushed earlier today). No behaviour change for the healthy unauthenticated-local case; the changes harden security surface that was caught in review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2778832 commit 70cbf3f

2 files changed

Lines changed: 63 additions & 12 deletions

File tree

main.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -885,20 +885,36 @@ async def _direct_under_sem(work):
885885

886886

887887
@app.get("/viz", response_class=HTMLResponse)
888-
async def viz():
888+
async def viz(
889+
key: str | None = None,
890+
x_api_key: str | None = Header(default=None),
891+
):
889892
"""Self-contained status dashboard at /viz.
890893
891-
Returns the HTML page from static/viz.html. The page client-side fetches
892-
/graph, /repair/status, and /health in parallel and renders five panels:
894+
Returns the HTML page from static/viz.html. The page then fetches
895+
/graph, /repair/status, and /health client-side and renders five panels:
893896
KG force-graph (D3), wings bar chart, wing/room hierarchy (Mermaid),
894-
tunnels list, KG stats. Auth happens on the data endpoints — the HTML
895-
shell itself is public so /viz?key=... in the URL works for ergonomic
896-
bookmarking. Cached at module load to avoid disk reads per request.
897+
tunnels list, KG stats.
898+
899+
Auth: same as every other endpoint — ``X-Api-Key`` header. As an
900+
ergonomic shortcut for browser bookmarking, ``?key=...`` is also
901+
accepted; the page reads it from the URL and re-supplies it to the
902+
data endpoints. The ``?key=...`` shape leaks the key into browser
903+
history, proxy logs, and referer headers — prefer the header for
904+
anything beyond a personal bookmark.
905+
906+
The HTML template is read from disk lazily on the first request and
907+
cached in-process thereafter (one disk read per daemon process).
897908
898909
Inspired by upstream PRs #1022 (D3 KG viz), #393 (Mermaid diagrams),
899910
#431 (CLI stats), #256 (sync_status MCP), #601 (brief overview) — none
900911
cherry-picked, just patterns synthesized over the daemon's /graph.
901912
"""
913+
# Accept the API key from either the X-Api-Key header (preferred) or
914+
# the ?key= query parameter (bookmarkable). _check_auth is a no-op
915+
# when PALACE_API_KEY is unset, so this preserves the
916+
# zero-config-local-dev experience.
917+
_check_auth(x_api_key or key)
902918
global _VIZ_HTML_CACHE
903919
if _VIZ_HTML_CACHE is None:
904920
try:

static/viz.html

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,22 @@
3535
<meta name="viewport" content="width=device-width, initial-scale=1" />
3636
<title>palace-daemon — viz</title>
3737
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='26' font-size='28'>◈</text></svg>" />
38-
<script src="https://d3js.org/d3.v7.min.js"></script>
39-
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
38+
<!--
39+
CDN scripts are pinned to specific versions and verified via SRI so a
40+
compromised CDN response can't run arbitrary JS in the dashboard.
41+
`crossorigin="anonymous"` is required for SRI to work on cross-origin
42+
scripts. Bump the version + integrity hash together when upgrading.
43+
-->
44+
<script
45+
src="https://cdn.jsdelivr.net/npm/d3@7.8.5/dist/d3.min.js"
46+
integrity="sha384-su5kReKyYlIFrI62mbQRKXHzFobMa7BHp1cK6julLPbnYcCW9NIZKJiTODjLPeDh"
47+
crossorigin="anonymous"
48+
referrerpolicy="no-referrer"></script>
49+
<script
50+
src="https://cdn.jsdelivr.net/npm/mermaid@10.9.1/dist/mermaid.min.js"
51+
integrity="sha384-WmdflGW9aGfoBdHc4rRyWzYuAjEmDwMdGdiPNacbwfGKxBW/SO6guzuQ76qjnSlr"
52+
crossorigin="anonymous"
53+
referrerpolicy="no-referrer"></script>
4054
<style>
4155
:root {
4256
--bg: #0a0e14;
@@ -218,6 +232,12 @@ <h2>KG stats</h2>
218232
//------------------------------------------------------------------ params + auth
219233
const params = new URLSearchParams(location.search);
220234
const REFRESH_SECONDS = parseInt(params.get("refresh") || "0", 10);
235+
// `?key=...` is supported as a bookmarkable shortcut for the API key. This
236+
// is convenient but the key will appear in the browser's history, in any
237+
// referer header sent to a third-party origin, and in the access logs of
238+
// any forward proxy or reverse proxy on the path. **Do not use ?key= when
239+
// the dashboard might be screen-shared, screencast, or proxied through a
240+
// shared intermediary.** Prefer the `X-Api-Key` header for those cases.
221241
const KEY = params.get("key") || "";
222242

223243
function withAuth(opts) {
@@ -341,9 +361,15 @@ <h2>KG stats</h2>
341361

342362
// Mermaid label sanitizer — allow only chars that don't blow up the parser.
343363
// (Mermaid escapes its own labels but a label containing `]` or `"` will
344-
// terminate the node definition early.)
364+
// terminate the node definition early.) Also strips ASCII control chars
365+
// (newline, carriage return, tab, etc.) since Mermaid's label parser
366+
// breaks on raw newlines and we'd rather collapse than render half a
367+
// label on each side of the break.
345368
function mermaidSafe(s) {
346-
return String(s == null ? "" : s).replace(/["\[\]<>|`]/g, "_").slice(0, 60);
369+
return String(s == null ? "" : s)
370+
.replace(/[\x00-\x1f\x7f]/g, "_") // ASCII control chars (incl. \n \r \t)
371+
.replace(/["\[\]<>|`]/g, "_")
372+
.slice(0, 60);
347373
}
348374

349375
function renderHierarchy(graph) {
@@ -371,8 +397,12 @@ <h2>KG stats</h2>
371397
target.textContent = lines.join("\n");
372398
if (window.mermaid && mermaid.run) {
373399
mermaid.run({ querySelector: "#hierarchy" }).catch(e => {
400+
// The target is a <pre>; appending a <div> here would be invalid HTML.
401+
// Surface the failure as plain text inside the existing element and
402+
// tag the container so CSS can style it as an error if desired.
374403
clear(target);
375-
target.appendChild(el("div", { class: "err" }, "mermaid render failed: " + e.message));
404+
target.classList.add("err");
405+
target.textContent = "mermaid render failed: " + (e && e.message ? e.message : e);
376406
});
377407
}
378408
}
@@ -466,10 +496,15 @@ <h2>KG stats</h2>
466496

467497
document.addEventListener("DOMContentLoaded", () => {
468498
if (window.mermaid) {
499+
// securityLevel "strict" is the default and the safe choice — it sandboxes
500+
// labels through Mermaid's HTML escaping and disables click handlers on
501+
// diagram nodes. We never need clickable nodes here (this is a read-only
502+
// visual of /graph), and our labels are already sanitised by mermaidSafe()
503+
// before they reach the diagram. Using "loose" was over-permissive.
469504
mermaid.initialize({
470505
startOnLoad: false,
471506
theme: matchMedia("(prefers-color-scheme: light)").matches ? "default" : "dark",
472-
securityLevel: "loose",
507+
securityLevel: "strict",
473508
});
474509
}
475510
loadAll();

0 commit comments

Comments
 (0)