diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d6bcf6..997e250 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Hermes Web UI -- Changelog +## [v0.50.43] fix: markdown link rendering + KaTeX CSP fonts + +**Markdown link rendering — `renderMd()` in `static/ui.js`** (PR #475, fixes #470) + +Three related bugs fixed: + +1. **Double-linking via autolink pass** — `[label](url)` was converted to ``, then the bare-URL autolink pass re-matched the URL sitting inside `href="..."` and wrapped it in a second `` tag. Fixed with three stash/restore layers: `\x00L` (inlineMd labeled links), `\x00A` (existing `` tags before outer link pass), `\x00B` (existing `` tags before autolink pass). + +2. **`esc()` on `href` values corrupts query strings** — `esc()` is HTML-entity encoding; applying it to URLs converted `&` → `&` in query strings. Removed `esc()` from href values in all three locations. Display text (link labels) still uses `esc()` for XSS safety. `"` in URLs replaced with `%22` (URL encoding) to close the attribute-injection vector identified during review. + +3. **Backtick code spans inside `**bold**` rendered as `<code>`** — `esc()` was applied to code spans after bold/italic processing. Added `\x00C` stash to protect backtick spans in `inlineMd()` before bold/italic regex runs. + +**Security audit:** `javascript:` injection blocked by `https?://` prefix requirement. `"` attribute breakout fixed by `.replace(/"/g, '%22')`. Label/display text still HTML-escaped. + +24 tests in `tests/test_issue470.py`. + +**KaTeX CSP font-src** (fixes #477) + +`api/helpers.py` CSP `font-src` now includes `https://cdn.jsdelivr.net` so KaTeX math rendering fonts load correctly. Previously ~50 CSP font-blocking errors appeared in the console on any page with math content. The CDN was already allowed in `script-src` and `style-src` for KaTeX JS/CSS — this extends the same allowance to fonts. + +3 tests in `tests/test_issue477.py`. + +- Total tests: 1150 (was 1130) + ## [v0.50.42] fix: session display + model UX polish (sprint 42) **Context indicator always shows latest usage** (PR #471, fixes #437) diff --git a/TESTING.md b/TESTING.md index b80b22a..b897ddf 100644 --- a/TESTING.md +++ b/TESTING.md @@ -8,7 +8,7 @@ > Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser. > Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}. > -> Automated tests: 1130 total (1130 passing, 0 known failures). Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, and the onboarding skip/existing-config guard. +> Automated tests: 1150 total (1150 passing, 0 known failures). Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, and the onboarding skip/existing-config guard. > Run: `pytest tests/ -v --timeout=60` --- diff --git a/api/helpers.py b/api/helpers.py index 127813c..92c024c 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -45,7 +45,7 @@ def _security_headers(handler): "default-src 'self'; " "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " - "img-src 'self' data:; font-src 'self' data:; connect-src 'self'; " + "img-src 'self' data:; font-src 'self' data: https://cdn.jsdelivr.net; connect-src 'self'; " "base-uri 'self'; form-action 'self'" ) handler.send_header( diff --git a/static/index.html b/static/index.html index e9f1c1a..79948ce 100644 --- a/static/index.html +++ b/static/index.html @@ -536,7 +536,7 @@
System
Instance version and access controls.
- v0.50.42 + v0.50.43
diff --git a/static/ui.js b/static/ui.js index 2bcdbf7..00926b2 100644 --- a/static/ui.js +++ b/static/ui.js @@ -459,21 +459,32 @@ function renderMd(raw){ // Used inside list items and blockquotes where the text may already contain // HTML from the pre-pass → bold pipeline, so we cannot call esc() directly. function inlineMd(t){ + // Stash backtick code spans first so bold/italic never esc() their content + const _code_stash=[]; + t=t.replace(/`([^`\n]+)`/g,(_,x)=>{_code_stash.push(`${esc(x)}`);return `\x00C${_code_stash.length-1}\x00`;}); t=t.replace(/\*\*\*(.+?)\*\*\*/g,(_,x)=>`${esc(x)}`); t=t.replace(/\*\*(.+?)\*\*/g,(_,x)=>`${esc(x)}`); t=t.replace(/\*([^*\n]+)\*/g,(_,x)=>`${esc(x)}`); - t=t.replace(/`([^`\n]+)`/g,(_,x)=>`${esc(x)}`); - t=t.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,lb,u)=>`${esc(lb)}`); - t=t.replace(/(https?:\/\/[^\s<>"')\]]+)/g,(url)=>{const trail=url.match(/[.,;:!?)]$/)?url.slice(-1):'';const clean=trail?url.slice(0,-1):url;return `${esc(clean)}${trail}`;}); + t=t.replace(/\x00C(\d+)\x00/g,(_,i)=>_code_stash[+i]); + // Stash [label](url) links before autolink so the URL in href= is not re-linked + const _link_stash=[]; + t=t.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,lb,u)=>{_link_stash.push(`${esc(lb)}`);return `\x00L${_link_stash.length-1}\x00`;}); + t=t.replace(/(https?:\/\/[^\s<>"')\]]+)/g,(url)=>{const trail=url.match(/[.,;:!?)]$/)?url.slice(-1):'';const clean=trail?url.slice(0,-1):url;return `${esc(clean)}${trail}`;}); + t=t.replace(/\x00L(\d+)\x00/g,(_,i)=>_link_stash[+i]); // Escape any plain text that isn't already wrapped in a tag we produced // by escaping bare < > that aren't part of our own tags const SAFE_INLINE=/^<\/?(strong|em|code|a)([\s>]|$)/i; t=t.replace(/<\/?[a-z][^>]*>/gi,tag=>SAFE_INLINE.test(tag)?tag:esc(tag)); return t; } + // Stash tags from the backtick pass above so the outer bold/italic + // regexes don't esc() their content (e.g. **`code`** → code) + const _ob_stash=[]; + s=s.replace(/([^<]*<\/code>)/g,m=>{_ob_stash.push(m);return `\x00O${_ob_stash.length-1}\x00`;}); s=s.replace(/\*\*\*(.+?)\*\*\*/g,(_,t)=>`${esc(t)}`); s=s.replace(/\*\*(.+?)\*\*/g,(_,t)=>`${esc(t)}`); s=s.replace(/\*([^*\n]+)\*/g,(_,t)=>`${esc(t)}`); + s=s.replace(/\x00O(\d+)\x00/g,(_,i)=>_ob_stash[+i]); s=s.replace(/^### (.+)$/gm,(_,t)=>`

${inlineMd(t)}

`).replace(/^## (.+)$/gm,(_,t)=>`

${inlineMd(t)}

`).replace(/^# (.+)$/gm,(_,t)=>`

${inlineMd(t)}

`); s=s.replace(/^---+$/gm,'
'); s=s.replace(/^> (.+)$/gm,(_,t)=>`
${inlineMd(t)}
`); @@ -498,8 +509,9 @@ function renderMd(raw){ } return html+''; }); - s=s.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,label,url)=>`${esc(label)}`); // Tables: | col | col | header row followed by | --- | --- | separator then data rows + // NOTE: table pass runs BEFORE outer link pass so [label](url) in table cells + // is handled by inlineMd() only — prevents double-linking. s=s.replace(/((?:^\|.+\|\n?)+)/gm,block=>{ const rows=block.trim().split('\n').filter(r=>r.trim()); if(rows.length<2)return block; @@ -511,19 +523,30 @@ function renderMd(raw){ const body=rows.slice(2).map(r=>`${parseRow(r)}`).join(''); return `${header}${body}
`; }); + // Outer link pass for labeled links in plain paragraphs (outside table cells). + // Runs AFTER the table pass so table cells are processed by inlineMd() only. + // Stash existing tags first to avoid re-linking already-linked URLs. + const _a_stash=[]; + s=s.replace(/(]*>[\s\S]*?<\/a>)/g,m=>{_a_stash.push(m);return `\x00A${_a_stash.length-1}\x00`;}); + s=s.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,label,url)=>`${esc(label)}`); + s=s.replace(/\x00A(\d+)\x00/g,(_,i)=>_a_stash[+i]); // Escape any remaining HTML tags that are NOT from our own markdown output. // Our pipeline only emits: ,,,
,,