Skip to content

Commit 9e27ad2

Browse files
authored
Reject non-positive result counts in the search endpoints (#1358)
1 parent 0842056 commit 9e27ad2

3 files changed

Lines changed: 59 additions & 3 deletions

File tree

core/web/apiv2/search.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,26 @@
11
from typing import Literal
22

33
from fastapi import APIRouter, Request
4-
from pydantic import BaseModel, ConfigDict
4+
from pydantic import BaseModel, ConfigDict, Field
55

66
from core.database_arango import ArangoYetiConnector
77

88
# API endpoints
99
router = APIRouter()
1010

11+
# Both search endpoints bucket results per type, so this bounds each bucket
12+
# independently -- a request with no root_type can still return this many
13+
# results for every type it searches.
14+
MAX_RESULTS_PER_TYPE = 50
15+
1116

1217
class SearchRequest(BaseModel):
1318
"""Global search request message."""
1419

1520
model_config = ConfigDict(extra="forbid")
1621

1722
query: str
18-
count_per_type: int = 5
23+
count_per_type: int = Field(default=5, ge=1, le=MAX_RESULTS_PER_TYPE)
1924

2025

2126
class SearchResultSection(BaseModel):
@@ -38,7 +43,12 @@ class SemanticSearchRequest(BaseModel):
3843
model_config = ConfigDict(extra="forbid")
3944

4045
query: str
41-
count: int = 10
46+
# ChromaDB rejects a non-positive n_results, so an unconstrained count
47+
# turns a client mistake into a server error. The ceiling bounds work
48+
# rather than payload: count is overfetched into n_results (see
49+
# _semantic_search_one_type) and each surviving candidate costs an ACL
50+
# check and a database read, per type queried.
51+
count: int = Field(default=10, ge=1, le=MAX_RESULTS_PER_TYPE)
4252
root_type: Literal["entity", "indicator", "dfiq"] | None = None
4353

4454

tests/apiv2/search.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from core.schemas import dfiq, entity, indicator, observable, rbac, roles, user
99
from core.schemas.user import UserSensitive
1010
from core.web import webapp
11+
from core.web.apiv2.search import MAX_RESULTS_PER_TYPE
1112

1213
client = TestClient(webapp.app)
1314

@@ -165,6 +166,26 @@ def test_search_count_per_type_limits_results(self) -> None:
165166
self.assertEqual(sections["entity"]["total"], 3, data)
166167
self.assertEqual(len(sections["entity"]["results"]), 2, data)
167168

169+
def test_search_rejects_count_per_type_outside_the_allowed_range(self) -> None:
170+
"""A non-positive count_per_type used to be accepted and silently
171+
return no results, which reads as "nothing matched" rather than
172+
"your request was wrong"."""
173+
for count_per_type in (0, -1, MAX_RESULTS_PER_TYPE + 1):
174+
with self.subTest(count_per_type=count_per_type):
175+
response = client.post(
176+
"/api/v2/search",
177+
json={"query": "test", "count_per_type": count_per_type},
178+
)
179+
self.assertEqual(response.status_code, 422, response.text)
180+
181+
for count_per_type in (1, MAX_RESULTS_PER_TYPE):
182+
with self.subTest(count_per_type=count_per_type):
183+
response = client.post(
184+
"/api/v2/search",
185+
json={"query": "test", "count_per_type": count_per_type},
186+
)
187+
self.assertEqual(response.status_code, 200, response.text)
188+
168189

169190
class searchRbacTest(unittest.TestCase):
170191
def setUp(self) -> None:

tests/core_tests/chromadb_test.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from core import database_arango
99
from core.schemas import entity, rbac, roles, user
1010
from core.web import webapp
11+
from core.web.apiv2.search import MAX_RESULTS_PER_TYPE
1112
from plugins.analytics.public.chromadb_indexer import ChromaDBIndexer
1213

1314
client = TestClient(webapp.app)
@@ -620,6 +621,30 @@ def test_documents_an_object_stops_producing_are_pruned(self, mock_get_client):
620621
}
621622
self.assertEqual(remaining, {"self", "approach:0"})
622623

624+
@mock.patch("core.chromadb_client.get_client")
625+
def test_count_outside_the_allowed_range_is_rejected(self, mock_get_client):
626+
"""count is overfetched into ChromaDB's n_results, which raises on any
627+
non-positive value -- unbounded, that surfaces as a 500 for what is a
628+
client error. The upper bound caps the ACL checks and database reads a
629+
single request can trigger."""
630+
mock_get_client.return_value = self.chroma_client
631+
632+
for count in (0, -1, MAX_RESULTS_PER_TYPE + 1):
633+
with self.subTest(count=count):
634+
response = client.post(
635+
"/api/v2/search/semantic",
636+
json={"query": "russian actor", "count": count},
637+
)
638+
self.assertEqual(response.status_code, 422, response.json())
639+
640+
for count in (1, MAX_RESULTS_PER_TYPE):
641+
with self.subTest(count=count):
642+
response = client.post(
643+
"/api/v2/search/semantic",
644+
json={"query": "russian actor", "count": count},
645+
)
646+
self.assertEqual(response.status_code, 200, response.json())
647+
623648

624649
class SimilarityScoreTest(unittest.TestCase):
625650
def test_converts_squared_l2_distance_to_a_bounded_similarity(self):

0 commit comments

Comments
 (0)