This is an automated email from the ASF dual-hosted git repository.

Croway pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new 1ead256f1bfc CAMEL-24475: camel-xpath - parse an InputSource document 
type with the hardened XML parser (#25683)
1ead256f1bfc is described below

commit 1ead256f1bfcef36c2572a88809bcd7547bdc111
Author: Andrea Cosentino <[email protected]>
AuthorDate: Fri Aug 28 12:19:48 2026 +0200

    CAMEL-24475: camel-xpath - parse an InputSource document type with the 
hardened XML parser (#25683)
    
    XPathBuilder handed an InputSource straight to XPathExpression, which 
builds a
    DocumentBuilder of its own with the JDK defaults - so 
documentType=InputSource
    (and SAXSource) accepted a DOCTYPE declaration and resolved external 
entities,
    while the default documentType of Document did not.
    
    All four evaluation sites now convert through the type converter, reusing 
the
    same hardened DocumentBuilderFactory the default document type already goes
    through. This adds no document parse: evaluate(InputSource) already built a
    full DOM internally.
    
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
 .../apache/camel/language/xpath/XPathBuilder.java  | 33 ++++++++--
 .../apache/camel/builder/xml/XPathFeatureTest.java | 72 ++++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc    | 27 ++++++++
 3 files changed, 128 insertions(+), 4 deletions(-)

diff --git 
a/components/camel-xpath/src/main/java/org/apache/camel/language/xpath/XPathBuilder.java
 
b/components/camel-xpath/src/main/java/org/apache/camel/language/xpath/XPathBuilder.java
index da44704e201a..bb75df7e9c4a 100644
--- 
a/components/camel-xpath/src/main/java/org/apache/camel/language/xpath/XPathBuilder.java
+++ 
b/components/camel-xpath/src/main/java/org/apache/camel/language/xpath/XPathBuilder.java
@@ -942,7 +942,8 @@ public class XPathBuilder extends ServiceSupport
             // fetch all namespaces
             if (document instanceof InputSource) {
                 InputSource inputSource = (InputSource) document;
-                answer = (NodeList) xpathExpression.evaluate(inputSource, 
XPathConstants.NODESET);
+                answer = (NodeList) 
xpathExpression.evaluate(toHardenedDocument(exchange, inputSource),
+                        XPathConstants.NODESET);
             } else if (document instanceof DOMSource) {
                 DOMSource source = (DOMSource) document;
                 answer = (NodeList) xpathExpression.evaluate(source.getNode(), 
XPathConstants.NODESET);
@@ -950,7 +951,8 @@ public class XPathBuilder extends ServiceSupport
                 SAXSource source = (SAXSource) document;
                 // since its a SAXSource it may not return an NodeList (for
                 // example if using Saxon)
-                Object result = 
xpathExpression.evaluate(source.getInputSource(), XPathConstants.NODESET);
+                Object result = 
xpathExpression.evaluate(toHardenedDocument(exchange, source.getInputSource()),
+                        XPathConstants.NODESET);
                 if (result instanceof NodeList) {
                     answer = (NodeList) result;
                 } else {
@@ -1018,7 +1020,7 @@ public class XPathBuilder extends ServiceSupport
                 }
                 if (document instanceof InputSource) {
                     InputSource inputSource = (InputSource) document;
-                    answer = xpathExpression.evaluate(inputSource, 
resultQName);
+                    answer = 
xpathExpression.evaluate(toHardenedDocument(exchange, inputSource), 
resultQName);
                 } else if (document instanceof DOMSource) {
                     DOMSource source = (DOMSource) document;
                     answer = xpathExpression.evaluate(source.getNode(), 
resultQName);
@@ -1028,7 +1030,7 @@ public class XPathBuilder extends ServiceSupport
             } else {
                 if (document instanceof InputSource) {
                     InputSource inputSource = (InputSource) document;
-                    answer = xpathExpression.evaluate(inputSource);
+                    answer = 
xpathExpression.evaluate(toHardenedDocument(exchange, inputSource));
                 } else if (document instanceof DOMSource) {
                     DOMSource source = (DOMSource) document;
                     answer = xpathExpression.evaluate(source.getNode());
@@ -1229,6 +1231,29 @@ public class XPathBuilder extends ServiceSupport
         return false;
     }
 
+    /**
+     * Parses an {@link InputSource} into a DOM document before it is 
evaluated.
+     * <p>
+     * {@link XPathExpression#evaluate(InputSource)} and its overloads build a 
{@link javax.xml.parsers.DocumentBuilder}
+     * of their own using the JDK defaults, which accept a {@code DOCTYPE} 
declaration and resolve external entities.
+     * Routing the source through the type converter instead reuses the 
hardened {@code DocumentBuilderFactory} that the
+     * default {@code documentType} of {@link Document} already goes through, 
so both document types are parsed with the
+     * same configuration. The XPath engine builds a full DOM from the source 
either way, so this does not add a parse
+     * that was not already happening.
+     */
+    protected Document toHardenedDocument(Exchange exchange, InputSource 
inputSource) {
+        Document document = null;
+        if (inputSource != null) {
+            document = 
exchange.getContext().getTypeConverter().convertTo(Document.class, exchange, 
inputSource);
+        }
+        if (document == null) {
+            throw new RuntimeCamelException(
+                    "Cannot convert the InputSource to a org.w3c.dom.Document 
for evaluating the XPath expression: "
+                                            + getText());
+        }
+        return document;
+    }
+
     /**
      * Strategy method to extract the document from the exchange.
      */
diff --git 
a/core/camel-core/src/test/java/org/apache/camel/builder/xml/XPathFeatureTest.java
 
b/core/camel-core/src/test/java/org/apache/camel/builder/xml/XPathFeatureTest.java
index d40fdc35177d..2422701d920e 100644
--- 
a/core/camel-core/src/test/java/org/apache/camel/builder/xml/XPathFeatureTest.java
+++ 
b/core/camel-core/src/test/java/org/apache/camel/builder/xml/XPathFeatureTest.java
@@ -16,8 +16,13 @@
  */
 package org.apache.camel.builder.xml;
 
+import java.io.ByteArrayInputStream;
 import java.io.FileNotFoundException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
 
+import org.xml.sax.InputSource;
 import org.xml.sax.SAXParseException;
 
 import org.apache.camel.ContextTestSupport;
@@ -26,17 +31,23 @@ import org.apache.camel.NoTypeConversionAvailableException;
 import org.apache.camel.RuntimeCamelException;
 import org.apache.camel.TypeConversionException;
 import org.apache.camel.converter.jaxp.XmlConverter;
+import org.apache.camel.language.xpath.XPathBuilder;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.parallel.ResourceLock;
 import org.junit.jupiter.api.parallel.Resources;
 
+import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.apache.camel.language.xpath.XPathBuilder.xpath;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.junit.jupiter.api.Assertions.*;
 
 @ResourceLock(Resources.SYSTEM_PROPERTIES)
 public class XPathFeatureTest extends ContextTestSupport {
     public static final String DOM_BUILDER_FACTORY_FEATURE = 
XmlConverter.DOCUMENT_BUILDER_FACTORY_FEATURE;
 
+    private static final String CANARY = "CANARY-SHOULD-NOT-BE-READ";
+
     public static final String XML_DATA
             = " <!DOCTYPE foo [ " + " <!ELEMENT foo ANY > <!ENTITY xxe SYSTEM 
\"file:///bin/test.sh\" >]> <test> &xxe; </test>";
     public static final String XML_DATA_INVALID
@@ -76,6 +87,67 @@ public class XPathFeatureTest extends ContextTestSupport {
         }
     }
 
+    /**
+     * {@code documentType=InputSource} used to hand the payload straight to 
{@link javax.xml.xpath.XPathExpression},
+     * which builds a DocumentBuilder of its own with the JDK defaults - so 
the DOCTYPE that
+     * {@link #testXPathDocTypeDisallowed()} pins as refused on the default 
document type was accepted here, and the
+     * external entity was resolved and expanded into the evaluated document. 
The two document types must agree on the
+     * parser configuration.
+     */
+    @Test
+    void docTypeIsAlsoDisallowedForAnInputSourceDocumentType() throws 
Exception {
+        Path secret = Files.createTempFile("camel-xpath-entity", ".txt");
+        try {
+            Files.writeString(secret, CANARY);
+            // an InputStream body, since that is what converts to an 
InputSource - and what a streaming
+            // documentType=InputSource deployment actually receives
+            String xml = "<!DOCTYPE foo [ <!ELEMENT foo ANY > <!ENTITY xxe 
SYSTEM \""
+                         + secret.toUri() + "\" >]> <test> &xxe; </test>";
+
+            // both branches of doInEvaluateAs: with a result QName and 
without one
+            for (XPathBuilder builder : 
List.of(xpath("/").documentType(InputSource.class).stringResult(),
+                    xpath("/test").documentType(InputSource.class))) {
+                assertThatThrownBy(() -> builder.evaluate(createExchange(new 
ByteArrayInputStream(xml.getBytes(UTF_8)))))
+                        .as("a DOCTYPE must be refused for 
documentType=InputSource, as it is for the default type")
+                        .hasRootCauseInstanceOf(SAXParseException.class)
+                        .rootCause().hasMessageContaining("DOCTYPE");
+            }
+        } finally {
+            Files.deleteIfExists(secret);
+        }
+    }
+
+    /**
+     * The {@code InputSource} document type now shares the default type's 
parser, so it also shares its escape hatch:
+     * the same system properties that {@link #testXPath()} uses relax it. 
Points at a file that does not exist, so a
+     * {@code FileNotFoundException} is what proves the DOCTYPE was accepted 
and resolution attempted.
+     */
+    @Test
+    void 
theDocumentBuilderFactoryFeaturesAlsoRelaxTheInputSourceDocumentType() {
+        System.setProperty(DOM_BUILDER_FACTORY_FEATURE + ":" + 
"http://xml.org/sax/features/external-general-entities";, "true");
+        System.setProperty(DOM_BUILDER_FACTORY_FEATURE + ":" + 
"http://apache.org/xml/features/disallow-doctype-decl";, "false");
+        try {
+            assertThatThrownBy(() -> 
xpath("/").documentType(InputSource.class).stringResult()
+                    .evaluate(createExchange(new 
ByteArrayInputStream(XML_DATA.getBytes(UTF_8)))))
+                    .hasRootCauseInstanceOf(FileNotFoundException.class);
+        } finally {
+            System.clearProperty(DOM_BUILDER_FACTORY_FEATURE + ":" + 
"http://xml.org/sax/features/external-general-entities";);
+            System.clearProperty(DOM_BUILDER_FACTORY_FEATURE + ":" + 
"http://apache.org/xml/features/disallow-doctype-decl";);
+        }
+    }
+
+    /**
+     * Guards the assumption the test above rests on: an {@code InputStream} 
body really does reach the
+     * {@code InputSource} branch, rather than failing earlier for want of a 
type converter.
+     */
+    @Test
+    void anInputStreamBodyConvertsToAnInputSourceDocumentType() {
+        Object result = 
xpath("/test/text()").documentType(InputSource.class).stringResult()
+                .evaluate(createExchange(new 
ByteArrayInputStream("<test>ok</test>".getBytes(UTF_8))));
+
+        assertThat(result).isEqualTo("ok");
+    }
+
     @Test
     public void testXPathNoTypeConverter() {
         // define a class without type converter as document type
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index 64b7415c521e..aad1d4a1b9b0 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -834,3 +834,30 @@ mapped exactly as before.
 Routes that deliberately read a `Camel`-prefixed header produced by the Tika 
parse must set it
 themselves after the `tika:parse` step, for example with a `setHeader` reading 
the corresponding
 non-prefixed metadata name.
+
+=== camel-xpath
+
+The XPath language now parses the message with the same hardened XML parser 
for every `documentType`.
+
+With the default `documentType` of `org.w3c.dom.Document` the payload was 
already converted to a DOM
+through Camel's `DocumentBuilderFactory`, which disallows a `DOCTYPE` 
declaration and does not resolve
+external entities. When `documentType` was set to `org.xml.sax.InputSource` 
(or `javax.xml.transform.sax.SAXSource`)
+the payload was instead handed straight to `javax.xml.xpath.XPathExpression`, 
which builds a
+`DocumentBuilder` of its own using the JDK defaults — so a `DOCTYPE` was 
accepted and external entities
+were resolved on that path only.
+
+Those document types now go through the same conversion as `Document`. A 
message carrying a `DOCTYPE`
+declaration that previously evaluated is now rejected with a 
`SAXParseException`, matching the behaviour
+the default `documentType` has always had.
+
+A deployment that genuinely needs to parse documents with a `DOCTYPE` can 
relax the parser as before,
+through the `org.apache.camel.xmlconverter.documentBuilderFactory.feature:` 
system properties, for
+example:
+
+[source,text]
+----
+-Dorg.apache.camel.xmlconverter.documentBuilderFactory.feature:http://apache.org/xml/features/disallow-doctype-decl=false
+----
+
+This is not a functional change for messages without a `DOCTYPE`, and it does 
not add a document parse:
+`XPathExpression.evaluate(InputSource)` already built a full DOM internally.

Reply via email to