Fix settled rendering for file markdown links

This commit is contained in:
dobby-d-elf
2026-05-18 22:19:07 -06:00
parent 71c70352c1
commit 11e1e9a342
4 changed files with 71 additions and 11 deletions

View File

@@ -966,19 +966,31 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(assistantBody&&!fade){_sanitizeSmdLinks(assistantBody);}
}
// Allowed URL schemes for anchors and images rendered from agent-streamed markdown.
// Matches the effective allowlist of renderMd() (http/https via regex + relative).
const _SMD_SAFE_URL_RE=/^(?:https?:|mailto:|tel:|\/|#|\?|\.)/i;
// Raw file:// anchors are rewritten to /api/media before the user can click them.
const _SMD_SAFE_URL_RE=/^(?:https?:|mailto:|tel:|\/|#|\?|\.|api)/i;
const _SMD_SAFE_IMG_URL_RE=/^(?:https?:|mailto:|tel:|\/|#|\?|\.)/i;
function _smdFileHref(raw){
const href=String(raw||'');
if(!/^file:\/\//i.test(href)) return href;
try{
const path=decodeURIComponent(href.replace(/^file:\/\//i,''));
return 'api/media?path='+encodeURIComponent(path)+'&inline=1';
}catch(_){
return 'api/media?path='+encodeURIComponent(href.replace(/^file:\/\//i,''))+'&inline=1';
}
}
function _sanitizeSmdLinks(root){
if(!root||!root.querySelectorAll) return;
const _a=root.querySelectorAll('a[href]');
for(let i=0;i<_a.length;i++){
const n=_a[i],v=n.getAttribute('href')||'';
if(/^file:\/\//i.test(v)){n.setAttribute('href',_smdFileHref(v));continue;}
if(!_SMD_SAFE_URL_RE.test(v)){n.removeAttribute('href');n.setAttribute('data-blocked-scheme','1');}
}
const _im=root.querySelectorAll('img[src]');
for(let i=0;i<_im.length;i++){
const n=_im[i],v=n.getAttribute('src')||'';
if(!_SMD_SAFE_URL_RE.test(v)){n.removeAttribute('src');n.setAttribute('data-blocked-scheme','1');}
if(!_SMD_SAFE_IMG_URL_RE.test(v)){n.removeAttribute('src');n.setAttribute('data-blocked-scheme','1');}
}
}
@@ -1082,7 +1094,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
renderer.set_attr=(data,attr,value)=>{
const isHref=window.smd&&attr===window.smd.HREF;
const isSrc=window.smd&&attr===window.smd.SRC;
if((isHref||isSrc)&&!_SMD_SAFE_URL_RE.test(String(value||''))){
const safeUrl=isSrc?_SMD_SAFE_IMG_URL_RE:_SMD_SAFE_URL_RE;
if(isHref&&/^file:\/\//i.test(String(value||''))){
baseSetAttr(data,attr,_smdFileHref(value));
return;
}
if((isHref||isSrc)&&!safeUrl.test(String(value||''))){
const node=data&&data.nodes&&data.nodes[data.index];
if(node&&node.setAttribute) node.setAttribute('data-blocked-scheme','1');
return;

View File

@@ -2756,7 +2756,7 @@ function renderMd(raw){
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?|file):\/\/[^\)]+)\)/g,(_,lb,u)=>{_link_stash.push(`<a href="${_markdownHref(u)}" 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]);
t=t.replace(/\x00G(\d+)\x00/g,(_,i)=>_img_stash[+i]);
@@ -2849,7 +2849,7 @@ function renderMd(raw){
// 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(/\[([^\]]+)\]\(((?:https?|file):\/\/[^\)]+)\)/g,(_,label,url)=>`<a href="${_markdownHref(url)}" target="_blank" rel="noopener">${esc(label)}</a>`);
s=s.replace(/\x00A(\d+)\x00/g,(_,i)=>_a_stash[+i]);
// Restore raw <pre> only after markdown rewrites so literal preformatted
// content stays placeholder-protected, then let the sanitizer normalize tags.
@@ -2865,6 +2865,18 @@ function renderMd(raw){
function _safeAttrValue(v){
return String(v||'').replace(/&quot;/g,'"').replace(/&#39;/g,"'").replace(/&amp;/g,'&').trim();
}
function _markdownHref(raw){
const href=String(raw||'').replace(/"/g,'%22');
if(/^file:\/\//i.test(href)){
try{
const path=decodeURIComponent(href.replace(/^file:\/\//i,''));
return 'api/media?path='+encodeURIComponent(path)+'&inline=1';
}catch(_){
return 'api/media?path='+encodeURIComponent(href.replace(/^file:\/\//i,''))+'&inline=1';
}
}
return href;
}
function _isSafeUrl(v, img){
const raw=_safeAttrValue(v);
const compact=raw.replace(/[\u0000-\u001f\u007f\s]+/g,'').toLowerCase();

View File

@@ -33,6 +33,12 @@ def _make_link(url, label):
return f'<a href="{url}" target="_blank" rel="noopener">{esc(label)}</a>'
def markdown_href(url):
if url.lower().startswith("file://"):
return "api/media?path=" + __import__("urllib.parse").parse.quote(url[7:], safe="") + "&inline=1"
return url
# Minimal Python mirror of the FIXED renderMd() — enough to test link behaviour.
# Mirrors the stash-based approach introduced by the fix.
@@ -48,9 +54,9 @@ def render_links_only(text):
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>')
link_stash.append(f'<a href="{markdown_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)
s = re.sub(r'\[([^\]]+)\]\(((?:https?|file)://[^\)]+)\)', stash_link, s)
# Autolink bare URLs (should NOT match inside already-stashed placeholders)
def autolink(m):
@@ -83,9 +89,9 @@ def render_table_with_links(md):
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>')
stash.append(f'<a href="{markdown_href(u)}" target="_blank" rel="noopener">{esc(lb)}</a>')
return f'\x00L{len(stash)-1}\x00'
t = re.sub(r'\[([^\]]+)\]\((https?://[^\)]+)\)', stash_fn, t)
t = re.sub(r'\[([^\]]+)\]\(((?:https?|file)://[^\)]+)\)', stash_fn, t)
# autolink remaining bare URLs
def autolink(m):
url = m.group(1)
@@ -170,6 +176,17 @@ def test_labeled_link_renders_as_single_anchor():
assert f']({url})' not in result
def test_labeled_file_link_renders_as_single_anchor():
"""A labeled local file link must survive the settled render path."""
url = 'file:///Users/agent/Documents/Obsidian/Meal-Prep/halal-cart.html'
md = f'[Halal Cart Chicken]({url})'
result = render_links_only(md)
assert result.count('<a ') == 1, f"Expected 1 <a> tag, got: {result}"
assert 'href="api/media?path=%2FUsers%2Fagent%2FDocuments%2FObsidian%2FMeal-Prep%2Fhalal-cart.html&inline=1"' in result
assert 'Halal Cart Chicken' in result
assert '[Halal Cart Chicken]' 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'
@@ -261,6 +278,13 @@ def test_js_source_sanitizes_quotes_in_href():
"URL placed in href should have double-quotes percent-encoded via .replace to %22"
)
def test_js_source_rewrites_file_links_to_media_endpoint():
"""Browser pages cannot reliably navigate to file://, so renderMd must use /api/media."""
assert "function _markdownHref" in UI_JS
assert "api/media?path=" in UI_JS
assert "file:\\/\\/" in UI_JS
# ── Code-inside-bold tests (pre-existing bug, fixed in same PR) ───────────────
def test_js_inlinemd_stashes_code_before_bold():

View File

@@ -554,7 +554,8 @@ class TestSmdUrlSchemeSanitization:
def test_sanitize_uses_scheme_allowlist(self):
# The allowlist regex must permit the safe schemes that the legacy
# renderMd path emitted (http/https + relative/anchor paths + mailto/tel)
# and reject everything else — including javascript:, data:, vbscript:, file:.
# and reject dangerous executable schemes. file:// anchors are rewritten
# to api/media before click time rather than allowed through raw.
assert "_SMD_SAFE_URL_RE" in MESSAGES_JS, (
"Expected a _SMD_SAFE_URL_RE regex defining the safe-scheme allowlist"
)
@@ -565,11 +566,17 @@ class TestSmdUrlSchemeSanitization:
pattern = m.group(1)
# Must mention https? and must NOT mention javascript/vbscript/data
assert "https?" in pattern, "allowlist must permit https?:"
assert "file:" not in pattern, "raw file: anchors must be rewritten, not allowed through"
assert "api" in MESSAGES_JS, "allowlist must permit rewritten api/media anchors"
for bad in ("javascript", "vbscript", "data:"):
assert bad not in pattern, (
f"allowlist must NOT mention {bad!r} — schemes are denied by default"
)
def test_file_anchor_rewrite_helper_exists(self):
assert "_smdFileHref" in MESSAGES_JS
assert "api/media?path=" in MESSAGES_JS
def test_sanitize_called_after_smd_write(self):
# _smdWrite must invoke _sanitizeSmdLinks on assistantBody after feeding the parser,
# so anchors/images created mid-stream get their javascript:/data:/vbscript: