This is an automated email from the ASF dual-hosted git repository.
davsclaus 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 5e61b102e695 CAMEL-26086: camel-stax - xtokenize: decoded text in mode
t, a '>' in an attribute value in modes i and u (#28594)
5e61b102e695 is described below
commit 5e61b102e69566778375143aef29e9625b2ac5fc
Author: allthingssecurity <[email protected]>
AuthorDate: Wed Oct 28 21:34:16 2026 +0530
CAMEL-26086: camel-stax - xtokenize: decoded text in mode t, a '>' in an
attribute value in modes i and u (#28594)
xtokenize in mode t now returns the decoded text of the element, and modes
i and u handle a '>' inside an attribute value. Mode i reads the namespace
declarations of the start tag with a quote-aware scan instead of a regex, so a
prefix such as o-x or o.y is not declared twice. Tests cover the XXE, character
reference, comment and processing instruction cases.
Co-Authored-By: Claude <[email protected]>
---
.../xtokenizer/XMLTokenExpressionIterator.java | 165 +++++++++++++---
.../xtokenizer/XMLTokenizeTextAndStartTagTest.java | 213 +++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 8 +
3 files changed, 357 insertions(+), 29 deletions(-)
diff --git
a/components/camel-stax/src/main/java/org/apache/camel/language/xtokenizer/XMLTokenExpressionIterator.java
b/components/camel-stax/src/main/java/org/apache/camel/language/xtokenizer/XMLTokenExpressionIterator.java
index 240756db896a..70170f146d1e 100644
---
a/components/camel-stax/src/main/java/org/apache/camel/language/xtokenizer/XMLTokenExpressionIterator.java
+++
b/components/camel-stax/src/main/java/org/apache/camel/language/xtokenizer/XMLTokenExpressionIterator.java
@@ -21,6 +21,7 @@ import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
+import java.io.StringReader;
import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
import java.util.HashMap;
@@ -30,10 +31,10 @@ import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
-import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.xml.namespace.QName;
+import javax.xml.stream.XMLInputFactory;
import javax.xml.stream.XMLStreamConstants;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamReader;
@@ -45,6 +46,7 @@ import org.apache.camel.InvalidPayloadException;
import org.apache.camel.converter.jaxp.StaxConverter;
import org.apache.camel.spi.NamespaceAware;
import org.apache.camel.support.ExpressionAdapter;
+import org.apache.camel.util.EscapeHelper;
import org.apache.camel.util.IOHelper;
import org.apache.camel.util.ObjectHelper;
import org.apache.camel.util.StringHelper;
@@ -170,7 +172,6 @@ public class XMLTokenExpressionIterator extends
ExpressionAdapter implements Nam
static class XMLTokenIterator implements Iterator<Object>, Closeable {
private static final Logger LOG =
LoggerFactory.getLogger(XMLTokenIterator.class);
- private static final Pattern NAMESPACE_PATTERN =
Pattern.compile("xmlns(:\\w+|)\\s*=\\s*('[^']*'|\"[^\"]*\")");
private AttributedQName[] splitpath;
private int index;
@@ -361,7 +362,7 @@ public class XMLTokenExpressionIterator extends
ExpressionAdapter implements Nam
return token;
}
- private String createContextualToken(String token) {
+ private String createContextualToken(String token) throws
XMLStreamException {
StringBuilder sb = new StringBuilder();
if (mode == 'w' && group == 1) {
for (int i = 0; i < segments.size(); i++) {
@@ -374,23 +375,10 @@ public class XMLTokenExpressionIterator extends
ExpressionAdapter implements Nam
}
} else if (mode == 'i') {
- final String stag = token.substring(0, token.indexOf('>') + 1);
+ final String stag = token.substring(0, startTagEnd(token) + 1);
+ // the prefixes that the start tag declares itself ("" for the
default namespace)
Set<String> skip = new HashSet<>();
- Matcher matcher = NAMESPACE_PATTERN.matcher(stag);
- char quote = 0;
- while (matcher.find()) {
- String prefix = matcher.group(1);
- if (prefix.length() > 0) {
- prefix = prefix.substring(1);
- }
- skip.add(prefix);
- if (quote == 0) {
- quote = matcher.group(2).charAt(0);
- }
- }
- if (quote == 0) {
- quote = '"';
- }
+ char quote = scanNamespaceDeclarations(stag, skip);
boolean empty = stag.endsWith("/>");
sb.append(token, 0, stag.length() - (empty ? 2 : 1));
for (Entry<String, String> e :
getCurrentNamespaceBindings().entrySet()) {
@@ -406,21 +394,15 @@ public class XMLTokenExpressionIterator extends
ExpressionAdapter implements Nam
}
sb.append(token, stag.length() - (empty ? 2 : 1),
token.length());
} else if (mode == 'u') {
- int bp = token.indexOf(">");
+ int bp = startTagEnd(token);
int ep = token.lastIndexOf("</");
if (bp > 0 && ep > 0) {
sb.append(token, bp + 1, ep);
}
} else if (mode == 't') {
- int bp = 0;
- for (;;) {
- int ep = token.indexOf('>', bp);
- bp = token.indexOf('<', ep);
- if (bp < 0) {
- break;
- }
- sb.append(token, ep + 1, bp);
- }
+ String text = getTextContent(token);
+ // a group of texts is wrapped in a group element, so it must
stay XML text
+ sb.append(group > 1 ? EscapeHelper.xml(text) : text);
} else {
return token;
}
@@ -428,6 +410,114 @@ public class XMLTokenExpressionIterator extends
ExpressionAdapter implements Nam
return sb.toString();
}
+ /**
+ * Adds the prefixes of the namespace declarations of the start tag to
the set ("" for xmlns), reading the
+ * attribute names and skipping the quoted values, so text in a value
is not taken for a declaration.
+ *
+ * @return the quote of the first declaration, or '"' if the start tag
declares none
+ */
+ private static char scanNamespaceDeclarations(String stag, Set<String>
prefixes) {
+ char quote = 0;
+ int len = stag.length();
+ int i = 1;
+ // the element name
+ while (i < len && !isTagDelimiter(stag.charAt(i))) {
+ i++;
+ }
+ while (i < len) {
+ char c = stag.charAt(i);
+ if (isXmlWhitespace(c)) {
+ i++;
+ continue;
+ }
+ if (c == '/' || c == '>') {
+ break;
+ }
+ int nameStart = i;
+ while (i < len && !isTagDelimiter(stag.charAt(i)) &&
stag.charAt(i) != '=') {
+ i++;
+ }
+ String name = stag.substring(nameStart, i);
+ while (i < len && isXmlWhitespace(stag.charAt(i))) {
+ i++;
+ }
+ if (i >= len || stag.charAt(i) != '=') {
+ continue;
+ }
+ i++;
+ while (i < len && isXmlWhitespace(stag.charAt(i))) {
+ i++;
+ }
+ if (i >= len) {
+ break;
+ }
+ char valueQuote = stag.charAt(i);
+ int valueEnd = stag.indexOf(valueQuote, i + 1);
+ i = valueEnd < 0 ? len : valueEnd + 1;
+ if ("xmlns".equals(name) || name.startsWith("xmlns:")) {
+ prefixes.add(name.length() > 5 ? name.substring(6) : "");
+ if (quote == 0) {
+ quote = valueQuote;
+ }
+ }
+ }
+ return quote != 0 ? quote : '"';
+ }
+
+ private static boolean isTagDelimiter(char c) {
+ return isXmlWhitespace(c) || c == '/' || c == '>';
+ }
+
+ private static boolean isXmlWhitespace(char c) {
+ return c == ' ' || c == '\t' || c == '\r' || c == '\n';
+ }
+
+ /**
+ * The index of the '>' that ends the start tag at the beginning of
the token. A '>' inside a quoted attribute
+ * value does not end the start tag.
+ */
+ private static int startTagEnd(String token) {
+ char quote = 0;
+ for (int i = 0; i < token.length(); i++) {
+ char c = token.charAt(i);
+ if (quote != 0) {
+ if (c == quote) {
+ quote = 0;
+ }
+ } else if (c == '"' || c == '\'') {
+ quote = c;
+ } else if (c == '>') {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ /**
+ * The text content of the element token as an XML parser gives it:
entity and character references decoded, the
+ * content of CDATA sections included, comments and processing
instructions left out.
+ */
+ private static String getTextContent(String token) throws
XMLStreamException {
+ StringBuilder sb = new StringBuilder();
+ XMLStreamReader r =
TextInputFactory.FACTORY.createXMLStreamReader(new StringReader(token));
+ try {
+ while (r.hasNext()) {
+ int event = r.next();
+ if (event == XMLStreamConstants.CHARACTERS || event ==
XMLStreamConstants.CDATA
+ || event == XMLStreamConstants.SPACE) {
+ sb.append(r.getText());
+ } else if (event == XMLStreamConstants.ENTITY_REFERENCE) {
+ // an entity that the token does not declare is kept
as it is written
+ String text = r.getText();
+ sb.append(text != null ? text : "&" + r.getLocalName()
+ ";");
+ }
+ }
+ } finally {
+ r.close();
+ }
+ return sb.toString();
+ }
+
private String getGroupedToken() {
StringBuilder sb = new StringBuilder();
if (mode == 'w') {
@@ -598,6 +688,23 @@ public class XMLTokenExpressionIterator extends
ExpressionAdapter implements Nam
}
}
+ /**
+ * The factory of the readers that get the text of a token in the text
mode, created once. It is configured before
+ * it is published and then only creates readers, from the threads of all
the routes that use the text mode. That
+ * relies on createXMLStreamReader being thread-safe, which holds when
Woodstox, the provider camel-stax depends on,
+ * is the provider found; StaxConverter itself pools its factories instead.
+ */
+ private static final class TextInputFactory {
+ static final XMLInputFactory FACTORY = create();
+
+ private static XMLInputFactory create() {
+ // not namespace aware, as the token may use prefixes that its
ancestors declare
+ XMLInputFactory factory =
StaxConverter.createXMLInputFactory(false);
+ factory.setProperty(XMLInputFactory.IS_COALESCING, true);
+ return factory;
+ }
+ }
+
static class AttributedQName extends QName {
private static final long serialVersionUID = 9878370226894144L;
private Pattern lcpattern;
diff --git
a/components/camel-stax/src/test/java/org/apache/camel/language/xtokenizer/XMLTokenizeTextAndStartTagTest.java
b/components/camel-stax/src/test/java/org/apache/camel/language/xtokenizer/XMLTokenizeTextAndStartTagTest.java
new file mode 100644
index 000000000000..904eceadf927
--- /dev/null
+++
b/components/camel-stax/src/test/java/org/apache/camel/language/xtokenizer/XMLTokenizeTextAndStartTagTest.java
@@ -0,0 +1,213 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.language.xtokenizer;
+
+import java.io.StringReader;
+import java.nio.file.Path;
+
+import javax.xml.XMLConstants;
+import javax.xml.parsers.DocumentBuilderFactory;
+
+import org.w3c.dom.Element;
+
+import org.xml.sax.InputSource;
+
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.support.builder.Namespaces;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Valid XML that xtokenize must split as an XML parser reads it: the text
mode gives the text content of the element
+ * (entities decoded, CDATA included), and a '>' inside an attribute value
does not end the start tag in the inject and
+ * unwrap modes.
+ */
+class XMLTokenizeTextAndStartTagTest extends CamelTestSupport {
+
+ @Test
+ void textModeDecodesEntityReferences() throws Exception {
+ MockEndpoint mock = getMockEndpoint("mock:text");
+ mock.expectedBodiesReceived("AT&T <5>");
+
+ template.sendBody("direct:text", "<?xml
version='1.0'?><orders><order>AT&T <5></order></orders>");
+
+ mock.assertIsSatisfied();
+ }
+
+ @Test
+ void textModeKeepsCdataContent() throws Exception {
+ MockEndpoint mock = getMockEndpoint("mock:text");
+ mock.expectedBodiesReceived("a<b");
+
+ template.sendBody("direct:text", "<?xml
version='1.0'?><orders><order><![CDATA[a<b]]></order></orders>");
+
+ mock.assertIsSatisfied();
+ }
+
+ @Test
+ void textModeGroupIsXml() throws Exception {
+ MockEndpoint mock = getMockEndpoint("mock:textGroup");
+ mock.expectedMessageCount(1);
+
+ template.sendBody("direct:textGroup",
+ "<?xml
version='1.0'?><orders><order>AT&T</order><order><![CDATA[<5>]]></order></orders>");
+
+ mock.assertIsSatisfied();
+ String token =
mock.getReceivedExchanges().get(0).getIn().getBody(String.class);
+ assertEquals("AT&T<5>", parse(token).getTextContent(), token);
+ }
+
+ @Test
+ void injectModeKeepsAnAttributeValueWithGreaterThan() throws Exception {
+ MockEndpoint mock = getMockEndpoint("mock:inject");
+ mock.expectedMessageCount(1);
+
+ template.sendBody("direct:inject",
+ "<?xml version='1.0'?><orders xmlns='urn:orders'><order
note='qty > 5'>1</order></orders>");
+
+ mock.assertIsSatisfied();
+ String token =
mock.getReceivedExchanges().get(0).getIn().getBody(String.class);
+ Element order = parse(token);
+ assertEquals("urn:orders", order.getNamespaceURI(), token);
+ assertEquals("qty > 5", order.getAttribute("note"), token);
+ }
+
+ @Test
+ void injectModeKeepsAnEmptyElementWithGreaterThanInAnAttributeValue()
throws Exception {
+ MockEndpoint mock = getMockEndpoint("mock:inject");
+ mock.expectedMessageCount(1);
+
+ template.sendBody("direct:inject",
+ "<?xml version='1.0'?><orders xmlns='urn:orders'><order
note=\"a -> b\"/></orders>");
+
+ mock.assertIsSatisfied();
+ String token =
mock.getReceivedExchanges().get(0).getIn().getBody(String.class);
+ Element order = parse(token);
+ assertEquals("urn:orders", order.getNamespaceURI(), token);
+ assertEquals("a -> b", order.getAttribute("note"), token);
+ }
+
+ @Test
+ void injectModeDoesNotDeclareAgainAPrefixThatTheStartTagDeclares() throws
Exception {
+ MockEndpoint mock = getMockEndpoint("mock:inject");
+ mock.expectedMessageCount(1);
+
+ template.sendBody("direct:inject",
+ "<?xml version='1.0'?><orders xmlns='urn:orders'
xmlns:o-x='urn:x' xmlns:o.y='urn:y'>"
+ + "<order xmlns:o-x='urn:x'
xmlns:o.y='urn:y' o-x:id='1' o.y:id='2'>1</order></orders>");
+
+ mock.assertIsSatisfied();
+ String token =
mock.getReceivedExchanges().get(0).getIn().getBody(String.class);
+ // a declaration added a second time is a duplicate attribute, which
the parser refuses
+ Element order = parse(token);
+ assertEquals("urn:orders", order.getNamespaceURI(), token);
+ assertEquals("1", order.getAttributeNS("urn:x", "id"), token);
+ assertEquals("2", order.getAttributeNS("urn:y", "id"), token);
+ }
+
+ @Test
+ void
injectModeDeclaresTheDefaultNamespaceWhenAnAttributeValueContainsXmlns() throws
Exception {
+ MockEndpoint mock = getMockEndpoint("mock:inject");
+ mock.expectedMessageCount(1);
+
+ template.sendBody("direct:inject",
+ "<?xml version='1.0'?><orders xmlns='urn:orders'><order
note=\"xmlns='z'\">1</order></orders>");
+
+ mock.assertIsSatisfied();
+ String token =
mock.getReceivedExchanges().get(0).getIn().getBody(String.class);
+ Element order = parse(token);
+ assertEquals("urn:orders", order.getNamespaceURI(), token);
+ assertEquals("xmlns='z'", order.getAttribute("note"), token);
+ }
+
+ @Test
+ void
injectModeDeclaresTheDefaultNamespaceWhenAnAttributeNameEndsWithXmlns() throws
Exception {
+ MockEndpoint mock = getMockEndpoint("mock:inject");
+ mock.expectedMessageCount(1);
+
+ template.sendBody("direct:inject",
+ "<?xml version='1.0'?><orders xmlns='urn:orders'><order
dataxmlns='v'>1</order></orders>");
+
+ mock.assertIsSatisfied();
+ String token =
mock.getReceivedExchanges().get(0).getIn().getBody(String.class);
+ Element order = parse(token);
+ assertEquals("urn:orders", order.getNamespaceURI(), token);
+ assertEquals("v", order.getAttribute("dataxmlns"), token);
+ }
+
+ @Test
+ void
textModeDecodesCharacterReferencesAndLeavesOutCommentsAndProcessingInstructions()
throws Exception {
+ MockEndpoint mock = getMockEndpoint("mock:text");
+ mock.expectedBodiesReceived("a<b>cd");
+
+ template.sendBody("direct:text",
+ "<?xml version='1.0'?><orders><order>a<b>c<!-- note
--><?pi data?>d</order></orders>");
+
+ mock.assertIsSatisfied();
+ }
+
+ @Test
+ void textModeDoesNotResolveAnExternalEntity(@TempDir Path dir) throws
Exception {
+ // the file does not exist, so a reader that tried to resolve the
entity would fail the exchange; the token
+ // of mode t never carries the doctype, so its own reader cannot see
the declaration either
+ Path missing = dir.resolve("missing.txt");
+ MockEndpoint mock = getMockEndpoint("mock:text");
+ mock.expectedBodiesReceived("&e;");
+
+ template.sendBody("direct:text", "<?xml version='1.0'?><!DOCTYPE
orders [<!ENTITY e SYSTEM \""
+ + missing.toUri() +
"\">]><orders><order>&e;</order></orders>");
+
+ mock.assertIsSatisfied();
+ }
+
+ @Test
+ void unwrapModeWithGreaterThanInAnAttributeValue() throws Exception {
+ MockEndpoint mock = getMockEndpoint("mock:unwrap");
+ mock.expectedBodiesReceived("<id>1</id>");
+
+ template.sendBody("direct:unwrap",
+ "<?xml version='1.0'?><orders><order note='qty >
5'><id>1</id></order></orders>");
+
+ mock.assertIsSatisfied();
+ }
+
+ private static Element parse(String xml) throws Exception {
+ DocumentBuilderFactory f = DocumentBuilderFactory.newInstance();
+ f.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
+ f.setNamespaceAware(true);
+ return f.newDocumentBuilder().parse(new InputSource(new
StringReader(xml))).getDocumentElement();
+ }
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+ Namespaces none = new Namespaces();
+ Namespaces orders = new Namespaces("o", "urn:orders");
+ from("direct:text").split().xtokenize("//order", 't',
none).to("mock:text");
+ from("direct:textGroup").split().xtokenize("//order", 't',
none, 2).to("mock:textGroup");
+ from("direct:inject").split().xtokenize("//o:order", 'i',
orders).to("mock:inject");
+ from("direct:unwrap").split().xtokenize("//order", 'u',
none).to("mock:unwrap");
+ }
+ };
+ }
+}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index ee6beaa4923e..205a36a7675b 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -1359,6 +1359,14 @@ It is now written with all its decimals, so a record
that is unmarshalled and ma
`Double` that is the result of a calculation is written with all its digits
too (`0.1 + 0.2` as `0.30000000000000004`,
before `0.3`). Set `precision` to get a fixed number of decimals, as before.
+=== camel-stax - the text mode of xtokenize gives the text as an XML parser
reads it
+
+With mode `t`, `xtokenize` returned the characters between the tags of the
element as they are written: entity and
+character references were not decoded (`AT&T` instead of `AT&T`), and the
content of a CDATA section was left
+out. The text is now decoded and includes the content of CDATA sections, so a
route that decoded the entities itself
+must stop doing so. With a `group` greater than 1, the texts in the `<group>`
element are escaped as XML text, so the
+group is still well-formed XML.
+
=== camel-smooks - allowExternalEntities applies to Smooks Source bodies and
to a reader declared without a class
The Smooks component and `SmooksProcessor` now parse a message body that is
already a Smooks `Source`