fix(sessions): redact sensitive titles in session list and search responses (#405)
* fix(sessions): redact titles in list and search responses * docs: v0.50.26 release — version badge and CHANGELOG --------- Co-authored-by: hinotoi-agent <paperlantern.agent@gmail.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Hermes Web UI -- Changelog
|
# Hermes Web UI -- Changelog
|
||||||
|
|
||||||
|
## [v0.50.26] fix(sessions): redact sensitive titles in session list and search responses [SECURITY] (#400)
|
||||||
|
|
||||||
|
- `api/routes.py`: apply `_redact_text()` to session titles in all four response paths — `/api/sessions` merged list, `/api/sessions/search` empty-q, title-match, and content-match; use `dict(s)` copy before mutating to avoid corrupting the in-memory session cache
|
||||||
|
- `tests/test_session_summary_redaction.py`: 2 integration tests verifying `sk-` prefixed secrets in session titles are redacted from both list and search endpoint responses
|
||||||
|
- Original PR by @Hinotoi-agent (note: fix commit had a display artifact — `sk-` prefix was visually rendered as `***` in terminal output but the actual bytes were correct and the token was recognized by the redaction engine)
|
||||||
|
- 1022 tests total (up from 1020)
|
||||||
|
|
||||||
## [v0.50.25] Multi-PR batch: mobile scroll, import timestamps, profile security, mic fallback
|
## [v0.50.25] Multi-PR batch: mobile scroll, import timestamps, profile security, mic fallback
|
||||||
|
|
||||||
### fix: restore mobile chat scrolling and drawer close (#397)
|
### fix: restore mobile chat scrolling and drawer close (#397)
|
||||||
|
|||||||
@@ -410,11 +410,13 @@ def handle_get(handler, parsed) -> bool:
|
|||||||
deduped_cli = []
|
deduped_cli = []
|
||||||
merged = webui_sessions + deduped_cli
|
merged = webui_sessions + deduped_cli
|
||||||
merged.sort(key=lambda s: s.get("updated_at", 0) or 0, reverse=True)
|
merged.sort(key=lambda s: s.get("updated_at", 0) or 0, reverse=True)
|
||||||
# Redact credentials from session titles before returning
|
safe_merged = []
|
||||||
for s in merged:
|
for s in merged:
|
||||||
if isinstance(s.get("title"), str):
|
item = dict(s)
|
||||||
s["title"] = _redact_text(s["title"])
|
if isinstance(item.get("title"), str):
|
||||||
return j(handler, {"sessions": merged, "cli_count": len(deduped_cli)})
|
item["title"] = _redact_text(item["title"])
|
||||||
|
safe_merged.append(item)
|
||||||
|
return j(handler, {"sessions": safe_merged, "cli_count": len(deduped_cli)})
|
||||||
|
|
||||||
if parsed.path == "/api/projects":
|
if parsed.path == "/api/projects":
|
||||||
return j(handler, {"projects": load_projects()})
|
return j(handler, {"projects": load_projects()})
|
||||||
@@ -1192,12 +1194,21 @@ def _handle_sessions_search(handler, parsed):
|
|||||||
content_search = qs.get("content", ["1"])[0] == "1"
|
content_search = qs.get("content", ["1"])[0] == "1"
|
||||||
depth = int(qs.get("depth", ["5"])[0])
|
depth = int(qs.get("depth", ["5"])[0])
|
||||||
if not q:
|
if not q:
|
||||||
return j(handler, {"sessions": all_sessions()})
|
safe_sessions = []
|
||||||
|
for s in all_sessions():
|
||||||
|
item = dict(s)
|
||||||
|
if isinstance(item.get("title"), str):
|
||||||
|
item["title"] = _redact_text(item["title"])
|
||||||
|
safe_sessions.append(item)
|
||||||
|
return j(handler, {"sessions": safe_sessions})
|
||||||
results = []
|
results = []
|
||||||
for s in all_sessions():
|
for s in all_sessions():
|
||||||
title_match = q in (s.get("title") or "").lower()
|
title_match = q in (s.get("title") or "").lower()
|
||||||
if title_match:
|
if title_match:
|
||||||
results.append(dict(s, match_type="title"))
|
item = dict(s, match_type="title")
|
||||||
|
if isinstance(item.get("title"), str):
|
||||||
|
item["title"] = _redact_text(item["title"])
|
||||||
|
results.append(item)
|
||||||
continue
|
continue
|
||||||
if content_search:
|
if content_search:
|
||||||
try:
|
try:
|
||||||
@@ -1212,7 +1223,10 @@ def _handle_sessions_search(handler, parsed):
|
|||||||
if isinstance(p, dict) and p.get("type") == "text"
|
if isinstance(p, dict) and p.get("type") == "text"
|
||||||
)
|
)
|
||||||
if q in str(c).lower():
|
if q in str(c).lower():
|
||||||
results.append(dict(s, match_type="content"))
|
item = dict(s, match_type="content")
|
||||||
|
if isinstance(item.get("title"), str):
|
||||||
|
item["title"] = _redact_text(item["title"])
|
||||||
|
results.append(item)
|
||||||
break
|
break
|
||||||
except (KeyError, Exception):
|
except (KeyError, Exception):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -535,7 +535,7 @@
|
|||||||
<div class="settings-section-title">System</div>
|
<div class="settings-section-title">System</div>
|
||||||
<div class="settings-section-meta">Instance version and access controls.</div>
|
<div class="settings-section-meta">Instance version and access controls.</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="settings-version-badge">v0.50.25</span>
|
<span class="settings-version-badge">v0.50.26</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
||||||
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
||||||
|
|||||||
66
tests/test_session_summary_redaction.py
Normal file
66
tests/test_session_summary_redaction.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent.parent))
|
||||||
|
|
||||||
|
_needs_server = pytest.mark.usefixtures("test_server")
|
||||||
|
BASE = "http://127.0.0.1:8788"
|
||||||
|
_FULL_SECRET = "sk-" + ("B" * 24)
|
||||||
|
|
||||||
|
|
||||||
|
def _get(path):
|
||||||
|
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
|
||||||
|
|
||||||
|
def _write_session_with_secret_title():
|
||||||
|
from tests.conftest import TEST_STATE_DIR
|
||||||
|
|
||||||
|
sid = "sec_summary_" + uuid.uuid4().hex[:8]
|
||||||
|
sessions_dir = TEST_STATE_DIR / "sessions"
|
||||||
|
sessions_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
now = time.time()
|
||||||
|
(sessions_dir / f"{sid}.json").write_text(json.dumps({
|
||||||
|
"session_id": sid,
|
||||||
|
"title": f"session with {_FULL_SECRET}",
|
||||||
|
"workspace": "/tmp",
|
||||||
|
"model": "test",
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
"pinned": False,
|
||||||
|
"archived": False,
|
||||||
|
"project_id": None,
|
||||||
|
"profile": "default",
|
||||||
|
"input_tokens": 0,
|
||||||
|
"output_tokens": 0,
|
||||||
|
"estimated_cost": None,
|
||||||
|
"personality": None,
|
||||||
|
"messages": [],
|
||||||
|
"tool_calls": [],
|
||||||
|
}))
|
||||||
|
return sid
|
||||||
|
|
||||||
|
|
||||||
|
@_needs_server
|
||||||
|
def test_api_sessions_search_redacts_titles(test_server):
|
||||||
|
sid = _write_session_with_secret_title()
|
||||||
|
data = _get("/api/sessions/search?q=" + urllib.parse.quote("B" * 24))
|
||||||
|
dump = json.dumps(data)
|
||||||
|
assert sid in dump
|
||||||
|
assert _FULL_SECRET not in dump
|
||||||
|
|
||||||
|
|
||||||
|
@_needs_server
|
||||||
|
def test_api_sessions_list_redacts_secret_titles(test_server):
|
||||||
|
sid = _write_session_with_secret_title()
|
||||||
|
data = _get("/api/sessions")
|
||||||
|
dump = json.dumps(data)
|
||||||
|
assert sid in dump
|
||||||
|
assert _FULL_SECRET not in dump
|
||||||
Reference in New Issue
Block a user