Merge pull request #478 from nesquena/release/v0.50.43

release: v0.50.43 — markdown rendering fixes + KaTeX CSP
This commit is contained in:
nesquena-hermes
2026-04-14 14:23:38 -07:00
committed by GitHub
8 changed files with 412 additions and 17 deletions

View File

@@ -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 `<a href="...">`, then the bare-URL autolink pass re-matched the URL sitting inside `href="..."` and wrapped it in a second `<a>` tag. Fixed with three stash/restore layers: `\x00L` (inlineMd labeled links), `\x00A` (existing `<a>` tags before outer link pass), `\x00B` (existing `<a>` tags before autolink pass).
2. **`esc()` on `href` values corrupts query strings** — `esc()` is HTML-entity encoding; applying it to URLs converted `&``&amp;` 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 `&lt;code&gt;`** — `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)

View File

@@ -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`
---

View File

@@ -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(

View File

@@ -536,7 +536,7 @@
<div class="settings-section-title">System</div>
<div class="settings-section-meta">Instance version and access controls.</div>
</div>
<span class="settings-version-badge">v0.50.42</span>
<span class="settings-version-badge">v0.50.43</span>
</div>
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>

View File

@@ -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(`<code>${esc(x)}</code>`);return `\x00C${_code_stash.length-1}\x00`;});
t=t.replace(/\*\*\*(.+?)\*\*\*/g,(_,x)=>`<strong><em>${esc(x)}</em></strong>`);
t=t.replace(/\*\*(.+?)\*\*/g,(_,x)=>`<strong>${esc(x)}</strong>`);
t=t.replace(/\*([^*\n]+)\*/g,(_,x)=>`<em>${esc(x)}</em>`);
t=t.replace(/`([^`\n]+)`/g,(_,x)=>`<code>${esc(x)}</code>`);
t=t.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,lb,u)=>`<a href="${esc(u)}" target="_blank" rel="noopener">${esc(lb)}</a>`);
t=t.replace(/(https?:\/\/[^\s<>"')\]]+)/g,(url)=>{const trail=url.match(/[.,;:!?)]$/)?url.slice(-1):'';const clean=trail?url.slice(0,-1):url;return `<a href="${esc(clean)}" target="_blank" rel="noopener">${esc(clean)}</a>${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(`<a href="${u.replace(/"/g,'%22')}" target="_blank" rel="noopener">${esc(lb)}</a>`);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 `<a href="${clean}" target="_blank" rel="noopener">${esc(clean)}</a>${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 <code> tags from the backtick pass above so the outer bold/italic
// regexes don't esc() their content (e.g. **`code`** → <strong><code>code</code></strong>)
const _ob_stash=[];
s=s.replace(/(<code>[^<]*<\/code>)/g,m=>{_ob_stash.push(m);return `\x00O${_ob_stash.length-1}\x00`;});
s=s.replace(/\*\*\*(.+?)\*\*\*/g,(_,t)=>`<strong><em>${esc(t)}</em></strong>`);
s=s.replace(/\*\*(.+?)\*\*/g,(_,t)=>`<strong>${esc(t)}</strong>`);
s=s.replace(/\*([^*\n]+)\*/g,(_,t)=>`<em>${esc(t)}</em>`);
s=s.replace(/\x00O(\d+)\x00/g,(_,i)=>_ob_stash[+i]);
s=s.replace(/^### (.+)$/gm,(_,t)=>`<h3>${inlineMd(t)}</h3>`).replace(/^## (.+)$/gm,(_,t)=>`<h2>${inlineMd(t)}</h2>`).replace(/^# (.+)$/gm,(_,t)=>`<h1>${inlineMd(t)}</h1>`);
s=s.replace(/^---+$/gm,'<hr>');
s=s.replace(/^> (.+)$/gm,(_,t)=>`<blockquote>${inlineMd(t)}</blockquote>`);
@@ -498,8 +509,9 @@ function renderMd(raw){
}
return html+'</ol>';
});
s=s.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,label,url)=>`<a href="${esc(url)}" target="_blank" rel="noopener">${esc(label)}</a>`);
// 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=>`<tr>${parseRow(r)}</tr>`).join('');
return `<table><thead>${header}</thead><tbody>${body}</tbody></table>`;
});
// 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 <a> tags first to avoid re-linking already-linked URLs.
const _a_stash=[];
s=s.replace(/(<a\b[^>]*>[\s\S]*?<\/a>)/g,m=>{_a_stash.push(m);return `\x00A${_a_stash.length-1}\x00`;});
s=s.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,label,url)=>`<a href="${url.replace(/"/g,'%22')}" target="_blank" rel="noopener">${esc(label)}</a>`);
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: <strong>,<em>,<code>,<pre>,<h1-6>,<ul>,<ol>,<li>,
// <table>,<thead>,<tbody>,<tr>,<th>,<td>,<hr>,<blockquote>,<p>,<br>,<a>,
// <div class="..."> (mermaid/pre-header). Everything else is untrusted input.
const SAFE_TAGS=/^<\/?(strong|em|code|pre|h[1-6]|ul|ol|li|table|thead|tbody|tr|th|td|hr|blockquote|p|br|a|div|span)([\s>]|$)/i;
s=s.replace(/<\/?[a-z][^>]*>/gi,tag=>SAFE_TAGS.test(tag)?tag:esc(tag));
// Autolink: convert plain URLs to clickable links (not inside existing <a> tags, not in code)
s=s.replace(/(https?:\/\/[^\s<>"')\]]+)/g,(url)=>{
// Autolink: convert plain URLs to clickable links.
// Stash existing <a> tags first so we never re-link a URL already inside href="...".
const _al_stash=[];
s=s.replace(/(<a\b[^>]*>[\s\S]*?<\/a>)/g,m=>{_al_stash.push(m);return `\x00B${_al_stash.length-1}\x00`;});
s=s.replace(/(https?:\/\/[^\s<>"'\)\]]+)/g,(url)=>{
// Strip trailing punctuation that was likely not part of the URL
const trail=url.match(/[.,;:!?)]$/)?url.slice(-1):'';
const clean=trail?url.slice(0,-1):url;
return `<a href="${esc(clean)}" target="_blank" rel="noopener">${esc(clean)}</a>${trail}`;
return `<a href="${clean}" target="_blank" rel="noopener">${esc(clean)}</a>${trail}`;
});
s=s.replace(/\x00B(\d+)\x00/g,(_,i)=>_al_stash[+i]);
// Restore math stash → katex placeholder spans/divs
// These will be rendered by renderKatexBlocks() after DOM insertion
s=s.replace(/\x00M(\d+)\x00/g,(_,i)=>{

View File

@@ -38,15 +38,23 @@ def test_autolink_regex_in_rendermd():
def test_autolink_uses_esc_for_xss_safety():
"""The autolink code must use esc() to escape URLs, preventing XSS."""
"""The autolink code must use esc() to escape the display text of URLs, preventing XSS.
Note: esc() is intentionally NOT applied to the href value (that would corrupt & in
query strings). It IS applied to the visible link text (esc(clean)) to prevent XSS."""
content = read_ui_js()
# Find the autolink section (between the SAFE_TAGS pass and paragraph wrap)
autolink_idx = content.find('// Autolink: convert plain URLs')
assert autolink_idx != -1, "Autolink comment not found in ui.js"
# Extract the autolink block (next ~300 chars after the comment)
autolink_block = content[autolink_idx:autolink_idx + 400]
# Extract the autolink block (next ~600 chars after the comment)
autolink_block = content[autolink_idx:autolink_idx + 600]
# esc() must be used on the visible link text to prevent XSS
assert 'esc(clean)' in autolink_block, (
"Autolink block should use esc(clean) for XSS-safe URL escaping, but it was not found."
"Autolink block should use esc(clean) for the link display text (XSS safety), "
"but it was not found."
)
# esc() must NOT be used on the href value — that breaks URLs containing &
assert 'href="${esc(clean)}"' not in autolink_block, (
"Autolink block should use href=\"${clean}\" (not esc'd) to preserve & in query strings."
)
@@ -87,12 +95,13 @@ def test_autolink_target_blank_and_rel():
content = read_ui_js()
autolink_idx = content.find('// Autolink: convert plain URLs')
assert autolink_idx != -1, "Autolink comment not found"
autolink_block = content[autolink_idx:autolink_idx + 400]
# Use a larger window to account for the stash preamble added by the fix
autolink_block = content[autolink_idx:autolink_idx + 700]
assert 'target="_blank"' in autolink_block, (
"Autolinked URLs should have target=\"_blank\""
'Autolinked URLs should have target="_blank"'
)
assert 'rel="noopener"' in autolink_block, (
"Autolinked URLs should have rel=\"noopener\" for security"
'Autolinked URLs should have rel="noopener" for security'
)

313
tests/test_issue470.py Normal file
View File

@@ -0,0 +1,313 @@
"""
Tests for issue #470 — markdown link rendering bugs in renderMd():
1. Double-linking: [label](url) converted to <a>, then autolink re-matches
the URL inside href="..." and wraps it in a second <a>.
2. esc() applied to URLs in href attributes turns & → &amp;, breaking
URLs with query strings and producing &amp; in displayed link text.
3. Same double-linking bug inside table cells via inlineMd().
These tests verify the fixes by asserting against the rendered HTML that
ui.js serves, using a live server request to evaluate the actual JS output
indirectly (via checking ui.js source for the fixed patterns) AND by
running a lightweight Python mirror of the fixed renderMd logic.
Strategy: verify the fix is present in the JS source, then test the
expected rendering behaviour through the Python mirror.
"""
import pathlib
import re
import html as _html
REPO_ROOT = pathlib.Path(__file__).parent.parent
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text()
# ── Helpers ──────────────────────────────────────────────────────────────────
def esc(s):
return _html.escape(str(s), quote=True)
def _make_link(url, label):
"""Expected output for a [label](url) link after fix: href is NOT esc()-ed."""
return f'<a href="{url}" target="_blank" rel="noopener">{esc(label)}</a>'
# Minimal Python mirror of the FIXED renderMd() — enough to test link behaviour.
# Mirrors the stash-based approach introduced by the fix.
def render_links_only(text):
"""
Simplified render that only applies the link-related passes from the fixed
renderMd(): [label](url) conversion + autolink, with the stash protection.
Sufficient for testing that links render correctly without double-linking.
"""
s = text
# Stash [label](url) links (fix: store href as raw URL, not esc(url))
link_stash = []
def stash_link(m):
label, url = m.group(1), m.group(2)
link_stash.append(f'<a href="{url}" target="_blank" rel="noopener">{esc(label)}</a>')
return f'\x00L{len(link_stash)-1}\x00'
s = re.sub(r'\[([^\]]+)\]\((https?://[^\)]+)\)', stash_link, s)
# Autolink bare URLs (should NOT match inside already-stashed placeholders)
def autolink(m):
url = m.group(1)
trail = url[-1] if url[-1] in '.,;:!?)' else ''
clean = url[:-1] if trail else url
return f'<a href="{clean}" target="_blank" rel="noopener">{esc(clean)}</a>{trail}'
s = re.sub(r'(https?://[^\s<>"\')\]]+)', autolink, s)
# Restore stashed links
s = re.sub(r'\x00L(\d+)\x00', lambda m: link_stash[int(m.group(1))], s)
return s
def render_table_with_links(md):
"""
Render a markdown table that may contain [label](url) cells.
Mirrors the fixed inlineMd() + table rendering.
"""
lines = md.strip().split('\n')
if len(lines) < 2:
return md
def is_sep(r):
return bool(re.match(r'^\|[\s|:-]+\|$', r.strip()))
if not is_sep(lines[1]):
return md
def inline_md_fixed(t):
"""Fixed inlineMd: stash links before autolink."""
stash = []
def stash_fn(m):
lb, u = m.group(1), m.group(2)
stash.append(f'<a href="{u}" target="_blank" rel="noopener">{esc(lb)}</a>')
return f'\x00L{len(stash)-1}\x00'
t = re.sub(r'\[([^\]]+)\]\((https?://[^\)]+)\)', stash_fn, t)
# autolink remaining bare URLs
def autolink(m):
url = m.group(1)
trail = url[-1] if url[-1] in '.,;:!?)' else ''
clean = url[:-1] if trail else url
return f'<a href="{clean}" target="_blank" rel="noopener">{esc(clean)}</a>{trail}'
t = re.sub(r'(https?://[^\s<>"\')\]]+)', autolink, t)
t = re.sub(r'\x00L(\d+)\x00', lambda m: stash[int(m.group(1))], t)
return t
def parse_row(r):
cells = r.strip().lstrip('|').rstrip('|').split('|')
return ''.join(f'<td>{inline_md_fixed(c.strip())}</td>' for c in cells)
def parse_header(r):
cells = r.strip().lstrip('|').rstrip('|').split('|')
return ''.join(f'<th>{inline_md_fixed(c.strip())}</th>' for c in cells)
header = f'<tr>{parse_header(lines[0])}</tr>'
body = ''.join(f'<tr>{parse_row(r)}</tr>' for r in lines[2:])
return f'<table><thead>{header}</thead><tbody>{body}</tbody></table>'
# ── Source-level checks (verify fix is in the JS) ─────────────────────────────
def test_inlinemd_uses_link_stash():
"""Fixed inlineMd() must stash [label](url) links before autolink runs."""
assert '_link_stash' in UI_JS, (
"inlineMd() should use _link_stash to prevent double-linking"
)
def test_inlinemd_no_esc_on_href():
"""Fixed inlineMd() must not call esc() on the URL in href."""
# The old broken pattern had esc(u) inside the href
assert 'href="${esc(u)}"' not in UI_JS, (
"inlineMd() should not call esc() on href URL — it breaks & in query strings"
)
def test_outer_link_pass_uses_a_stash():
"""Fixed outer link pass must stash existing <a> tags before running."""
assert '_a_stash' in UI_JS, (
"Outer [label](url) pass should stash existing <a> tags to prevent autolink re-matching"
)
def test_autolink_pass_uses_al_stash():
"""Fixed autolink pass must stash existing <a> tags before running."""
assert '_al_stash' in UI_JS, (
"Autolink pass should stash existing <a> tags to prevent double-linking"
)
def test_autolink_no_esc_on_href():
"""Fixed autolink pass must not call esc() on href URL."""
idx = UI_JS.find('// Autolink: convert plain URLs to clickable links.')
assert idx != -1, "New autolink comment not found"
autolink_section = UI_JS[idx:idx+600]
# The return line should have href="${clean}" (JS template literal, no esc call)
assert 'href="${clean}"' in autolink_section, (
'Autolink should use href="${clean}" not href="${esc(clean)}"'
)
assert 'href="${esc(clean)}"' not in autolink_section, (
"Autolink should not esc() the URL in href"
)
# ── Behaviour tests (Python mirror of fixed renderMd) ─────────────────────────
def test_labeled_link_renders_as_single_anchor():
"""[#461](https://github.com/.../461) must produce exactly one <a> tag."""
url = 'https://github.com/nesquena/hermes-webui/issues/461'
md = f'[#461]({url})'
result = render_links_only(md)
assert result.count('<a ') == 1, f"Expected 1 <a> tag, got: {result}"
assert result.count('</a>') == 1
assert f'href="{url}"' in result
assert '#461' in result
# Must not contain the raw brackets
assert '[#461]' not in result
assert f']({url})' not in result
def test_href_not_html_escaped():
"""URLs with & must appear as literal & in href, not &amp;."""
url = 'https://example.com/search?q=foo&bar=baz'
md = f'[Search]({url})'
result = render_links_only(md)
assert f'href="{url}"' in result, (
f"& in URL should not be escaped to &amp; in href. Got: {result}"
)
assert '&amp;' not in result
def test_bare_url_not_double_linked():
"""A bare https:// URL must produce exactly one <a> tag."""
url = 'https://github.com/nesquena/hermes-webui/issues/461'
result = render_links_only(url)
assert result.count('<a ') == 1, f"Expected 1 <a> tag, got: {result}"
assert result.count('</a>') == 1
def test_labeled_link_in_table_cell_single_anchor():
"""[#461](url) inside a markdown table cell must produce exactly one <a> tag."""
url = 'https://github.com/nesquena/hermes-webui/issues/461'
md = f'| Issue | Title |\n|---|---|\n| [#461]({url}) | Reasoning effort |'
result = render_table_with_links(md)
assert result.count('<a ') == 1, f"Expected 1 <a> in table, got: {result}"
assert f'href="{url}"' in result
assert '#461' in result
# No raw brackets should appear in output
assert '[#461]' not in result
def test_multiple_links_in_table_no_double_linking():
"""Multiple [label](url) links in a table must each produce exactly one <a>."""
urls = [
'https://github.com/nesquena/hermes-webui/issues/461',
'https://github.com/nesquena/hermes-webui/issues/462',
'https://github.com/nesquena/hermes-webui/issues/463',
]
rows = '\n'.join(f'| [#{461+i}]({url}) | Title {i} |' for i, url in enumerate(urls))
md = f'| Issue | Title |\n|---|---|\n{rows}'
result = render_table_with_links(md)
assert result.count('<a ') == 3, f"Expected 3 <a> tags, got {result.count('<a ')}:\n{result}"
assert result.count('</a>') == 3
for url in urls:
assert f'href="{url}"' in result
def test_link_label_is_escaped():
"""The label text (not the URL) must still be HTML-escaped."""
url = 'https://example.com'
md = f'[Click <here>]({url})'
result = render_links_only(md)
assert '&lt;here&gt;' in result, "Label text should be HTML-escaped"
assert '<here>' not in result
def test_link_not_broken_by_prior_autolink():
"""A [label](url) followed by a bare URL must each produce one clean <a>."""
url1 = 'https://github.com/issues/461'
url2 = 'https://github.com/issues/462'
md = f'See [#461]({url1}) and also {url2}'
result = render_links_only(md)
assert result.count('<a ') == 2, f"Expected 2 links, got: {result}"
assert f'href="{url1}"' in result
assert f'href="{url2}"' in result
assert '#461' in result
def test_href_quote_sanitized():
"""A URL containing a double-quote must have it percent-encoded in href to prevent attribute breakout."""
# This would break out of href="..." and inject an event handler without the fix
url = 'https://evil.com" onmouseover="alert(1)'
# The [label](url) regex captures up to the closing ), so we test via the render helper
# by constructing a URL that contains a literal quote character
safe_url = 'https://example.com/path"with"quotes'
result = render_links_only(f'[click]({safe_url})')
# The href must not contain a raw unencoded double-quote
href_start = result.find('href="') + 6
href_end = result.find('"', href_start)
href_val = result[href_start:href_end]
assert '"' not in href_val, (
f"href value must not contain unencoded double-quote. Got href: {href_val}"
)
def test_js_source_sanitizes_quotes_in_href():
"""JS source must apply quote percent-encoding to URLs before placing in href."""
# Both the inlineMd stash and outer link pass must sanitize quotes
assert "%22" in UI_JS, (
"URL placed in href should have double-quotes percent-encoded via .replace to %22"
)
# ── Code-inside-bold tests (pre-existing bug, fixed in same PR) ───────────────
def test_js_inlinemd_stashes_code_before_bold():
"""Fixed inlineMd() must stash backtick code spans before bold/italic processing."""
assert '_code_stash' in UI_JS, (
"inlineMd() should use _code_stash to protect backtick spans from bold/italic esc()"
)
def test_code_inside_bold_renders_correctly():
"""Inline code inside bold text must render as <strong><code>...</code></strong>,
not with escaped &lt;code&gt; tags visible on screen."""
# This was the pre-existing bug: **`esc()`** → <strong>&lt;code&gt;esc()&lt;/code&gt;</strong>
text = '**`esc()` on `href`**: breaks URLs'
# Simulate the fixed inlineMd()
code_stash = []
t = text
t = re.sub(r'`([^`\n]+)`',
lambda m: (code_stash.append(f'<code>{esc(m.group(1))}</code>') or f'\x00C{len(code_stash)-1}\x00'), t)
t = re.sub(r'\*\*(.+?)\*\*', lambda m: f'<strong>{esc(m.group(1))}</strong>', t)
t = re.sub(r'\x00C(\d+)\x00', lambda m: code_stash[int(m.group(1))], t)
assert '&lt;code&gt;' not in t, (
f"Code tags should not be HTML-escaped inside bold. Got: {t}"
)
assert '<code>esc()</code>' in t, (
f"Code tags should render as <code> elements inside bold. Got: {t}"
)
assert '<strong>' in t, "Bold should still render"
def test_code_and_bold_mixed_no_escaping():
"""Bold text containing multiple backtick spans must render all code tags correctly."""
cases = [
('**`esc()` on `href`**', '<strong>', '<code>esc()</code>', '<code>href</code>'),
('***`code` in bold-italic***', '<strong>', '<code>code</code>'),
('`code` then **bold**', '<code>code</code>', '<strong>bold</strong>'),
]
for args in cases:
text = args[0]
expected_fragments = args[1:]
code_stash = []
t = text
t = re.sub(r'`([^`\n]+)`',
lambda m: (code_stash.append(f'<code>{esc(m.group(1))}</code>') or f'\x00C{len(code_stash)-1}\x00'), t)
t = re.sub(r'\*\*\*(.+?)\*\*\*', lambda m: f'<strong><em>{esc(m.group(1))}</em></strong>', t)
t = re.sub(r'\*\*(.+?)\*\*', lambda m: f'<strong>{esc(m.group(1))}</strong>', t)
t = re.sub(r'\x00C(\d+)\x00', lambda m: code_stash[int(m.group(1))], t)
assert '&lt;code&gt;' not in t, f"Escaped code tag in: {text!r}{t}"
for frag in expected_fragments:
assert frag in t, f"Expected {frag!r} in output of {text!r}, got: {t}"

26
tests/test_issue477.py Normal file
View File

@@ -0,0 +1,26 @@
"""Tests for fix #477: KaTeX font-src CSP fix."""
import pathlib
REPO = pathlib.Path(__file__).parent.parent
HELPERS_PY = (REPO / "api" / "helpers.py").read_text(encoding="utf-8")
def test_font_src_allows_jsdelivr():
"""font-src must include cdn.jsdelivr.net for KaTeX fonts."""
assert "font-src 'self' data: https://cdn.jsdelivr.net" in HELPERS_PY, (
"api/helpers.py CSP must allow cdn.jsdelivr.net in font-src "
"so KaTeX math rendering fonts load without console errors."
)
def test_font_src_still_allows_self_and_data():
"""font-src must still allow self and data: (used by other font assets)."""
assert "'self'" in HELPERS_PY.split("font-src")[1].split(";")[0]
assert "data:" in HELPERS_PY.split("font-src")[1].split(";")[0]
def test_script_src_already_allows_jsdelivr():
"""script-src already allows cdn.jsdelivr.net — font-src should too."""
assert "https://cdn.jsdelivr.net" in HELPERS_PY.split("font-src")[0], (
"script-src should already allow cdn.jsdelivr.net (KaTeX JS)"
)