Skip to content

[Security/Medium] readPrivate dateOfBirth-search gate bypassable via Lucene query_string #1724

Description

@jlkravitz

Severity: Medium — Confidence: 0.85

Location: backend/social-work-app/lambdas/python/search/handlers/search.py:204-258 (_query_references_field / _validate_date_of_birth_permission). Identical vulnerable copy at backend/cosmetology-app/lambdas/python/search/handlers/search.py:204-258.

Not affected: compact-connect (production) — its search handler has no such gate and does not index dateOfBirth into OpenSearch, so this vector does not apply there.

Summary

The advanced search endpoint accepts a raw OpenSearch DSL query (SearchProvidersRequestSchema.query = Raw, only index/_index keys blocked) and is authorized only by @authorize_compact_level_only_action(READ_GENERAL). Searching on dateOfBirth (indexed as date, opensearch_client.py:282) is meant to require the higher readPrivate scope. The sole enforcement, _query_references_field, only flags a dict key ending in .dateOfBirth or a string value that equals/ends-with .dateOfBirth — it never inspects substrings inside a string value.

Exploit chain

A staff user with only {compact}/readGeneral (implicitly held by all staff), not readPrivate, POSTs to /v1/compacts/{compact}/providers/search:

{"query":{"bool":{"must":[
  {"term":{"providerId":""}},
  {"query_string":{"query":"licenses.dateOfBirth:[1985-01-01 TO 1985-06-30]"}}
]}}}

The value "licenses.dateOfBirth:[...]" ends with ], not .dateOfBirth, so the gate passes and the query runs. The response schema doesn't echo DOB, but hit-vs-no-hit + total is a boolean oracle — binary-searching the date range (~15 requests) yields any provider's exact DOB, defeating the readGeneral/readPrivate boundary. wildcard/regexp/script types are also unrestricted.

Reachability

Wired & real on main. Route added in stacks/search_api_stack/v1_api/provider_search.py (add_method('POST',...)), scope ['{compact}/readGeneral'] (v1_api/api.py:29-40); SearchApiStack instantiated at pipeline/backend_stage.py:197 for both apps; dateOfBirth is in the index mapping (type: date).

Suggested fix

Don't rely on field-name string matching over attacker-controlled DSL.

  • Preferred: allowlist safe query types (term/terms/match/bool/nested/range) and reject free-form query_string/simple_query_string/wildcard/regexp/script.
  • Minimal stopgap: match the dateOfBirth substring inside any string value and reject fields specs containing *.
  • Best long-term: OpenSearch field-level masking.

Apply to both social-work-app and cosmetology-app copies.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions