Fix settled rendering for file markdown links
This commit is contained in:
@@ -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;
|
||||
|
||||
16
static/ui.js
16
static/ui.js
@@ -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(/"/g,'"').replace(/'/g,"'").replace(/&/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();
|
||||
|
||||
@@ -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 &."""
|
||||
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():
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user