From 4de4ed9a1528b0a6cc6df09349fb146bf42cacbe Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 13 Apr 2026 22:20:21 -0700 Subject: [PATCH] fix(sessions): redact sensitive titles in session list and search responses (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sessions): redact titles in list and search responses * docs: v0.50.26 release — version badge and CHANGELOG --------- Co-authored-by: hinotoi-agent Co-authored-by: Nathan Esquenazi --- CHANGELOG.md | 7 +++ api/routes.py | 28 ++++++++--- static/index.html | 2 +- tests/test_session_summary_redaction.py | 66 +++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 tests/test_session_summary_redaction.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 26c3f8e..3687037 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 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 ### fix: restore mobile chat scrolling and drawer close (#397) diff --git a/api/routes.py b/api/routes.py index 34628f8..d872225 100644 --- a/api/routes.py +++ b/api/routes.py @@ -410,11 +410,13 @@ def handle_get(handler, parsed) -> bool: deduped_cli = [] merged = webui_sessions + deduped_cli 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: - if isinstance(s.get("title"), str): - s["title"] = _redact_text(s["title"]) - return j(handler, {"sessions": merged, "cli_count": len(deduped_cli)}) + item = dict(s) + if isinstance(item.get("title"), str): + 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": return j(handler, {"projects": load_projects()}) @@ -1192,12 +1194,21 @@ def _handle_sessions_search(handler, parsed): content_search = qs.get("content", ["1"])[0] == "1" depth = int(qs.get("depth", ["5"])[0]) 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 = [] for s in all_sessions(): title_match = q in (s.get("title") or "").lower() 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 if content_search: try: @@ -1212,7 +1223,10 @@ def _handle_sessions_search(handler, parsed): if isinstance(p, dict) and p.get("type") == "text" ) 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 except (KeyError, Exception): pass diff --git a/static/index.html b/static/index.html index 34c9b73..a61aa0c 100644 --- a/static/index.html +++ b/static/index.html @@ -535,7 +535,7 @@
System
- v0.50.25 + v0.50.26
diff --git a/tests/test_session_summary_redaction.py b/tests/test_session_summary_redaction.py new file mode 100644 index 0000000..dfa6728 --- /dev/null +++ b/tests/test_session_summary_redaction.py @@ -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