From aae3b418f0f9aa52521c67edde2a6130ca8874fe Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Sat, 30 May 2026 20:11:45 -0400 Subject: [PATCH 01/14] fix: render file artifact links as media --- CHANGELOG.md | 8 ++++++++ static/ui.js | 21 ++++++++++++++++++++- tests/test_media_inline.py | 9 +++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) 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 `
'+esc(c)+'');return '\x00F'+(fence_stash.length-1)+'\x00';});
+ // Bare file:// artifact links → media. Some gateway/tool surfaces emit bare
+ // file:// links for local artifacts instead of MEDIA: tokens; browser clients
+ // cannot open the server filesystem directly, so route them through /api/media.
+ // This runs AFTER fenced-block (\x00P) and inline-code (\x00F) stashing so a
+ // file:// inside a code block / backtick span stays literal text (#3219/#3234).
+ // Only bare URLs (line-start or whitespace-delimited) are matched, so normal
+ // [label](file://...) markdown anchors keep 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';
+ });
// Math stash: protect $$..$$ and $..$ from markdown processing
// Runs AFTER fence_stash so backtick code spans protect their dollar-sign contents
const math_stash=[];
diff --git a/tests/test_renderer_js_behaviour.py b/tests/test_renderer_js_behaviour.py
index 72a33485..5c92eb7b 100644
--- a/tests/test_renderer_js_behaviour.py
+++ b/tests/test_renderer_js_behaviour.py
@@ -702,3 +702,33 @@ class TestHeadingLevelsH1ThroughH6:
assert "(Codex review #6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. routes.py: hoist a single case-folded path helper (_norm/_within_ci/_equal_ci) used for ALL deny + carve-out comparisons (consistent macOS/Windows safety). 2. routes.py: split the deny into (a) dir-based denies that ALWAYS fire (even inside the active workspace — so a workspace overlapping a state dir cannot expose sessions/memories), and (b) filename denies relaxed only by the carve-out. Fix the over-block: a workspace that is a proper DESCENDANT of a Hermes root (e.g. STATE_DIR/workspace) is a legit project workspace and keeps the carve-out; only a root-itself / ancestor / $HOME / profiles / state-subdir workspace disables it. 3. ui.js: move the bare file:// media-stash pass after the raw-stash too, so file:// inside a rawblock stays literal (not just fenced/inline code). --- api/routes.py | 91 +++++++++++++++++++++++++++------------------------ static/ui.js | 22 ++++++------- 2 files changed, 60 insertions(+), 53 deletions(-) diff --git a/api/routes.py b/api/routes.py index 79bcc19f..e024532e 100644 --- a/api/routes.py +++ b/api/routes.py @@ -8135,14 +8135,42 @@ def _handle_media(handler, parsed): if _r is not None and _r not in _hermes_roots: _hermes_roots.append(_r) - # Active-workspace carve-out: a file inside the active workspace is the - # user's own content, not Hermes internal state — never deny it here. - # BUT the carve-out is only safe for a genuine PROJECT workspace. If the - # active workspace is pathologically set to a broad/internal root ($HOME, - # any Hermes root, an ancestor of a Hermes root, STATE_DIR, a */profiles - # dir, a named profile root, or an internal state subdir), honoring it would - # re-open the disclosure (e.g. workspace=~/.hermes → state.db served). In - # that case we DISABLE the carve-out so the deny rules below still fire. + # Case-insensitive path helpers so STATE.DB / Sessions/ casing variants + # cannot bypass the deny on macOS/Windows filesystems (Codex review #3234). + def _norm(p): + return os.path.normcase(str(Path(p).resolve())).casefold() + def _within_ci(child, root): + try: + c, r = _norm(child), _norm(root) + return os.path.commonpath([c, r]) == r + except (ValueError, OSError): + return False + def _equal_ci(a, b): + try: + return _norm(a) == _norm(b) + except (ValueError, OSError): + return False + + # State-subdir deny set: each DENY_SUBDIR directly under any Hermes root, + # plus STATE_DIR itself (sensitive in its entirety). These ALWAYS apply — + # even to a file that lives under the active workspace — so a workspace + # pointed at (or overlapping) a state dir cannot expose sessions/memories/etc. + _deny_dirs = [] + if _state_dir is not None: + _deny_dirs.append(_state_dir) + for _root in _hermes_roots: + for _sub in _DENY_SUBDIRS: + _deny_dirs.append((_root / _sub).resolve()) + _deny_names_ci = {n.casefold() for n in _DENY_FILENAMES} + + # Active-workspace carve-out: a file inside a genuine PROJECT workspace is + # the user's own content, so the secret/config FILENAME denies are relaxed + # for it. The carve-out is DISABLED when the workspace is a broad/internal + # location ($HOME, a Hermes root itself, an ANCESTOR of a Hermes root, a + # */profiles dir, a named-profile root, or a state subdir) — honoring those + # would re-open the disclosure. A workspace that is a proper DESCENDANT of a + # Hermes root (e.g. STATE_DIR/workspace) is still a legit project workspace + # and keeps the carve-out. The dir-based denies above are NOT relaxed. _active_workspace = None try: from api.workspace import get_last_workspace @@ -8155,16 +8183,14 @@ def _handle_media(handler, parsed): def _workspace_is_safe_carveout(ws): if ws is None: return False - # Never trust a workspace that IS, CONTAINS, or is CONTAINED BY a Hermes - # root, or that is $HOME / a profiles dir / a state subdir. - if ws == _HOME.resolve(): + if _equal_ci(ws, _HOME): return False for _root in _hermes_roots: - # ws == root, ws inside root, or ws an ancestor of root → unsafe. - if ws == _root or _path_is_within_root(ws, _root) or _path_is_within_root(_root, ws): + # ws IS a root, or ws is an ANCESTOR of a root → unsafe. (A proper + # descendant of a root is fine — that's a normal project workspace.) + if _equal_ci(ws, _root) or _within_ci(_root, ws): return False - # A */profiles dir or a named-profile root (…/profiles/). - if ws.name == "profiles" or (ws.parent.name == "profiles"): + if ws.name == "profiles" or ws.parent.name == "profiles": return False if ws.name in _DENY_SUBDIRS: return False @@ -8173,36 +8199,17 @@ def _handle_media(handler, parsed): _in_active_workspace = ( _active_workspace is not None and _workspace_is_safe_carveout(_active_workspace) - and _path_is_within_root(target, _active_workspace) + and _within_ci(target, _active_workspace) ) + # Dir-based denies always fire (even inside the active workspace). + if any(_within_ci(target, d) for d in _deny_dirs): + return bad(handler, "Path not in allowed location", 403) + # Filename-based denies fire for files under a Hermes root, UNLESS the file + # is inside a genuine project workspace (carve-out). if not _in_active_workspace: - # Case-insensitive containment + basename compare so STATE.DB / Sessions/ - # cannot bypass the deny on macOS/Windows filesystems (Codex review #3234). - def _within_ci(child, root): - try: - c = os.path.normcase(str(Path(child).resolve())) - r = os.path.normcase(str(Path(root).resolve())) - return os.path.commonpath([c, r]) == r - except (ValueError, OSError): - return False - _deny_names_ci = {n.casefold() for n in _DENY_FILENAMES} - _under_hermes_root = any( - _within_ci(target, _root) for _root in _hermes_roots - ) - # State-subdir deny: any DENY_SUBDIR directly under a Hermes root, and - # the STATE_DIR itself (sensitive in its entirety). - _deny_dirs = [] - if _state_dir is not None: - _deny_dirs.append(_state_dir) - for _root in _hermes_roots: - for _sub in _DENY_SUBDIRS: - _deny_dirs.append((_root / _sub).resolve()) - _denied = ( - (_under_hermes_root and target.name.casefold() in _deny_names_ci) - or any(_within_ci(target, d) for d in _deny_dirs) - ) - if _denied: + _under_hermes_root = any(_within_ci(target, _root) for _root in _hermes_roots) + if _under_hermes_root and target.name.casefold() in _deny_names_ci: return bad(handler, "Path not in allowed location", 403) # ── end #3234 deny ─────────────────────────────────────────────────────── diff --git a/static/ui.js b/static/ui.js index 2fc753ec..a002f18b 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3091,17 +3091,6 @@ function renderMd(raw){ return lead+'\x00P'+(_preBlock_stash.length-1)+'\x00'; }); s=s.replace(/`([^`\n]+)`/g,(_,c)=>{fence_stash.push(' '+esc(c)+'');return '\x00F'+(fence_stash.length-1)+'\x00';}); - // Bare file:// artifact links → media. Some gateway/tool surfaces emit bare - // file:// links for local artifacts instead of MEDIA: tokens; browser clients - // cannot open the server filesystem directly, so route them through /api/media. - // This runs AFTER fenced-block (\x00P) and inline-code (\x00F) stashing so a - // file:// inside a code block / backtick span stays literal text (#3219/#3234). - // Only bare URLs (line-start or whitespace-delimited) are matched, so normal - // [label](file://...) markdown anchors keep 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'; - }); // Math stash: protect $$..$$ and $..$ from markdown processing // Runs AFTER fence_stash so backtick code spans protect their dollar-sign contents const math_stash=[]; @@ -3121,6 +3110,17 @@ function renderMd(raw){ // backticks for multiline code and break subsequent code-box rendering. const rawPreStash=[]; s=s.replace(/(]*>[\s\S]*?<\/pre>)/gi,m=>{rawPreStash.push(m);return `\x00R${rawPreStash.length-1}\x00`;}); + // Bare file:// artifact links → media. Some gateway/tool surfaces emit bare + // file:// links for local artifacts instead of MEDIA: tokens; browser clients + // cannot open the server filesystem directly, so route them through /api/media. + // Runs AFTER fenced-block (\x00P), inline-code (\x00F), AND raw-(\x00R) + // stashing so a file:// inside any code/preformatted region stays literal text + // (#3219/#3234). Only bare URLs (line-start or whitespace-delimited) match, so + // normal [label](file://...) markdown anchors keep 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'; + }); s=s.replace(/([\s\S]*?)<\/strong>/gi,(_,t)=>'**'+t+'**'); s=s.replace(/([\s\S]*?)<\/b>/gi,(_,t)=>'**'+t+'**'); s=s.replace(/([\s\S]*?)<\/em>/gi,(_,t)=>'*'+t+'*'); From 124044dc111e0f54fc0e8a501466a684bd58f2b6 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Sun, 31 May 2026 03:41:21 +0000 Subject: [PATCH 10/14] =?UTF-8?q?fix(security=20#3234):=20do=20not=20deny?= =?UTF-8?q?=20STATE=5FDIR=20wholesale=20=E2=80=94=20keep=20STATE=5FDIR/wor?= =?UTF-8?q?kspace=20media=20(Codex=20review=20#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default workspace lives at STATE_DIR/workspace, so denying STATE_DIR itself 403'd legitimate workspace media. STATE_DIR is already in _hermes_roots, so its sensitive subdirs (STATE_DIR/sessions, /memories, /profiles, etc.) are still covered by the per-root subdir loop; direct sensitive files are still caught by the filename denies. Drop the wholesale _state_dir deny. Adds a regression test proving STATE_DIR/workspace/shot.png serves while STATE_DIR/sessions/*.json 403s. --- api/routes.py | 14 ++++---- tests/test_media_inline.py | 68 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/api/routes.py b/api/routes.py index e024532e..bc4f6162 100644 --- a/api/routes.py +++ b/api/routes.py @@ -8151,13 +8151,15 @@ def _handle_media(handler, parsed): except (ValueError, OSError): return False - # State-subdir deny set: each DENY_SUBDIR directly under any Hermes root, - # plus STATE_DIR itself (sensitive in its entirety). These ALWAYS apply — - # even to a file that lives under the active workspace — so a workspace - # pointed at (or overlapping) a state dir cannot expose sessions/memories/etc. + # State-subdir deny set: each DENY_SUBDIR directly under any Hermes root + # (which includes STATE_DIR — so STATE_DIR/sessions, STATE_DIR/memories, + # etc. are covered). These ALWAYS apply — even to a file under the active + # workspace — so a workspace pointed at (or overlapping) a state dir cannot + # expose sessions/memories/profiles/etc. We do NOT deny STATE_DIR itself + # wholesale: the default workspace lives at STATE_DIR/workspace, and that is + # legitimate user media — direct sensitive files there are still caught by + # the filename denies below. (Codex review #3234.) _deny_dirs = [] - if _state_dir is not None: - _deny_dirs.append(_state_dir) for _root in _hermes_roots: for _sub in _DENY_SUBDIRS: _deny_dirs.append((_root / _sub).resolve()) diff --git a/tests/test_media_inline.py b/tests/test_media_inline.py index b04366df..2d4bc1b9 100644 --- a/tests/test_media_inline.py +++ b/tests/test_media_inline.py @@ -336,6 +336,74 @@ class TestMediaEndpointUnit(unittest.TestCase): "Hermes home (carve-out must be gated against internal roots)", ) + def test_active_workspace_under_state_dir_serves_but_sessions_denied(self): + """#3234: a workspace at STATE_DIR/workspace is legitimate user media — + STATE_DIR/workspace/shot.png must serve (not 403), while a sibling + STATE_DIR/sessions/.json (internal state) must stay denied (403). + + Regression for the over-block where STATE_DIR was denied wholesale. + """ + from api import routes + + class _Handler: + def __init__(self): + self.status = None + self.headers = {} + def send_response(self, code): + self.status = code + def send_header(self, *a, **k): + pass + def end_headers(self): + pass + class _W: + def write(self_inner, b): + pass + def flush(self_inner): + pass + wfile = _W() + + png_bytes = ( + b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01' + b'\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00' + b'\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82' + ) + with tempfile.TemporaryDirectory() as home: + hermes_home = pathlib.Path(home) / ".hermes" + state_dir = hermes_home / "webui-state" + ws = state_dir / "workspace" + sessions = state_dir / "sessions" + ws.mkdir(parents=True) + sessions.mkdir(parents=True) + shot = ws / "shot.png" + shot.write_bytes(png_bytes) + sess_file = sessions / "abc.json" + sess_file.write_text('{"messages":[]}', encoding="utf-8") + + env = { + "HERMES_HOME": str(hermes_home), + "HERMES_WEBUI_STATE_DIR": str(state_dir), + } + with mock.patch.dict(os.environ, env), \ + mock.patch.object(routes, "get_last_workspace", lambda: str(ws)), \ + mock.patch("api.auth.is_auth_enabled", lambda: False), \ + mock.patch("api.config.STATE_DIR", state_dir): + # workspace media → not blocked by the #3234 deny + h1 = _Handler() + routes._handle_media(h1, SimpleNamespace( + query=f"path={urllib.parse.quote(str(shot.resolve()))}&inline=1", + path="/api/media")) + self.assertNotEqual( + h1.status, 403, + "STATE_DIR/workspace/shot.png must NOT be blocked (legit media)") + # sessions state → still denied + h2 = _Handler() + routes._handle_media(h2, SimpleNamespace( + query=f"path={urllib.parse.quote(str(sess_file.resolve()))}", + path="/api/media")) + self.assertEqual( + h2.status, 403, + "STATE_DIR/sessions/abc.json must stay denied (internal state)") + def test_media_endpoints_advertise_byte_range_support(self): routes_src = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8") self.assertIn("Accept-Ranges", routes_src) From ce7a2c14dbe34c4c83d4a054787c93fd0f8b92f4 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Sun, 31 May 2026 03:48:59 +0000 Subject: [PATCH 11/14] fix(security #3234): enumerate named-profile roots, drop wholesale profiles deny (Codex review #8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Denying the whole /profiles tree 403'd legitimate named-profile workspace media ( /profiles/p1/workspace/shot.png). Fix: remove 'profiles' from _DENY_SUBDIRS and instead enumerate each /profiles/ directory as its own Hermes root — so each profile's sensitive subdirs (sessions/memories/cron/ logs/checkpoints/backups) + secret filenames are denied, while that profile's workspace/ is allowed via the carve-out. Adds a regression test: named-profile workspace media serves, profile + sibling-profile auth.json stay 403. --- api/routes.py | 26 ++++++- .../test_issue1800_file_html_interactions.py | 14 ++-- tests/test_media_inline.py | 69 +++++++++++++++++++ 3 files changed, 100 insertions(+), 9 deletions(-) diff --git a/api/routes.py b/api/routes.py index bc4f6162..b5cdbda8 100644 --- a/api/routes.py +++ b/api/routes.py @@ -8108,9 +8108,13 @@ def _handle_media(handler, parsed): "google_token.json", "google_client_secret.json", "gateway_state.json", "channel_directory.json", "jobs.json", } - # Internal state subdirs that are sensitive in their entirety. + # Internal state subdirs that are sensitive in their entirety. NOTE: + # `profiles` is intentionally NOT here — it is a container of profile roots, + # each of which has its own legitimate workspace/. We instead enumerate each + # named-profile root below and deny ITS state subdirs, so a sibling profile's + # secrets are blocked without 403-ing a named-profile workspace. (#3234.) _DENY_SUBDIRS = ( - "sessions", "memories", "profiles", "cron", "logs", + "sessions", "memories", "cron", "logs", "checkpoints", "backups", ) _state_dir = None @@ -8134,6 +8138,24 @@ def _handle_media(handler, parsed): ): if _r is not None and _r not in _hermes_roots: _hermes_roots.append(_r) + # Enumerate named-profile roots ( /profiles/ ) and treat each as a + # Hermes root in its own right, so a sibling/other profile's sensitive subdirs + # + secret files are denied — WITHOUT denying the whole `profiles` container + # (which would block a legit named-profile workspace at + # /profiles/ /workspace/). (Codex review #3234.) + _profile_roots = [] + for _root in list(_hermes_roots): + _profiles_dir = (_root / "profiles") + try: + if _profiles_dir.is_dir(): + for _pchild in _profiles_dir.iterdir(): + if _pchild.is_dir(): + _pr = _pchild.resolve() + if _pr not in _hermes_roots and _pr not in _profile_roots: + _profile_roots.append(_pr) + except OSError: + pass + _hermes_roots.extend(_profile_roots) # Case-insensitive path helpers so STATE.DB / Sessions/ casing variants # cannot bypass the deny on macOS/Windows filesystems (Codex review #3234). diff --git a/tests/test_issue1800_file_html_interactions.py b/tests/test_issue1800_file_html_interactions.py index 5c0e810d..0bee348a 100644 --- a/tests/test_issue1800_file_html_interactions.py +++ b/tests/test_issue1800_file_html_interactions.py @@ -68,13 +68,13 @@ def test_html_media_open_full_uses_inline_new_tab_not_download(): def test_media_html_inline_keeps_csp_sandbox(): """api/media may serve HTML inline only behind a CSP sandbox.""" - # Slice widened to 12000 (was 5000) after the #3234 security work landed a - # multi-profile-aware deny-list + active-workspace carve-out + carve-out - # safety gate earlier in _handle_media, pushing the CSP block to ~9850 chars - # past the def. (Originally widened 4000→5000 for PR #2044's - # MEDIA_ALLOWED_ROOTS parsing.) The assertion is structural, not positional — - # generous headroom avoids re-breaking on small future edits. - body = _slice_after(ROUTES_PY, "def _handle_media", 12000) + # Slice widened to 16000 (was 5000) after the #3234 security work landed a + # multi-profile-aware deny-list + named-profile-root enumeration + + # active-workspace carve-out + safety gate earlier in _handle_media, pushing + # the CSP block to ~12100 chars past the def. (Originally widened 4000→5000 + # for PR #2044's MEDIA_ALLOWED_ROOTS parsing.) The assertion is structural, + # not positional — generous headroom avoids re-breaking on small future edits. + body = _slice_after(ROUTES_PY, "def _handle_media", 16000) assert 'html_inline_ok = inline_preview and mime == "text/html"' in body assert 'csp = "sandbox allow-scripts" if html_inline_ok else None' in body assert "csp=csp" in body diff --git a/tests/test_media_inline.py b/tests/test_media_inline.py index 2d4bc1b9..c3cad01d 100644 --- a/tests/test_media_inline.py +++ b/tests/test_media_inline.py @@ -404,6 +404,75 @@ class TestMediaEndpointUnit(unittest.TestCase): h2.status, 403, "STATE_DIR/sessions/abc.json must stay denied (internal state)") + def test_named_profile_workspace_serves_but_profile_secrets_denied(self): + """#3234: a named-profile workspace ( /profiles/p1/workspace) is + legitimate media and must serve, while that profile's secrets + ( /profiles/p1/auth.json) and a SIBLING profile's secrets + ( /profiles/other/auth.json) must stay denied (403). + + Regression for the over-block where the whole `profiles` tree was denied. + """ + from api import routes + + class _Handler: + def __init__(self): + self.status = None + self.headers = {} + def send_response(self, code): + self.status = code + def send_header(self, *a, **k): + pass + def end_headers(self): + pass + class _W: + def write(self_inner, b): + pass + def flush(self_inner): + pass + wfile = _W() + + png_bytes = ( + b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01' + b'\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00' + b'\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82' + ) + with tempfile.TemporaryDirectory() as home: + base = pathlib.Path(home) / ".hermes" + p1_ws = base / "profiles" / "p1" / "workspace" + p1_ws.mkdir(parents=True) + (p1_ws / "shot.png").write_bytes(png_bytes) + p1_secret = base / "profiles" / "p1" / "auth.json" + p1_secret.write_text("{}", encoding="utf-8") + other_secret = base / "profiles" / "other" / "auth.json" + other_secret.parent.mkdir(parents=True) + other_secret.write_text("{}", encoding="utf-8") + + active = base / "profiles" / "p1" # active profile HERMES_HOME + with mock.patch.dict(os.environ, {"HERMES_HOME": str(active)}), \ + mock.patch.object(routes, "get_last_workspace", lambda: str(p1_ws)), \ + mock.patch("api.auth.is_auth_enabled", lambda: False), \ + mock.patch("api.profiles._DEFAULT_HERMES_HOME", base): + # named-profile workspace media → served + h1 = _Handler() + routes._handle_media(h1, SimpleNamespace( + query=f"path={urllib.parse.quote(str((p1_ws / 'shot.png').resolve()))}&inline=1", + path="/api/media")) + self.assertNotEqual( + h1.status, 403, + "named-profile workspace media must NOT be blocked") + # this profile's own secret → denied + h2 = _Handler() + routes._handle_media(h2, SimpleNamespace( + query=f"path={urllib.parse.quote(str(p1_secret.resolve()))}", + path="/api/media")) + self.assertEqual(h2.status, 403, "profile auth.json must be denied") + # sibling profile's secret → denied + h3 = _Handler() + routes._handle_media(h3, SimpleNamespace( + query=f"path={urllib.parse.quote(str(other_secret.resolve()))}", + path="/api/media")) + self.assertEqual(h3.status, 403, "sibling profile auth.json must be denied") + def test_media_endpoints_advertise_byte_range_support(self): routes_src = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8") self.assertIn("Accept-Ranges", routes_src) From d1645ff80336c51a24845b0d6f80059ba2e389b9 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Sun, 31 May 2026 03:58:19 +0000 Subject: [PATCH 12/14] fix(security #3234): deny passkeys.json + .passkey_challenges.json + .login_attempts.json (Codex review #9) Codex direct probe found three more auth-state basenames under STATE_DIR that /api/media still served: passkeys.json + .passkey_challenges.json (api/passkeys.py) and .login_attempts.json (api/auth.py). Add them to _DENY_FILENAMES. --- api/routes.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api/routes.py b/api/routes.py index b5cdbda8..74f2f894 100644 --- a/api/routes.py +++ b/api/routes.py @@ -8107,6 +8107,7 @@ def _handle_media(handler, parsed): ".signing_key", ".pbkdf2_key", ".sessions.json", "google_token.json", "google_client_secret.json", "gateway_state.json", "channel_directory.json", "jobs.json", + "passkeys.json", ".passkey_challenges.json", ".login_attempts.json", } # Internal state subdirs that are sensitive in their entirety. NOTE: # `profiles` is intentionally NOT here — it is a container of profile roots, From a62d35a0b3d234b6ba54461716515f00c920fa02 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Sun, 31 May 2026 04:05:47 +0000 Subject: [PATCH 13/14] fix(security #3234): deny atomic-write temp auth/state files under Hermes roots (Codex review #10) auth.py/passkeys.py write via tmp*. .tmp sidecars then rename; deny those suffixes (.sessions.tmp, .login_attempts.tmp, .passkeys.tmp, .passkey_challenges.tmp) under Hermes roots so a momentary temp file can't be fetched via /api/media. --- api/routes.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/api/routes.py b/api/routes.py index 74f2f894..c989eebe 100644 --- a/api/routes.py +++ b/api/routes.py @@ -8234,7 +8234,17 @@ def _handle_media(handler, parsed): # is inside a genuine project workspace (carve-out). if not _in_active_workspace: _under_hermes_root = any(_within_ci(target, _root) for _root in _hermes_roots) - if _under_hermes_root and target.name.casefold() in _deny_names_ci: + _name_cf = target.name.casefold() + # Exact secret/state basenames, plus atomic-write temp files for those + # (api/auth.py and api/passkeys.py write via a `tmp*. .tmp` / `tmp*.tmp` + # sidecar then rename) — deny those suffixes too so a momentary temp file + # cannot be fetched. (Codex review #3234.) + _deny_tmp_suffixes = (".sessions.tmp", ".login_attempts.tmp", + ".passkeys.tmp", ".passkey_challenges.tmp") + if _under_hermes_root and ( + _name_cf in _deny_names_ci + or _name_cf.endswith(_deny_tmp_suffixes) + ): return bad(handler, "Path not in allowed location", 403) # ── end #3234 deny ─────────────────────────────────────────────────────── From d3b69ab98b4fb1ef809d1608991fd1f4dfcc7e2e Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Sun, 31 May 2026 04:11:35 +0000 Subject: [PATCH 14/14] fix(security #3234): deny per-profile webui_state state subdirs (Codex review #11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-profile WebUI state lives at /webui_state (api/workspace.py), so /profiles/ /webui_state/sessions/*.json was reachable — it is not a direct child of the profile root, so the prior deny-subdir loop missed it. Add /webui_state/ to the deny dirs for every Hermes root. Adds a regression assertion (profile webui_state/sessions/*.json → 403). --- api/routes.py | 6 ++++++ tests/test_media_inline.py | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/api/routes.py b/api/routes.py index c989eebe..fa6c7557 100644 --- a/api/routes.py +++ b/api/routes.py @@ -8186,6 +8186,12 @@ def _handle_media(handler, parsed): for _root in _hermes_roots: for _sub in _DENY_SUBDIRS: _deny_dirs.append((_root / _sub).resolve()) + # Per-profile WebUI state lives at /webui_state (api/workspace.py), + # so its state subdirs ( /webui_state/sessions, etc.) must be denied + # too — they are NOT direct children of . (Codex review #3234.) + _ws_state = (_root / "webui_state") + for _sub in _DENY_SUBDIRS: + _deny_dirs.append((_ws_state / _sub).resolve()) _deny_names_ci = {n.casefold() for n in _DENY_FILENAMES} # Active-workspace carve-out: a file inside a genuine PROJECT workspace is diff --git a/tests/test_media_inline.py b/tests/test_media_inline.py index c3cad01d..9276d9be 100644 --- a/tests/test_media_inline.py +++ b/tests/test_media_inline.py @@ -472,6 +472,18 @@ class TestMediaEndpointUnit(unittest.TestCase): query=f"path={urllib.parse.quote(str(other_secret.resolve()))}", path="/api/media")) self.assertEqual(h3.status, 403, "sibling profile auth.json must be denied") + # per-profile webui_state/sessions → denied (not a direct child of root) + ws_sess = active / "webui_state" / "sessions" + ws_sess.mkdir(parents=True, exist_ok=True) + ws_sess_file = ws_sess / "s1.json" + ws_sess_file.write_text('{"messages":[]}', encoding="utf-8") + h4 = _Handler() + routes._handle_media(h4, SimpleNamespace( + query=f"path={urllib.parse.quote(str(ws_sess_file.resolve()))}", + path="/api/media")) + self.assertEqual( + h4.status, 403, + "profile webui_state/sessions/*.json must be denied") def test_media_endpoints_advertise_byte_range_support(self): routes_src = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")