security(dci): fail closed on unresolved search sender + cap page_size#260
security(dci): fail closed on unresolved search sender + cap page_size#260gonzalesedwin1123 wants to merge 3 commits into
Conversation
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.
There was a problem hiding this comment.
Code Review
This pull request implements a fail-closed security mechanism by requiring an active registered sender for both synchronous and asynchronous search requests, preventing potential PII leaks. It also introduces a configurable maximum page size limit to prevent registry enumeration, alongside comprehensive unit tests. The review feedback highlights two important improvements: explicitly checking self.sender_id.active in the transaction model to prevent deactivated senders from bypassing checks due to Odoo's Many2one behavior, and wrapping the dci.max_page_size integer conversion in a try-except block to handle potential database misconfigurations gracefully.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## 19.0 #260 +/- ##
==========================================
+ Coverage 74.75% 75.22% +0.46%
==========================================
Files 1090 1092 +2
Lines 64813 64900 +87
==========================================
+ Hits 48453 48818 +365
+ Misses 16360 16082 -278
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
- 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.
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.
Summary
DCI search could reach an unscoped
sudores.partnersearch whenever no active sender was resolved. Withsender=None/False,DCIConsentAdapterdisengages (build_consented_domainreturns the base domain unchanged), so full registrant PII (identifiers, names, birth dates, addresses, phones, emails, members) is returned with no consent/legal-basis check. Three routes funnel into this sink (DCISocialSearchService/transaction.process_async_search): sync search, async search, and bulk upload. Separately,page_sizehad no upper bound (the schema only enforces> 0), enabling whole-registry enumeration in one request.The report's literal claim (service constructed without a sender) was already fixed by
0944b7ff; this PR closes the residual fail-open resolution and the unbounded page_size, and hardens the shared sink so every path is covered.Fix (fail closed across the shared sink + request paths)
routers/search.py— reject (403) when the verified sender doesn't resolve to an active registered sender; never run withsender=None(removedor None).middleware/signature.py—verify_dci_signaturenow requires an active sender record, so a deactivated sender can't authenticate (keeps verification and resolution in sync; previously verify omitted theactivefilter).models/transaction.py—process_async_searchrefuses (rejected, no search) when the transaction has no resolved sender. This is the shared-sink guard that protects the async and bulk paths even independent of their route-level checks.routers/async_router.py—async_searchrejects (403) an unresolved sender instead of persistingsender_id=Falseand queuing an unscoped job (closes the dev/unsigned-mode residual).services/search_service.py— clamppage_sizetodci.max_page_size(system param, default 100) so one request can't enumerate the registry.Found by post-implementation review; tracked as separate follow-ups (not this PR)
bulk_upload.pyaccepts an UNSIGNED, caller-assertedsender_id(Form(...), noverify_dci_signature) → impersonation risk; needs signature binding. (Its unscoped-dump path — unresolved sender →False— is closed here by the sink guard; the impersonation/auth-design gap is the follow-up.)build_consented_domainappends("consent_ids.status","=","active"), and there is noactivestatus, sois_require_consentsenders match nothing (functional, fail-closed — not a leak). Make it a correct consent domain.Tests
Unresolved/inactive sender → 403 (sync + async) and 401 (signature middleware); the shared sink refuses a sender-less transaction;
page_sizeclamped (1000 → cap). All green:spp_dci_server325/325 ·spp_dci_server_social75/75. Lint-clean.A post-implementation adversarial review of the sync fix verdict'd it safe and surfaced the sibling sink/async/bulk exposure (the sink + async hardening here implement its recommendation; bulk-auth deferred as above).