Reject an oversized statuses filter before it reaches Firestore - #10038
Conversation
GET /v2/integrations/{app_id}/conversations declares
statuses: List[str] = Query([]),
with no length cap, and passes it straight to conversations_db.get_conversations, which builds
conversations_ref.where(filter=FieldFilter('status', 'in', statuses))
Firestore rejects an `in` filter with more than 30 values. This repo already knows that and
guards for it in two other places (database/apps.py: "Firestore 'in' limited to 30 items",
database/chat.py: "Firestore IN operator supports max 30 values, so chunk the queries"). This
route has no such guard, and nothing wraps the stream() call, so a request repeating the query
key more than thirty times raises out of the Firestore client and surfaces as an unhandled
HTTP 500 rather than a 4xx.
ConversationStatus has five legitimate values (in_progress, processing, merging, completed,
failed), so no real client is anywhere near this. It is malformed-input surface reachable by any
caller holding a valid per-app integration API key.
The cap rejects rather than clamps, matching this file's own idiom (every other invalid-input
case raises HTTPException) and the convention for oversized list inputs elsewhere in the codebase
(action_items.py bounds its id lists with Field(..., max_length=...) and rejects). With only five
valid values, a cap of twenty cannot reject a request any real client would send.
The check is in the handler body rather than as Query(..., max_length=20) on purpose. Declaring it
on the parameter would change the generated OpenAPI, and docs/api-reference is a compatibility
boundary; the Query([]) declaration is left byte-identical so no spec or generated client moves.
It also keeps the bound testable: a Query-level constraint is enforced by FastAPI's request
parsing, which a direct handler call bypasses entirely, so an in-body check is the only form a
hermetic unit test can actually exercise.
Tests (tests/unit/test_integration_conversations_statuses_cap.py):
- an oversized statuses list is rejected with 400 before reaching the database
- a normal statuses list reaches the database unmodified
The fake standing in for the database seam mirrors real Firestore behaviour by raising above
thirty values, so the test asserts what the DB layer actually received rather than only the
response shape.
Verification (run in backend/):
- pytest tests/unit/test_integration_conversations_statuses_cap.py: 2 passed
- prove-fail: with routers/integration.py reverted to origin/main and confirmed byte-identical
(git diff origin/main empty), the oversized case fails with
"expected a clean HTTPException(400), got Exception(\"400 Bad Request: 'in' filters support a
maximum of 30 elements.\")", while the normal-path test passes both ways. Re-applied: 2 passed
- pytest with test_integration_malformed_records.py: 10 passed
- the test imports routers.integration at module scope, so the heavy router import is collection
cost rather than per-test CPU; both tests are 0.02s or less in the call phase and the file
passes under the strict duration guard (BACKEND_FAST_UNIT_FAIL_SECONDS=0.12, exit 0)
- black --line-length 120 --skip-string-normalization --check: clean
- scripts/check_module_stub_pollution.py: 736 files, 0 violations
- scripts/scan_import_time_side_effects.py: 717 files, 0 violations
- routers/integration.py is 740 lines and is not in the product file line-count ratchet baseline
kodjima33
left a comment
There was a problem hiding this comment.
Backend hardening (reject oversized statuses filter). Approve-only — Hygiene CI red; author to fix lint.
|
Verified this end-to-end and it holds up cleanly. The route was forwarding an unbounded I checked each claim in the description against the source:
The tests cover the right behavior: an oversized list is rejected as a clean 400 without ever reaching the db seam, and a normal list passes through unmodified. One small note, not blocking: the Strong positive signal from automated review. Leaving formal approval to a human maintainer per the repo's review policy on integration/data-handling-adjacent endpoints. by AI on behalf of David — if you need David’s attention urgently, please @Git-on-my-level and escalate with |
GET /v2/integrations/{app_id}/conversationsdeclareswith no length cap, and passes it straight to
conversations_db.get_conversations, which buildsFirestore rejects an
infilter with more than 30 values. This repo already knows that andguards for it in two other places:
database/apps.py:# Firestore 'in' limited to 30 itemsdatabase/chat.py:# Firestore IN operator supports max 30 values, so chunk the queriesThis route has no such guard, and nothing wraps the
stream()call, so a request repeating thequery key more than thirty times (
?statuses=a&statuses=b&...) raises out of the Firestore clientand surfaces as an unhandled HTTP 500 rather than a 4xx.
ConversationStatushas five legitimate values (in_progress,processing,merging,completed,failed), so no real client is anywhere near this. It is malformed-input surfacereachable by any caller holding a valid per-app integration API key.
Two deliberate choices
Reject rather than clamp. That matches this file's own idiom — every other invalid-input case
raises
HTTPException— and the convention for oversized list inputs elsewhere(
action_items.pybounds its id lists withField(..., max_length=...)and rejects). With onlyfive valid values, a cap of twenty cannot reject anything a real client would send.
In the handler body, not
Query(..., max_length=20). Declaring it on the parameter wouldchange the generated OpenAPI, and
docs/api-referenceis a compatibility boundary perbackend/AGENTS.md; theQuery([])declaration is left byte-identical so no spec or generatedclient moves. It also keeps the bound testable — a
Query-level constraint is enforced byFastAPI's request-parsing layer, which a direct handler call bypasses entirely, so an in-body
check is the only form a hermetic unit test can actually exercise.
Tests
tests/unit/test_integration_conversations_statuses_cap.py:The fake standing in for the database seam mirrors real Firestore behaviour by raising above
thirty values, so the test asserts what the DB layer actually received rather than only the
response shape.
Verification
Run in
backend/:pytest tests/unit/test_integration_conversations_statuses_cap.py: 2 passedrouters/integration.pyreverted to origin/main and confirmed byte-identical(
git diff origin/mainempty), the oversized case fails withexpected a clean HTTPException(400), got Exception("400 Bad Request: 'in' filters support a maximum of 30 elements."), while the normal-path test passes both ways. Re-applied: 2 passedtest_integration_malformed_records.py: 10 passedrouters.integrationat module scope, so the heavy router import is collectioncost rather than per-test CPU; both tests are 0.02s or less in the call phase, and the file
passes under the strict duration guard (
BACKEND_FAST_UNIT_FAIL_SECONDS=0.12, exit 0)black --line-length 120 --skip-string-normalization --check: cleanscripts/check_module_stub_pollution.py: 736 files, 0 violationsscripts/scan_import_time_side_effects.py: 717 files, 0 violationsrouters/integration.pyis 740 lines and is not in the product file line-count ratchet baselineRelated, not fixed here
routers/conversations.py's/v1/conversationshas the same underlying exposure through acomma-separated
statuses: Optional[str]parameter. Different parameter shape and a differentroute, so I left it alone rather than widen this PR; worth a follow-up.