From 10166f4fe93e6d89a47713ccdc1b594bc1777cb9 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Thu, 10 Sep 2026 02:32:52 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITIC?= =?UTF-8?q?AL]=20Fix=20URL-encoded=20path=20traversal=20bypass=20in=20loca?= =?UTF-8?q?l=20HTTP=20request=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .jules/sentinel.md | 5 +++++ backend/core/local_http.py | 12 +++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) 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" From a9a9177f844f0ce1d7e7590061e6e3e59da92bdc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 11:43:10 +0900 Subject: [PATCH 2/2] test(security): cover nested local path decoding --- backend/tests/test_local_http.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) 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", [