Files
odysseus/tests/test_research_session_id_validation.py
Ernest Hysa cb6f6b65ea fix(research): validate session_id to block path traversal
Every research endpoint interpolates session_id into filesystem paths
(Path('data/deep_research') / f'{session_id}.json') without checking
for traversal sequences. A crafted ID like '../../data/auth' reaches
arbitrary JSON files — readable via research_detail (which also leaks
file paths in error messages), writable via research_archive, and
deletable via research_delete.

Add _validate_session_id() which rejects anything outside
[a-zA-Z0-9-]{1,128}. Called before filesystem access in all 12
endpoints that accept a session_id path parameter.
2026-06-01 23:25:38 +01:00

56 lines
1.8 KiB
Python

"""Regression tests: research session_id must reject path-traversal sequences."""
import re
import unittest
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
class TestResearchSessionIdValidation(unittest.TestCase):
"""Validate the regex used to guard research session_id path params."""
def test_accepts_rp_prefixed_id(self):
self.assertIsNotNone(_SESSION_ID_RE.fullmatch("rp-abc123def456"))
def test_accepts_standard_uuid(self):
self.assertIsNotNone(
_SESSION_ID_RE.fullmatch("550e8400-e29b-41d4-a716-446655440000")
)
def test_accepts_custom_alphanumeric(self):
self.assertIsNotNone(_SESSION_ID_RE.fullmatch("custom-id-123"))
def test_rejects_double_dot(self):
self.assertIsNone(_SESSION_ID_RE.fullmatch(".."))
def test_rejects_single_dot(self):
self.assertIsNone(_SESSION_ID_RE.fullmatch("."))
def test_rejects_dot_slash_traversal(self):
self.assertIsNone(_SESSION_ID_RE.fullmatch("../../data/auth"))
def test_rejects_deep_traversal(self):
self.assertIsNone(_SESSION_ID_RE.fullmatch("../../../etc/passwd"))
def test_rejects_mixed_traversal(self):
self.assertIsNone(_SESSION_ID_RE.fullmatch("normal/../../traversal"))
def test_rejects_dot_prefix_traversal(self):
self.assertIsNone(_SESSION_ID_RE.fullmatch("./../../secret"))
def test_rejects_empty(self):
self.assertIsNone(_SESSION_ID_RE.fullmatch(""))
def test_rejects_whitespace(self):
self.assertIsNone(_SESSION_ID_RE.fullmatch(" "))
def test_rejects_slash(self):
self.assertIsNone(_SESSION_ID_RE.fullmatch("a/b"))
def test_rejects_null_byte(self):
self.assertIsNone(_SESSION_ID_RE.fullmatch("rp-test\x00"))
if __name__ == "__main__":
unittest.main()