Skip to content

security(registry): enforce configured search limits server-side in search_registrants#335

Draft
gonzalesedwin1123 wants to merge 6 commits into
19.0from
security-registry-search-limit-bypass
Draft

security(registry): enforce configured search limits server-side in search_registrants#335
gonzalesedwin1123 wants to merge 6 commits into
19.0from
security-registry-search-limit-bypass

Conversation

@gonzalesedwin1123

@gonzalesedwin1123 gonzalesedwin1123 commented Jul 23, 2026

Copy link
Copy Markdown
Member

Problem

spp_registry_search/models/res_partner.py::search_registrants (@api.model, callable by any
authenticated user via /web/dataset/call_kw) searches sensitive registrant PII — name, ID number,
phone, email. The administrator-configurable Registry Search controls added with this feature were
enforced only in the JavaScript portal client; the RPC itself applied none of them:

  1. min_chars bypass. The server's only term check was if not search_term. A direct call with
    a one-character term ran ilike across all PII fields. Worse: ilike does not escape SQL LIKE
    wildcards, so search_term="%%%" (three characters — passing even a naive length check) matched
    every registrant.
  2. Result-limit bypass. limit = max(10, min(200, limit or config_limit)) let the caller's
    limit override the configured spp_registry_search.result_limit, clamped only to the
    hardcoded [10, 200] — an admin cap of 20 was bypassed with limit=200.
  3. Targeted-mode downgrade. if search_mode == "targeted" and search_field: fell back to
    unified search over all PII fields whenever search_field was omitted (or invalid), silently
    undoing the admin's targeted-mode restriction.

Adjacent weaknesses in the same entry point (included, same class as #325): a non-numeric limit
(e.g. "abc") raised TypeError → unhandled generic 500, as did a non-string search_term; and
the server did not mirror get_search_config()'s [10, 200] / [1, 10] clamps.

Not an ACL bypass — all searches run un-sudo'd, so ACLs and record rules still apply. The issue is
that the search-governance/anti-enumeration controls were client-side only. Severity: medium
(authenticated PII enumeration aid + throttling bypass).

Fix

Enforce everything server-side at the RPC entry point (single choke point; the JS client already
complies, so no client change):

  • Input validation: non-string search_term[]; limit coerced via int() with fallback
    to the configured limit (no more TypeError → 500).
  • min_chars on effective characters: SQL LIKE wildcards (%, _) are stripped before counting,
    so %%% and Al% are rejected under min_chars=3. Matching semantics are deliberately
    unchanged — terms containing _ (e.g. emails) behave exactly as before.
  • Limit capped at admin config: limit = min(limit, config_limit) if limit > 0 else config_limit,
    with config_limit/min_chars clamped exactly as get_search_config() clamps them for the JS.
    Callers may request fewer results than the maximum, never more.
  • Targeted mode never widens: missing or unknown search_field falls back to the configured
    target_field; valid caller-supplied fields stay honored (the settings help text documents that
    portal users may change the field).

Tests

Written test-first (red → green): the module had no tests at all for search_registrants.
New tests/test_search_registrants.py (14 tests, 10 failing before the fix):

  • min_chars: short term, wildcard-only term (%%%), wildcard-padded term (Al%) → []
    (all matched registrants before).
  • Limit: caller limit=200 vs configured 10 → ≤10 (returned 12 before); lower caller limit
    honored; non-numeric limit no longer crashes (raised TypeError: '<' not supported between instances of 'str' and 'int' before).
  • Targeted mode: missing search_field stays targeted (found the registrant via phone in unified
    fallback before); invalid field uses the configured default; valid caller field still honored.
  • Regressions: compliant search finds results, search_type filters, non-string term rejected.

./spp t spp_registry_search0 failed, 0 error(s) of 36 tests (was 7 failed + 2 errors).
Ruff/ruff-format clean. No existing tests removed or modified.

Staff-review round

An adversarial staff review confirmed the core fix (limit logic simulated against bool/float/negative/
huge/garbage inputs, targeted mode traced fail-closed, no JS regression) and caught one gap, fixed here:
a non-dict advanced_filters value reached .get() -> AttributeError -> unhandled 500 (the same
malformed-input class this PR closes). Guarded with isinstance, plus a test; also added a test pinning
that targeted mode fails closed when the admin-configured target_field is itself invalid.

Scope

  • No sibling security branch or open PR touches spp_registry_search.
  • Version bump 19.0.2.1.1 → 19.0.2.1.2 + HISTORY fragment; README.rst /
    static/description/index.html to be regenerated from CI's pinned generator.

…passes

The Registry Search controls (min_chars, result_limit, targeted mode) were
enforced only in the JS client. Add regression tests (currently failing)
pinning server-side enforcement in the search_registrants RPC: short and
wildcard-only terms, caller limit above the configured maximum, targeted
mode falling back to unified when search_field is missing/invalid, and
TypeError crashes on malformed limit/search_term.
…earch_registrants

The admin-configurable Registry Search controls were applied only in the
JavaScript portal; a direct RPC call could search registrant PII (name, ID
number, phone, email) with a one-character or wildcard-only term despite
spp_registry_search.min_chars, request up to 200 results regardless of the
configured maximum, and downgrade targeted mode to unified search by
omitting search_field.

Enforce everything at the RPC entry point:
- min_chars counted on effective (non-wildcard) characters, so '%%%' no
  longer matches every registrant; matching semantics are unchanged
- caller limit capped at the configured result_limit (callers may request
  fewer results, never more), mirroring get_search_config's clamps
- targeted mode never falls back to unified: a missing or unknown
  search_field uses the configured target_field (valid caller-supplied
  fields stay honored, matching the portal UX)
- malformed RPC inputs (non-string term, non-numeric limit) are rejected
  or coerced instead of raising TypeError as a generic 500

The JS client already complies with all of these, so no client change.
…rch limits

Patch bump 19.0.2.1.1 -> 19.0.2.1.2. README.rst and
static/description/index.html to be regenerated from CI's pinned generator.
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 68.13%. Comparing base (1caf794) to head (178c39b).
⚠️ Report is 3 commits behind head on 19.0.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             19.0     #335      +/-   ##
==========================================
- Coverage   74.28%   68.13%   -6.16%     
==========================================
  Files         372      144     -228     
  Lines       25385    13483   -11902     
==========================================
- Hits        18857     9186    -9671     
+ Misses       6528     4297    -2231     
Flag Coverage Δ
spp_analytics ?
spp_api_v2_cycles ?
spp_api_v2_entitlements ?
spp_api_v2_gis ?
spp_api_v2_programs ?
spp_api_v2_simulation ?
spp_audit_programs ?
spp_base_common 91.07% <ø> (+0.80%) ⬆️
spp_case_demo ?
spp_case_entitlements ?
spp_case_programs ?
spp_cr_type_assign_program ?
spp_dci_compliance ?
spp_dci_demo ?
spp_dci_server_social ?
spp_demo ?
spp_demo_phl_luzon ?
spp_drims ?
spp_drims_sl ?
spp_drims_sl_demo ?
spp_farmer_registry 88.58% <ø> (?)
spp_farmer_registry_cr 61.24% <ø> (?)
spp_farmer_registry_demo 61.06% <ø> (+0.04%) ⬆️
spp_gis_report ?
spp_grm_demo ?
spp_indicator ?
spp_indicator_studio ?
spp_metric_service ?
spp_mis_demo_v2 70.99% <ø> (+0.03%) ⬆️
spp_programs 65.27% <ø> (+<0.01%) ⬆️
spp_registry 86.94% <ø> (+0.10%) ⬆️
spp_registry_search 75.53% <100.00%> (?)
spp_security 69.56% <ø> (+2.89%) ⬆️
spp_simulation ?
spp_starter_sp_mis 86.66% <ø> (?)

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

Files with missing lines Coverage Δ
spp_registry_search/models/res_partner.py 70.65% <100.00%> (ø)

... and 295 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.

Generated files applied verbatim from CI's pinned oca-gen-addon-readme
output, matching the HISTORY.md fragment.
…in target_field

From staff review: a non-dict advanced_filters value raised AttributeError
-> generic 500 (currently failing test), and the fail-closed behavior of
targeted mode with an invalid admin-configured target_field was verified
but untested.
A malformed advanced_filters RPC value (string, list, int) reached .get()
and raised AttributeError -> unhandled 500, the same malformed-input class
this fix closes for search_term and limit. Guard with isinstance so the
filter block is skipped for non-dict values.
@gonzalesedwin1123

Copy link
Copy Markdown
Member Author

Staff-review follow-ups filed (pre-existing, out of this PR's scope):

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.

1 participant