From 42da32c71eb2a1fb93d1e6c72f1a053d5fae8da0 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Wed, 30 Sep 2026 08:56:04 -0700 Subject: [PATCH] fix(proxy): reject paths containing encoded '?' or '#' The server decodes the path, so request.url.path (used for routing, auth, filter checks and forwarding) is truncated at a decoded '?'/'#'. e.g. DELETE /collections/c/items/a%3Fb was checked against and applied to item 'a'. Reject such requests instead of acting on another record. Co-Authored-By: Claude Opus 5.5 --- src/stac_auth_proxy/handlers/reverse_proxy.py | 9 +++++++++ tests/test_proxy.py | 12 ++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/stac_auth_proxy/handlers/reverse_proxy.py b/src/stac_auth_proxy/handlers/reverse_proxy.py index 40863d8c..f7feb8dd 100644 --- a/src/stac_auth_proxy/handlers/reverse_proxy.py +++ b/src/stac_auth_proxy/handlers/reverse_proxy.py @@ -82,6 +82,15 @@ def _prepare_headers(self, request: Request) -> MutableHeaders: async def proxy_request(self, request: Request) -> Response: """Proxy a request to the upstream STAC API.""" + # An encoded "?" or "#" in the path (e.g. item id "a%3Fb") is decoded by + # the server, so request.url.path is truncated at it ("a") and the + # request would act on a different record than the client addressed. + if "?" in request.scope["path"] or "#" in request.scope["path"]: + return Response( + status_code=400, + content='Path must not contain encoded "?" or "#"', + ) + headers = self._prepare_headers(request) # https://github.com/fastapi/fastapi/discussions/7382#discussioncomment-5136466 diff --git a/tests/test_proxy.py b/tests/test_proxy.py index 1ce9c904..515ed8e2 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -1,5 +1,6 @@ """Test authentication cases for the proxy app.""" +import pytest from fastapi.testclient import TestClient from utils import AppFactory, get_upstream_request @@ -38,3 +39,14 @@ async def test_proxied_headers_with_encoding(source_api_server, mock_upstream): proxied_request = await get_upstream_request(mock_upstream) assert proxied_request.headers.get("accept-encoding") == "gzip" + + +@pytest.mark.parametrize("encoded", ["%3F", "%23"]) +async def test_encoded_path_delimiters_rejected( + source_api_server, mock_upstream, encoded +): + """DELETE of item 'a?b' must not be truncated to, and applied to, item 'a'.""" + client = TestClient(app_factory(upstream_url=source_api_server)) + response = client.delete(f"/collections/c/items/a{encoded}b") + assert response.status_code == 400 + assert mock_upstream.call_count == 0