One Endpoint on a HIPAA App That Forgot to Check Who You Are
----[ Target
A patient facing health assistant built on top of Epic MyChart. Patients link their hospital records, the app reads their chart, summarises an inbox, runs an AI search over their history. The backend is a small REST API. Every patient route is behind a login. Every one but a single status endpoint.
The program is not new. Launched in 2024, more than sixty thousand dollars paid out, years of researchers ahead of me. The kind of target where the reasonable assumption is that the easy surface is gone. I spent one evening checking whether that assumption holds. It did not.
----[ Observation
The single page app loads its patient data through a set of routes
under /api. With no cookie and no Authorization header, the
whole set answers the same way:
POST /api/epic-sync/create -> 401 Unauthorized
POST /api/ai-search -> 401 Unauthorized
GET /api/in-basket-summary/x -> 401 Unauthorized
GET /api/disclaimer -> 401 Unauthorized
POST /api/message-report -> 401 Unauthorized
One sibling does not:
GET /api/epic-sync/status?patientFhirId=...&partner=hhc
No 401. The request goes straight to the backend lookup. The auth middleware that wraps the other routes was never attached to this one.
----[ Confirmation
The endpoint validates its input before it touches the database, and it does all of it unauthenticated. Walking the chain shows the request reaching the lookup:
GET /api/epic-sync/status -> 400 patientFhirId required
GET /api/epic-sync/status?patientFhirId=x -> 400 partner required
GET /api/epic-sync/status?patientFhirId=x&partner=hhc -> 404 No sync found
The 404 is the tell. A blocked route returns 401 before it parses
anything. This one parses both parameters, runs the query, finds
no matching record for the junk id, and reports it. The lookup
ran. It just had nothing to return for x.
For a real patientFhirId with a linked Epic record, the same
call returns the sync object instead of "No sync found". The
client requests it with the selection set:
{ id, status, createdAt }
So a positive response hands an unauthenticated caller the sync
record id, the link status (PENDING / IN_PROGRESS /
COMPLETED / FAILED), and the date the patient connected their
MyChart records.
----[ The limit, stated honestly
patientFhirId is an opaque Epic identifier, long and not
guessable. Blind enumeration is not realistic, and wildcard, SQL
and NoSQL payloads (%, *, ' OR '1'='1, {"$ne":null}) are
all treated as literal strings and return "No sync found". You
need the exact id.
That makes this a confirmation oracle, not a dump. The value shows up when an attacker already holds a target's FHIR id, which leaks in referrals, screenshots, support tickets, or through a separate IDOR. Given the id, an unauthenticated party can confirm the person is a patient on this platform and read the state and date of their EHR link. In a HIPAA setting, confirming that someone is a patient without a login is the finding, regardless of how thin the rest of the record is.
I did not pull a real patient's record. The missing control and the shape of the positive response are both proven above without touching anyone's PHI.
----[ Remediation
Attach the same auth middleware the sibling routes already use to
/api/epic-sync/status. Then enforce object level authorization
so a caller can only read the sync status tied to their own
patientFhirId, not any value they type into the query string.
----[ Closing
Reported. Triaged. Two hundred dollars.
Not a large bounty, and not a large bug. The point is smaller and more useful than the payout. A program with years of history and a five figure bounty table still had one route where someone forgot to type the middleware. The mature target is not the secure target. It is the one where the obvious holes are gone and the boring one is still sitting there, waiting for a chill evening.
The other routes prove the team knows how to require a login. This one just slipped the net.