From 6ab4c3d689233fc0e0a59ad95116279d4ffcb4a4 Mon Sep 17 00:00:00 2001 From: stumpylog <797416+stumpylog@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:37:28 -0700 Subject: [PATCH] fix(search): cap the global search query too GlobalSearchView calls the backend directly rather than through the shared helper the cap lives in, so "every query string is length-checked" was a claim with an exception rather than an invariant. It hardcodes SearchMode.TEXT, which is linear rather than quadratic, so this path was never the CPU-exhaustion vector and this is not a fix for one. It is capped so the invariant holds without a footnote: the view already bounds the query from below, and a later change letting it select a search mode would otherwise reopen the hole with nothing to catch it. Co-Authored-By: Claude Opus 5 --- .../tests/test_api_search_query_length.py | 34 +++++++++++++++++++ src/documents/views.py | 4 +++ 2 files changed, 38 insertions(+) diff --git a/src/documents/tests/test_api_search_query_length.py b/src/documents/tests/test_api_search_query_length.py index 9c2c39647..e94101cb2 100644 --- a/src/documents/tests/test_api_search_query_length.py +++ b/src/documents/tests/test_api_search_query_length.py @@ -149,3 +149,37 @@ class TestPostSelectionPathsEnforceTheCap: message = str(response.data["query"]) assert str(_MAX_QUERY_LENGTH) in message assert str(_MAX_QUERY_LENGTH + 1) in message + + +class TestGlobalSearchEnforcesTheCapToo: + """GlobalSearchView calls the backend directly, not through the shared helper. + + It hardcodes SearchMode.TEXT, which is linear rather than quadratic, so it + was never the CPU-exhaustion vector. It is capped anyway so that "every + user query string reaching the backend passes a length check" is an + invariant rather than a claim with an exception: the view already bounds + the query from below, and a later change letting it select a mode would + otherwise reopen the hole silently. + """ + + def test_query_one_over_the_cap_is_a_400( + self, + admin_client: APIClient, + indexed_document: Document, + ) -> None: + response = admin_client.get( + "/api/search/", + {"query": "a" * (_MAX_QUERY_LENGTH + 1)}, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_query_at_exactly_the_cap_is_accepted( + self, + admin_client: APIClient, + indexed_document: Document, + ) -> None: + response = admin_client.get( + "/api/search/", + {"query": "a" * _MAX_QUERY_LENGTH}, + ) + assert response.status_code == status.HTTP_200_OK diff --git a/src/documents/views.py b/src/documents/views.py index d4ce37fe3..15284f22c 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -3639,6 +3639,10 @@ class GlobalSearchView(PassUserMixin): return HttpResponseBadRequest("Query required") if len(query) < 3: return HttpResponseBadRequest("Query must be at least 3 characters") + if len(query) > _MAX_QUERY_LENGTH: + return HttpResponseBadRequest( + f"Query must be at most {_MAX_QUERY_LENGTH} characters", + ) db_only = request.query_params.get("db_only", False)