Skip to content

security(cr): writable selected_field bypasses CR conflict checks#343

Draft
gonzalesedwin1123 wants to merge 4 commits into
19.0from
security-cr-selected-field-conflict-bypass
Draft

security(cr): writable selected_field bypasses CR conflict checks#343
gonzalesedwin1123 wants to merge 4 commits into
19.0from
security-cr-selected-field-conflict-bypass

Conversation

@gonzalesedwin1123

@gonzalesedwin1123 gonzalesedwin1123 commented Jul 24, 2026

Copy link
Copy Markdown
Member

Summary

Conflict and duplicate detection in spp_change_request_v2 scoped field-level
checks using a user-writable label. selected_field_name is only
view/model-readonly (not server write-protection) and group_cr_user has
write on their own spp.change.request. So a user could create a CR blocked by
a field-scoped conflict rule, then RPC-write the label to a field NOT in the
rule's conflict_fields; the check re-runs, returns empty, conflict_status
resets to none, and the CR submits/approves without the configured override
path. _calculate_similarity (duplicate detection) had the identical flaw.

Fix

Scope field-level conflict/duplicate detection to the fields that actually
differ from the registrant
— the exact set the field_mapping apply strategy
will write — derived server-side, not from any declared label:

  • New _proposed_changed_fields() on the conflict mixin: for use_dynamic_approval
    CR types, returns the mapped detail fields whose value differs from the
    registrant (via apply_mapping_ids); None for non-dynamic types (full
    configured field set always used — their details aren't registrant-prefilled).
  • _filter_by_field_conflicts and _calculate_similarity scope to that diff.
  • Neither selected_field_name nor the detail's field_to_modify is trusted
    for the security decision.

Why not just "derive from field_to_modify"

The first iteration derived the field from detail.field_to_modify instead of
selected_field_name. A two-reviewer staff review caught that this was still
bypassable:
field_to_modify is also user-writable, and — crucially — it
does not constrain apply. field_mapping.apply writes every mapped field that
differs from the registrant, so write({'field_to_modify': 'phone', 'given_name': 'X'}) cleared the given_name conflict while still applying the given_name
change. Deriving from the actual diff (this version) makes both writable labels
irrelevant.

Testing

  • ./spp t spp_change_request_v2339 passed, 0 failed, 0 errors (5 new).
  • New tests: writing selected_field_name cannot clear a field-scoped conflict
    (dynamic and static types) or drop duplicate similarity; mislabeling
    field_to_modify
    while actually changing a conflict field cannot bypass
    detection (own-side and candidate-side). Each fails against the pre-fix code.
  • Added apply_mapping_ids to the dynamic test CR type (a field_mapping type
    requires them; the fixture had omitted them).

Scope / follow-ups

Draft: awaiting human security review before undraft/merge.

Merge order (CR security cluster)

Per the cross-PR interaction analysis (2026-07-24, internal/plans/security-prs-interaction-analysis.md), hard order: #264#261#338#343 (this PR, last).

…e selected_field_name

Conflict and duplicate detection treated a non-empty selected_field_name as
'only that field is a proposed change'. selected_field_name is only
view-readonly; group_cr_user has write on their own spp.change.request, so a
user could create a CR blocked by a field-scoped conflict rule, then RPC-write
selected_field_name to a field outside conflict_fields - _filter_by_field_conflicts
returned empty, resetting conflict_status to none and allowing submission
without the configured override path. The duplicate-detection path
(_calculate_similarity) had the same flaw.

Derive the effective changed field server-side from the detail's validated
field_to_modify Selection via a new _effective_selected_field() helper, gated on
use_dynamic_approval (non-dynamic types always use the full conflict_fields).
The writable selected_field_name is no longer trusted for these decisions.

Bump 19.0.3.0.0 -> 19.0.3.0.3 (above #261's .0.2 and #264's .0.1 on the same
module) + HISTORY. No migration: behavior fix on existing fields.

Scope: conflict/duplicate detection only. The same writable field is also read
by dynamic-approval routing (_resolve_dynamic_approval); filed as a follow-up.
@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 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.83673% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.33%. Comparing base (1caf794) to head (d38ae99).
⚠️ Report is 3 commits behind head on 19.0.

Files with missing lines Patch % Lines
spp_change_request_v2/models/conflict_mixin.py 91.83% 4 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             19.0     #343      +/-   ##
==========================================
- Coverage   74.28%   70.33%   -3.95%     
==========================================
  Files         372      205     -167     
  Lines       25385    17911    -7474     
==========================================
- Hits        18857    12598    -6259     
+ Misses       6528     5313    -1215     
Flag Coverage Δ
spp_analytics ?
spp_api_v2_change_request 66.53% <ø> (?)
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_change_request_v2 77.97% <91.83%> (?)
spp_cr_type_assign_program 92.07% <ø> (+0.90%) ⬆️
spp_dci_compliance ?
spp_dci_demo 94.28% <ø> (+0.88%) ⬆️
spp_dci_server_social ?
spp_demo ?
spp_demo_phl_luzon ?
spp_drims ?
spp_drims_sl ?
spp_drims_sl_demo ?
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_security 69.56% <ø> (+2.89%) ⬆️
spp_simulation ?
spp_starter_sp_mis 86.66% <ø> (?)
spp_studio_change_requests 84.85% <ø> (?)

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

Files with missing lines Coverage Δ
spp_change_request_v2/models/conflict_mixin.py 90.27% <91.83%> (ø)

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

@gonzalesedwin1123

Copy link
Copy Markdown
Member Author

Filed the related dynamic-approval routing bypass (same writable selected_field_name, different sink) as #344 — out of scope for this conflict-detection PR.

…hangelog

Applies CI's pinned oca-gen output verbatim for the new HISTORY fragment.
…a writable label

Staff review found the first fix incomplete: it derived the effective field from
detail.field_to_modify, but that field is ALSO user-writable and does not
constrain apply — field_mapping.apply writes every mapped field that differs
from the registrant, not just field_to_modify. So the bypass survived with one
extra field: write({'field_to_modify': 'phone', 'given_name': 'X'}) cleared the
given_name conflict while still applying the given_name change.

Derive the proposed changes server-side from the detail-vs-registrant diff (the
exact set apply writes) via _proposed_changed_fields(), and scope both
_filter_by_field_conflicts and _calculate_similarity to it. Neither
selected_field_name nor field_to_modify is trusted for the decision.

Add field mappings to the dynamic test type (a field_mapping type needs them)
and two regression tests for the mislabel bypass (own-side and candidate-side).
339 tests pass.
….3.0.3 changelog

Applies CI's pinned oca-gen output verbatim for the corrected HISTORY fragment.
@gonzalesedwin1123

Copy link
Copy Markdown
Member Author

Follow-up filed: #346 — once this PR and #264 both land, the duplicate-similarity exact-set-equality check becomes evadable via free diff-padding (#264 makes the padding side-effect-less). Conflict detection is not affected. Coordinate with #344.

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