VYPR
High severity7.5NVD Advisory· Published Oct 27, 2025· Updated May 12, 2026

CVE-2025-55752

CVE-2025-55752

Description

Relative Path Traversal vulnerability in Apache Tomcat.

The fix for bug 60013 introduced a regression where the rewritten URL was normalized before it was decoded. This introduced the possibility that, for rewrite rules that rewrite query parameters to the URL, an attacker could manipulate the request URI to bypass security constraints including the protection for /WEB-INF/ and /META-INF/. If PUT requests were also enabled then malicious files could be uploaded leading to remote code execution. PUT requests are normally limited to trusted users and it is considered unlikely that PUT requests would be enabled in conjunction with a rewrite that manipulated the URI.

This issue affects Apache Tomcat: from 11.0.0-M1 through 11.0.10, from 10.1.0-M1 through 10.1.44, from 9.0.0.M11 through 9.0.108.

The following versions were EOL at the time the CVE was created but are known to be affected: 8.5.6 though 8.5.100. Other, older, EOL versions may also be affected. Users are recommended to upgrade to version 11.0.11 or later, 10.1.45 or later or 9.0.109 or later, which fix the issue.

Affected products

1
  • Apache Software Foundation/Apache Tomcatv5
    Range: 11.0.0-M1

Patches

3
fec06c610ed7

Fix a couple of issues with QSA/QSD handling and associated tests

https://github.com/apache/tomcatMark ThomasAug 29, 2025via ghsa
4 files changed · +131 17
  • java/org/apache/catalina/valves/rewrite/RewriteValve.java+23 12 modified
    @@ -325,7 +325,7 @@ public void invoke(Request request, Response response) throws IOException, Servl
     
                 // As long as MB isn't a char sequence or affiliated, this has to be converted to a string
                 Charset uriCharset = request.getConnector().getURICharset();
    -            String originalQueryStringEncoded = request.getQueryString();
    +            String queryStringOriginalEncoded = request.getQueryString();
                 MessageBytes urlMB = context ? request.getRequestPathMB() : request.getDecodedRequestURIMB();
                 urlMB.toChars();
                 CharSequence urlDecoded = urlMB.getCharChunk();
    @@ -426,18 +426,18 @@ public void invoke(Request request, Response response) throws IOException, Servl
                         StringBuilder urlStringEncoded =
                                 new StringBuilder(REWRITE_DEFAULT_ENCODER.encode(urlStringRewriteEncoded, uriCharset));
     
    -                    if (!qsd && originalQueryStringEncoded != null && !originalQueryStringEncoded.isEmpty()) {
    +                    if (!qsd && queryStringOriginalEncoded != null && !queryStringOriginalEncoded.isEmpty()) {
                             if (rewrittenQueryStringRewriteEncoded == null) {
                                 urlStringEncoded.append('?');
    -                            urlStringEncoded.append(originalQueryStringEncoded);
    +                            urlStringEncoded.append(queryStringOriginalEncoded);
                             } else {
                                 if (qsa) {
                                     // if qsa is specified append the query
                                     urlStringEncoded.append('?');
                                     urlStringEncoded.append(
                                             REWRITE_QUERY_ENCODER.encode(rewrittenQueryStringRewriteEncoded, uriCharset));
                                     urlStringEncoded.append('&');
    -                                urlStringEncoded.append(originalQueryStringEncoded);
    +                                urlStringEncoded.append(queryStringOriginalEncoded);
                                 } else if (index == urlStringEncoded.length() - 1) {
                                     // if the ? is the last character delete it, its only purpose was to
                                     // prevent the rewrite module from appending the query string
    @@ -553,24 +553,31 @@ public void invoke(Request request, Response response) throws IOException, Servl
     
                         // Step 3. Complete the 2nd stage to encoding.
                         chunk.append(REWRITE_DEFAULT_ENCODER.encode(urlStringRewriteEncoded, uriCharset));
    -                    // Decoded and normalized URI
    -                    // Rewriting may have denormalized the URL
    -                    urlStringRewriteEncoded = RequestUtil.normalize(urlStringRewriteEncoded);
    +                    // Rewriting may have denormalized the URL and added encoded characters
    +                    // Decode then normalize
    +                    String urlStringRewriteDecoded = URLDecoder.decode(urlStringRewriteEncoded, uriCharset);
    +                    urlStringRewriteDecoded = RequestUtil.normalize(urlStringRewriteDecoded);
                         request.getCoyoteRequest().decodedURI().setChars(MessageBytes.EMPTY_CHAR_ARRAY, 0, 0);
                         chunk = request.getCoyoteRequest().decodedURI().getCharChunk();
                         if (context) {
                             // This is decoded and normalized
                             chunk.append(request.getServletContext().getContextPath());
                         }
    -                    chunk.append(URLDecoder.decode(urlStringRewriteEncoded, uriCharset));
    -                    // Set the new Query if there is one
    -                    if (queryStringRewriteEncoded != null) {
    +                    chunk.append(urlStringRewriteDecoded);
    +                    // Set the new Query String
    +                    if (queryStringRewriteEncoded == null) {
    +                         // No new query string. Therefore the original is retained unless QSD is defined.
    +                        if (qsd) {
    +                            request.getCoyoteRequest().queryString().setChars(MessageBytes.EMPTY_CHAR_ARRAY, 0, 0);
    +                        }
    +                    } else {
    +                        // New query string. Therefore the original is dropped unless QSA is defined (and QSD is not).
                             request.getCoyoteRequest().queryString().setChars(MessageBytes.EMPTY_CHAR_ARRAY, 0, 0);
                             chunk = request.getCoyoteRequest().queryString().getCharChunk();
                             chunk.append(REWRITE_QUERY_ENCODER.encode(queryStringRewriteEncoded, uriCharset));
    -                        if (qsa && originalQueryStringEncoded != null && !originalQueryStringEncoded.isEmpty()) {
    +                        if (qsa && queryStringOriginalEncoded != null && !queryStringOriginalEncoded.isEmpty()) {
                                 chunk.append('&');
    -                            chunk.append(originalQueryStringEncoded);
    +                            chunk.append(queryStringOriginalEncoded);
                             }
                         }
                         // Set the new host if it changed
    @@ -665,6 +672,10 @@ public static Object parse(String line) {
                         while (flagsTokenizer.hasMoreElements()) {
                             parseRuleFlag(line, rule, flagsTokenizer.nextToken());
                         }
    +                    // If QSD and QSA are present, QSD always takes precedence
    +                    if (rule.isQsdiscard()) {
    +                        rule.setQsappend(false);
    +                    }
                     }
                     return rule;
                 } else if (token.equals("RewriteMap")) {
    
  • test/org/apache/catalina/startup/TomcatBaseTest.java+1 1 modified
    @@ -544,7 +544,7 @@ public void service(HttpServletRequest request,
                             value.append(';');
                         }
                     }
    -                out.println("PARAM/" + name + ": " + value);
    +                out.println("PARAM:" + name + ": " + value);
                 }
     
                 out.println("SESSION-REQUESTED-ID: " +
    
  • test/org/apache/catalina/valves/rewrite/TestRewriteValve.java+103 4 modified
    @@ -301,17 +301,112 @@ public void testNonAsciiPathRedirect() throws Exception {
         }
     
         @Test
    -    public void testQueryString() throws Exception {
    +    public void testQueryStringTargetOnly() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?je=2", "/b/id=1", "/c/id=1", "je=2");
    +    }
    +
    +    @Test
    +    public void testQueryStringTargetOnlyQSA() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?je=2 [QSA]", "/b/id=1", "/c/id=1", "je=2");
    +    }
    +
    +    @Test
    +    public void testQueryStringTargetOnlyQSD() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?je=2 [QSD]", "/b/id=1", "/c/id=1", "je=2");
    +    }
    +
    +    @Test
    +    public void testQueryStringTargetOnlyQSAQSD() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?je=2 [QSA,QSD]", "/b/id=1", "/c/id=1", "je=2");
    +    }
    +
    +    @Test
    +    public void testQueryStringTargetOnlyQS() throws Exception {
             doTestRewrite("RewriteRule ^/b/(.*) /c?$1", "/b/id=1", "/c", "id=1");
         }
     
    +    @Test
    +    public void testQueryStringTargetOnlyQSAQS() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c?$1 [QSA]", "/b/id=1", "/c", "id=1");
    +    }
    +
    +    @Test
    +    public void testQueryStringTargetOnlyQSDQS() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c?$1 [QSD]", "/b/id=1", "/c", "id=1");
    +    }
    +
    +    @Test
    +    public void testQueryStringTargetOnlyQSAQSDQS() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c?$1 [QSA,QSD]", "/b/id=1", "/c", "id=1");
    +    }
    +
    +    @Test
    +    public void testQueryStringSourceOnly() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1", "/b/d?id=1", "/c/d", "id=1");
    +    }
    +
    +    @Test
    +    public void testQueryStringSourceOnlyQSA() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1 [QSA]", "/b/d?id=1", "/c/d", "id=1");
    +    }
    +
    +    @Test
    +    public void testQueryStringSourceOnlyQSD() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1 [QSD]", "/b/d?id=1", "/c/d", null);
    +    }
    +
    +    @Test
    +    public void testQueryStringSourceOnlyQSAQSD() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1 [QSA,QSD]", "/b/d?id=1", "/c/d", null);
    +    }
    +
    +    @Test
    +    public void testQueryStringSourceAndTarget() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?id=1", "/b/d?je=2", "/c/d", "id=1");
    +    }
    +
    +    @Test
    +    public void testQueryStringSourceAndTargetQSA() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?id=1 [QSA]", "/b/d?je=2", "/c/d", "id=1&je=2");
    +    }
    +
    +    @Test
    +    public void testQueryStringSourceAndTargetQSD() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?id=1 [QSD]", "/b/d?je=2", "/c/d", "id=1");
    +    }
    +
    +    @Test
    +    public void testQueryStringSourceAndTargetQSAQSD() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?id=1 [QSA,QSD]", "/b/d?je=2", "/c/d", "id=1");
    +    }
    +
    +    @Test
    +    public void testQueryStringEncoded01() throws Exception {
    +        doTestRewrite("RewriteCond %{QUERY_STRING} a=(.*)\nRewriteRule ^/b.*$ /%1 [QSD]", "/b?a=c", "/c", null);
    +    }
    +
    +    @Test
    +    public void testQueryStringEncoded02() throws Exception {
    +        doTestRewrite("RewriteCond %{QUERY_STRING} a=(.*)\nRewriteRule ^/b.*$ /z/%1 [QSD]", "/b?a=%2e%2e%2fc%2faAbB", "/z/%2e%2e%2fc%2faAbB", null);
    +    }
    +
         @Test
         public void testQueryStringRemove() throws Exception {
    -        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?", "/b/d?=1", "/c/d", null);
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?", "/b/d?id=1", "/c/d", null);
         }
     
         @Test
         public void testQueryStringRemove02() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1 [QSD]", "/b/d?id=1", "/c/d", null);
    +    }
    +
    +    @Test
    +    public void testQueryStringRemoveInvalid() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?", "/b/d?=1", "/c/d", null);
    +    }
    +
    +    @Test
    +    public void testQueryStringRemoveInvalid02() throws Exception {
             doTestRewrite("RewriteRule ^/b/(.*) /c/$1 [QSD]", "/b/d?=1", "/c/d", null);
         }
     
    @@ -616,7 +711,7 @@ public void testUtf8FlagsRBNE() throws Exception {
         public void testFlagsNC() throws Exception {
             // https://bz.apache.org/bugzilla/show_bug.cgi?id=60116
             doTestRewrite("RewriteCond %{QUERY_STRING} a=([a-z]*) [NC]\n" + "RewriteRule .* - [E=X-Test:%1]", "/c?a=aAa",
    -                "/c", null, "aAa");
    +                "/c", "a=aAa", "aAa");
         }
     
         @Test
    @@ -806,12 +901,16 @@ public void invoke(Request request, Response response) throws IOException, Servl
                 // were written into the request target
                 Assert.assertEquals(400, rc);
             } else {
    +            // If there is an expected URI, the request should be successful
    +            Assert.assertEquals(200, rc);
                 String body = res.toString();
                 RequestDescriptor requestDesc = SnoopResult.parse(body);
                 String requestURI = requestDesc.getRequestInfo("REQUEST-URI");
                 Assert.assertEquals(expectedURI, requestURI);
     
    -            if (expectedQueryString != null) {
    +            if (expectedQueryString == null) {
    +                Assert.assertTrue(requestDesc.getParams().isEmpty());
    +            } else {
                     String queryString = requestDesc.getRequestInfo("REQUEST-QUERY-STRING");
                     Assert.assertEquals(expectedQueryString, queryString);
                 }
    
  • webapps/docs/changelog.xml+4 0 modified
    @@ -121,6 +121,10 @@
             Refactor <code>WebResource</code> locking to use the new
             <code>KeyedReentrantReadWriteLock</code>. (markt)
           </scode>
    +      <fix>
    +        Fix handling of <code>QSA</code> and <code>QSD</code> flags in
    +        <code>RewriteValve</code>. (markt)
    +      </fix>
         </changelog>
       </subsection>
       <subsection name="Coyote">
    
b5042622b8b7

Fix a couple of issues with QSA/QSD handling and associated tests

https://github.com/apache/tomcatMark ThomasAug 29, 2025via ghsa
4 files changed · +131 17
  • java/org/apache/catalina/valves/rewrite/RewriteValve.java+23 12 modified
    @@ -326,7 +326,7 @@ public void invoke(Request request, Response response) throws IOException, Servl
     
                 // As long as MB isn't a char sequence or affiliated, this has to be converted to a string
                 Charset uriCharset = request.getConnector().getURICharset();
    -            String originalQueryStringEncoded = request.getQueryString();
    +            String queryStringOriginalEncoded = request.getQueryString();
                 MessageBytes urlMB = context ? request.getRequestPathMB() : request.getDecodedRequestURIMB();
                 urlMB.toChars();
                 CharSequence urlDecoded = urlMB.getCharChunk();
    @@ -427,18 +427,18 @@ public void invoke(Request request, Response response) throws IOException, Servl
                         StringBuilder urlStringEncoded =
                                 new StringBuilder(REWRITE_DEFAULT_ENCODER.encode(urlStringRewriteEncoded, uriCharset));
     
    -                    if (!qsd && originalQueryStringEncoded != null && !originalQueryStringEncoded.isEmpty()) {
    +                    if (!qsd && queryStringOriginalEncoded != null && !queryStringOriginalEncoded.isEmpty()) {
                             if (rewrittenQueryStringRewriteEncoded == null) {
                                 urlStringEncoded.append('?');
    -                            urlStringEncoded.append(originalQueryStringEncoded);
    +                            urlStringEncoded.append(queryStringOriginalEncoded);
                             } else {
                                 if (qsa) {
                                     // if qsa is specified append the query
                                     urlStringEncoded.append('?');
                                     urlStringEncoded.append(
                                             REWRITE_QUERY_ENCODER.encode(rewrittenQueryStringRewriteEncoded, uriCharset));
                                     urlStringEncoded.append('&');
    -                                urlStringEncoded.append(originalQueryStringEncoded);
    +                                urlStringEncoded.append(queryStringOriginalEncoded);
                                 } else if (index == urlStringEncoded.length() - 1) {
                                     // if the ? is the last character delete it, its only purpose was to
                                     // prevent the rewrite module from appending the query string
    @@ -554,24 +554,31 @@ public void invoke(Request request, Response response) throws IOException, Servl
     
                         // Step 3. Complete the 2nd stage to encoding.
                         chunk.append(REWRITE_DEFAULT_ENCODER.encode(urlStringRewriteEncoded, uriCharset));
    -                    // Decoded and normalized URI
    -                    // Rewriting may have denormalized the URL
    -                    urlStringRewriteEncoded = RequestUtil.normalize(urlStringRewriteEncoded);
    +                    // Rewriting may have denormalized the URL and added encoded characters
    +                    // Decode then normalize
    +                    String urlStringRewriteDecoded = URLDecoder.decode(urlStringRewriteEncoded, uriCharset.name());
    +                    urlStringRewriteDecoded = RequestUtil.normalize(urlStringRewriteDecoded);
                         request.getCoyoteRequest().decodedURI().setChars(MessageBytes.EMPTY_CHAR_ARRAY, 0, 0);
                         chunk = request.getCoyoteRequest().decodedURI().getCharChunk();
                         if (context) {
                             // This is decoded and normalized
                             chunk.append(request.getServletContext().getContextPath());
                         }
    -                    chunk.append(URLDecoder.decode(urlStringRewriteEncoded, uriCharset.name()));
    -                    // Set the new Query if there is one
    -                    if (queryStringRewriteEncoded != null) {
    +                    chunk.append(urlStringRewriteDecoded);
    +                    // Set the new Query String
    +                    if (queryStringRewriteEncoded == null) {
    +                         // No new query string. Therefore the original is retained unless QSD is defined.
    +                        if (qsd) {
    +                            request.getCoyoteRequest().queryString().setChars(MessageBytes.EMPTY_CHAR_ARRAY, 0, 0);
    +                        }
    +                    } else {
    +                        // New query string. Therefore the original is dropped unless QSA is defined (and QSD is not).
                             request.getCoyoteRequest().queryString().setChars(MessageBytes.EMPTY_CHAR_ARRAY, 0, 0);
                             chunk = request.getCoyoteRequest().queryString().getCharChunk();
                             chunk.append(REWRITE_QUERY_ENCODER.encode(queryStringRewriteEncoded, uriCharset));
    -                        if (qsa && originalQueryStringEncoded != null && !originalQueryStringEncoded.isEmpty()) {
    +                        if (qsa && queryStringOriginalEncoded != null && !queryStringOriginalEncoded.isEmpty()) {
                                 chunk.append('&');
    -                            chunk.append(originalQueryStringEncoded);
    +                            chunk.append(queryStringOriginalEncoded);
                             }
                         }
                         // Set the new host if it changed
    @@ -666,6 +673,10 @@ public static Object parse(String line) {
                         while (flagsTokenizer.hasMoreElements()) {
                             parseRuleFlag(line, rule, flagsTokenizer.nextToken());
                         }
    +                    // If QSD and QSA are present, QSD always takes precedence
    +                    if (rule.isQsdiscard()) {
    +                        rule.setQsappend(false);
    +                    }
                     }
                     return rule;
                 } else if (token.equals("RewriteMap")) {
    
  • test/org/apache/catalina/startup/TomcatBaseTest.java+1 1 modified
    @@ -553,7 +553,7 @@ public void service(HttpServletRequest request,
                             value.append(';');
                         }
                     }
    -                out.println("PARAM/" + name + ": " + value);
    +                out.println("PARAM:" + name + ": " + value);
                 }
     
                 out.println("SESSION-REQUESTED-ID: " +
    
  • test/org/apache/catalina/valves/rewrite/TestRewriteValve.java+103 4 modified
    @@ -301,17 +301,112 @@ public void testNonAsciiPathRedirect() throws Exception {
         }
     
         @Test
    -    public void testQueryString() throws Exception {
    +    public void testQueryStringTargetOnly() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?je=2", "/b/id=1", "/c/id=1", "je=2");
    +    }
    +
    +    @Test
    +    public void testQueryStringTargetOnlyQSA() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?je=2 [QSA]", "/b/id=1", "/c/id=1", "je=2");
    +    }
    +
    +    @Test
    +    public void testQueryStringTargetOnlyQSD() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?je=2 [QSD]", "/b/id=1", "/c/id=1", "je=2");
    +    }
    +
    +    @Test
    +    public void testQueryStringTargetOnlyQSAQSD() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?je=2 [QSA,QSD]", "/b/id=1", "/c/id=1", "je=2");
    +    }
    +
    +    @Test
    +    public void testQueryStringTargetOnlyQS() throws Exception {
             doTestRewrite("RewriteRule ^/b/(.*) /c?$1", "/b/id=1", "/c", "id=1");
         }
     
    +    @Test
    +    public void testQueryStringTargetOnlyQSAQS() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c?$1 [QSA]", "/b/id=1", "/c", "id=1");
    +    }
    +
    +    @Test
    +    public void testQueryStringTargetOnlyQSDQS() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c?$1 [QSD]", "/b/id=1", "/c", "id=1");
    +    }
    +
    +    @Test
    +    public void testQueryStringTargetOnlyQSAQSDQS() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c?$1 [QSA,QSD]", "/b/id=1", "/c", "id=1");
    +    }
    +
    +    @Test
    +    public void testQueryStringSourceOnly() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1", "/b/d?id=1", "/c/d", "id=1");
    +    }
    +
    +    @Test
    +    public void testQueryStringSourceOnlyQSA() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1 [QSA]", "/b/d?id=1", "/c/d", "id=1");
    +    }
    +
    +    @Test
    +    public void testQueryStringSourceOnlyQSD() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1 [QSD]", "/b/d?id=1", "/c/d", null);
    +    }
    +
    +    @Test
    +    public void testQueryStringSourceOnlyQSAQSD() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1 [QSA,QSD]", "/b/d?id=1", "/c/d", null);
    +    }
    +
    +    @Test
    +    public void testQueryStringSourceAndTarget() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?id=1", "/b/d?je=2", "/c/d", "id=1");
    +    }
    +
    +    @Test
    +    public void testQueryStringSourceAndTargetQSA() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?id=1 [QSA]", "/b/d?je=2", "/c/d", "id=1&je=2");
    +    }
    +
    +    @Test
    +    public void testQueryStringSourceAndTargetQSD() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?id=1 [QSD]", "/b/d?je=2", "/c/d", "id=1");
    +    }
    +
    +    @Test
    +    public void testQueryStringSourceAndTargetQSAQSD() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?id=1 [QSA,QSD]", "/b/d?je=2", "/c/d", "id=1");
    +    }
    +
    +    @Test
    +    public void testQueryStringEncoded01() throws Exception {
    +        doTestRewrite("RewriteCond %{QUERY_STRING} a=(.*)\nRewriteRule ^/b.*$ /%1 [QSD]", "/b?a=c", "/c", null);
    +    }
    +
    +    @Test
    +    public void testQueryStringEncoded02() throws Exception {
    +        doTestRewrite("RewriteCond %{QUERY_STRING} a=(.*)\nRewriteRule ^/b.*$ /z/%1 [QSD]", "/b?a=%2e%2e%2fc%2faAbB", "/z/%2e%2e%2fc%2faAbB", null);
    +    }
    +
         @Test
         public void testQueryStringRemove() throws Exception {
    -        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?", "/b/d?=1", "/c/d", null);
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?", "/b/d?id=1", "/c/d", null);
         }
     
         @Test
         public void testQueryStringRemove02() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1 [QSD]", "/b/d?id=1", "/c/d", null);
    +    }
    +
    +    @Test
    +    public void testQueryStringRemoveInvalid() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?", "/b/d?=1", "/c/d", null);
    +    }
    +
    +    @Test
    +    public void testQueryStringRemoveInvalid02() throws Exception {
             doTestRewrite("RewriteRule ^/b/(.*) /c/$1 [QSD]", "/b/d?=1", "/c/d", null);
         }
     
    @@ -616,7 +711,7 @@ public void testUtf8FlagsRBNE() throws Exception {
         public void testFlagsNC() throws Exception {
             // https://bz.apache.org/bugzilla/show_bug.cgi?id=60116
             doTestRewrite("RewriteCond %{QUERY_STRING} a=([a-z]*) [NC]\n" + "RewriteRule .* - [E=X-Test:%1]", "/c?a=aAa",
    -                "/c", null, "aAa");
    +                "/c", "a=aAa", "aAa");
         }
     
         @Test
    @@ -806,12 +901,16 @@ public void invoke(Request request, Response response) throws IOException, Servl
                 // were written into the request target
                 Assert.assertEquals(400, rc);
             } else {
    +            // If there is an expected URI, the request should be successful
    +            Assert.assertEquals(200, rc);
                 String body = res.toString();
                 RequestDescriptor requestDesc = SnoopResult.parse(body);
                 String requestURI = requestDesc.getRequestInfo("REQUEST-URI");
                 Assert.assertEquals(expectedURI, requestURI);
     
    -            if (expectedQueryString != null) {
    +            if (expectedQueryString == null) {
    +                Assert.assertTrue(requestDesc.getParams().isEmpty());
    +            } else {
                     String queryString = requestDesc.getRequestInfo("REQUEST-QUERY-STRING");
                     Assert.assertEquals(expectedQueryString, queryString);
                 }
    
  • webapps/docs/changelog.xml+4 0 modified
    @@ -117,6 +117,10 @@
             when the store was used with the <code>PersistentValve</code>. Based on
             pull request <pr>882</pr> by Aaron Ogburn. (markt)
           </fix>
    +      <fix>
    +        Fix handling of <code>QSA</code> and <code>QSD</code> flags in
    +        <code>RewriteValve</code>. (markt)
    +      </fix>
         </changelog>
       </subsection>
       <subsection name="Coyote">
    
130d36d8492e

Fix a couple of issues with QSA/QSD handling and associated tests

https://github.com/apache/tomcatMark ThomasAug 29, 2025via ghsa
4 files changed · +131 17
  • java/org/apache/catalina/valves/rewrite/RewriteValve.java+23 12 modified
    @@ -326,7 +326,7 @@ public void invoke(Request request, Response response) throws IOException, Servl
     
                 // As long as MB isn't a char sequence or affiliated, this has to be converted to a string
                 Charset uriCharset = request.getConnector().getURICharset();
    -            String originalQueryStringEncoded = request.getQueryString();
    +            String queryStringOriginalEncoded = request.getQueryString();
                 MessageBytes urlMB = context ? request.getRequestPathMB() : request.getDecodedRequestURIMB();
                 urlMB.toChars();
                 CharSequence urlDecoded = urlMB.getCharChunk();
    @@ -427,18 +427,18 @@ public void invoke(Request request, Response response) throws IOException, Servl
                         StringBuilder urlStringEncoded =
                                 new StringBuilder(REWRITE_DEFAULT_ENCODER.encode(urlStringRewriteEncoded, uriCharset));
     
    -                    if (!qsd && originalQueryStringEncoded != null && !originalQueryStringEncoded.isEmpty()) {
    +                    if (!qsd && queryStringOriginalEncoded != null && !queryStringOriginalEncoded.isEmpty()) {
                             if (rewrittenQueryStringRewriteEncoded == null) {
                                 urlStringEncoded.append('?');
    -                            urlStringEncoded.append(originalQueryStringEncoded);
    +                            urlStringEncoded.append(queryStringOriginalEncoded);
                             } else {
                                 if (qsa) {
                                     // if qsa is specified append the query
                                     urlStringEncoded.append('?');
                                     urlStringEncoded.append(
                                             REWRITE_QUERY_ENCODER.encode(rewrittenQueryStringRewriteEncoded, uriCharset));
                                     urlStringEncoded.append('&');
    -                                urlStringEncoded.append(originalQueryStringEncoded);
    +                                urlStringEncoded.append(queryStringOriginalEncoded);
                                 } else if (index == urlStringEncoded.length() - 1) {
                                     // if the ? is the last character delete it, its only purpose was to
                                     // prevent the rewrite module from appending the query string
    @@ -554,24 +554,31 @@ public void invoke(Request request, Response response) throws IOException, Servl
     
                         // Step 3. Complete the 2nd stage to encoding.
                         chunk.append(REWRITE_DEFAULT_ENCODER.encode(urlStringRewriteEncoded, uriCharset));
    -                    // Decoded and normalized URI
    -                    // Rewriting may have denormalized the URL
    -                    urlStringRewriteEncoded = RequestUtil.normalize(urlStringRewriteEncoded);
    +                    // Rewriting may have denormalized the URL and added encoded characters
    +                    // Decode then normalize
    +                    String urlStringRewriteDecoded = URLDecoder.decode(urlStringRewriteEncoded, uriCharset);
    +                    urlStringRewriteDecoded = RequestUtil.normalize(urlStringRewriteDecoded);
                         request.getCoyoteRequest().decodedURI().setChars(MessageBytes.EMPTY_CHAR_ARRAY, 0, 0);
                         chunk = request.getCoyoteRequest().decodedURI().getCharChunk();
                         if (context) {
                             // This is decoded and normalized
                             chunk.append(request.getServletContext().getContextPath());
                         }
    -                    chunk.append(URLDecoder.decode(urlStringRewriteEncoded, uriCharset));
    -                    // Set the new Query if there is one
    -                    if (queryStringRewriteEncoded != null) {
    +                    chunk.append(urlStringRewriteDecoded);
    +                    // Set the new Query String
    +                    if (queryStringRewriteEncoded == null) {
    +                         // No new query string. Therefore the original is retained unless QSD is defined.
    +                        if (qsd) {
    +                            request.getCoyoteRequest().queryString().setChars(MessageBytes.EMPTY_CHAR_ARRAY, 0, 0);
    +                        }
    +                    } else {
    +                        // New query string. Therefore the original is dropped unless QSA is defined (and QSD is not).
                             request.getCoyoteRequest().queryString().setChars(MessageBytes.EMPTY_CHAR_ARRAY, 0, 0);
                             chunk = request.getCoyoteRequest().queryString().getCharChunk();
                             chunk.append(REWRITE_QUERY_ENCODER.encode(queryStringRewriteEncoded, uriCharset));
    -                        if (qsa && originalQueryStringEncoded != null && !originalQueryStringEncoded.isEmpty()) {
    +                        if (qsa && queryStringOriginalEncoded != null && !queryStringOriginalEncoded.isEmpty()) {
                                 chunk.append('&');
    -                            chunk.append(originalQueryStringEncoded);
    +                            chunk.append(queryStringOriginalEncoded);
                             }
                         }
                         // Set the new host if it changed
    @@ -666,6 +673,10 @@ public static Object parse(String line) {
                         while (flagsTokenizer.hasMoreElements()) {
                             parseRuleFlag(line, rule, flagsTokenizer.nextToken());
                         }
    +                    // If QSD and QSA are present, QSD always takes precedence
    +                    if (rule.isQsdiscard()) {
    +                        rule.setQsappend(false);
    +                    }
                     }
                     return rule;
                 } else if (token.equals("RewriteMap")) {
    
  • test/org/apache/catalina/startup/TomcatBaseTest.java+1 1 modified
    @@ -543,7 +543,7 @@ public void service(HttpServletRequest request,
                             value.append(';');
                         }
                     }
    -                out.println("PARAM/" + name + ": " + value);
    +                out.println("PARAM:" + name + ": " + value);
                 }
     
                 out.println("SESSION-REQUESTED-ID: " +
    
  • test/org/apache/catalina/valves/rewrite/TestRewriteValve.java+103 4 modified
    @@ -301,17 +301,112 @@ public void testNonAsciiPathRedirect() throws Exception {
         }
     
         @Test
    -    public void testQueryString() throws Exception {
    +    public void testQueryStringTargetOnly() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?je=2", "/b/id=1", "/c/id=1", "je=2");
    +    }
    +
    +    @Test
    +    public void testQueryStringTargetOnlyQSA() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?je=2 [QSA]", "/b/id=1", "/c/id=1", "je=2");
    +    }
    +
    +    @Test
    +    public void testQueryStringTargetOnlyQSD() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?je=2 [QSD]", "/b/id=1", "/c/id=1", "je=2");
    +    }
    +
    +    @Test
    +    public void testQueryStringTargetOnlyQSAQSD() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?je=2 [QSA,QSD]", "/b/id=1", "/c/id=1", "je=2");
    +    }
    +
    +    @Test
    +    public void testQueryStringTargetOnlyQS() throws Exception {
             doTestRewrite("RewriteRule ^/b/(.*) /c?$1", "/b/id=1", "/c", "id=1");
         }
     
    +    @Test
    +    public void testQueryStringTargetOnlyQSAQS() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c?$1 [QSA]", "/b/id=1", "/c", "id=1");
    +    }
    +
    +    @Test
    +    public void testQueryStringTargetOnlyQSDQS() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c?$1 [QSD]", "/b/id=1", "/c", "id=1");
    +    }
    +
    +    @Test
    +    public void testQueryStringTargetOnlyQSAQSDQS() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c?$1 [QSA,QSD]", "/b/id=1", "/c", "id=1");
    +    }
    +
    +    @Test
    +    public void testQueryStringSourceOnly() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1", "/b/d?id=1", "/c/d", "id=1");
    +    }
    +
    +    @Test
    +    public void testQueryStringSourceOnlyQSA() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1 [QSA]", "/b/d?id=1", "/c/d", "id=1");
    +    }
    +
    +    @Test
    +    public void testQueryStringSourceOnlyQSD() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1 [QSD]", "/b/d?id=1", "/c/d", null);
    +    }
    +
    +    @Test
    +    public void testQueryStringSourceOnlyQSAQSD() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1 [QSA,QSD]", "/b/d?id=1", "/c/d", null);
    +    }
    +
    +    @Test
    +    public void testQueryStringSourceAndTarget() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?id=1", "/b/d?je=2", "/c/d", "id=1");
    +    }
    +
    +    @Test
    +    public void testQueryStringSourceAndTargetQSA() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?id=1 [QSA]", "/b/d?je=2", "/c/d", "id=1&je=2");
    +    }
    +
    +    @Test
    +    public void testQueryStringSourceAndTargetQSD() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?id=1 [QSD]", "/b/d?je=2", "/c/d", "id=1");
    +    }
    +
    +    @Test
    +    public void testQueryStringSourceAndTargetQSAQSD() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?id=1 [QSA,QSD]", "/b/d?je=2", "/c/d", "id=1");
    +    }
    +
    +    @Test
    +    public void testQueryStringEncoded01() throws Exception {
    +        doTestRewrite("RewriteCond %{QUERY_STRING} a=(.*)\nRewriteRule ^/b.*$ /%1 [QSD]", "/b?a=c", "/c", null);
    +    }
    +
    +    @Test
    +    public void testQueryStringEncoded02() throws Exception {
    +        doTestRewrite("RewriteCond %{QUERY_STRING} a=(.*)\nRewriteRule ^/b.*$ /z/%1 [QSD]", "/b?a=%2e%2e%2fc%2faAbB", "/z/%2e%2e%2fc%2faAbB", null);
    +    }
    +
         @Test
         public void testQueryStringRemove() throws Exception {
    -        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?", "/b/d?=1", "/c/d", null);
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?", "/b/d?id=1", "/c/d", null);
         }
     
         @Test
         public void testQueryStringRemove02() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1 [QSD]", "/b/d?id=1", "/c/d", null);
    +    }
    +
    +    @Test
    +    public void testQueryStringRemoveInvalid() throws Exception {
    +        doTestRewrite("RewriteRule ^/b/(.*) /c/$1?", "/b/d?=1", "/c/d", null);
    +    }
    +
    +    @Test
    +    public void testQueryStringRemoveInvalid02() throws Exception {
             doTestRewrite("RewriteRule ^/b/(.*) /c/$1 [QSD]", "/b/d?=1", "/c/d", null);
         }
     
    @@ -616,7 +711,7 @@ public void testUtf8FlagsRBNE() throws Exception {
         public void testFlagsNC() throws Exception {
             // https://bz.apache.org/bugzilla/show_bug.cgi?id=60116
             doTestRewrite("RewriteCond %{QUERY_STRING} a=([a-z]*) [NC]\n" + "RewriteRule .* - [E=X-Test:%1]", "/c?a=aAa",
    -                "/c", null, "aAa");
    +                "/c", "a=aAa", "aAa");
         }
     
         @Test
    @@ -806,12 +901,16 @@ public void invoke(Request request, Response response) throws IOException, Servl
                 // were written into the request target
                 Assert.assertEquals(400, rc);
             } else {
    +            // If there is an expected URI, the request should be successful
    +            Assert.assertEquals(200, rc);
                 String body = res.toString();
                 RequestDescriptor requestDesc = SnoopResult.parse(body);
                 String requestURI = requestDesc.getRequestInfo("REQUEST-URI");
                 Assert.assertEquals(expectedURI, requestURI);
     
    -            if (expectedQueryString != null) {
    +            if (expectedQueryString == null) {
    +                Assert.assertTrue(requestDesc.getParams().isEmpty());
    +            } else {
                     String queryString = requestDesc.getRequestInfo("REQUEST-QUERY-STRING");
                     Assert.assertEquals(expectedQueryString, queryString);
                 }
    
  • webapps/docs/changelog.xml+4 0 modified
    @@ -117,6 +117,10 @@
             when the store was used with the <code>PersistentValve</code>. Based on
             pull request <pr>882</pr> by Aaron Ogburn. (markt)
           </fix>
    +      <fix>
    +        Fix handling of <code>QSA</code> and <code>QSD</code> flags in
    +        <code>RewriteValve</code>. (markt)
    +      </fix>
         </changelog>
       </subsection>
       <subsection name="Coyote">
    

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

13

News mentions

1