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 @@