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

markt pushed a commit to branch 11.0.x
in repository https://gitbox.apache.org/repos/asf/tomcat.git


The following commit(s) were added to refs/heads/11.0.x by this push:
     new fde1a8235f Add validation of chunk extensions
fde1a8235f is described below

commit fde1a8235fb73125217bd41e162aa0a113f33552
Author: Mark Thomas <[email protected]>
AuthorDate: Tue Jan 27 19:33:00 2026 +0000

    Add validation of chunk extensions
---
 .../coyote/http11/filters/ChunkedInputFilter.java  | 109 ++++++++-----
 .../tomcat/util/http/parser/ChunkExtension.java    | 126 +++++++++++++++
 .../apache/tomcat/util/http/parser/HttpParser.java |   4 +
 .../util/http/parser/LocalStrings.properties       |   2 +
 .../http11/filters/TestChunkedInputFilter.java     |  91 +++++++++++
 .../util/http/parser/TestChunkExtension.java       | 176 +++++++++++++++++++++
 webapps/docs/changelog.xml                         |   4 +
 7 files changed, 474 insertions(+), 38 deletions(-)

diff --git a/java/org/apache/coyote/http11/filters/ChunkedInputFilter.java 
b/java/org/apache/coyote/http11/filters/ChunkedInputFilter.java
index de2457838b..62a7251ef9 100644
--- a/java/org/apache/coyote/http11/filters/ChunkedInputFilter.java
+++ b/java/org/apache/coyote/http11/filters/ChunkedInputFilter.java
@@ -30,6 +30,8 @@ import org.apache.coyote.http11.Constants;
 import org.apache.coyote.http11.InputFilter;
 import org.apache.tomcat.util.buf.ByteChunk;
 import org.apache.tomcat.util.buf.HexUtils;
+import org.apache.tomcat.util.http.parser.ChunkExtension;
+import org.apache.tomcat.util.http.parser.ChunkExtension.State;
 import org.apache.tomcat.util.http.parser.HttpHeaderParser;
 import org.apache.tomcat.util.http.parser.HttpHeaderParser.HeaderDataSource;
 import org.apache.tomcat.util.http.parser.HttpHeaderParser.HeaderParseStatus;
@@ -106,7 +108,7 @@ public class ChunkedInputFilter implements InputFilter, 
ApplicationBufferHandler
     private volatile ParseState parseState = ParseState.CHUNK_HEADER;
     private volatile boolean crFound = false;
     private volatile int chunkSizeDigitsRead = 0;
-    private volatile boolean parsingExtension = false;
+    private volatile State extensionState = null;
     private final AtomicLong extensionSize = new AtomicLong(0);
     private final HttpHeaderParser httpHeaderParser;
 
@@ -251,7 +253,7 @@ public class ChunkedInputFilter implements InputFilter, 
ApplicationBufferHandler
         parseState = ParseState.CHUNK_HEADER;
         crFound = false;
         chunkSizeDigitsRead = 0;
-        parsingExtension = false;
+        extensionState = null;
         extensionSize.set(0);
         httpHeaderParser.recycle();
     }
@@ -355,19 +357,38 @@ public class ChunkedInputFilter implements InputFilter, 
ApplicationBufferHandler
             }
 
             byte chr = readChunk.get(readChunk.position());
-            if (chr == Constants.CR || chr == Constants.LF) {
-                parsingExtension = false;
+
+            if (extensionState != null) {
+                extensionState = ChunkExtension.parse(chr, extensionState);
+                if (extensionState == State.CR) {
+                    if (!parseCRLF()) {
+                        return false;
+                    }
+                    eol = true;
+                    extensionState = null;
+                } else {
+                    // Check the size
+                    long extSize = extensionSize.incrementAndGet();
+                    if (maxExtensionSize > -1 && extSize > maxExtensionSize) {
+                        
throwBadRequestException(sm.getString("chunkedInputFilter.maxExtension"));
+                    }
+                }
+            } else if (chr == Constants.CR || chr == Constants.LF) {
                 if (!parseCRLF()) {
                     return false;
                 }
                 eol = true;
-            } else if (chr == Constants.SEMI_COLON && !parsingExtension) {
-                // First semicolon marks the start of the extension. Further
-                // semicolons may appear to separate multiple chunk-extensions.
-                // These need to be processed as part of parsing the 
extensions.
-                parsingExtension = true;
-                extensionSize.incrementAndGet();
-            } else if (!parsingExtension) {
+            } else if (chr == Constants.SEMI_COLON) {
+                /*
+                 * First semicolon marks the start of the extension. 
ChunkedExtension parser takes over for the
+                 * remainder of the extension.
+                 */
+                extensionState = State.PRE_NAME;
+                long extSize = extensionSize.incrementAndGet();
+                if (maxExtensionSize > -1 && extSize > maxExtensionSize) {
+                    return false;
+                }
+            } else {
                 int charValue = HexUtils.getDec(chr);
                 if (charValue != -1 && chunkSizeDigitsRead < 8) {
                     chunkSizeDigitsRead++;
@@ -376,17 +397,9 @@ public class ChunkedInputFilter implements InputFilter, 
ApplicationBufferHandler
                     // Isn't valid hex so this is an error condition
                     
throwBadRequestException(sm.getString("chunkedInputFilter.invalidHeader"));
                 }
-            } else {
-                // Extension 'parsing'
-                // Note that the chunk-extension is neither parsed nor
-                // validated. Currently it is simply ignored.
-                long extSize = extensionSize.incrementAndGet();
-                if (maxExtensionSize > -1 && extSize > maxExtensionSize) {
-                    
throwBadRequestException(sm.getString("chunkedInputFilter.maxExtension"));
-                }
             }
 
-            // Parsing the CRLF increments pos
+            // Parsing the CRLF increments position
             if (!eol) {
                 readChunk.position(readChunk.position() + 1);
             }
@@ -418,19 +431,47 @@ public class ChunkedInputFilter implements InputFilter, 
ApplicationBufferHandler
             }
 
             byte chr = readChunk.get(readChunk.position());
-            if (chr == Constants.CR || chr == Constants.LF) {
-                parsingExtension = false;
+
+            if (extensionState != null) {
+                try {
+                    extensionState = ChunkExtension.parse(chr, extensionState);
+                } catch (IOException ioe) {
+                    /*
+                     * Can't throw the exception here. Need to swallow it. It 
will be thrown when parseChunkHeader()
+                     * is called. Not very efficient but it is an error 
condition for something that is hardly ever
+                     * used.
+                     */
+                    return false;
+                }
+                if (extensionState == State.CR) {
+                    if (!skipCRLF()) {
+                        return false;
+                    }
+                    eol = true;
+                    extensionState = null;
+                } else {
+                    // Check the size
+                    long extSize = extensionSize.incrementAndGet();
+                    if (maxExtensionSize > -1 && extSize > maxExtensionSize) {
+                        return false;
+                    }
+                }
+            } else if (chr == Constants.CR || chr == Constants.LF) {
                 if (!skipCRLF()) {
                     return false;
                 }
                 eol = true;
-            } else if (chr == Constants.SEMI_COLON && !parsingExtension) {
-                // First semicolon marks the start of the extension. Further
-                // semicolons may appear to separate multiple chunk-extensions.
-                // These need to be processed as part of parsing the 
extensions.
-                parsingExtension = true;
-                extensionSize.incrementAndGet();
-            } else if (!parsingExtension) {
+            } else if (chr == Constants.SEMI_COLON) {
+                /*
+                 * First semicolon marks the start of the extension. 
ChunkedExtension parser takes over for the
+                 * remainder of the extension.
+                 */
+                extensionState = State.PRE_NAME;
+                long extSize = extensionSize.incrementAndGet();
+                if (maxExtensionSize > -1 && extSize > maxExtensionSize) {
+                    return false;
+                }
+            } else {
                 int charValue = HexUtils.getDec(chr);
                 if (charValue != -1 && chunkSizeDigitsRead < 8) {
                     chunkSizeDigitsRead++;
@@ -439,17 +480,9 @@ public class ChunkedInputFilter implements InputFilter, 
ApplicationBufferHandler
                     // Isn't valid hex so this is an error condition
                     return false;
                 }
-            } else {
-                // Extension 'parsing'
-                // Note that the chunk-extension is neither parsed nor
-                // validated. Currently it is simply ignored.
-                long extSize = extensionSize.incrementAndGet();
-                if (maxExtensionSize > -1 && extSize > maxExtensionSize) {
-                    return false;
-                }
             }
 
-            // Parsing the CRLF increments pos
+            // Parsing the CRLF increments position
             if (!eol) {
                 readChunk.position(readChunk.position() + 1);
             }
diff --git a/java/org/apache/tomcat/util/http/parser/ChunkExtension.java 
b/java/org/apache/tomcat/util/http/parser/ChunkExtension.java
new file mode 100644
index 0000000000..136956b56f
--- /dev/null
+++ b/java/org/apache/tomcat/util/http/parser/ChunkExtension.java
@@ -0,0 +1,126 @@
+/*
+ * 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.tomcat.util.http.parser;
+
+import java.io.IOException;
+
+import org.apache.tomcat.util.res.StringManager;
+
+/*
+ * Unlike other HTTP parsers, this is a stateless (state is held by the 
calling code), streaming parser as chunk headers
+ * are read as part of the request body and it is not always possible to 
buffer then entire chunk header in memory.
+ */
+public class ChunkExtension {
+
+    private static final StringManager sm = 
StringManager.getManager(ChunkExtension.class);
+
+    public static State parse(byte b, State state) throws IOException {
+
+        char c = (char) (0xFF & b);
+
+        switch (state) {
+            case PRE_NAME:
+                if (HttpParser.isWhiteSpace(c)) {
+                    return State.PRE_NAME;
+                } else if (HttpParser.isToken(c)) {
+                    return State.NAME;
+                }
+                break;
+            case NAME:
+                if (HttpParser.isWhiteSpace(c)) {
+                    return State.POST_NAME;
+                } else if (HttpParser.isToken(c)) {
+                    return State.NAME;
+                } else if (c == '=') {
+                    return State.EQUALS;
+                } else if (c == '\r') {
+                    return State.CR;
+                }
+                break;
+            case POST_NAME:
+                if (HttpParser.isWhiteSpace(c)) {
+                    return State.POST_NAME;
+                } else if (c == '=') {
+                    return State.EQUALS;
+                } else if (c == '\r') {
+                    return State.CR;
+                }
+                break;
+            case EQUALS:
+                if (HttpParser.isWhiteSpace(c)) {
+                    return State.EQUALS;
+                } else if (HttpParser.isToken(c)) {
+                    return State.VALUE;
+                } else if (c == '"') {
+                    return State.QUOTED_VALUE;
+                }
+                break;
+            case VALUE:
+                if (HttpParser.isToken(c)) {
+                    return State.VALUE;
+                } else if (HttpParser.isWhiteSpace(c)) {
+                    return State.POST_VALUE;
+                } else if (c == ';') {
+                    return State.PRE_NAME;
+                } else if (c == '\r') {
+                    return State.CR;
+                }
+                break;
+            case QUOTED_VALUE:
+                if (c == '"') {
+                    return State.POST_VALUE;
+                } else if (c == '\\' || c == 127) {
+                    throw new 
IOException(sm.getString("chunkExtension.invalid"));
+                } else if (c == '\t') {
+                    return State.QUOTED_VALUE;
+                } else if (c > 31) {
+                    return State.QUOTED_VALUE;
+                }
+                break;
+            case POST_VALUE:
+                if (HttpParser.isWhiteSpace(c)) {
+                    return State.POST_VALUE;
+                } else if (c == ';') {
+                    return State.PRE_NAME;
+                } else if (c == '\r') {
+                    return State.CR;
+                }
+                break;
+            case CR:
+                break;
+        }
+
+        throw new IOException(sm.getString("chunkExtension.invalid"));
+    }
+
+
+    private ChunkExtension() {
+        // Tomcat doesn't use this data. It only parses it to ensure that it 
is correctly formatted.
+    }
+
+
+    public enum State {
+        PRE_NAME,
+        NAME,
+        POST_NAME,
+        EQUALS,
+        VALUE,
+        QUOTED_VALUE,
+        POST_VALUE,
+        CR
+    }
+}
diff --git a/java/org/apache/tomcat/util/http/parser/HttpParser.java 
b/java/org/apache/tomcat/util/http/parser/HttpParser.java
index 409237e7f0..8b41c52140 100644
--- a/java/org/apache/tomcat/util/http/parser/HttpParser.java
+++ b/java/org/apache/tomcat/util/http/parser/HttpParser.java
@@ -378,6 +378,10 @@ public class HttpParser {
     }
 
 
+    public static boolean isWhiteSpace(int c) {
+        return c == 9 || c == 32;
+    }
+
     public static boolean isAbsolutePath(int c) {
         return DEFAULT.isAbsolutePathRelaxed(c);
     }
diff --git a/java/org/apache/tomcat/util/http/parser/LocalStrings.properties 
b/java/org/apache/tomcat/util/http/parser/LocalStrings.properties
index f5b18cc540..dc5168a789 100644
--- a/java/org/apache/tomcat/util/http/parser/LocalStrings.properties
+++ b/java/org/apache/tomcat/util/http/parser/LocalStrings.properties
@@ -16,6 +16,8 @@
 # Do not edit this file directly.
 # To edit translations see: 
https://tomcat.apache.org/getinvolved.html#Translations
 
+chunkExtension.invalid=Invalid chunk extension data found
+
 cookie.fallToDebug=\n\
 \ Note: further occurrences of this error will be logged at DEBUG level.
 cookie.invalidCookieValue=A cookie header was received [{0}] that contained an 
invalid cookie. That cookie will be ignored.
diff --git a/test/org/apache/coyote/http11/filters/TestChunkedInputFilter.java 
b/test/org/apache/coyote/http11/filters/TestChunkedInputFilter.java
index a35773bb23..fa3a58cebb 100644
--- a/test/org/apache/coyote/http11/filters/TestChunkedInputFilter.java
+++ b/test/org/apache/coyote/http11/filters/TestChunkedInputFilter.java
@@ -1021,4 +1021,95 @@ public class TestChunkedInputFilter extends 
TomcatBaseTest {
          */
         Assert.assertEquals("5,4", client.getResponseBody());
     }
+
+
+    @Test
+    public void testExtension01() throws Exception {
+        doTestExtension("abc", true);
+    }
+
+
+    @Test
+    public void testExtension02() throws Exception {
+        doTestExtension("abc=def", true);
+    }
+
+
+    @Test
+    public void testExtension03() throws Exception {
+        doTestExtension(" a = b ", true);
+    }
+
+
+    @Test
+    public void testExtension04() throws Exception {
+        doTestExtension(" a = \"b\" ", true);
+    }
+
+
+    @Test
+    public void testExtension05() throws Exception {
+        doTestExtension("a=b=c", false);
+    }
+
+
+    @Test
+    public void testExtension06() throws Exception {
+        doTestExtension("a=b;", false);
+    }
+
+
+    @Test
+    public void testExtension07() throws Exception {
+        doTestExtension("a=\"aa\r\n\"", false);
+    }
+
+
+    private void doTestExtension(String extension, boolean ok) throws 
Exception {
+        // Setup Tomcat instance
+        Tomcat tomcat = getTomcatInstance();
+
+        Assert.assertTrue(tomcat.getConnector().setProperty(
+                "maxExtensionSize", Integer.toString(EXT_SIZE_LIMIT)));
+
+        // No file system docBase required
+        Context ctx = getProgrammaticRootContext();
+
+        Tomcat.addServlet(ctx, "servlet", new EchoHeaderServlet(ok));
+        ctx.addServletMappingDecoded("/", "servlet");
+
+        tomcat.start();
+
+        // @formatter:off
+        String[] request = new String[] {
+                "POST /echo-params.jsp HTTP/1.1" + CRLF +
+                    "Host: any" + CRLF +
+                    "Transfer-encoding: chunked" + CRLF +
+                    
SimpleHttpClient.HTTP_HEADER_CONTENT_TYPE_FORM_URL_ENCODING +
+                    "Connection: close" + CRLF +
+                    CRLF +
+                    "3;" + extension + CRLF +
+                    "a=0" + CRLF +
+                    "4" + CRLF +
+                    "&b=1" + CRLF +
+                    "0" + CRLF +
+                    CRLF
+        };
+        // @formatter:on
+
+        TrailerClient client =
+                new TrailerClient(tomcat.getConnector().getLocalPort());
+        client.setRequest(request);
+
+        client.connect();
+        client.processRequest();
+
+        if (ok) {
+            Assert.assertTrue(client.isResponse200());
+        } else {
+            Assert.assertTrue(client.isResponse500());
+        }
+    }
+
+
 }
diff --git a/test/org/apache/tomcat/util/http/parser/TestChunkExtension.java 
b/test/org/apache/tomcat/util/http/parser/TestChunkExtension.java
new file mode 100644
index 0000000000..8bfd5bdfb9
--- /dev/null
+++ b/test/org/apache/tomcat/util/http/parser/TestChunkExtension.java
@@ -0,0 +1,176 @@
+/*
+ * 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.tomcat.util.http.parser;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import org.apache.tomcat.util.http.parser.ChunkExtension.State;
+
+public class TestChunkExtension {
+
+    @Test
+    public void testEmpty() {
+        doTest("\r\n", true);
+    }
+
+    @Test
+    public void testInvalid() {
+        doTest("x\r\n", false);
+    }
+
+    @Test
+    public void testNoToken01() {
+        doTest(";\r\n", false);
+    }
+
+    @Test
+    public void testNoToken02() {
+        doTest(" ;\r\n", false);
+    }
+
+    @Test
+    public void testNoToken03() {
+        doTest("; \r\n", false);
+    }
+
+    @Test
+    public void testNoToken04() {
+        doTest(";\t\r\n", false);
+    }
+
+    @Test
+    public void testInvalidToken01() {
+        doTest("; =\r\n", false);
+    }
+
+    @Test
+    public void testTokenOnly01() {
+        doTest("; abc\r\n", true);
+    }
+
+    @Test
+    public void testTokenOnly02() {
+        doTest("; abc \r\n", true);
+    }
+
+    @Test
+    public void testTokenOnly03() {
+        doTest("; abc  \r\n", true);
+    }
+
+    @Test
+    public void testTokenToken01() {
+        doTest(";abc=abc\r\n", true);
+    }
+
+    @Test
+    public void testTokenToken02() {
+        doTest("; abc = abc \r\n", true);
+    }
+
+    @Test
+    public void testTokenQs01() {
+        doTest("; abc =\"\"\r\n", true);
+    }
+
+    @Test
+    public void testTokenQs02() {
+        doTest("; abc =\"abc\"\r\n", true);
+    }
+
+    @Test
+    public void testTokenQs03() {
+        doTest("; abc =\"a\tbc\"\r\n", true);
+    }
+
+    @Test
+    public void testTokenInvalidQs01() {
+        doTest("; abc =\"a\rbc\"\r\n", false);
+    }
+
+    @Test
+    public void testTokenInvalidQs02() {
+        doTest("; abc =\"a\\bc\"\r\n", false);
+    }
+
+    @Test
+    public void testTokenInvalidQs03() {
+        doTest("; abc =\"a\u007f\"\r\n", false);
+    }
+
+    @Test
+    public void testTokenInvalid01() {
+        doTest("; abc =\r\n", false);
+    }
+
+    @Test
+    public void testTokenInvalid02() {
+        doTest("; abc ==\r\n", false);
+    }
+
+    @Test
+    public void testTokenInvalid03() {
+        doTest(";a=b=c\r\n", false);
+    }
+
+    @Test
+    public void testTokenInvalid04() {
+        doTest(";a\"r\n", false);
+    }
+
+    @Test
+    public void testTokenInvalid05() {
+        doTest(";a \"r\n", false);
+    }
+
+    @Test
+    public void testValidValid() {
+        doTest(";abc=def;ghi=jkl\r\n", true);
+    }
+
+    @Test
+    public void testValidInvalid() {
+        doTest(";abc=def;=\r\n", false);
+    }
+
+    private void doTest(String input, boolean valid) {
+        byte[] bytes = input.getBytes(StandardCharsets.ISO_8859_1);
+
+        try {
+            // This state assumes either ';' or CRLF will follow, preceded by 
optional white space.
+            State state = State.POST_VALUE;
+            for (byte b : bytes) {
+                state = ChunkExtension.parse(b, state);
+                /*
+                 * The test values all end in \r\n but ChunkExtension only 
looks for \r. In real usage the
+                 * ChunkedInputFilter then parses the CRLF.
+                 */
+                if (state == State.CR) {
+                    break;
+                }
+            }
+            Assert.assertTrue("The input was invalid but no exception was 
thrown", valid);
+            Assert.assertEquals("Parsing ended at state other than CR", 
State.CR, state);
+        } catch (IOException ioe) {
+            Assert.assertFalse("The input was valid but an exception was 
thrown", valid);
+        }
+    }
+}
diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml
index 11cd071b10..18563b802e 100644
--- a/webapps/docs/changelog.xml
+++ b/webapps/docs/changelog.xml
@@ -195,6 +195,10 @@
         stream reset or a 400 response as appropriate rather then with a
         connection reset. (markt)
       </fix>
+      <fix>
+        Add validation of chunk extensions for chunked transfer encoding.
+        (markt)
+      </fix>
     </changelog>
   </subsection>
   <subsection name="Jasper">


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to