diff --git a/spp_dci_server_social/services/search_service.py b/spp_dci_server_social/services/search_service.py index ef76fc90..d98060ec 100644 --- a/spp_dci_server_social/services/search_service.py +++ b/spp_dci_server_social/services/search_service.py @@ -2,6 +2,7 @@ """DCI Social Registry Search Service - Maps Odoo partners to DCI schemas.""" import logging +import re import uuid from datetime import UTC, datetime from typing import Any @@ -26,6 +27,14 @@ _logger = logging.getLogger(__name__) +# Parameterized DCI CEL metrics that expose registry-derived facts which are +# not safe for unauthorised external predicate filtering. DCI Social Registry +# search predicates are a caller-supplied oracle (including total_count), so +# deny these sensitive metrics before compiling the expression. Keep this +# local instead of importing spp_dci_indicators: that addon is optional and is +# not a dependency of the DCI Social Registry server. +_DCI_PREDICATE_DENIED_METRICS = frozenset(("dr.dci.severity", "crvs.dci.has_event")) + class DCISocialSearchService: """Service for DCI Social Registry search operations. @@ -352,6 +361,8 @@ def _parse_predicate(self, predicate) -> list: if not expression or not expression.strip(): return [] + self._validate_external_predicate_expression(expression) + # Use CEL service to compile expression to domain cel_service = self.env["spp.cel.service"] @@ -373,6 +384,21 @@ def _parse_predicate(self, predicate) -> list: return result.get("domain", []) + def _validate_external_predicate_expression(self, expression: str) -> None: + """Reject sensitive DCI metrics in sender-supplied predicates. + + DCI predicate searches return counts and pageable matches, so allowing + callers to filter on raw DCI indicator cache values can disclose the + value by repeated queries even when the value is not present in the + response schema. Internal CEL use can still compile these metrics; this + guard only applies to external Social Registry predicate search. + """ + for accessor in _DCI_PREDICATE_DENIED_METRICS: + method_pattern = rf"(? list: """ Parse DCI expression into Odoo domain. diff --git a/spp_dci_server_social/tests/test_search_service_internals.py b/spp_dci_server_social/tests/test_search_service_internals.py index 3a2fa694..6d94a217 100644 --- a/spp_dci_server_social/tests/test_search_service_internals.py +++ b/spp_dci_server_social/tests/test_search_service_internals.py @@ -84,6 +84,18 @@ def test_parse_predicate_compile_failure_raises(self): self.service._parse_predicate("r.broken ==") self.assertIn("bad syntax", str(ctx.exception)) + def test_parse_predicate_rejects_sensitive_dci_method_metrics(self): + blocked = [ + "dr.dci.severity('Vision') >= 3", + "crvs.dci.has_event('death') == true", + "metric('dr.dci.severity', me, arg='Vision') >= 3", + 'metric("crvs.dci.has_event", me, arg="death") == true', + ] + for expression in blocked: + with self.assertRaises(ValueError) as ctx: + self.service._parse_predicate(expression) + self.assertIn("sensitive DCI metric", str(ctx.exception)) + # --- _to_dci_member ------------------------------------------------------ def test_to_dci_member_with_identifier_and_demographics(self):