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.