CVE-2026-24880
Description
Inconsistent Interpretation of HTTP Requests ('HTTP Request/Response Smuggling') vulnerability in Apache Tomcat via invalid chunk extension.
This issue affects Apache Tomcat: from 11.0.0-M1 through 11.0.18, from 10.1.0-M1 through 10.1.52, from 9.0.0.M1 through 9.0.115, from 8.5.0 through 8.5.100, from 7.0.0 through 7.0.109. Other, unsupported versions may also be affected.
Users are recommended to upgrade to version 11.0.20, 10.1.52 or 9.0.116, which fix the issue.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
org.apache.tomcat:tomcat-tribesMaven | >= 7.0.0, < 9.0.116 | 9.0.116 |
org.apache.tomcat:tomcat-tribesMaven | >= 10.1.0-M1, < 10.1.52 | 10.1.52 |
org.apache.tomcat:tomcat-tribesMaven | >= 11.0.0-M1, < 11.0.20 | 11.0.20 |
org.apache.tomcat:tomcatMaven | >= 7.0.0, < 9.0.116 | 9.0.116 |
org.apache.tomcat:tomcatMaven | >= 10.1.0-M1, < 10.1.52 | 10.1.52 |
org.apache.tomcat:tomcatMaven | >= 11.0.0-M1, < 11.0.20 | 11.0.20 |
Affected products
1Patches
66d478dbe18b7Fix issues found by Copilot reviewing the chunked extension validation
4 files changed · +22 −4
java/org/apache/coyote/http11/filters/ChunkedInputFilter.java+7 −3 modified@@ -359,13 +359,17 @@ private boolean parseChunkHeader() throws IOException { byte chr = readChunk.get(readChunk.position()); if (extensionState != null) { - extensionState = ChunkExtension.parse(chr, extensionState); + try { + extensionState = ChunkExtension.parse(chr, extensionState); + } catch (IOException ioe) { + throwBadRequestException(sm.getString("chunkedInputFilter.invalidHeader")); + } if (extensionState == State.CR) { + extensionState = null; if (!parseCRLF()) { return false; } eol = true; - extensionState = null; } else { // Check the size long extSize = extensionSize.incrementAndGet(); @@ -444,11 +448,11 @@ private boolean skipChunkHeader() { return false; } if (extensionState == State.CR) { + extensionState = null; if (!skipCRLF()) { return false; } eol = true; - extensionState = null; } else { // Check the size long extSize = extensionSize.incrementAndGet();
java/org/apache/tomcat/util/http/parser/ChunkExtension.java+4 −0 modified@@ -45,6 +45,8 @@ public static State parse(byte b, State state) throws IOException { return State.POST_NAME; } else if (HttpParser.isToken(c)) { return State.NAME; + } else if (c == ';') { + return State.PRE_NAME; } else if (c == '=') { return State.EQUALS; } else if (c == '\r') { @@ -54,6 +56,8 @@ public static State parse(byte b, State state) throws IOException { case POST_NAME: if (HttpParser.isWhiteSpace(c)) { return State.POST_NAME; + } else if (c == ';') { + return State.PRE_NAME; } else if (c == '=') { return State.EQUALS; } else if (c == '\r') {
test/org/apache/catalina/nonblocking/TestNonBlockingAPI.java+1 −1 modified@@ -597,7 +597,7 @@ public void testNonBlockingReadChunkedSplitInFinalCrlf() throws Exception { public void testNonBlockingReadChunkedSplitMaximum() throws Exception { // @formatter:off String requestBody = new String( - "14" + CRLF + + "14;a=b;c" + CRLF + "012345678901FINISHED" + CRLF + "0" + CRLF + TRAILER_HEADER_NAME + ": " + TRAILER_HEADER_VALUE + CRLF +
test/org/apache/tomcat/util/http/parser/TestChunkExtension.java+10 −0 modified@@ -76,6 +76,16 @@ public void testTokenOnly03() { doTest("; abc \r\n", true); } + @Test + public void testTokenOnlyTokenOnly01() { + doTest(";abc;abc\r\n", true); + } + + @Test + public void testTokenOnlyTokenOnly02() { + doTest("; abc ; abc \r\n", true); + } + @Test public void testTokenToken01() { doTest(";abc=abc\r\n", true);
2cb06c34f661Fix issues found by Copilot reviewing the chunked extension validation
4 files changed · +22 −4
java/org/apache/coyote/http11/filters/ChunkedInputFilter.java+7 −3 modified@@ -359,13 +359,17 @@ private boolean parseChunkHeader() throws IOException { byte chr = readChunk.get(readChunk.position()); if (extensionState != null) { - extensionState = ChunkExtension.parse(chr, extensionState); + try { + extensionState = ChunkExtension.parse(chr, extensionState); + } catch (IOException ioe) { + throwBadRequestException(sm.getString("chunkedInputFilter.invalidHeader")); + } if (extensionState == State.CR) { + extensionState = null; if (!parseCRLF()) { return false; } eol = true; - extensionState = null; } else { // Check the size long extSize = extensionSize.incrementAndGet(); @@ -444,11 +448,11 @@ private boolean skipChunkHeader() { return false; } if (extensionState == State.CR) { + extensionState = null; if (!skipCRLF()) { return false; } eol = true; - extensionState = null; } else { // Check the size long extSize = extensionSize.incrementAndGet();
java/org/apache/tomcat/util/http/parser/ChunkExtension.java+4 −0 modified@@ -45,6 +45,8 @@ public static State parse(byte b, State state) throws IOException { return State.POST_NAME; } else if (HttpParser.isToken(c)) { return State.NAME; + } else if (c == ';') { + return State.PRE_NAME; } else if (c == '=') { return State.EQUALS; } else if (c == '\r') { @@ -54,6 +56,8 @@ public static State parse(byte b, State state) throws IOException { case POST_NAME: if (HttpParser.isWhiteSpace(c)) { return State.POST_NAME; + } else if (c == ';') { + return State.PRE_NAME; } else if (c == '=') { return State.EQUALS; } else if (c == '\r') {
test/org/apache/catalina/nonblocking/TestNonBlockingAPI.java+1 −1 modified@@ -599,7 +599,7 @@ public void testNonBlockingReadChunkedSplitInFinalCrlf() throws Exception { public void testNonBlockingReadChunkedSplitMaximum() throws Exception { // @formatter:off String requestBody = new String( - "14" + CRLF + + "14;a=b;c" + CRLF + "012345678901FINISHED" + CRLF + "0" + CRLF + TRAILER_HEADER_NAME + ": " + TRAILER_HEADER_VALUE + CRLF +
test/org/apache/tomcat/util/http/parser/TestChunkExtension.java+10 −0 modified@@ -76,6 +76,16 @@ public void testTokenOnly03() { doTest("; abc \r\n", true); } + @Test + public void testTokenOnlyTokenOnly01() { + doTest(";abc;abc\r\n", true); + } + + @Test + public void testTokenOnlyTokenOnly02() { + doTest("; abc ; abc \r\n", true); + } + @Test public void testTokenToken01() { doTest(";abc=abc\r\n", true);
1e71441a1597Fix issues found by Copilot reviewing the chunked extension validation
4 files changed · +22 −4
java/org/apache/coyote/http11/filters/ChunkedInputFilter.java+7 −3 modified@@ -359,13 +359,17 @@ private boolean parseChunkHeader() throws IOException { byte chr = readChunk.get(readChunk.position()); if (extensionState != null) { - extensionState = ChunkExtension.parse(chr, extensionState); + try { + extensionState = ChunkExtension.parse(chr, extensionState); + } catch (IOException ioe) { + throwBadRequestException(sm.getString("chunkedInputFilter.invalidHeader")); + } if (extensionState == State.CR) { + extensionState = null; if (!parseCRLF()) { return false; } eol = true; - extensionState = null; } else { // Check the size long extSize = extensionSize.incrementAndGet(); @@ -444,11 +448,11 @@ private boolean skipChunkHeader() { return false; } if (extensionState == State.CR) { + extensionState = null; if (!skipCRLF()) { return false; } eol = true; - extensionState = null; } else { // Check the size long extSize = extensionSize.incrementAndGet();
java/org/apache/tomcat/util/http/parser/ChunkExtension.java+4 −0 modified@@ -45,6 +45,8 @@ public static State parse(byte b, State state) throws IOException { return State.POST_NAME; } else if (HttpParser.isToken(c)) { return State.NAME; + } else if (c == ';') { + return State.PRE_NAME; } else if (c == '=') { return State.EQUALS; } else if (c == '\r') { @@ -54,6 +56,8 @@ public static State parse(byte b, State state) throws IOException { case POST_NAME: if (HttpParser.isWhiteSpace(c)) { return State.POST_NAME; + } else if (c == ';') { + return State.PRE_NAME; } else if (c == '=') { return State.EQUALS; } else if (c == '\r') {
test/org/apache/catalina/nonblocking/TestNonBlockingAPI.java+1 −1 modified@@ -598,7 +598,7 @@ public void testNonBlockingReadChunkedSplitInFinalCrlf() throws Exception { public void testNonBlockingReadChunkedSplitMaximum() throws Exception { // @formatter:off String requestBody = new String( - "14" + CRLF + + "14;a=b;c" + CRLF + "012345678901FINISHED" + CRLF + "0" + CRLF + TRAILER_HEADER_NAME + ": " + TRAILER_HEADER_VALUE + CRLF +
test/org/apache/tomcat/util/http/parser/TestChunkExtension.java+10 −0 modified@@ -76,6 +76,16 @@ public void testTokenOnly03() { doTest("; abc \r\n", true); } + @Test + public void testTokenOnlyTokenOnly01() { + doTest(";abc;abc\r\n", true); + } + + @Test + public void testTokenOnlyTokenOnly02() { + doTest("; abc ; abc \r\n", true); + } + @Test public void testTokenToken01() { doTest(";abc=abc\r\n", true);
fde1a8235fb7Add validation of chunk extensions
7 files changed · +474 −38
java/org/apache/coyote/http11/filters/ChunkedInputFilter.java+71 −38 modified@@ -30,6 +30,8 @@ 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 void recycle() { parseState = ParseState.CHUNK_HEADER; crFound = false; chunkSizeDigitsRead = 0; - parsingExtension = false; + extensionState = null; extensionSize.set(0); httpHeaderParser.recycle(); } @@ -355,19 +357,38 @@ private boolean parseChunkHeader() throws IOException { } 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 @@ private boolean parseChunkHeader() throws IOException { // 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 @@ private boolean skipChunkHeader() { } 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 @@ private boolean skipChunkHeader() { // 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); }
java/org/apache/tomcat/util/http/parser/ChunkExtension.java+126 −0 added@@ -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 + } +}
java/org/apache/tomcat/util/http/parser/HttpParser.java+4 −0 modified@@ -378,6 +378,10 @@ private static boolean isRelaxable(int c) { } + public static boolean isWhiteSpace(int c) { + return c == 9 || c == 32; + } + public static boolean isAbsolutePath(int c) { return DEFAULT.isAbsolutePathRelaxed(c); }
java/org/apache/tomcat/util/http/parser/LocalStrings.properties+2 −0 modified@@ -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.
test/org/apache/coyote/http11/filters/TestChunkedInputFilter.java+91 −0 modified@@ -1021,4 +1021,95 @@ public void testChunkedSplitWithNonBlocking() throws Exception { */ 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()); + } + } + + }
test/org/apache/tomcat/util/http/parser/TestChunkExtension.java+176 −0 added@@ -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); + } + } +}
webapps/docs/changelog.xml+4 −0 modified@@ -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">
1b586d6aa8aeAdd validation of chunk extensions
7 files changed · +474 −38
java/org/apache/coyote/http11/filters/ChunkedInputFilter.java+71 −38 modified@@ -30,6 +30,8 @@ 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 void recycle() { parseState = ParseState.CHUNK_HEADER; crFound = false; chunkSizeDigitsRead = 0; - parsingExtension = false; + extensionState = null; extensionSize.set(0); httpHeaderParser.recycle(); } @@ -355,19 +357,38 @@ private boolean parseChunkHeader() throws IOException { } 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 @@ private boolean parseChunkHeader() throws IOException { // 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 @@ private boolean skipChunkHeader() { } 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 @@ private boolean skipChunkHeader() { // 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); }
java/org/apache/tomcat/util/http/parser/ChunkExtension.java+126 −0 added@@ -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 + } +}
java/org/apache/tomcat/util/http/parser/HttpParser.java+4 −0 modified@@ -378,6 +378,10 @@ private static boolean isRelaxable(int c) { } + public static boolean isWhiteSpace(int c) { + return c == 9 || c == 32; + } + public static boolean isAbsolutePath(int c) { return DEFAULT.isAbsolutePathRelaxed(c); }
java/org/apache/tomcat/util/http/parser/LocalStrings.properties+2 −0 modified@@ -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.
test/org/apache/coyote/http11/filters/TestChunkedInputFilter.java+91 −0 modified@@ -1021,4 +1021,95 @@ public void testChunkedSplitWithNonBlocking() throws Exception { */ 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()); + } + } + + }
test/org/apache/tomcat/util/http/parser/TestChunkExtension.java+176 −0 added@@ -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); + } + } +}
webapps/docs/changelog.xml+4 −0 modified@@ -199,6 +199,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">
f07df938d00fAdd validation of chunk extensions
7 files changed · +474 −38
java/org/apache/coyote/http11/filters/ChunkedInputFilter.java+71 −38 modified@@ -30,6 +30,8 @@ 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 void recycle() { parseState = ParseState.CHUNK_HEADER; crFound = false; chunkSizeDigitsRead = 0; - parsingExtension = false; + extensionState = null; extensionSize.set(0); httpHeaderParser.recycle(); } @@ -355,19 +357,38 @@ private boolean parseChunkHeader() throws IOException { } 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 @@ private boolean parseChunkHeader() throws IOException { // 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 @@ private boolean skipChunkHeader() { } 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 @@ private boolean skipChunkHeader() { // 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); }
java/org/apache/tomcat/util/http/parser/ChunkExtension.java+126 −0 added@@ -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 + } +}
java/org/apache/tomcat/util/http/parser/HttpParser.java+4 −0 modified@@ -378,6 +378,10 @@ private static boolean isRelaxable(int c) { } + public static boolean isWhiteSpace(int c) { + return c == 9 || c == 32; + } + public static boolean isAbsolutePath(int c) { return DEFAULT.isAbsolutePathRelaxed(c); }
java/org/apache/tomcat/util/http/parser/LocalStrings.properties+2 −0 modified@@ -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.
test/org/apache/coyote/http11/filters/TestChunkedInputFilter.java+91 −0 modified@@ -1021,4 +1021,95 @@ public void testChunkedSplitWithNonBlocking() throws Exception { */ 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()); + } + } + + }
test/org/apache/tomcat/util/http/parser/TestChunkExtension.java+176 −0 added@@ -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); + } + } +}
webapps/docs/changelog.xml+4 −0 modified@@ -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">
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
14- www.openwall.com/lists/oss-security/2026/04/09/20nvdMailing ListThird Party AdvisoryWEB
- github.com/advisories/GHSA-563x-q5rq-57qpghsaADVISORY
- lists.apache.org/thread/2c682qnlg2tv4o5knlggqbl9yc2gb5snnvdMailing ListVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-24880ghsaADVISORY
- github.com/apache/tomcat/commit/1b586d6aa8ae65726da5fa8799427b5d4718478aghsaWEB
- github.com/apache/tomcat/commit/1e71441a15972f56e661b0b549fb9e5d838b83bbghsaWEB
- github.com/apache/tomcat/commit/2cb06c34f661ca42f7570bbcc21e99806184bcc5ghsaWEB
- github.com/apache/tomcat/commit/6d478dbe18b7c4bb671c30fedf130309b0dab77cghsaWEB
- github.com/apache/tomcat/commit/f07df938d00f7419b40fa65aa912966d0efac522ghsaWEB
- github.com/apache/tomcat/commit/fde1a8235fb73125217bd41e162aa0a113f33552ghsaWEB
- tomcat.apache.org/security-10.htmlghsaWEB
- tomcat.apache.org/security-11.htmlghsaWEB
- tomcat.apache.org/security-9.htmlghsaWEB
- www.herodevs.com/vulnerability-directory/cve-2026-24880ghsaWEB
News mentions
0No linked articles in our index yet.