Skip to content

ID-146 Escaping search parameters rework - #296

Open
FlexonyoPizza wants to merge 7 commits into
inferno-framework:mainfrom
FlexonyoPizza:LeonsBranch
Open

ID-146 Escaping search parameters rework#296
FlexonyoPizza wants to merge 7 commits into
inferno-framework:mainfrom
FlexonyoPizza:LeonsBranch

Conversation

@FlexonyoPizza

@FlexonyoPizza FlexonyoPizza commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes an issue where previously only escaped commas were searched after all token pieces have already been joined. FHIR requires escaping four characters: \ | $ ,

The pipe | is a special character because it's also the literal separator between a token's system and code pieces, so escaping after joining these pieces together in the end destroys that distinction and you would no longer be able to tell a real separator from a | that was actually part of the data.

Changes made:

resource_search_param_checker.rb:

  • escape_search_value/unescape_search_value: the general \-prepend and /-strip helper methods that are applied and executed per-string (code and system separately) rather than at the end when they're already joined.
  • token_search_value: escapes the system and code segments independently, then joins them with a |. This is what lets a literal pipe inside a code (co|de) survive as | without being confused with the real separator.
  • UNESCAPED_PIPE / parse_escaped_token / split_escaped_search_value: split on characters not already escaped, then unescape after - The order matters here, you must split first and then unescape or else something like an escaped comma gets misread as an actual OR separator.

Applied on the write side in search_param_value in search_test.rb and the read side in both copies of resource_matches_param?

Testing Guidance

  1. You can start a fhir server to hold test data with:
    docker run -d --rm --name hapi-fhir -p 8080:8080 hapiproject/hapi:latest

  2. Then you can seed a patient with escapable characters - Here is an example:

  -H 'Content-Type: application/fhir+json' \
  -d '{"resourceType":"Patient","id":"escape-demo",
       "identifier":[{"system":"http://example.org/mrn","value":"abc$123"}],
       "name":[{"family":"Smith|Jr","given":["Amy"]}],
       "gender":"female","birthDate":"1980-01-01"}'
  1. Start inferno:
    bundle exec inferno services start
    bundle exec inferno start

  2. In your local host browser, pick a US core server and for the example provided in step 2, run the patient tests group.
    Your fhir endpoint will be: http://localhost:8080/fhir
    Your patient ID will be: escape-demo

  3. Go to group 2.2.03 (Patient search by name), click on the requests tab, then click details to view the response body.

You should see that a backslash %5C is prepended to the pipe %7C and Patient?name=Smith%5C%7CJr decodes to name=Smith|Jr. It correctly read \| as a literal pipe rather than the actual separator.

As a plus, with the same example if you go to the 2.2.02 group (patient search by identifier), you will see that the URL sent consists of Patient?identifier=abc%5C%24123, which is decoded to identifier=abc$123.
%5C = \ and %24 = $.

Full response body from that example test:

  "resourceType": "Bundle",
  "id": "bd3b5929-57a8-4407-aaef-3c78378fa933",
  "meta": {
    "lastUpdated": "2026-07-13T19:01:43.768+00:00"
  },
  "type": "searchset",
  "total": 1,
  "link": [
    {
      "relation": "self",
      "url": "http://localhost:8080/fhir/Patient?name=Smith%5C%7CJr"
    }
  ],
  "entry": [
    {
      "fullUrl": "http://localhost:8080/fhir/Patient/escape-demo",
      "resource": {
        "resourceType": "Patient",
        "id": "escape-demo",
        "meta": {
          "versionId": "1",
          "lastUpdated": "2026-07-13T17:33:01.818+00:00"
        },
        "identifier": [
          {
            "system": "http://example.org/mrn",
            "value": "abc$123"
          }
        ],
        "name": [
          {
            "family": "Smith|Jr",
            "given": [
              "Amy"
            ]
          }
        ],
        "gender": "female",
        "birthDate": "1980-01-01"
      },
      "search": {
        "mode": "match"
      }
    }
  ]
}

Comment thread lib/us_core_test_kit/resource_search_param_checker.rb Outdated
Comment thread lib/us_core_test_kit/resource_search_param_checker.rb Outdated
Comment thread lib/us_core_test_kit/resource_search_param_checker.rb
Comment thread lib/us_core_test_kit/resource_search_param_checker.rb Outdated
Comment thread lib/us_core_test_kit/search_test.rb Outdated
@FlexonyoPizza

Copy link
Copy Markdown
Contributor Author

I think there are some fixes made in this PR that should address the failing 610 tests.

Comment thread lib/us_core_test_kit/resource_search_param_checker.rb
Comment thread lib/us_core_test_kit/granular_scope_read_test.rb
Comment thread lib/us_core_test_kit/granular_scope_read_test.rb

@karlnaden karlnaden left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A few things to clean up

Comment on lines +44 to +68
def escape_search_value(value)
value&.gsub(FHIRSearchEscaping::SPECIAL_CHARACTERS) { |character| "\\#{character}" }
end

def unescape_search_value(value)
value&.gsub(/\\(.)/m) { Regexp.last_match(1) }
end

# Build a token search value, escaping the system and code so that any
# special characters they contain are not mistaken for the unescaped `|`
# that separates the system from the code.
def token_search_value(system, code, include_system)
return escape_search_value(code) unless include_system

"#{escape_search_value(system)}|#{escape_search_value(code)}"
end

def parse_escaped_token(escaped_search_value)
system, code = escaped_search_value.split(FHIRSearchEscaping::UNESCAPED_PIPE, 2)
[unescape_search_value(system), unescape_search_value(code)]
end

# Split an escaped search value on the unescaped occurrences of `delimiter`,
# then unescape each of the resulting values.
def split_escaped_search_value(escaped_search_value, delimiter)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think these methods specific to escaping should go into the new FHIRSearchEscaping module instead of here.

# then unescape each of the resulting values.
def split_escaped_search_value(escaped_search_value, delimiter)
escaped_search_value
.split(/#{FHIRSearchEscaping::UNESCAPED}#{Regexp.escape(delimiter)}/)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

you could cache the Regexp.escape(delimiter) value so that repeated calls don't require building a new object each time.

# they must be escaped by prepending a backslash.
SPECIAL_CHARACTERS = /[\\|$,]/.freeze

UNESCAPED = /(?<!(?<!\\)\\)/.freeze

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Current approach only handles 0, 1, or 2 backslashes. Update to handle arbitrary numbers of them:

Suggested change
UNESCAPED = /(?<!(?<!\\)\\)/.freeze
# Handle arbitrary number of backslashes: odd number is escaped, even is unescaped
# Use the "consume complete backslash pairs, then check the delimiter" idiom,
# using \K to discard the consumed pairs from the match
UNESCAPED = /(?<!\\)(?:\\\\)*\K/.freeze

Comment on lines +142 to +144
if escaped_search_value&.match?(FHIRSearchEscaping::UNESCAPED_PIPE)
system, value = parse_escaped_token(escaped_search_value)
values_found.any? { |identifier| identifier.system == system && identifier.value == value }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

maintain the interpolated approach to continue previous behavior. Also add a spec test that verifies behavior for a search string like |id1234.

Suggested change
if escaped_search_value&.match?(FHIRSearchEscaping::UNESCAPED_PIPE)
system, value = parse_escaped_token(escaped_search_value)
values_found.any? { |identifier| identifier.system == system && identifier.value == value }
if escaped_search_value&.match?(FHIRSearchEscaping::UNESCAPED_PIPE)
system, value = parse_escaped_token(escaped_search_value)
values_found.any? { |identifier| "#{identifier.system}|#{identifier.value}" == "#{system}|#{value}" }

@@ -633,17 +633,17 @@ def search_param_value(name, resource, include_system: false)
element.reference

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this should be escaped. I don't think that valid references will actually contain characters to escape, but if they do, they need to be escaped. So better to be safe. Additionally, prior to this change, at least commas were escaped by the catch-all at the end of the method.

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.

3 participants