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&amp;T &lt;5&gt;</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&amp;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&#60;b&#x3E;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&amp;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`

Reply via email to