Skip to content

Commit 9d8a2ba

Browse files
security(dci): fail closed on unresolved search sender + cap page_size (#260)
* security(dci): fail closed on unresolved sender (search) + cap page_size DCI search reached an unscoped sudo res.partner search whenever no active sender was resolved: with sender=None/False, DCIConsentAdapter disengages (build_consented_domain returns the base domain unchanged), so full PII is returned. Three routes funnel into this: the sync search route, the async search route, and bulk upload (all via DCISocialSearchService / transaction.process_async_search). page_size was also unbounded (schema only enforces > 0), enabling whole-registry enumeration in one request. Fail closed across the shared sink and the request paths: - routers/search.py: reject (403) when the verified sender doesn't resolve to an active registered sender; never run with sender=None (removed `or None`). - middleware/signature.py: verify_dci_signature requires an active sender, so a deactivated sender cannot authenticate (keeps verify and resolution in sync). - models/transaction.py: process_async_search refuses (rejected, no search) when the transaction has no resolved sender -- the shared sink guard that protects the async and bulk paths even before their route-level checks. - routers/async_router.py: async_search rejects (403) an unresolved sender instead of persisting sender_id=False and queuing an unscoped job. - search_service.py: clamp page_size to dci.max_page_size (default 100). The report's literal claim (service built without a sender) was already fixed by 0944b7f; this closes the residual fail-open resolution + unbounded page_size. Separately tracked follow-ups: bulk_upload accepts an UNSIGNED, caller-asserted sender_id (impersonation -- needs signature binding), and the dead consent domain (status=="active" matches nothing for require_consent senders; functional, fail-closed). Tests: unresolved/inactive sender -> 403 (sync + async) / 401 (signature); sink refuses sender-less transaction; page_size clamped. spp_dci_server 325/325, spp_dci_server_social 75/75. * fix(dci): address CI/review on the search consent hardening - pre-commit (semgrep): ruff-format had wrapped the signature.py sudo lookup across lines, pushing the nosemgrep off the matched `.sudo()` line. Keep `.sudo()` on a single line with the nosemgrep directly above (and likewise for the new search_service config read). Semgrep passes locally. - transaction.py (Gemini security-high): a Many2one still dereferences a DEACTIVATED record, and process_async_search runs asynchronously, so a sender deactivated after the transaction was created would slip past `if not self.sender_id`. Check `not self.sender_id.active` too (fail closed). - search_service.py (Gemini medium): a misconfigured dci.max_page_size (empty/non-integer) would crash int(); wrap in try/except, fall back to 100. Tests: add a sink test for a deactivated sender -> refused. spp_dci_server 326/326, spp_dci_server_social 75/75. * security(dci): treat non-positive max_page_size as default, not no-cap A non-positive dci.max_page_size (0 or negative) previously skipped the page_size clamp entirely, silently disabling the cap and letting a client pull an arbitrarily large page. Treat such a misconfiguration as the default (100) so the cap can never be turned off by accident. Addresses staff-review nit on PR #260.
1 parent 9102547 commit 9d8a2ba

9 files changed

Lines changed: 245 additions & 21 deletions

File tree

spp_dci_server/middleware/signature.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,12 +177,15 @@ async def verify_dci_signature(
177177
error_code="err.signature.missing",
178178
)
179179

180-
# Look up sender in registry
180+
# Look up sender in registry. Require an ACTIVE record: a deactivated
181+
# sender must not authenticate (and downstream consent resolution also
182+
# requires active, so accepting an inactive sender here would desync).
181183
# nosemgrep: odoo-sudo-without-context
182-
sender_registry = env["spp.dci.sender.registry"].sudo().search([("sender_id", "=", sender_id)], limit=1)
184+
SenderRegistry = env["spp.dci.sender.registry"].sudo()
185+
sender_registry = SenderRegistry.search([("sender_id", "=", sender_id), ("active", "=", True)], limit=1)
183186

184187
if not sender_registry:
185-
_logger.warning("Unknown sender_id in DCI request: %s", sender_id)
188+
_logger.warning("Unknown sender_id (inactive or unregistered) in DCI request: %s", sender_id)
186189
raise DCIHTTPException(
187190
status_code=status.HTTP_401_UNAUTHORIZED,
188191
error_message=f"Unknown sender_id: {sender_id}",

spp_dci_server/models/transaction.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,23 @@ def process_async_search(self):
210210
self.ensure_one()
211211
self.state = "processing"
212212

213+
# Fail closed: a search with no resolved (or no longer active) sender
214+
# would disengage consent filtering (DCIConsentAdapter has no sender)
215+
# and return unscoped PII. A Many2one still dereferences a deactivated
216+
# record, and this job runs asynchronously, so the sender may have been
217+
# deactivated after the transaction was created -- check active too.
218+
# Every caller that reaches this sink (sync search is guarded at the
219+
# route; async/bulk reach here) must have an active sender resolved.
220+
if not self.sender_id or not self.sender_id.active:
221+
self.state = "rejected"
222+
self.error_code = SearchStatusReasonCode.SEARCH_CRITERIA_INVALID.value
223+
self.error_message = "No active registered sender resolved for this transaction; search refused."
224+
_logger.warning(
225+
"DCI async search refused: transaction %s has no resolved sender",
226+
self.transaction_id,
227+
)
228+
return
229+
213230
try:
214231
# Parse request
215232
request_data = json.loads(self.request_payload)

spp_dci_server/routers/async_router.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -146,10 +146,21 @@ async def async_search(
146146
) from e
147147

148148
# SECURITY: Use verified_sender_id from signature verification
149-
# instead of reading from envelope.header to prevent spoofing
150-
# Use sudo() for API access - authentication is handled by signature verification
149+
# instead of reading from envelope.header to prevent spoofing.
150+
# Fail closed: require an ACTIVE registered sender. Persisting
151+
# sender_id=False here would later run the search with no sender, which
152+
# disengages consent filtering and returns unscoped PII.
151153
# nosemgrep: odoo-sudo-without-context — DCI protocol handler with JWT/signature verification
152-
sender = env["spp.dci.sender.registry"].sudo().search([("sender_id", "=", verified_sender_id)], limit=1)
154+
sender = env["spp.dci.sender.registry"].sudo().get_by_sender_id(verified_sender_id)
155+
if not sender:
156+
_logger.warning(
157+
"Async search rejected: sender %s is not an active registered sender",
158+
verified_sender_id,
159+
)
160+
raise HTTPException(
161+
status_code=status.HTTP_403_FORBIDDEN,
162+
detail="Sender is not an active registered DCI sender",
163+
)
153164

154165
# Get callback URI from header
155166
callback_uri = envelope.header.sender_uri
@@ -171,7 +182,7 @@ async def async_search(
171182
"correlation_id": correlation_id,
172183
"action": "search",
173184
"reg_type": "SOCIAL_REGISTRY", # Default, could be derived from request
174-
"sender_id": sender.id if sender else False,
185+
"sender_id": sender.id,
175186
"sender_uri": verified_sender_id,
176187
"callback_uri": callback_uri,
177188
"request_payload": json.dumps(envelope.model_dump(mode="json")),
@@ -183,7 +194,7 @@ async def async_search(
183194
_logger.info(
184195
"Created async search transaction %s for sender_id=%s",
185196
transaction.transaction_id,
186-
sender.id if sender else "unknown",
197+
sender.id,
187198
)
188199

189200
# Queue the search job with job_worker

spp_dci_server/routers/search.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,19 +99,31 @@ async def search_registry(
9999
len(search_request.search_request),
100100
)
101101

102+
# Resolve the authenticated sender to an ACTIVE registered sender record
103+
# before returning any data. The consent adapter disengages (no filtering)
104+
# when sender is None, so we must fail closed here rather than run a
105+
# consent-bearing search with sender=None and leak unscoped PII.
106+
# sudo: technical lookup of an already-verified sender id; the endpoint
107+
# user (often public) has no read access to the registry.
108+
SenderRegistry = env["spp.dci.sender.registry"].sudo() # nosemgrep: odoo-sudo-without-context
109+
sender_registry = SenderRegistry.get_by_sender_id(verified_sender_id)
110+
if not sender_registry:
111+
_logger.warning(
112+
"DCI search rejected: sender %s is not an active registered sender",
113+
verified_sender_id,
114+
)
115+
raise HTTPException(
116+
status_code=status.HTTP_403_FORBIDDEN,
117+
detail="Sender is not an active registered DCI sender",
118+
)
119+
102120
# Execute search using DCISocialSearchService
103121
try:
104122
from odoo.addons.spp_dci_server_social.services.search_service import (
105123
DCISocialSearchService,
106124
)
107125

108-
# Hand the verified sender to the service so consent filtering
109-
# engages - the consent adapter disengages when sender is None.
110-
# sudo: technical lookup of an already-verified sender id; the
111-
# endpoint user (often public) has no read access to the registry.
112-
SenderRegistry = env["spp.dci.sender.registry"].sudo() # nosemgrep: odoo-sudo-without-context
113-
sender_registry = SenderRegistry.get_by_sender_id(verified_sender_id)
114-
search_service = DCISocialSearchService(env, sender_registry=sender_registry or None)
126+
search_service = DCISocialSearchService(env, sender_registry=sender_registry)
115127
search_response = search_service.execute_search(search_request)
116128
_logger.info(
117129
"DCI search completed - transaction_id: %s, items: %d",

spp_dci_server/tests/test_async_router.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,64 @@ def test_unexpected_error_returns_500(self):
135135
self._call(envelope)
136136
self.assertEqual(ctx.exception.status_code, 500)
137137

138+
def test_unresolved_sender_rejected(self):
139+
"""A verified sender that doesn't resolve to an active registry record
140+
must be rejected (403), not queued with sender_id=False (which would run
141+
the background search unscoped and leak unscoped PII to the callback)."""
142+
envelope = self._build_envelope()
143+
with self.assertRaises(HTTPException) as ctx:
144+
_run(
145+
self.async_router.async_search(
146+
envelope,
147+
self.env,
148+
_bearer_token="t",
149+
verified_sender_id="ghost.sender.unregistered",
150+
_rate_limit_check=None,
151+
response=Response(),
152+
)
153+
)
154+
self.assertEqual(ctx.exception.status_code, 403)
155+
156+
def test_process_async_search_without_sender_is_refused(self):
157+
"""The shared async sink must refuse a transaction with no resolved
158+
sender rather than run a consent-disengaged (unscoped) search. This
159+
protects every caller of the sink (async + bulk)."""
160+
txn = self.Transaction.sudo().create(
161+
{
162+
"transaction_id": f"txn-nosender-{uuid.uuid4()}",
163+
"message_id": f"msg-{uuid.uuid4()}",
164+
"correlation_id": str(uuid.uuid4()),
165+
"action": "search",
166+
"reg_type": "SOCIAL_REGISTRY",
167+
"sender_id": False,
168+
"request_payload": json.dumps({"message": {"transaction_id": "t", "search_request": []}}),
169+
"state": "received",
170+
}
171+
)
172+
txn.process_async_search()
173+
self.assertEqual(txn.state, "rejected")
174+
self.assertIn("sender", (txn.error_message or "").lower())
175+
176+
def test_process_async_search_with_inactive_sender_is_refused(self):
177+
"""A sender deactivated after the transaction was created must also be
178+
refused: a Many2one still dereferences the deactivated record, so a
179+
plain truthiness check would let the unscoped search through."""
180+
txn = self.Transaction.sudo().create(
181+
{
182+
"transaction_id": f"txn-inactive-{uuid.uuid4()}",
183+
"message_id": f"msg-{uuid.uuid4()}",
184+
"correlation_id": str(uuid.uuid4()),
185+
"action": "search",
186+
"reg_type": "SOCIAL_REGISTRY",
187+
"sender_id": self.test_sender.id,
188+
"request_payload": json.dumps({"message": {"transaction_id": "t", "search_request": []}}),
189+
"state": "received",
190+
}
191+
)
192+
self.test_sender.active = False
193+
txn.process_async_search()
194+
self.assertEqual(txn.state, "rejected")
195+
138196

139197
# =============================================================================
140198
# /subscribe

spp_dci_server/tests/test_search_router.py

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,10 @@ def _build_response(self, statuses=("succ",)):
8383
search_response=items,
8484
)
8585

86-
def _call(self, envelope, search_response, sender_id="external.test.gov"):
86+
def _call(self, envelope, search_response, sender_id=None):
87+
# Default to the active test sender so the route's fail-closed sender
88+
# resolution passes; tests probing rejection pass an explicit bad id.
89+
sender_id = sender_id or self.test_sender.sender_id
8790
with patch(
8891
"odoo.addons.spp_dci_server_social.services.search_service.DCISocialSearchService"
8992
) as mock_service_cls:
@@ -212,6 +215,40 @@ def test_sync_search_sender_lookup_survives_low_privilege_endpoint_user(self):
212215
)
213216
self.assertEqual(result.header.status, "succ")
214217

218+
def test_unresolved_sender_is_rejected(self):
219+
"""A verified sender_id that does not resolve to an active registry
220+
record must be rejected (403), never run with sender=None (which would
221+
disengage consent filtering and return unscoped PII)."""
222+
envelope = self._build_envelope()
223+
with self.assertRaises(HTTPException) as ctx:
224+
_run(
225+
self.search_registry(
226+
envelope,
227+
self.env,
228+
_bearer_token="t",
229+
verified_sender_id="ghost-sender-not-registered",
230+
_rate_limit_check=None,
231+
)
232+
)
233+
self.assertEqual(ctx.exception.status_code, 403)
234+
235+
def test_inactive_sender_is_rejected(self):
236+
"""A deactivated sender (which can still pass signature lookup if that
237+
lookup omits the active filter) must not get data via the search path."""
238+
self.test_sender.active = False
239+
envelope = self._build_envelope()
240+
with self.assertRaises(HTTPException) as ctx:
241+
_run(
242+
self.search_registry(
243+
envelope,
244+
self.env,
245+
_bearer_token="t",
246+
verified_sender_id=self.test_sender.sender_id,
247+
_rate_limit_check=None,
248+
)
249+
)
250+
self.assertEqual(ctx.exception.status_code, 403)
251+
215252
# --- service errors -------------------------------------------------------
216253

217254
def test_search_service_exception_rejects_all_items(self):
@@ -225,7 +262,7 @@ def test_search_service_exception_rejects_all_items(self):
225262
envelope,
226263
self.env,
227264
_bearer_token="t",
228-
verified_sender_id="s",
265+
verified_sender_id=self.test_sender.sender_id,
229266
_rate_limit_check=None,
230267
)
231268
)
@@ -251,7 +288,7 @@ def test_social_module_import_error_falls_back_to_rjct(self):
251288
envelope,
252289
self.env,
253290
_bearer_token="t",
254-
verified_sender_id="s",
291+
verified_sender_id=self.test_sender.sender_id,
255292
_rate_limit_check=None,
256293
)
257294
)
@@ -291,7 +328,7 @@ def test_signing_failure_is_tolerated(self):
291328
envelope,
292329
self.env,
293330
_bearer_token="t",
294-
verified_sender_id="s",
331+
verified_sender_id=self.test_sender.sender_id,
295332
_rate_limit_check=None,
296333
)
297334
)
@@ -320,7 +357,7 @@ def test_unexpected_error_returns_500(self):
320357
envelope,
321358
self.env,
322359
_bearer_token="t",
323-
verified_sender_id="s",
360+
verified_sender_id=self.test_sender.sender_id,
324361
_rate_limit_check=None,
325362
)
326363
)

spp_dci_server/tests/test_signature_middleware.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,23 @@ def test_verify_missing_sender(self):
8686
self.assertEqual(exc.status_code, 401, "Should return 401 Unauthorized")
8787
self.assertIn("unknown sender", exc.detail.lower())
8888

89+
def test_verify_inactive_sender_rejected(self):
90+
"""A deactivated sender must not authenticate, even with a valid
91+
signature (the registry lookup requires active=True)."""
92+
self.test_sender.active = False
93+
envelope_data = self.create_signed_envelope(
94+
sender_id=self.test_sender_id,
95+
receiver_id="registry.test.gov",
96+
action="search",
97+
)
98+
99+
import asyncio
100+
101+
with self.assertRaises(HTTPException) as context:
102+
asyncio.run(self._verify_signature(envelope_data))
103+
104+
self.assertEqual(context.exception.status_code, 401, "Inactive sender should be rejected")
105+
89106
def test_verify_invalid_signature(self):
90107
"""Test that bad signature returns 401 Unauthorized."""
91108
# Create a properly signed envelope

spp_dci_server_social/services/search_service.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,20 @@ def _process_search_item(self, search_req: SearchRequestItem) -> SearchResponseI
218218
# Apply consent filtering if available
219219
domain = self._apply_consent_filter(domain)
220220

221-
# Get pagination parameters
221+
# Get pagination parameters. Cap page_size so a single request cannot
222+
# pull/enumerate the whole registry (the schema only enforces > 0).
222223
page_size = criteria.pagination.page_size if criteria.pagination else 20
224+
# nosemgrep: odoo-sudo-without-context
225+
config = self.env["ir.config_parameter"].sudo()
226+
try:
227+
max_page_size = int(config.get_param("dci.max_page_size", 100))
228+
except (TypeError, ValueError):
229+
max_page_size = 100
230+
# A non-positive value is a misconfiguration, not "no limit": fall back
231+
# to the default so the cap can never be silently disabled.
232+
if max_page_size <= 0:
233+
max_page_size = 100
234+
page_size = min(page_size, max_page_size)
223235
page_number = criteria.pagination.page_number if criteria.pagination else 1
224236

225237
# OpenSPP extension: cursor-based pagination

spp_dci_server_social/tests/test_search_service.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,63 @@ def test_search_pagination(self):
491491
self.assertLessEqual(len(response_item.data.reg_records), 2)
492492
self.assertGreaterEqual(response_item.pagination.total_count, 3) # At least our 3 individuals
493493

494+
def test_search_caps_page_size(self):
495+
"""page_size is clamped to dci.max_page_size so a single request cannot
496+
enumerate the whole registry (the schema only enforces page_size > 0)."""
497+
self.env["ir.config_parameter"].sudo().set_param("dci.max_page_size", "1")
498+
criteria = SearchCriteria(
499+
reg_type="SOCIAL_REGISTRY",
500+
reg_event_type="ACTIVE",
501+
query_type="expression",
502+
query={"seq": []}, # match all registrants
503+
pagination=PaginationRequest(page_size=1000, page_number=1),
504+
)
505+
search_req = SearchRequestItem(
506+
reference_id="test-ref-cap",
507+
timestamp=datetime.now(UTC),
508+
search_criteria=criteria,
509+
)
510+
request = SearchRequest(transaction_id="test-txn-cap", search_request=[search_req])
511+
self.env.user.write({"group_ids": [(4, self.env.ref("spp_registry.group_registry_viewer").id)]})
512+
513+
response = self.search_service.execute_search(request)
514+
515+
item = response.search_response[0]
516+
self.assertEqual(item.status, "succ")
517+
# Returned page and reported page_size are clamped to the cap (1),
518+
# even though there are >= 3 matching registrants.
519+
self.assertLessEqual(len(item.data.reg_records), 1)
520+
self.assertEqual(item.pagination.page_size, 1)
521+
self.assertGreaterEqual(item.pagination.total_count, 3)
522+
523+
def test_search_non_positive_cap_falls_back_to_default(self):
524+
"""A non-positive dci.max_page_size (0 or negative) must NOT disable the
525+
cap. Such a misconfiguration should fall back to the default (100), not
526+
leave page_size unbounded at whatever the client requested."""
527+
self.env["ir.config_parameter"].sudo().set_param("dci.max_page_size", "0")
528+
criteria = SearchCriteria(
529+
reg_type="SOCIAL_REGISTRY",
530+
reg_event_type="ACTIVE",
531+
query_type="expression",
532+
query={"seq": []}, # match all registrants
533+
pagination=PaginationRequest(page_size=1000, page_number=1),
534+
)
535+
search_req = SearchRequestItem(
536+
reference_id="test-ref-cap0",
537+
timestamp=datetime.now(UTC),
538+
search_criteria=criteria,
539+
)
540+
request = SearchRequest(transaction_id="test-txn-cap0", search_request=[search_req])
541+
self.env.user.write({"group_ids": [(4, self.env.ref("spp_registry.group_registry_viewer").id)]})
542+
543+
response = self.search_service.execute_search(request)
544+
545+
item = response.search_response[0]
546+
self.assertEqual(item.status, "succ")
547+
# The client asked for 1000; a 0 cap must clamp to the default 100,
548+
# never honor the unbounded request.
549+
self.assertEqual(item.pagination.page_size, 100)
550+
494551
def test_search_pagination_second_page(self):
495552
"""Test retrieving second page of results."""
496553
criteria = SearchCriteria(

0 commit comments

Comments
 (0)