After TrustLaunder: Apex's Other Four Pathling Findings

Identified by Apex, Cantina's autonomous AppSec agent.
Why healthcare security has a different blast radius
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 records a FHIR analytics server processes name people, their conditions, prescriptions, and lab results, which is why HIPAA and GDPR's special-category-data carve-out treat health information differently from generic personal data.
That is the deployment context for these findings. A FHIR server that accepts caller-supplied URLs and forwards them with operator credentials looks like a generic SSRF surface in a code-review checklist, but in a hospital deployment it is a PHI exfiltration primitive. The four surfaces below sit in the second reading.
What Apex was looking for
Apex reviewed Pathling, the open-source FHIR analytics server hospitals and research institutions run against patient records, and walked one trust-boundary pattern across every operation: convenience surfaces that accept caller-supplied URLs, filenames, or scope-bearing tokens and forward them into sinks elsewhere in the server. Five high-severity findings came out of that review.
We covered TrustLaunder, the most high-impact of the five. A single $import-pnp request, from any authenticated caller, leaks operator OAuth credentials to an attacker-controlled token endpoint, performs authenticated SSRF from Pathling's network position, and ingests attacker-controlled NDJSON into the analytical warehouse as trusted patient data.
This post covers the other four at the same depth. Three of them are the same trust-laundering shape on different operations and different inputs. The fourth is the highest-impact of the five for any authentication-enabled deployment: a documented OAuth scope contract that Pathling never enforced at the typed FHIR API. Each finding requires only the access the relevant operation normally permits, which for the path-traversal surface in default-config deployments means an unauthenticated remote caller and for the others a low-scope authenticated token.
All four are patched in server-v2.0.0.
Surface 1: The authorization scope contract Pathling documented but did not enforce
Apex flagged: GHSA-q62q-2m46-r7rv / CVE-2026-47663
Pathling's authorization model is documented as two layers. An operation authority (pathling:search, pathling:update) grants the action. A per-resource authority (pathling:read:Patient, pathling:write:Observation) grants the resource family. Both layers are required.
The check Apex ran was concrete. Do the typed CRUD, search, and batch providers actually enforce both layers, or only the operation authority?
They enforced only the operation authority. The SearchProvider, UpdateProvider, and the analogous create, read, delete, and batch providers were annotated only with @OperationAccess(...). They acted on the provider-selected resource type without checking the per-resource authority that was supposed to scope them. The PoC took one line each:
- A JWT holding only
pathling:searchcallsGET /fhir/Observation?_count=1and receives an Observation Bundle. Nopathling:read:Observationin the token. - A JWT holding
pathling:updatepluspathling:write:PatientcallsPUT /fhir/Condition/cond-1and overwrites the Condition resource with contentPWNED. Nopathling:write:Conditionin the token.
What this means in practice. Any deployment that issues OAuth tokens scoped to one FHIR resource family is silently granting every resource family. A token cut for an analytics integration that should only read Encounter records will, in practice, read Patient records. A token cut to update one resource family will write across every family. Audit logs that record only the operation authority do not surface the gap, because the operation authority was always held legitimately. This is the highest-impact finding in the batch for any authentication-enabled Pathling deployment.
The fix. server-v2.0.0 enforces the per-resource authority on every typed CRUD, search, and batch entrypoint. The check existed in the codebase already, in BulkSubmitExecutor.checkResourceWriteAuthority. It just was not called from the typed providers.
Surface 2: $bulk-submit accepts caller-supplied output URLs
Apex flagged: GHSA-26hp-x47g-x95q / CVE-2026-47662
$bulk-submit is Pathling's operation for ingesting Bulk Data Access manifests. It fetches a manifest the caller specifies, reads the output URLs from the manifest, and downloads each one with operator-configured bearer tokens attached. The output URLs were not validated.
This is TrustLaunder's shape on a different operation. Where $import-pnp laundered URLs via a synthetic file:// allowlist after extraction, $bulk-submit launders them at the manifest stage. The attacker hands Pathling a manifest with attacker-controlled output URLs. Pathling sends its operator bearer tokens to those URLs. The attacker returns NDJSON. Pathling ingests it into the warehouse as trusted patient and observation data.
What this means in practice. Same blast radius as TrustLaunder, accessed through a different door. Operator credentials reach an attacker host. The warehouse takes attacker-supplied resources as authoritative. Downstream views, cohort selections, and ML pipelines that draw from the warehouse compile against those resources without knowing they were planted.
The fix. server-v2.0.0 adds pathling.bulkSubmit.allowableSources, a fail-closed allowlist defaulting to []. The allowlist is checked against the manifest URL, against every output URL the manifest specifies, and against the discovered OAuth token endpoint. URI-aware matching replaces the previous String.startsWith comparison, so host-suffix bypasses (https://allowed.com.attacker.org matching https://allowed.com) no longer work.
Surface 3: $bulk-submit OAuth discovery accepts caller-supplied metadata URL
Apex flagged: GHSA-245h-c573-9vr5 / CVE-2026-47660
The second $bulk-submit finding targets the OAuth discovery step instead of the manifest output URLs. $bulk-submit accepts an explicit oauthMetadataUrl parameter. The handler fetches OAuth metadata from that URL, reads token_endpoint from the response, and uses the discovered token endpoint as the destination for the operator's client_id and client_secret.
The bug is that oauthMetadataUrl was not validated against pathling.bulkSubmit.allowableSources, even though manifestUrl and fhirBaseUrl were. Apex's source-level confirmation named the exact files:
BulkSubmitValidator.javachecksallowableSourcesformanifestUrlandfhirBaseUrl. Not foroauthMetadataUrl.BulkSubmitAuthProvider.java'sdiscoverTokenEndpointfetches metadata from the caller-supplied URL without restriction.SubmitterConfiguration.java'stoAuthConfigcopies the stored client secret or JWK into the outbound token request targeted at whatever endpoint the metadata response specified.
The attacker chain: send a $bulk-submit request with allowlisted manifestUrl and fhirBaseUrl, but oauthMetadataUrl pointing at the attacker. The attacker's metadata response names an attacker-controlled token_endpoint. Pathling POSTs the configured submitter's client_id and client_secret to that endpoint. The attacker returns a bearer token of their choice. Pathling then uses that token on subsequent requests to the allowlisted manifest and data sources.
What this means in practice. Operator OAuth client credentials reach an attacker-controlled endpoint. If client_secret is reused across submitter configurations, one exploitation compromises every outbound integration that shares the secret.
The fix. server-v2.0.0 adds oauthMetadataUrl to the set of fields validated against pathling.bulkSubmit.allowableSources. With the mandatory-allowlist default and the URI-aware matcher, the OAuth discovery path is now constrained to operator-configured destinations.
Surface 4: Path traversal in async export endpoints
Apex flagged: GHSA-8w85-f63v-3wh6 / CVE-2026-47661 and GHSA-5h9r-m7r5-8jxq / CVE-2026-47659
Pathling's $result endpoint serves the artifacts of an async export job, and $import-pnp exposes a parallel job-staging endpoint at /jobs/{jobId}/{filename}. Both accept a filename parameter that the handler concatenates into a filesystem path without canonicalisation against the job's staging directory. The same root cause is exposed through two endpoints; the GitHub Security Response Team assigned a separate CVE to each surface.
ExportResultProvider builds the $result path directly from caller input:
final Path requestedFilepath =
new Path(
URI.create(databasePath).getPath()
+ Path.SEPARATOR
+ "jobs"
+ Path.SEPARATOR
+ jobId
+ Path.SEPARATOR
+ file);Async export scratch space lives under the same warehouse database root as persisted resource tables. A file value with ../../ segments resolves into the warehouse layout. The PoC against the documented default deployment (filesystem-backed warehouse, authentication disabled, export enabled):
- Create a marker file in the warehouse root and a Patient resource so
Patient.parquetmaterialises. - Start an async export job, capture the job ID.
GET /fhir/$result?job=<jobId>&file=../../proof.txtreturns the marker file (HTTP 200).GET /fhir/$result?job=<jobId>&file=../../Patient.parquet/_delta_log/00000000000000000000.jsonreturns the Delta log naming the data part file.GET /fhir/$result?job=<jobId>&file=../../Patient.parquet/<part-file>.parquetreturns the stored patient Parquet bytes.
The $import-pnp staging endpoint shares the same flaw on a different path, exposed through a different operation. Both endpoints reach the same warehouse files.
What this means in practice. In the documented default deployment, any remote caller reads arbitrary warehouse files, including the Parquet tables that store patient records. PHI disclosure in plain bytes. In an authentication-enabled deployment, anyone with export capability gets the same primitive: a single legitimate export job ID is enough to read every other job's output and the warehouse root.
This is the bug class that makes Pathling's documented default configuration unsafe on any untrusted network.
The fix. server-v2.0.0 resolves and canonicalises the requested file path on both endpoints and rejects any value that escapes the job's jobs/<jobId> directory. Path containment and symlink resolution checks were added across FileController, ExportResultProvider, ExportResponse, and $import-pnp download handling in the same release.
Four surfaces, one pattern
Three of these surfaces (2, 3, 4) share one architectural shape. A convenience surface accepts a caller-supplied value (URL, metadata URL, filename), forwards it into a sink (outbound credentialed request, file read), and the trust check that the safer sibling operation already implements is missing at the layer that has the information to enforce it.
Surface 1 is structurally different but shares the same root cause. The check that should have run at the typed provider's entry point existed elsewhere in the codebase, in BulkSubmitExecutor.checkResourceWriteAuthority, and was simply not invoked from where it was needed.
Four surfaces, one pattern: documented security contracts that lived only in the documentation. The fix in server-v2.0.0 moves each contract into the code at the entry point.
Mitigation
Upgrade to server-v2.0.0. The release fixes all five bugs in this disclosure batch (TrustLaunder plus the four covered here).
For environments that cannot upgrade immediately:
- Surface 1. Audit issued OAuth tokens. Any token holding an operation authority (
pathling:search,pathling:update, and similar) without the matching per-resource authority is a server-wide read or write primitive. Revoke until the upgrade lands. - Surfaces 2 and 3. Set
pathling.operations.bulkSubmitEnabled=false, or remove submitter OAuth credentials from configuration so the credential-leak link in the chain has nothing to leak. - Surface 4. Disable async export operations (
exportEnabled,patientExportEnabled,groupExportEnabled,bulkSubmitEnabled), or enable authentication and restrict export capability to trusted callers. In a default-config deployment with authentication disabled, this is mandatory.
Any deployment that ran these operations against untrusted callers in an affected version should treat its warehouse as potentially compromised and review its FHIR resource state for unexpected modifications.
Takeaways
1. The bugs Apex found in Pathling shared one shape. Documented security contracts that lived in the documentation, not in the code. Per-resource authorities, manifest URL allowlists, OAuth metadata URL allowlists, path containment on async-export endpoints. Each contract was specified somewhere in the docs. Each was un-enforced at the entry point that needed to enforce it. The contracts existed. The calls did not.
2. Where there is one trust-boundary gap, there are usually more. TrustLaunder pointed Apex at $import-pnp. The review surfaced a second path-traversal in the same operation (covered here under Surface 4). It also surfaced two structurally identical bugs in $bulk-submit, plus the authorization-layer gap that affects every typed provider. The pattern that produced TrustLaunder predicted the rest.
3. Convenience operations almost always inherit the API of their safer siblings, not the threat model. This is the lesson that repeated three times in this batch. $bulk-submit reused $import-pnp's pattern of forwarding caller-supplied URLs to operator-credentialed outbound requests. The async-export endpoints reused a path-construction pattern without the canonicalisation that safer surfaces use. 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.
4. In healthcare warehouses, "arbitrary file read" is PHI disclosure. A path traversal in a deployment where patient data is persisted as Parquet under the same root as scratch space is not a generic file-read bug. It is patient-data disclosure in plain bytes. The same primitive in a different deployment context might be a configuration-file leak. The healthcare context is what moves the severity from Low to High. Threat models that assume "this is just a file system" miss the deployment that matters.
How Cantina handles vulnerabilities like this
Apex is Cantina's autonomous AppSec agent. The review loop that produced these five Pathling findings is the same one that produced TrustLaunder, the X-Wing FFI memory-safety bug in swift-crypto, and CrateSwap in Cargo. Apex enumerates trust-boundary inputs in a target codebase, traces each one to its sink, and checks whether the boundary's contract is enforced at the layer that has the information to enforce it. When the answer is no, the result is what you see here: five high-severity advisories from one engagement.
Cantina covers AppSec, OpSec, and everything in between with one agentic operating system. If your team ships a service that accepts URLs, filenames, or scope-bearing tokens from any caller, which is almost every service, the loop that produced these findings can run against your code.