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.
56 lines
1.8 KiB
Python
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()
|