Skip to content

security(dci): fail closed on unresolved search sender + cap page_size#260

Open
gonzalesedwin1123 wants to merge 3 commits into
19.0from
security-dci-search-consent
Open

security(dci): fail closed on unresolved search sender + cap page_size#260
gonzalesedwin1123 wants to merge 3 commits into
19.0from
security-dci-search-consent

Conversation

@gonzalesedwin1123

Copy link
Copy Markdown
Member

Summary

DCI search could reach 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 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_size had 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 with sender=None (removed or None).
  • middleware/signature.pyverify_dci_signature now requires an active sender record, so a deactivated sender can't authenticate (keeps verification and resolution in sync; previously verify omitted the active filter).
  • models/transaction.pyprocess_async_search refuses (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.pyasync_search rejects (403) an unresolved sender instead of persisting sender_id=False and queuing an unscoped job (closes the dev/unsigned-mode residual).
  • services/search_service.py — clamp page_size to dci.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.py accepts an UNSIGNED, caller-asserted sender_id (Form(...), no verify_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.)
  • Dead consent domain: build_consented_domain appends ("consent_ids.status","=","active"), and there is no active status, so is_require_consent senders 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_size clamped (1000 → cap). All green: spp_dci_server 325/325 · spp_dci_server_social 75/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).

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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread spp_dci_server/models/transaction.py Outdated
Comment thread spp_dci_server_social/services/search_service.py Outdated
Comment thread spp_dci_server/middleware/signature.py Fixed
Comment thread spp_dci_server_social/services/search_service.py Fixed
@codecov

codecov Bot commented Jun 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.59259% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.22%. Comparing base (f04d936) to head (c53967d).

Files with missing lines Patch % Lines
spp_dci_server_social/services/search_service.py 75.00% 2 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            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     
Flag Coverage Δ
spp_base_common 90.26% <ø> (ø)
spp_dci_client_dr 85.60% <ø> (+31.98%) ⬆️
spp_dci_client_ibr 92.27% <ø> (+31.97%) ⬆️
spp_dci_compliance 92.76% <ø> (ø)
spp_dci_indicators 95.61% <ø> (ø)
spp_dci_server 90.34% <100.00%> (+0.06%) ⬆️
spp_dci_server_social 88.19% <75.00%> (-0.25%) ⬇️
spp_programs 65.12% <ø> (ø)
spp_registry 86.83% <ø> (ø)
spp_security 66.66% <ø> (ø)
spp_starter_farmer_registry 0.00% <ø> (?)
spp_starter_social_registry 0.00% <ø> (ø)
spp_starter_sp_mis 81.25% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
spp_dci_server/middleware/signature.py 91.53% <100.00%> (+0.06%) ⬆️
spp_dci_server/models/transaction.py 80.56% <100.00%> (+0.37%) ⬆️
spp_dci_server/routers/async_router.py 92.52% <100.00%> (+0.13%) ⬆️
spp_dci_server/routers/search.py 90.80% <100.00%> (+0.32%) ⬆️
spp_dci_server_social/services/search_service.py 89.93% <75.00%> (-0.39%) ⬇️

... and 10 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

- 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants