diff --git a/api/models.py b/api/models.py index 1ab6e537..293dc6b9 100644 --- a/api/models.py +++ b/api/models.py @@ -690,8 +690,10 @@ class Session: @classmethod def load(cls, sid): - # Validate session ID format to prevent path traversal - if not sid or not all(c in '0123456789abcdefghijklmnopqrstuvwxyz_' for c in sid): + # Validate session ID format to prevent path traversal. API/gateway + # session ids may contain hyphens (for example ``api-*`` and + # ``reachy-voice-*``); allow those but still reject dots/slashes. + if not sid or not all(c in '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-' for c in sid): return None p = SESSION_DIR / f'{sid}.json' if not p.exists(): @@ -718,7 +720,9 @@ class Session: top-level "messages" field and synthesize a small metadata-only object. Falls back to load() for legacy or unexpected file layouts. """ - if not sid or not all(c in '0123456789abcdefghijklmnopqrstuvwxyz_' for c in sid): + # Same path-safety contract as load(): hyphens are valid session ids, + # path separators and traversal dots are not. + if not sid or not all(c in '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-' for c in sid): return None p = SESSION_DIR / f'{sid}.json' if not p.exists(): diff --git a/tests/test_session_index.py b/tests/test_session_index.py index 4a137206..91db78c1 100644 --- a/tests/test_session_index.py +++ b/tests/test_session_index.py @@ -83,6 +83,28 @@ def test_compact_exposes_last_message_at_from_message_timestamp(): assert compact["last_message_at"] == 200.0 +def test_session_load_allows_hyphenated_safe_ids_but_rejects_traversal(): + sid = "api-182894de593468b6" + s = _make_session(sid, "API session", updated_at=100) + s.path.write_text(json.dumps(s.__dict__, ensure_ascii=False, indent=2), encoding="utf-8") + + assert Session.load(sid) is not None + assert Session.load_metadata_only(sid) is not None + assert Session.load("bad/../id") is None + assert Session.load_metadata_only("bad.id") is None + + +def test_full_index_rebuild_includes_hyphenated_sessions(): + sid = "reachy-voice-20260513-1131-d5542adf" + s = _make_session(sid, "Reachy voice", updated_at=100) + s.path.write_text(json.dumps(s.__dict__, ensure_ascii=False, indent=2), encoding="utf-8") + + _write_session_index(updates=None) + + ids = [entry["session_id"] for entry in _read_index(models.SESSION_INDEX_FILE)] + assert sid in ids + + def test_prune_session_from_index_removes_requested_row_only(): index_file = models.SESSION_INDEX_FILE s_a = _make_session("sess_a", "A", updated_at=100)