diff --git a/CHANGELOG.md b/CHANGELOG.md index 9feb92f1..8d7f61e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ ## [Unreleased] +## [v0.51.183] — 2026-05-31 — Release FC (stage-batchC — inline file:// media artifacts + /api/media state-file confinement) + +### Fixed +- Render assistant-emitted `file://` image artifact links inline through the authenticated `/api/media` route, so browser clients can view generated local images instead of seeing unusable server-local file paths. Only bare (line-start or whitespace-delimited) `file://` URLs are rewritten — `[label](file://...)` markdown anchors keep the normal link path (#3219). + +### Security +- `/api/media` now hard-denies WebUI state and secret/config files even when they fall under an allowed root (the WebUI state dir, `settings.json`, `state.db`, `auth.json`, `auth.lock`, `config.yaml`, `.env`, signing/PBKDF2 keys, the `sessions`/`memories`/`profiles` state subdirs). Previously the whole Hermes home was an allowed root, so an authenticated session viewing attacker-influenced agent output that emitted a `file://`/`MEDIA:` link to such a file could fetch it. Hardened the boundary at the route for every entry path (bare `file://`, markdown anchors, and `MEDIA:` tokens), closing #3234. + ## [v0.51.182] — 2026-05-31 — Release FB (stage-batchB2 — headless browser smoke gate + consolidated client-disconnect handling) ### Added diff --git a/static/ui.js b/static/ui.js index 4ca46c0f..94aeabe5 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3014,6 +3014,16 @@ function renderMd(raw){ media_stash.push(raw_ref); return '\x00D'+(media_stash.length-1)+'\x00'; }); + // Some gateway/tool surfaces still emit file:// links for local artifacts + // instead of MEDIA: tokens. Browser clients cannot open the server's + // filesystem directly, so treat bare file:// URLs as media artifacts and + // route them through /api/media. This intentionally runs only for bare URLs + // (line-start or whitespace-delimited), not inside markdown links, so normal + // [label](file://...) anchors continue to use the link path below. + s=s.replace(/(^|\s)(file:\/\/[^\s<>"')\]]+)/g,(_,lead,raw_ref)=>{ + media_stash.push(raw_ref); + return lead+'\x00D'+(media_stash.length-1)+'\x00'; + }); // ── End MEDIA stash ───────────────────────────────────────────────────────── // Pre-pass: decode HTML entities first so markdown processing works correctly. // This prevents double-escaping when LLM outputs entities like < > & @@ -3449,7 +3459,7 @@ function renderMd(raw){ s=s.replace(/\x00E(\d+)\x00/g,(_,i)=>_pre_stash[+i]); // ── Restore MEDIA stash → inline images or download links ───────────────── s=s.replace(/\x00D(\d+)\x00/g,(_,i)=>{ - const ref=media_stash[+i]; + let ref=media_stash[+i]; // Keep this logic self-contained: some tests extract renderMd() alone and // execute it in node, without the top-level helper functions from ui.js. const mediaKindForName=(name='')=>{ @@ -3468,6 +3478,15 @@ function renderMd(raw){ : ``; return `
${tag}
${safeName}
`; }; + if(/^file:\/\//i.test(ref)){ + try{ + const u=new URL(ref); + ref=decodeURIComponent(u.pathname||ref.replace(/^file:\/\//i,'')); + }catch(_){ + try{ref=decodeURIComponent(ref.replace(/^file:\/\//i,''));} + catch(__){ref=ref.replace(/^file:\/\//i,'');} + } + } // HTTP(S) URL if(/^https?:\/\//i.test(ref)){ // Rewrite localhost/127.0.0.1 to the actual server base URL so remote diff --git a/tests/test_media_inline.py b/tests/test_media_inline.py index 621da120..98d195e4 100644 --- a/tests/test_media_inline.py +++ b/tests/test_media_inline.py @@ -41,6 +41,15 @@ class TestMediaRenderMdStash(unittest.TestCase): self.assertIn("MEDIA:", UI_JS, "MEDIA: token regex must be present in renderMd()") + def test_bare_file_urls_are_stashed_as_media_artifacts(self): + self.assertIn("file:// links for local artifacts", UI_JS) + self.assertIn("file:\\/\\/[^\\s<>", UI_JS) + + def test_file_urls_are_rewritten_through_media_endpoint(self): + self.assertIn("new URL(ref)", UI_JS) + self.assertIn("u.pathname", UI_JS) + self.assertIn("api/media?path=", UI_JS) + def test_media_restore_produces_img_tag(self): self.assertIn("msg-media-img", UI_JS, "restore pass must produce ")