← back to research

Declared on the interface, dropped on the override: an authorization bypass in OpenMRS FHIR2

FHIR2 declares its read privileges as annotations on DAO interfaces. Where a concrete class overrides one of those methods, the annotation lookup lands on the override, which carries none, and the privilege check is skipped.


OpenMRS is an open-source electronic medical record system used widely in global health, and its FHIR2 module is the piece that serves the FHIR API in front of the record. Read permissions in that module are declarative: the DAO interface carries an annotation naming the privilege a caller needs, such as @Authorized(GET_OBS) on FhirObservationDao.getSearchResults, and a Spring AOP advisor enforces it on the way through. For nearly every method the mechanism works. The exception is a method the concrete class overrides. There the advisor’s lookup lands on the override, which carries only @Transactional and no privilege annotation, and the check is skipped without anything failing. A user with a valid login and no privileges at all can then read patient observations, one patient at a time or in bulk. Reported to OpenMRS and fixed in 4.1.0 (GHSA-54gp-r4pj-3wp3, CWE-862, High).

IMPORTANT

Who’s exposed. You run the FHIR2 module at 4.0.0, which is the version the advisory names, and someone who should not see all patient data can authenticate. That second condition is lower than it sounds: the account needs no privileges, so any login that exists at all is enough, including ones provisioned as deliberately empty. Authentication is still required, so this is not open to an anonymous caller.

The gate is on the interface, the call lands on the override

FhirObservationDao declares the privilege on its interface methods, getSearchResults and getSearchResultsCount, both annotated @Authorized(PrivilegeConstants.GET_OBS). The advisor in FhirAopConfiguration knows that annotations on an interface need finding, so it walks interfaces to recover the annotation for methods a concrete class merely inherits. That is why single reads, writes, and every resource type whose DAO does not override anything are gated correctly.

FhirObservationDaoImpl is the one FHIR2 DAO that overrides those two methods in the concrete class, to special-case the $lastn query. The override carries @Transactional and no privilege annotation. When the advisor resolves the method for an incoming call, it resolves the override, finds no @Authorized there, and enforces nothing. For an ordinary ?patient= search the override immediately delegates back to super.getSearchResults(theParams), so the query runs exactly as it always would, only without the gate in front of it.

What the server actually returned

The evidence that matters here is the negative controls, because they show the permission system working everywhere else. All of these are the same account, nurse_none, whose role holds zero privileges, against a patient that account has no relationship to:

RequestResult
GET /ws/fhir2/R4/Observation?patient=<uuid>200, 132 observations, including vitals and blood pressure
GET /ws/fhir2/R4/Observation?_count=50 (no patient filter)200, 5526 total, paginated across every seeded patient
GET /ws/fhir2/R4/Observation?_summary=count200, count path also ungated
GET /ws/fhir2/R4/Observation/<uuid> (single read, inherited)500, privileges required: Get Observations
GET /ws/fhir2/R4/Encounter?patient=<uuid>500, privileges required: Get Visits
GET /ws/fhir2/R4/Condition?patient=<uuid>500, privileges required: Get Diagnoses
GET /ws/rest/v1/obs?patient=<uuid> (the REST path)403, REST enforces
POST /ws/fhir2/R4/Observation (write)500, writes stay gated
GET /ws/fhir2/R4/Observation?patient=<uuid> with no auth header401

The single read of an observation by UUID is denied while the search that returns 132 of them succeeds, and both are the same resource for the same user. That is the whole finding in two rows. One more control settles it: removing the Get Observations privilege from a role that had it changed nothing about what came back, which is what you would expect if the privilege is not what is being consulted.

The same override pattern reaches medication data. On our instance a zero-privilege account read a drug order by UUID, returning the medication, the patient’s name, and the order status, and the advisory names the MedicationDispense reads on both R4 and R3 alongside the Observation collection endpoints. Because the overridden collection method is also what include-expansion calls, the data comes out through _include and _revinclude as well, not only through direct requests.

What it exposes

Read-only, and confidentiality only. What comes out is every patient’s observations: vitals, lab and measurement values, anything else stored as an observation, and the medication data above. Rated High, CVSS 7.5.

Remediation

If you maintain the code. There are two places to fix it and they are not equivalent. Re-declaring @Authorized(PrivilegeConstants.GET_OBS) on the overriding methods fixes the instances that are known today. Changing the advisor so that an interface-declared annotation is recovered whether or not the concrete method carries its own closes every instance at once, including the ones a future override would create, and that is the one worth doing. A regression test that calls each DAO method with a zero-privilege principal and asserts the authentication exception turns the whole class of bug into something the build catches, since the failure mode is silent by nature.

If you operate it. Upgrade the FHIR2 module to 4.1.0. Until you can:

  • Treat the FHIR2 Observation and medication endpoints as reachable by anyone who can log in, and check who actually holds an account. Empty-privilege and service accounts are the ones to look at first, because they are the ones nobody thinks of as having read access.
  • The REST API enforced its checks on the equivalent path, so this is specific to the FHIR2 surface. If something in front of your deployment can authorize requests to /ws/fhir2/, that is a place to put a temporary restriction that does not depend on the module’s own gate.
  • Access to these endpoints looks like ordinary API traffic, so if you need to reason about exposure after the fact, the useful signal is which accounts called the FHIR2 collection endpoints at all, rather than anything anomalous about the calls themselves.

Disclosure

  • Software: the OpenMRS FHIR2 module (org.openmrs.module.fhir2-api) 4.0.0. Tested against the deployed 4.0.0 build, with the annotations confirmed in the shipped jar. The advisory states 2.x and 3.x are not affected.
  • Class: CWE-862, missing authorization.
  • Severity: High, CVSS 7.5.
  • Advisory: GHSA-54gp-r4pj-3wp3, public 2026-08-20. No CVE assigned at time of writing.
  • Fix: 4.1.0.

All testing was against an instance we controlled. The patients, accounts, and clinical records in it are synthetic.

The annotation is not the enforcement

Writing a permission as an annotation moves the check out of the method and into whatever machinery goes looking for it later. That is usually a good trade, because it puts the rule where a reader can see it and keeps it out of the body of every function. What it also does is create two separate things that can drift: the place the rule is written, and the place the framework looks when the call arrives. Inheritance, overrides, proxies, and bridge methods all move the second one without touching the first, and none of them look like security changes while you are making them.

So the question worth asking of any declarative permission system is not whether the rules are correct. It is where the lookup lands. Enumerate the methods whose resolved target is not the same as their declaration site, because those are the ones where a rule that is plainly written in the source is not the rule being enforced. Here that set was small and easy to miss: a query optimization sitting on top of a permission that is declared somewhere else entirely, with nothing at the call site to show the difference.


This work sits in our API security practice and is part of our ongoing vulnerability research into the open-source software that everyone else builds on.