diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 9208f58b1..269bf3a61 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -138,3 +138,8 @@ **Vulnerability:** The `_safe_filename` function in `backend/services/attachment_parser.py` used `pathlib.Path().name` to strip directory components from attachment filenames, but failed to normalize backslashes beforehand. This allowed attackers to use Windows-style path separators (e.g., `..\..\upload`) to bypass path validation on POSIX systems. **Learning:** Checking for traversal sequences using `pathlib.Path().name` may leave the result vulnerable if the input path can contain Windows-style path separators but the program interprets it dynamically or decodes payloads using backslashes, because POSIX `pathlib` treats backslashes as valid filename characters, not separators. **Prevention:** Always convert backslashes to forward slashes before parsing filenames using `pathlib.Path().name`. + +## 2026-08-10 - [Prevent URL-Encoded Path Traversal in Local HTTP Request Validation] +**Vulnerability:** The `validate_local_request_target` function in `backend/core/local_http.py` checked for path traversals (like `..`) but only decoded the segment once using `urllib.parse.unquote`. This allowed attackers to bypass the validation by doubly URL-encoding the payload (e.g., `%252e%252e%252fadmin`). +**Learning:** Checking for traversal sequences on URL segments is insufficient if the input path can contain URL-encoded payloads and is only decoded once. The check could be bypassed since it happens after single decoding, yet the application or storage mechanism may later decode and use the dangerous payload. +**Prevention:** Always recursively decode `urllib.parse.unquote()` on raw input paths before validating to ensure doubly URL-encoded payloads are correctly decoded and caught, with a bounded loop to avoid DoS. diff --git a/backend/core/local_http.py b/backend/core/local_http.py index 97aa25575..87b0cc5be 100644 --- a/backend/core/local_http.py +++ b/backend/core/local_http.py @@ -110,8 +110,18 @@ def validate_local_request_target( "local request path contains invalid percent encoding" ) for raw_segment in parsed.path.split("/"): + decoded_segment = raw_segment try: - decoded_segment = unquote(raw_segment, errors="strict") + for _ in range(10): + next_segment = unquote(decoded_segment, errors="strict") + if next_segment == decoded_segment: + break + decoded_segment = next_segment + else: + if unquote(decoded_segment, errors="strict") != decoded_segment: + raise LocalHTTPValidationError( + "local request path contains excessive percent encoding" + ) except UnicodeDecodeError as exc: raise LocalHTTPValidationError( "local request path contains invalid percent encoding" diff --git a/backend/tests/test_local_http.py b/backend/tests/test_local_http.py index 6a99d9c04..3e4adf95f 100644 --- a/backend/tests/test_local_http.py +++ b/backend/tests/test_local_http.py @@ -133,6 +133,28 @@ def test_local_request_target_rejects_raw_and_encoded_traversal(path: str) -> No validate_local_request_target(path) +@pytest.mark.parametrize( + "path", + [ + "/api/%252e%252e%252fauth/session", + "/api/%25255cadmin", + "/api/%250Aadmin", + ], +) +def test_local_request_target_rejects_nested_encoded_unsafe_segments(path: str) -> None: + with pytest.raises(LocalHTTPValidationError, match="traversal|control characters"): + validate_local_request_target(path) + + +def test_local_request_target_rejects_excessive_percent_encoding_depth() -> None: + nested = "%2e" + for _ in range(11): + nested = nested.replace("%", "%25") + + with pytest.raises(LocalHTTPValidationError, match="excessive percent encoding"): + validate_local_request_target(f"/api/{nested}") + + @pytest.mark.parametrize( "path", [