mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-19 22:20:30 +00:00
Merge pull request #3240 from nesquena/release/stage-batchC
Release stage-batchC → v0.51.183 (#3219 inline file:// media + #3234 /api/media secret-file confinement)
This commit is contained in:
@@ -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
|
||||
|
||||
+171
@@ -8083,6 +8083,177 @@ def _handle_media(handler, parsed):
|
||||
target,
|
||||
_INLINE_IMAGE_TYPES,
|
||||
)
|
||||
|
||||
# ── #3234: hard-deny Hermes's own state + secret/config files ────────────
|
||||
# The allowlist above grants the whole Hermes home (and base ~/.hermes), so
|
||||
# an authenticated session rendering attacker-influenced agent output that
|
||||
# emits a file:// / MEDIA: link to a state/secret file could fetch it
|
||||
# through /api/media. This guard runs BEFORE the allow/serve decision so it
|
||||
# covers every entry path (bare file:// URLs, markdown anchors, MEDIA:
|
||||
# tokens, and session-token grants).
|
||||
#
|
||||
# Model: the ACTIVE WORKSPACE is a legitimate-media carve-out — the user is
|
||||
# entitled to their own workspace files (that is also how the workspace file
|
||||
# browser reaches them), even when a workspace happens to live under a
|
||||
# Hermes root. The deny rules target Hermes's OWN internal state, which lives
|
||||
# OUTSIDE any workspace. So: if the target is inside the active workspace, it
|
||||
# is never denied here; otherwise we deny known secret/config basenames and
|
||||
# the internal state subdirectories across every Hermes root the allowlist
|
||||
# accepts (active-profile HERMES_HOME, base ~/.hermes, the api.profiles
|
||||
# default home, and STATE_DIR — which also defends sibling profiles).
|
||||
_DENY_FILENAMES = {
|
||||
"settings.json", "state.db", "state.db-wal", "state.db-shm",
|
||||
"auth.json", "auth.lock", "config.yaml", "config.yml", ".env",
|
||||
".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,
|
||||
# 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", "cron", "logs",
|
||||
"checkpoints", "backups",
|
||||
)
|
||||
_state_dir = None
|
||||
try:
|
||||
from api.config import STATE_DIR as _STATE_DIR
|
||||
_state_dir = Path(_STATE_DIR).resolve()
|
||||
except Exception:
|
||||
_state_dir = None
|
||||
_base_hermes_home = None
|
||||
try:
|
||||
from api.profiles import _DEFAULT_HERMES_HOME as _BASE_HH
|
||||
_base_hermes_home = Path(_BASE_HH).resolve()
|
||||
except Exception:
|
||||
_base_hermes_home = None
|
||||
_hermes_roots = []
|
||||
for _r in (
|
||||
_HERMES_HOME.resolve(),
|
||||
(_HOME / ".hermes").resolve(),
|
||||
_base_hermes_home,
|
||||
_state_dir,
|
||||
):
|
||||
if _r is not None and _r not in _hermes_roots:
|
||||
_hermes_roots.append(_r)
|
||||
# Enumerate named-profile roots (<root>/profiles/<name>) 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
|
||||
# <root>/profiles/<name>/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).
|
||||
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
|
||||
# (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 = []
|
||||
for _root in _hermes_roots:
|
||||
for _sub in _DENY_SUBDIRS:
|
||||
_deny_dirs.append((_root / _sub).resolve())
|
||||
# Per-profile WebUI state lives at <root>/webui_state (api/workspace.py),
|
||||
# so its state subdirs (<root>/webui_state/sessions, etc.) must be denied
|
||||
# too — they are NOT direct children of <root>. (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
|
||||
# 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
|
||||
_aw = Path(get_last_workspace()).resolve()
|
||||
if _aw.is_dir():
|
||||
_active_workspace = _aw
|
||||
except Exception:
|
||||
_active_workspace = None
|
||||
|
||||
def _workspace_is_safe_carveout(ws):
|
||||
if ws is None:
|
||||
return False
|
||||
if _equal_ci(ws, _HOME):
|
||||
return False
|
||||
for _root in _hermes_roots:
|
||||
# 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
|
||||
if ws.name == "profiles" or ws.parent.name == "profiles":
|
||||
return False
|
||||
if ws.name in _DENY_SUBDIRS:
|
||||
return False
|
||||
return True
|
||||
|
||||
_in_active_workspace = (
|
||||
_active_workspace is not None
|
||||
and _workspace_is_safe_carveout(_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:
|
||||
_under_hermes_root = any(_within_ci(target, _root) for _root in _hermes_roots)
|
||||
_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*.<name>.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 ───────────────────────────────────────────────────────
|
||||
|
||||
if not within_allowed and not session_media_allowed:
|
||||
return bad(handler, "Path not in allowed location", 403)
|
||||
|
||||
|
||||
+21
-1
@@ -3110,6 +3110,17 @@ function renderMd(raw){
|
||||
// backticks for multiline code and break subsequent code-box rendering.
|
||||
const rawPreStash=[];
|
||||
s=s.replace(/(<pre\b[^>]*>[\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-<pre> (\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(/<strong>([\s\S]*?)<\/strong>/gi,(_,t)=>'**'+t+'**');
|
||||
s=s.replace(/<b>([\s\S]*?)<\/b>/gi,(_,t)=>'**'+t+'**');
|
||||
s=s.replace(/<em>([\s\S]*?)<\/em>/gi,(_,t)=>'*'+t+'*');
|
||||
@@ -3449,7 +3460,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 +3479,15 @@ function renderMd(raw){
|
||||
: `<audio class="msg-media-player msg-media-audio" src="${safeSrc}" controls preload="metadata" title="${safeName}"></audio>`;
|
||||
return `<div class="msg-media-editor msg-media-editor--${kind}" data-media-kind="${kind}">${tag}<div class="msg-media-meta"><span class="msg-media-name">${safeName}</span></div></div>`;
|
||||
};
|
||||
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
|
||||
|
||||
@@ -68,10 +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 5000 (was 4000) after PR #2044 added MEDIA_ALLOWED_ROOTS
|
||||
# parsing earlier in _handle_media, which pushed the CSP block past the
|
||||
# original window. The assertion is structural, not positional.
|
||||
body = _slice_after(ROUTES_PY, "def _handle_media", 5000)
|
||||
# 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
|
||||
|
||||
@@ -19,6 +19,7 @@ import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from tests._pytest_port import BASE, TEST_STATE_DIR
|
||||
@@ -41,6 +42,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 <img class='msg-media-img'>")
|
||||
@@ -281,6 +291,200 @@ class TestMediaEndpointUnit(unittest.TestCase):
|
||||
child.write_bytes(b"png")
|
||||
self.assertTrue(routes._path_is_within_root(child.resolve(), root))
|
||||
|
||||
def test_active_workspace_carveout_gated_against_hermes_roots(self):
|
||||
"""#3234: the active-workspace carve-out must NOT re-open the disclosure
|
||||
when the active workspace is pathologically set to a broad/internal root
|
||||
($HOME, ~/.hermes, a profile root, etc.). A state.db sitting under such a
|
||||
workspace must still be denied (403), not served.
|
||||
"""
|
||||
from api import routes
|
||||
|
||||
class _Handler:
|
||||
def __init__(self):
|
||||
self.status = None
|
||||
self._buf = []
|
||||
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
|
||||
wfile = _W()
|
||||
|
||||
with tempfile.TemporaryDirectory() as home:
|
||||
hermes_home = pathlib.Path(home) / ".hermes"
|
||||
hermes_home.mkdir(parents=True)
|
||||
secret = hermes_home / "state.db"
|
||||
secret.write_bytes(b"secret-state")
|
||||
target = secret.resolve()
|
||||
|
||||
handler = _Handler()
|
||||
parsed = SimpleNamespace(
|
||||
query=f"path={urllib.parse.quote(str(target))}", path="/api/media"
|
||||
)
|
||||
with mock.patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), \
|
||||
mock.patch.object(routes, "get_last_workspace", lambda: str(hermes_home)), \
|
||||
mock.patch("api.auth.is_auth_enabled", lambda: False):
|
||||
routes._handle_media(handler, parsed)
|
||||
|
||||
self.assertEqual(
|
||||
handler.status, 403,
|
||||
"state.db must stay denied even when the active workspace IS the "
|
||||
"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/<sid>.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_named_profile_workspace_serves_but_profile_secrets_denied(self):
|
||||
"""#3234: a named-profile workspace (<base>/profiles/p1/workspace) is
|
||||
legitimate media and must serve, while that profile's secrets
|
||||
(<base>/profiles/p1/auth.json) and a SIBLING profile's secrets
|
||||
(<base>/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")
|
||||
# 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")
|
||||
self.assertIn("Accept-Ranges", routes_src)
|
||||
@@ -456,6 +660,79 @@ class TestMediaEndpointIntegration(unittest.TestCase):
|
||||
)
|
||||
self.assertIn(status, {403, 404})
|
||||
|
||||
def test_webui_state_secret_files_denied(self):
|
||||
"""#3234: /api/media must hard-deny WebUI state/secret files even though
|
||||
they live under an allowed root (the whole Hermes home is allowed).
|
||||
|
||||
An authenticated session rendering attacker-influenced agent output that
|
||||
emits a file:// or MEDIA: link to settings.json / state.db / auth.json
|
||||
must NOT be able to fetch it through /api/media.
|
||||
"""
|
||||
state_dir = pathlib.Path(TEST_STATE_DIR)
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
# settings.json by name (deny-by-filename)
|
||||
settings = state_dir / "settings.json"
|
||||
settings.write_text('{"secret":"value"}', encoding="utf-8")
|
||||
try:
|
||||
_, status, _ = self._get(
|
||||
"/api/media?path=" + urllib.request.quote(str(settings.resolve()))
|
||||
)
|
||||
self.assertEqual(
|
||||
status, 403,
|
||||
f"settings.json under the state dir must be denied, got {status}",
|
||||
)
|
||||
finally:
|
||||
settings.unlink(missing_ok=True)
|
||||
|
||||
# a file inside the sessions/ state subdir (deny-by-dir)
|
||||
sess_dir = state_dir / "sessions"
|
||||
sess_dir.mkdir(parents=True, exist_ok=True)
|
||||
sess_file = sess_dir / "abc123.json"
|
||||
sess_file.write_text('{"messages":[]}', encoding="utf-8")
|
||||
try:
|
||||
_, status, _ = self._get(
|
||||
"/api/media?path=" + urllib.request.quote(str(sess_file.resolve()))
|
||||
)
|
||||
self.assertEqual(
|
||||
status, 403,
|
||||
f"files under the sessions/ state subdir must be denied, got {status}",
|
||||
)
|
||||
finally:
|
||||
sess_file.unlink(missing_ok=True)
|
||||
|
||||
def test_deny_list_does_not_overblock_legitimate_media(self):
|
||||
"""#3234 follow-up: the state/secret deny-list must NOT block ordinary
|
||||
media that merely shares a sensitive basename but lives OUTSIDE any
|
||||
Hermes state root (e.g. a user artifact in /tmp named settings.json).
|
||||
|
||||
The deny is scoped to files under a Hermes root; a /tmp PNG named
|
||||
settings.png — or even settings.json — is the user's own content and
|
||||
must still be served (200), not 403.
|
||||
"""
|
||||
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'
|
||||
)
|
||||
# A /tmp artifact whose stem collides with a denied basename — must serve
|
||||
# because /tmp is not a Hermes state root.
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".png", prefix="settings_artifact_", dir="/tmp", delete=False
|
||||
) as f:
|
||||
f.write(png_bytes)
|
||||
tmp_path = f.name
|
||||
try:
|
||||
body, status, headers = self._get(
|
||||
f"/api/media?path={urllib.request.quote(tmp_path)}"
|
||||
)
|
||||
self.assertEqual(
|
||||
status, 200,
|
||||
f"a /tmp PNG outside any Hermes root must serve, got {status}",
|
||||
)
|
||||
self.assertIn("image/png", headers.get("Content-Type", ""))
|
||||
finally:
|
||||
pathlib.Path(tmp_path).unlink(missing_ok=True)
|
||||
|
||||
def test_health_check_still_works(self):
|
||||
"""Sanity: server is up and /health works."""
|
||||
body, status, _ = self._get("/health")
|
||||
|
||||
@@ -702,3 +702,33 @@ class TestHeadingLevelsH1ThroughH6:
|
||||
assert "<h4><strong>bold</strong> in h4</h4>" in out, (
|
||||
f"inline markdown inside h4 must still render: {out!r}"
|
||||
)
|
||||
|
||||
|
||||
class TestBareFileUrlMediaRendering:
|
||||
"""#3219/#3234: bare file:// artifact links render as media, but file://
|
||||
inside fenced/inline code stays literal (the new pass runs AFTER code-stash)."""
|
||||
|
||||
def test_bare_file_url_becomes_media(self, driver_path):
|
||||
out = _render(driver_path, "Here is the screenshot file:///tmp/shot.png done")
|
||||
# Routed through /api/media as an inline image, not left as a raw path.
|
||||
assert "api/media?path=" in out
|
||||
assert "msg-media-img" in out or "<img" in out
|
||||
|
||||
def test_file_url_inside_fenced_code_stays_literal(self, driver_path):
|
||||
out = _render(driver_path, "```\nfile:///tmp/shot.png\n```")
|
||||
# Inside a code fence it must remain literal text, NOT become an <img>.
|
||||
assert "file:///tmp/shot.png" in out
|
||||
assert "<img" not in out
|
||||
assert "api/media?path=" not in out
|
||||
|
||||
def test_file_url_inside_inline_code_stays_literal(self, driver_path):
|
||||
out = _render(driver_path, "run `open file:///tmp/shot.png` now")
|
||||
assert "file:///tmp/shot.png" in out
|
||||
assert "<img" not in out
|
||||
assert "api/media?path=" not in out
|
||||
|
||||
def test_markdown_anchor_file_link_uses_link_path_not_media(self, driver_path):
|
||||
out = _render(driver_path, "[the file](file:///tmp/shot.png)")
|
||||
# Labeled anchors keep the normal link path (routed to /api/media as a link,
|
||||
# not auto-loaded as an <img>).
|
||||
assert "<img" not in out
|
||||
|
||||
Reference in New Issue
Block a user