diff --git a/static/messages.js b/static/messages.js index c72e8111..8969fe44 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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; diff --git a/static/ui.js b/static/ui.js index 5ab149f7..154ed641 100644 --- a/static/ui.js +++ b/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(`${esc(lb)}`);return `\x00L${_link_stash.length-1}\x00`;}); + t=t.replace(/\[([^\]]+)\]\(((?:https?|file):\/\/[^\)]+)\)/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]); t=t.replace(/\x00G(\d+)\x00/g,(_,i)=>_img_stash[+i]); @@ -2849,7 +2849,7 @@ function renderMd(raw){ // 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(/\[([^\]]+)\]\(((?:https?|file):\/\/[^\)]+)\)/g,(_,label,url)=>`${esc(label)}`); s=s.replace(/\x00A(\d+)\x00/g,(_,i)=>_a_stash[+i]); // Restore raw
 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();
diff --git a/tests/test_issue470.py b/tests/test_issue470.py
index b6f156d7..78893075 100644
--- a/tests/test_issue470.py
+++ b/tests/test_issue470.py
@@ -33,6 +33,12 @@ def _make_link(url, label):
     return f'{esc(label)}'
 
 
+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'{esc(label)}')
+        link_stash.append(f'{esc(label)}')
         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'{esc(lb)}')
+            stash.append(f'{esc(lb)}')
             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(' 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():
diff --git a/tests/test_streaming_markdown.py b/tests/test_streaming_markdown.py
index 777e0428..4506c32e 100644
--- a/tests/test_streaming_markdown.py
+++ b/tests/test_streaming_markdown.py
@@ -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: