fix(sessions): widen #3023 to all 5 session-id validators via shared is_safe_session_id helper

PR #3023 only updated Session.load() and Session.load_metadata_only(), leaving
three sibling validators (Session-internal _repair_stale_pending and the
/api/session/worktree/remove + /api/session/delete route handlers) still
gated on the old lowercase-only character set.  That would have shipped a
confusing UX where api-* and reachy-voice-* sessions could be loaded into
the sidebar but rejected with HTTP 400 on delete or worktree removal.

This commit factors the validation into a single is_safe_session_id helper
in api.models and updates all five call sites to use it.  Adds regression
coverage in tests/test_issue3023_safe_session_id_validators.py for both
the helper itself and a repo-wide guarantee that no narrow lowercase-only
magic string survives.

Closes the follow-up flagged by the parallel reviewer agent on #3023.
This commit is contained in:
nesquena-hermes
2026-05-28 02:09:05 +00:00
parent d76e23a9f2
commit c1942a1cd8
3 changed files with 120 additions and 5 deletions

View File

@@ -54,6 +54,29 @@ _INDEX_WRITE_LOCK = threading.RLock()
_SESSION_INDEX_REBUILD_LOCK = threading.Lock()
_SESSION_INDEX_REBUILD_THREAD = None
# Path-safety contract for session IDs. Accept alphanumerics, underscore, and
# hyphen so API/gateway-issued ids (``api-*``, ``reachy-voice-*``) round-trip
# through filesystem load/save/delete/worktree paths without traversal risk.
# Dots and slashes are rejected so the id can never name a parent directory
# or hide an unexpected extension.
_SAFE_SID_CHARS = frozenset(
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-'
)
def is_safe_session_id(sid) -> bool:
"""Return True iff ``sid`` is a non-empty path-safe session id.
Centralizes the validation previously duplicated across
``Session.load``, ``Session.load_metadata_only``,
``_repair_stale_pending``, ``/api/session/worktree/remove``, and
``/api/session/delete`` so every call site agrees on what characters
are allowed. See #3023.
"""
if not sid or not isinstance(sid, str):
return False
return all(c in _SAFE_SID_CHARS for c in sid)
def _cleanup_stale_tmp_files() -> None:
"""Best-effort removal of stale ``*.tmp.*`` files from SESSION_DIR.
@@ -693,7 +716,7 @@ class Session:
# 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):
if not is_safe_session_id(sid):
return None
p = SESSION_DIR / f'{sid}.json'
if not p.exists():
@@ -722,7 +745,7 @@ class Session:
"""
# 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):
if not is_safe_session_id(sid):
return None
p = SESSION_DIR / f'{sid}.json'
if not p.exists():
@@ -1871,7 +1894,7 @@ def _repair_stale_pending(session) -> bool:
_age = float('inf')
sid = session.session_id
if not sid or not all(c in '0123456789abcdefghijklmnopqrstuvwxyz_' for c in sid):
if not is_safe_session_id(sid):
return False
try: