Apache Tomcat: Bypass of rules in Rewrite Valve
Description
Improper Neutralization of Escape, Meta, or Control Sequences vulnerability in Apache Tomcat. For a subset of unlikely rewrite rule configurations, it was possible for a specially crafted request to bypass some rewrite rules. If those rewrite rules effectively enforced security constraints, those constraints could be bypassed.
This issue affects Apache Tomcat: from 11.0.0-M1 through 11.0.5, from 10.1.0-M1 through 10.1.39, from 9.0.0.M1 through 9.0.102. The following versions were EOL at the time the CVE was created but are known to be affected: 8.5.0 though 8.5.100. Other, older, EOL versions may also be affected.
Users are recommended to upgrade to version [FIXED_VERSION], which fixes the issue.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Apache Tomcat's rewrite rule engine fails to neutralize escape sequences, allowing specially crafted requests to bypass security constraints under certain configurations.
Vulnerability
CVE-2025-31651 is an improper neutralization of escape, meta, or control sequences vulnerability in Apache Tomcat's rewrite rule engine. For a subset of unlikely rewrite rule configurations, a specially crafted request can bypass some rewrite rules. If those rewrite rules effectively enforce security constraints, those constraints can be bypassed [1][2][3].
Exploitation
The vulnerability occurs when Tomcat's RewriteValve processes URLs. The fix in commit 066bf6b6a15a4e7e0941d4acf096841165b97098 improves handling of URLs containing literal ';' and '?' characters, which can be used to inject escape sequences that the rewrite rule engine fails to neutralize [4]. Attackers need to send a specially crafted request to a Tomcat instance with specific rewrite rule configurations, which are not common.
Impact
Successful exploitation allows an attacker to bypass security constraints enforced by rewrite rules. This could lead to unauthorized access to protected resources or functionality, depending on the rules in place.
Mitigation
Apache has released fixed versions for the affected branches: Tomcat 11.0.6, 10.1.40, 9.0.103, and later. Users are recommended to upgrade to these or later versions. The EOL branches 8.5.x and older are also affected but no longer receive updates; users should upgrade to a supported version [1][2][3].
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
org.apache.tomcat:tomcat-catalinaMaven | >= 9.0.76, < 9.0.104 | 9.0.104 |
org.apache.tomcat:tomcat-catalinaMaven | >= 10.1.10, < 10.1.40 | 10.1.40 |
org.apache.tomcat:tomcat-catalinaMaven | >= 11.0.0-M2, < 11.0.6 | 11.0.6 |
org.apache.tomcat.embed:tomcat-embed-coreMaven | >= 9.0.76, < 9.0.104 | 9.0.104 |
org.apache.tomcat.embed:tomcat-embed-coreMaven | >= 10.1.10, < 10.1.40 | 10.1.40 |
org.apache.tomcat.embed:tomcat-embed-coreMaven | >= 11.0.0-M2, < 11.0.6 | 11.0.6 |
org.apache.tomcat:tomcat-catalinaMaven | >= 8.5.0, <= 8.5.100 | — |
org.apache.tomcat.embed:tomcat-embed-coreMaven | >= 8.5.0, <= 8.5.100 | — |
Affected products
61- osv-coords59 versionspkg:apk/chainguard/camunda-zeebe-8.6pkg:apk/chainguard/camunda-zeebe-8.6-compatpkg:apk/chainguard/thingsboardpkg:apk/chainguard/thingsboard-tb-js-executorpkg:apk/chainguard/thingsboard-tb-mqtt-transportpkg:apk/chainguard/thingsboard-tb-nodepkg:apk/chainguard/thingsboard-tb-web-uipkg:apk/wolfi/thingsboardpkg:apk/wolfi/thingsboard-tb-js-executorpkg:apk/wolfi/thingsboard-tb-mqtt-transportpkg:apk/wolfi/thingsboard-tb-nodepkg:apk/wolfi/thingsboard-tb-web-uipkg:bitnami/tomcatpkg:maven/org.apache.tomcat.embed/tomcat-embed-corepkg:maven/org.apache.tomcat/tomcat-catalinapkg:rpm/almalinux/tomcatpkg:rpm/almalinux/tomcat9pkg:rpm/almalinux/tomcat9-admin-webappspkg:rpm/almalinux/tomcat9-docs-webapppkg:rpm/almalinux/tomcat9-el-3.0-apipkg:rpm/almalinux/tomcat9-jsp-2.3-apipkg:rpm/almalinux/tomcat9-libpkg:rpm/almalinux/tomcat9-servlet-4.0-apipkg:rpm/almalinux/tomcat9-webappspkg:rpm/almalinux/tomcat-admin-webappspkg:rpm/almalinux/tomcat-docs-webapppkg:rpm/almalinux/tomcat-el-3.0-apipkg:rpm/almalinux/tomcat-el-5.0-apipkg:rpm/almalinux/tomcat-jsp-2.3-apipkg:rpm/almalinux/tomcat-jsp-3.1-apipkg:rpm/almalinux/tomcat-libpkg:rpm/almalinux/tomcat-servlet-4.0-apipkg:rpm/almalinux/tomcat-servlet-6.0-apipkg:rpm/almalinux/tomcat-webappspkg:rpm/opensuse/tomcat10&distro=openSUSE%20Leap%2015.6pkg:rpm/opensuse/tomcat&distro=openSUSE%20Leap%2015.6pkg:rpm/suse/tomcat10&distro=SUSE%20Linux%20Enterprise%20High%20Performance%20Computing%2015%20SP5-ESPOSpkg:rpm/suse/tomcat10&distro=SUSE%20Linux%20Enterprise%20High%20Performance%20Computing%2015%20SP5-LTSSpkg:rpm/suse/tomcat10&distro=SUSE%20Linux%20Enterprise%20Module%20for%20Web%20and%20Scripting%2015%20SP6pkg:rpm/suse/tomcat10&distro=SUSE%20Linux%20Enterprise%20Module%20for%20Web%20and%20Scripting%2015%20SP7pkg:rpm/suse/tomcat10&distro=SUSE%20Linux%20Enterprise%20Server%2015%20SP5-LTSSpkg:rpm/suse/tomcat10&distro=SUSE%20Linux%20Enterprise%20Server%20for%20SAP%20Applications%2015%20SP5pkg:rpm/suse/tomcat&distro=SUSE%20Enterprise%20Storage%207.1pkg:rpm/suse/tomcat&distro=SUSE%20Linux%20Enterprise%20High%20Performance%20Computing%2015%20SP3-LTSSpkg:rpm/suse/tomcat&distro=SUSE%20Linux%20Enterprise%20High%20Performance%20Computing%2015%20SP4-ESPOSpkg:rpm/suse/tomcat&distro=SUSE%20Linux%20Enterprise%20High%20Performance%20Computing%2015%20SP4-LTSSpkg:rpm/suse/tomcat&distro=SUSE%20Linux%20Enterprise%20High%20Performance%20Computing%2015%20SP5-ESPOSpkg:rpm/suse/tomcat&distro=SUSE%20Linux%20Enterprise%20High%20Performance%20Computing%2015%20SP5-LTSSpkg:rpm/suse/tomcat&distro=SUSE%20Linux%20Enterprise%20Module%20for%20Web%20and%20Scripting%2015%20SP6pkg:rpm/suse/tomcat&distro=SUSE%20Linux%20Enterprise%20Module%20for%20Web%20and%20Scripting%2015%20SP7pkg:rpm/suse/tomcat&distro=SUSE%20Linux%20Enterprise%20Server%2012%20SP5-LTSSpkg:rpm/suse/tomcat&distro=SUSE%20Linux%20Enterprise%20Server%2015%20SP3-LTSSpkg:rpm/suse/tomcat&distro=SUSE%20Linux%20Enterprise%20Server%2015%20SP4-LTSSpkg:rpm/suse/tomcat&distro=SUSE%20Linux%20Enterprise%20Server%2015%20SP5-LTSSpkg:rpm/suse/tomcat&distro=SUSE%20Linux%20Enterprise%20Server%20for%20SAP%20Applications%2015%20SP3pkg:rpm/suse/tomcat&distro=SUSE%20Linux%20Enterprise%20Server%20for%20SAP%20Applications%2015%20SP4pkg:rpm/suse/tomcat&distro=SUSE%20Linux%20Enterprise%20Server%20for%20SAP%20Applications%2015%20SP5pkg:rpm/suse/tomcat&distro=SUSE%20Linux%20Enterprise%20Server%20LTSS%20Extended%20Security%2012%20SP5pkg:rpm/suse/tomcat&distro=SUSE%20Manager%20Server%204.3
< 8.6.15-r1+ 58 more
- (no CPE)range: < 8.6.15-r1
- (no CPE)range: < 8.6.15-r1
- (no CPE)range: < 4.0.1-r1
- (no CPE)range: < 4.0.1-r1
- (no CPE)range: < 4.0.1-r1
- (no CPE)range: < 4.0.1-r1
- (no CPE)range: < 4.0.1-r1
- (no CPE)range: < 4.0.1-r1
- (no CPE)range: < 4.0.1-r1
- (no CPE)range: < 4.0.1-r1
- (no CPE)range: < 4.0.1-r1
- (no CPE)range: < 4.0.1-r1
- (no CPE)range: < 9.0.104
- (no CPE)range: >= 9.0.76, < 9.0.104
- (no CPE)range: >= 9.0.76, < 9.0.104
- (no CPE)range: < 1:9.0.87-1.el8_10.7
- (no CPE)range: < 1:9.0.87-8.el10_1.1
- (no CPE)range: < 1:9.0.87-8.el10_1.1
- (no CPE)range: < 1:9.0.87-8.el10_1.1
- (no CPE)range: < 1:9.0.87-8.el10_1.1
- (no CPE)range: < 1:9.0.87-8.el10_1.1
- (no CPE)range: < 1:9.0.87-8.el10_1.1
- (no CPE)range: < 1:9.0.87-8.el10_1.1
- (no CPE)range: < 1:9.0.87-8.el10_1.1
- (no CPE)range: < 1:9.0.87-1.el8_10.7
- (no CPE)range: < 1:9.0.87-1.el8_10.7
- (no CPE)range: < 1:9.0.87-1.el8_10.7
- (no CPE)range: < 1:10.1.36-3.el10_1.1
- (no CPE)range: < 1:9.0.87-1.el8_10.7
- (no CPE)range: < 1:10.1.36-3.el10_1.1
- (no CPE)range: < 1:9.0.87-1.el8_10.7
- (no CPE)range: < 1:9.0.87-1.el8_10.7
- (no CPE)range: < 1:10.1.36-3.el10_1.1
- (no CPE)range: < 1:9.0.87-1.el8_10.7
- (no CPE)range: < 10.1.40-150200.5.40.1
- (no CPE)range: < 9.0.104-150200.81.1
- (no CPE)range: < 10.1.40-150200.5.40.1
- (no CPE)range: < 10.1.40-150200.5.40.1
- (no CPE)range: < 10.1.40-150200.5.40.1
- (no CPE)range: < 10.1.40-150200.5.40.1
- (no CPE)range: < 10.1.40-150200.5.40.1
- (no CPE)range: < 10.1.40-150200.5.40.1
- (no CPE)range: < 9.0.104-150200.81.1
- (no CPE)range: < 9.0.104-150200.81.1
- (no CPE)range: < 9.0.104-150200.81.1
- (no CPE)range: < 9.0.104-150200.81.1
- (no CPE)range: < 9.0.104-150200.81.1
- (no CPE)range: < 9.0.104-150200.81.1
- (no CPE)range: < 9.0.104-150200.81.1
- (no CPE)range: < 9.0.104-150200.81.1
- (no CPE)range: < 9.0.115-3.160.1
- (no CPE)range: < 9.0.104-150200.81.1
- (no CPE)range: < 9.0.104-150200.81.1
- (no CPE)range: < 9.0.104-150200.81.1
- (no CPE)range: < 9.0.104-150200.81.1
- (no CPE)range: < 9.0.104-150200.81.1
- (no CPE)range: < 9.0.104-150200.81.1
- (no CPE)range: < 9.0.36-3.142.1
- (no CPE)range: < 9.0.104-150200.81.1
- Apache Software Foundation/Apache Tomcatv5Range: 11.0.0-M1
Patches
41 file changed · +1 −1
java/org/apache/catalina/valves/rewrite/RewriteValve.java+1 −1 modified@@ -552,7 +552,7 @@ public void invoke(Request request, Response response) throws IOException, Servl // This is decoded and normalized chunk.append(request.getServletContext().getContextPath()); } - chunk.append(URLDecoder.decode(urlStringRewriteEncoded, uriCharset)); + chunk.append(URLDecoder.decode(urlStringRewriteEncoded, uriCharset.name())); // Set the new Query if there is one if (queryStringRewriteEncoded != null) { request.getCoyoteRequest().queryString().setChars(MessageBytes.EMPTY_CHAR_ARRAY, 0, 0);
fbecc915a10cBetter handling of URLs with literal ';' and '?'
5 files changed · +246 −55
java/org/apache/catalina/connector/CoyoteAdapter.java+9 −10 modified@@ -650,18 +650,17 @@ protected boolean postParseRequest(org.apache.coyote.Request req, Request reques } else { /* * The URI is chars or String, and has been sent using an in-memory protocol handler. The following - * assumptions are made: - req.requestURI() has been set to the 'original' non-decoded, non-normalized - * URI - req.decodedURI() has been set to the decoded, normalized form of req.requestURI() - - * 'suspicious' URI filtering - if required - has already been performed + * assumptions are made: + * + * - req.requestURI() has been set to the 'original' non-decoded, non-normalized URI that includes path + * parameters (if any) + * + * - req.decodedURI() has been set to the decoded, normalized form of req.requestURI() with any path + * parameters removed + * + * - 'suspicious' URI filtering, if required, has already been performed */ decodedURI.toChars(); - // Remove all path parameters; any needed path parameter should be set - // using the request object rather than passing it in the URL - CharChunk uriCC = decodedURI.getCharChunk(); - int semicolon = uriCC.indexOf(';'); - if (semicolon > 0) { - decodedURI.setChars(uriCC.getBuffer(), uriCC.getStart(), semicolon); - } } }
java/org/apache/catalina/valves/rewrite/RewriteValve.java+108 −28 modified@@ -21,6 +21,7 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.io.StringReader; +import java.net.URLDecoder; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -65,6 +66,24 @@ */ public class RewriteValve extends ValveBase { + private static final URLEncoder REWRITE_DEFAULT_ENCODER; + private static final URLEncoder REWRITE_QUERY_ENCODER; + + static { + /* + * See the detailed explanation of encoding/decoding during URL re-writing in the invoke() method. + * + * These encoders perform the second stage of encoding, after re-writing has completed. These rewrite specific + * encoders treat '%' as a safe character so that URLs and query strings already processed by encodeForRewrite() + * do not end up with double encoding of '%' characters. + */ + REWRITE_DEFAULT_ENCODER = (URLEncoder) URLEncoder.DEFAULT.clone(); + REWRITE_DEFAULT_ENCODER.addSafeCharacter('%'); + + REWRITE_QUERY_ENCODER = (URLEncoder) URLEncoder.QUERY.clone(); + REWRITE_QUERY_ENCODER.addSafeCharacter('%'); + } + /** * The rewrite rules that the valve will use. */ @@ -296,22 +315,51 @@ public void invoke(Request request, Response response) throws IOException, Servl invoked.set(Boolean.TRUE); - // As long as MB isn't a char sequence or affiliated, this has to be - // converted to a string + // 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(); MessageBytes urlMB = context ? request.getRequestPathMB() : request.getDecodedRequestURIMB(); urlMB.toChars(); CharSequence urlDecoded = urlMB.getCharChunk(); + + /* + * The URL presented to the rewrite valve is the URL that is used for request mapping. That URL has been + * processed to: remove path parameters; remove the query string; decode; and normalize the URL. It may + * contain literal '%', '?' and/or ';' characters at this point. + * + * The re-write rules need to be able to process URLs with literal '?' characters and add query strings + * without the two becoming confused. The re-write rules also need to be able to insert literal '%' + * characters without them being confused with %nn encoding. + * + * The re-write rules cannot insert path parameters. + * + * To meet these requirement, the URL is processed as follows. + * + * Step 1. The URL is partially re-encoded by encodeForRewrite(). This method encodes any literal '%', ';' + * and/or '?' characters in the URL using the standard %nn form. + * + * Step 2. The re-write processing runs with the provided re-write rules against the partially encoded URL. + * If a re-write rule needs to insert a literal '%', ';' or '?', it must do so in %nn encoded form. + * + * Step 3. The URL (and query string if present) is re-encoded using the re-write specific encoders + * (REWRITE_DEFAULT_ENCODER and REWRITE_QUERY_ENCODER) that behave the same was as the standard encoders + * apart from '%' being treated as a safe character. This prevents double encoding of any '%' characters + * present in the URL from steps 1 or 2. + */ + + // Step 1. Encode URL for processing by the re-write rules. + CharSequence urlRewriteEncoded = encodeForRewrite(urlDecoded); CharSequence host = request.getServerName(); boolean rewritten = false; boolean done = false; boolean qsa = false; boolean qsd = false; boolean valveSkip = false; + + // Step 2. Process the URL using the re-write rules. for (int i = 0; i < rules.length; i++) { RewriteRule rule = rules[i]; - CharSequence test = (rule.isHost()) ? host : urlDecoded; + CharSequence test = (rule.isHost()) ? host : urlRewriteEncoded; CharSequence newtest = rule.evaluate(test, resolver); if (newtest != null && !Objects.equals(test.toString(), newtest.toString())) { if (containerLog.isTraceEnabled()) { @@ -321,7 +369,7 @@ public void invoke(Request request, Response response) throws IOException, Servl if (rule.isHost()) { host = newtest; } else { - urlDecoded = newtest; + urlRewriteEncoded = newtest; } rewritten = true; } @@ -358,28 +406,30 @@ public void invoke(Request request, Response response) throws IOException, Servl if (rule.isRedirect() && newtest != null) { // Append the query string to the url if there is one and it // hasn't been rewritten - String urlStringDecoded = urlDecoded.toString(); - int index = urlStringDecoded.indexOf('?'); - String rewrittenQueryStringDecoded; + String urlStringRewriteEncoded = urlRewriteEncoded.toString(); + int index = urlStringRewriteEncoded.indexOf('?'); + String rewrittenQueryStringRewriteEncoded; if (index == -1) { - rewrittenQueryStringDecoded = null; + rewrittenQueryStringRewriteEncoded = null; } else { - rewrittenQueryStringDecoded = urlStringDecoded.substring(index + 1); - urlStringDecoded = urlStringDecoded.substring(0, index); + rewrittenQueryStringRewriteEncoded = urlStringRewriteEncoded.substring(index + 1); + urlStringRewriteEncoded = urlStringRewriteEncoded.substring(0, index); } + // Step 3. Complete the 2nd stage to encoding. StringBuilder urlStringEncoded = - new StringBuilder(URLEncoder.DEFAULT.encode(urlStringDecoded, uriCharset)); + new StringBuilder(REWRITE_DEFAULT_ENCODER.encode(urlStringRewriteEncoded, uriCharset)); + if (!qsd && originalQueryStringEncoded != null && !originalQueryStringEncoded.isEmpty()) { - if (rewrittenQueryStringDecoded == null) { + if (rewrittenQueryStringRewriteEncoded == null) { urlStringEncoded.append('?'); urlStringEncoded.append(originalQueryStringEncoded); } else { if (qsa) { // if qsa is specified append the query urlStringEncoded.append('?'); - urlStringEncoded - .append(URLEncoder.QUERY.encode(rewrittenQueryStringDecoded, uriCharset)); + urlStringEncoded.append( + REWRITE_QUERY_ENCODER.encode(rewrittenQueryStringRewriteEncoded, uriCharset)); urlStringEncoded.append('&'); urlStringEncoded.append(originalQueryStringEncoded); } else if (index == urlStringEncoded.length() - 1) { @@ -388,13 +438,14 @@ public void invoke(Request request, Response response) throws IOException, Servl urlStringEncoded.deleteCharAt(index); } else { urlStringEncoded.append('?'); - urlStringEncoded - .append(URLEncoder.QUERY.encode(rewrittenQueryStringDecoded, uriCharset)); + urlStringEncoded.append( + REWRITE_QUERY_ENCODER.encode(rewrittenQueryStringRewriteEncoded, uriCharset)); } } - } else if (rewrittenQueryStringDecoded != null) { + } else if (rewrittenQueryStringRewriteEncoded != null) { urlStringEncoded.append('?'); - urlStringEncoded.append(URLEncoder.QUERY.encode(rewrittenQueryStringDecoded, uriCharset)); + urlStringEncoded + .append(REWRITE_QUERY_ENCODER.encode(rewrittenQueryStringRewriteEncoded, uriCharset)); } // Insert the context if @@ -469,12 +520,12 @@ public void invoke(Request request, Response response) throws IOException, Servl if (rewritten) { if (!done) { // See if we need to replace the query string - String urlStringDecoded = urlDecoded.toString(); - String queryStringDecoded = null; - int queryIndex = urlStringDecoded.indexOf('?'); + String urlStringRewriteEncoded = urlRewriteEncoded.toString(); + String queryStringRewriteEncoded = null; + int queryIndex = urlStringRewriteEncoded.indexOf('?'); if (queryIndex != -1) { - queryStringDecoded = urlStringDecoded.substring(queryIndex + 1); - urlStringDecoded = urlStringDecoded.substring(0, queryIndex); + queryStringRewriteEncoded = urlStringRewriteEncoded.substring(queryIndex + 1); + urlStringRewriteEncoded = urlStringRewriteEncoded.substring(0, queryIndex); } // Save the current context path before re-writing starts String contextPath = null; @@ -488,22 +539,24 @@ public void invoke(Request request, Response response) throws IOException, Servl // This is neither decoded nor normalized chunk.append(contextPath); } - chunk.append(URLEncoder.DEFAULT.encode(urlStringDecoded, uriCharset)); + + // 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 - urlStringDecoded = RequestUtil.normalize(urlStringDecoded); + urlStringRewriteEncoded = RequestUtil.normalize(urlStringRewriteEncoded); 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(urlStringDecoded); + chunk.append(URLDecoder.decode(urlStringRewriteEncoded, uriCharset)); // Set the new Query if there is one - if (queryStringDecoded != null) { + if (queryStringRewriteEncoded != null) { request.getCoyoteRequest().queryString().setChars(MessageBytes.EMPTY_CHAR_ARRAY, 0, 0); chunk = request.getCoyoteRequest().queryString().getCharChunk(); - chunk.append(URLEncoder.QUERY.encode(queryStringDecoded, uriCharset)); + chunk.append(REWRITE_QUERY_ENCODER.encode(queryStringRewriteEncoded, uriCharset)); if (qsa && originalQueryStringEncoded != null && !originalQueryStringEncoded.isEmpty()) { chunk.append('&'); chunk.append(originalQueryStringEncoded); @@ -790,4 +843,31 @@ protected static void parseRuleFlag(String line, RewriteRule rule, String flag) throw new IllegalArgumentException(sm.getString("rewriteValve.invalidFlags", line, flag)); } } + + + private CharSequence encodeForRewrite(CharSequence input) { + StringBuilder result = null; + int pos = 0; + int mark = 0; + while (pos < input.length()) { + char c = input.charAt(pos); + if (c == '%' || c == ';' || c == '?') { + if (result == null) { + result = new StringBuilder((int) (input.length() * 1.1)); + } + result.append(input.subSequence(mark, pos)); + result.append('%'); + result.append(Character.forDigit((c >> 4) & 0xF, 16)); + result.append(Character.forDigit(c & 0xF, 16)); + mark = pos + 1; + } + pos++; + } + if (result != null) { + result.append(input.subSequence(mark, input.length())); + return result; + } else { + return input; + } + } }
test/org/apache/catalina/valves/rewrite/TestRewriteValve.java+102 −17 modified@@ -20,6 +20,7 @@ import java.io.IOException; import java.io.PrintWriter; import java.net.HttpURLConnection; +import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.HashMap; @@ -63,7 +64,7 @@ public void testNoRewrite() throws Exception { @Test public void testBackslashPercentSign() throws Exception { - doTestRewrite("RewriteRule ^(.*) /a/\\%5A", "/", "/a/%255A"); + doTestRewrite("RewriteRule ^(.*) /a/\\%5A", "/", "/a/%5A"); } @Test @@ -142,7 +143,7 @@ public void testRewriteMap09() throws Exception { @Test public void testRewriteMap10() throws Exception { - doTestRewrite("RewriteMap lc int:escape\n" + "RewriteRule ^(.*) ${lc:$1}", "/c/a%20aa", "/c/a%2520aa"); + doTestRewrite("RewriteMap lc int:escape\n" + "RewriteRule ^(.*) ${lc:$1}", "/c/a%20aa", "/c/a%20aa"); } @Test @@ -346,16 +347,16 @@ public void testNonAsciiQueryStringAndPathAndRedirect() throws Exception { public void testNonAsciiQueryStringWithB() throws Exception { doTestRewrite("RewriteRule ^/b/(.*)/id=(.*) /c?filename=$1&id=$2 [B]", "/b/file01/id=%E5%9C%A8%E7%BA%BF%E6%B5%8B%E8%AF%95", "/c", - "filename=file01&id=%25E5%259C%25A8%25E7%25BA%25BF%25E6%25B5%258B%25E8%25AF%2595"); + "filename=file01&id=%E5%9C%A8%E7%BA%BF%E6%B5%8B%E8%AF%95"); } @Test public void testNonAsciiQueryStringAndPathAndRedirectWithB() throws Exception { // Note the double encoding of the result (httpd produces the same result) doTestRewrite("RewriteRule ^/b/(.*)/(.*)/id=(.*) /c/$1?filename=$2&id=$3 [B,R]", - "/b/%E5%9C%A8%E7%BA%BF/file01/id=%E6%B5%8B%E8%AF%95", "/c/%25E5%259C%25A8%25E7%25BA%25BF", - "filename=file01&id=%25E6%25B5%258B%25E8%25AF%2595"); + "/b/%E5%9C%A8%E7%BA%BF/file01/id=%E6%B5%8B%E8%AF%95", "/c/%E5%9C%A8%E7%BA%BF", + "filename=file01&id=%E6%B5%8B%E8%AF%95"); } @@ -371,7 +372,7 @@ public void testUtf8WithBothQsFlagsNone() throws Exception { public void testUtf8WithBothQsFlagsB() throws Exception { // Note %C2%A1 == \u00A1 doTestRewrite("RewriteRule ^/b/(.*)/(.*) /c/\u00A1$1?$2 [B]", "/b/%C2%A1/id=%C2%A1?di=%C2%AE", - "/c/%C2%A1%25C2%25A1", "id=%25C2%25A1"); + "/c/%C2%A1%C2%A1", "id=%C2%A1"); } @@ -387,7 +388,7 @@ public void testUtf8WithBothQsFlagsR() throws Exception { public void testUtf8WithBothQsFlagsRB() throws Exception { // Note %C2%A1 == \u00A1 doTestRewrite("RewriteRule ^/b/(.*)/(.*) /c/\u00A1$1?$2 [R,B]", "/b/%C2%A1/id=%C2%A1?di=%C2%AE", - "/c/%C2%A1%25C2%25A1", "id=%25C2%25A1"); + "/c/%C2%A1%C2%A1", "id=%C2%A1"); } @@ -413,7 +414,7 @@ public void testUtf8WithBothQsFlagsRBNE() throws Exception { public void testUtf8WithBothQsFlagsBQSA() throws Exception { // Note %C2%A1 == \u00A1 doTestRewrite("RewriteRule ^/b/(.*)/(.*) /c/\u00A1$1?$2 [B,QSA]", "/b/%C2%A1/id=%C2%A1?di=%C2%AE", - "/c/%C2%A1%25C2%25A1", "id=%25C2%25A1&di=%C2%AE"); + "/c/%C2%A1%C2%A1", "id=%C2%A1&di=%C2%AE"); } @@ -429,7 +430,7 @@ public void testUtf8WithBothQsFlagsRQSA() throws Exception { public void testUtf8WithBothQsFlagsRBQSA() throws Exception { // Note %C2%A1 == \u00A1 doTestRewrite("RewriteRule ^/b/(.*)/(.*) /c/\u00A1$1?$2 [R,B,QSA]", "/b/%C2%A1/id=%C2%A1?di=%C2%AE", - "/c/%C2%A1%25C2%25A1", "id=%25C2%25A1&di=%C2%AE"); + "/c/%C2%A1%C2%A1", "id=%C2%A1&di=%C2%AE"); } @@ -461,7 +462,7 @@ public void testUtf8WithOriginalQsFlagsNone() throws Exception { @Test public void testUtf8WithOriginalQsFlagsB() throws Exception { // Note %C2%A1 == \u00A1 - doTestRewrite("RewriteRule ^/b/(.*) /c/\u00A1$1 [B]", "/b/%C2%A1?id=%C2%A1", "/c/%C2%A1%25C2%25A1", + doTestRewrite("RewriteRule ^/b/(.*) /c/\u00A1$1 [B]", "/b/%C2%A1?id=%C2%A1", "/c/%C2%A1%C2%A1", "id=%C2%A1"); } @@ -476,7 +477,7 @@ public void testUtf8WithOriginalQsFlagsR() throws Exception { @Test public void testUtf8WithOriginalQsFlagsRB() throws Exception { // Note %C2%A1 == \u00A1 - doTestRewrite("RewriteRule ^/b/(.*) /c/\u00A1$1 [R,B]", "/b/%C2%A1?id=%C2%A1", "/c/%C2%A1%25C2%25A1", + doTestRewrite("RewriteRule ^/b/(.*) /c/\u00A1$1 [R,B]", "/b/%C2%A1?id=%C2%A1", "/c/%C2%A1%C2%A1", "id=%C2%A1"); } @@ -510,8 +511,8 @@ public void testUtf8WithRewriteQsFlagsNone() throws Exception { @Test public void testUtf8WithRewriteQsFlagsB() throws Exception { // Note %C2%A1 == \u00A1 - doTestRewrite("RewriteRule ^/b/(.*)/(.*) /c/\u00A1$1?$2 [B]", "/b/%C2%A1/id=%C2%A1", "/c/%C2%A1%25C2%25A1", - "id=%25C2%25A1"); + doTestRewrite("RewriteRule ^/b/(.*)/(.*) /c/\u00A1$1?$2 [B]", "/b/%C2%A1/id=%C2%A1", "/c/%C2%A1%C2%A1", + "id=%C2%A1"); } @@ -534,8 +535,8 @@ public void testUtf8WithBothQsFlagsQSA() throws Exception { @Test public void testUtf8WithRewriteQsFlagsRB() throws Exception { // Note %C2%A1 == \u00A1 - doTestRewrite("RewriteRule ^/b/(.*)/(.*) /c/\u00A1$1?$2 [R,B]", "/b/%C2%A1/id=%C2%A1", "/c/%C2%A1%25C2%25A1", - "id=%25C2%25A1"); + doTestRewrite("RewriteRule ^/b/(.*)/(.*) /c/\u00A1$1?$2 [R,B]", "/b/%C2%A1/id=%C2%A1", "/c/%C2%A1%C2%A1", + "id=%C2%A1"); } @@ -575,7 +576,7 @@ public void testUtf8FlagsNone() throws Exception { @Test public void testUtf8FlagsB() throws Exception { // Note %C2%A1 == \u00A1 - doTestRewrite("RewriteRule ^/b/(.*) /c/\u00A1$1 [B]", "/b/%C2%A1", "/c/%C2%A1%25C2%25A1"); + doTestRewrite("RewriteRule ^/b/(.*) /c/\u00A1$1 [B]", "/b/%C2%A1", "/c/%C2%A1%C2%A1"); } @@ -589,7 +590,7 @@ public void testUtf8FlagsR() throws Exception { @Test public void testUtf8FlagsRB() throws Exception { // Note %C2%A1 == \u00A1 - doTestRewrite("RewriteRule ^/b/(.*) /c/\u00A1$1 [R,B]", "/b/%C2%A1", "/c/%C2%A1%25C2%25A1"); + doTestRewrite("RewriteRule ^/b/(.*) /c/\u00A1$1 [R,B]", "/b/%C2%A1", "/c/%C2%A1%C2%A1"); } @@ -784,6 +785,7 @@ public void invoke(Request request, Response response) throws IOException, Servl rewriteValve.setConfiguration(config); Tomcat.addServlet(ctx, "snoop", new SnoopServlet()); + ctx.addServletMappingDecoded("/a/Z", "snoop"); ctx.addServletMappingDecoded("/a/%5A", "snoop"); ctx.addServletMappingDecoded("/c/*", "snoop"); ctx.addServletMappingDecoded("/W/*", "snoop"); @@ -929,4 +931,87 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws Se } } } + + + @Test + public void testEncodedUriSimple() throws Exception { + doTestRewriteWithEncoding("aaa"); + } + + + @Test + public void testEncodedUriEncodedQuestionMark01() throws Exception { + doTestRewriteWithEncoding("a%3fa"); + } + + + @Test + public void testEncodedUriEncodedQuestionMark02() throws Exception { + doTestRewriteWithEncoding("%3faa"); + } + + + @Test + public void testEncodedUriEncodedQuestionMark03() throws Exception { + doTestRewriteWithEncoding("aa%3f"); + } + + + @Test + public void testEncodedUriEncodedQuestionMarkAndQueryString() throws Exception { + doTestRewriteWithEncoding("a%3fa?b=c", "a%3fa", "b=c"); + } + + + @Test + public void testEncodedUriEncodedSemicolon01() throws Exception { + doTestRewriteWithEncoding("a%3ba"); + } + + + @Test + public void testEncodedUriEncodedSemicolon02() throws Exception { + doTestRewriteWithEncoding("%3baa"); + } + + + @Test + public void testEncodedUriEncodedSemicolon03() throws Exception { + doTestRewriteWithEncoding("aa%3b"); + } + + + private void doTestRewriteWithEncoding(String segment) throws Exception { + doTestRewriteWithEncoding(segment, segment, null); + } + + private void doTestRewriteWithEncoding(String segment, String expectedSegment, String expectedQueryString) + throws Exception { + Tomcat tomcat = getTomcatInstance(); + + // No file system docBase required + Context ctx = tomcat.addContext("", null); + + RewriteValve rewriteValve = new RewriteValve(); + tomcat.getHost().getPipeline().addValve(rewriteValve); + + rewriteValve.setConfiguration("RewriteRule ^/source/(.*)$ /target/$1"); + + Tomcat.addServlet(ctx, "snoop", new SnoopServlet()); + ctx.addServletMappingDecoded("/target/*", "snoop"); + + tomcat.start(); + + ByteChunk res = new ByteChunk(); + int rc = getUrl("http://localhost:" + getPort() + "/source/" + segment, res, false); + + Assert.assertEquals(HttpServletResponse.SC_OK, rc); + + res.setCharset(StandardCharsets.UTF_8); + String body = res.toString(); + Assert.assertTrue(body, body.contains("REQUEST-URI: /target/" + expectedSegment)); + Assert.assertTrue(body, body.contains("PATH-INFO: /" + + URLDecoder.decode(expectedSegment, StandardCharsets.UTF_8))); + Assert.assertTrue(body, body.contains("REQUEST-QUERY-STRING: " + expectedQueryString)); + } }
webapps/docs/changelog.xml+5 −0 modified@@ -171,6 +171,11 @@ Fix stack trace trimming in <code>ErrorReportValve</code> after removal of the security manager support. (remm) </fix> + <fix> + Improve the handling of <code>%nn</code> URL encoding in the + RewriteValve and document how <code>%nn</code> URL encoding may be used + with rewrite rules. (markt) + </fix> </changelog> </subsection> <subsection name="Coyote">
webapps/docs/rewrite.xml+22 −0 modified@@ -56,6 +56,28 @@ </section> +<section name="Using rewrite rules with special characters"> + + <p>The URL presented to the rewrite valve is the same URL used for request + mapping with any literal <code>'%'</code>, <code>';'</code> and/or + <code>'?'</code> characters encoded in <code>%nn</code> form.</p> + + <p>A rewrite rule that wishes to insert a literal <code>'%'</code>, + <code>';'</code>, <code>'?'</code>, <code>'&'</code> or <code>'='</code> + character should do so in <code>%nn</code> form. Other characters maybe + inserted in either literal or <code>%nn</code> form.</p> + + <p>This enables the rewrite rules to: + <ul> + <li>process URLs containing literal <code>'?'</code> characters;</li> + <li>add a query string;</li> + <li>insert a literal <code>'%'</code> character without it being confused with + <code>%nn</code> encoding.</li> + </ul> + </p> + +</section> + <section name="Directives"> <p>The rewrite.config file contains a list of directives which closely
ee3ab548e923Better handling of URLs with literal ';' and '?'
5 files changed · +246 −54
java/org/apache/catalina/connector/CoyoteAdapter.java+9 −9 modified@@ -653,17 +653,17 @@ protected boolean postParseRequest(org.apache.coyote.Request req, Request reques } else { /* * The URI is chars or String, and has been sent using an in-memory protocol handler. The following - * assumptions are made: - req.requestURI() has been set to the 'original' non-decoded, non-normalized - * URI - req.decodedURI() has been set to the decoded, normalized form of req.requestURI() + * assumptions are made: + * + * - req.requestURI() has been set to the 'original' non-decoded, non-normalized URI that includes path + * parameters (if any) + * + * - req.decodedURI() has been set to the decoded, normalized form of req.requestURI() with any path + * parameters removed + * + * - 'suspicious' URI filtering, if required, has already been performed */ decodedURI.toChars(); - // Remove all path parameters; any needed path parameter should be set - // using the request object rather than passing it in the URL - CharChunk uriCC = decodedURI.getCharChunk(); - int semicolon = uriCC.indexOf(';'); - if (semicolon > 0) { - decodedURI.setChars(uriCC.getBuffer(), uriCC.getStart(), semicolon); - } } }
java/org/apache/catalina/valves/rewrite/RewriteValve.java+108 −28 modified@@ -21,6 +21,7 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.io.StringReader; +import java.net.URLDecoder; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -65,6 +66,24 @@ */ public class RewriteValve extends ValveBase { + private static final URLEncoder REWRITE_DEFAULT_ENCODER; + private static final URLEncoder REWRITE_QUERY_ENCODER; + + static { + /* + * See the detailed explanation of encoding/decoding during URL re-writing in the invoke() method. + * + * These encoders perform the second stage of encoding, after re-writing has completed. These rewrite specific + * encoders treat '%' as a safe character so that URLs and query strings already processed by encodeForRewrite() + * do not end up with double encoding of '%' characters. + */ + REWRITE_DEFAULT_ENCODER = (URLEncoder) URLEncoder.DEFAULT.clone(); + REWRITE_DEFAULT_ENCODER.addSafeCharacter('%'); + + REWRITE_QUERY_ENCODER = (URLEncoder) URLEncoder.QUERY.clone(); + REWRITE_QUERY_ENCODER.addSafeCharacter('%'); + } + /** * The rewrite rules that the valve will use. */ @@ -297,22 +316,51 @@ public void invoke(Request request, Response response) throws IOException, Servl invoked.set(Boolean.TRUE); - // As long as MB isn't a char sequence or affiliated, this has to be - // converted to a string + // 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(); MessageBytes urlMB = context ? request.getRequestPathMB() : request.getDecodedRequestURIMB(); urlMB.toChars(); CharSequence urlDecoded = urlMB.getCharChunk(); + + /* + * The URL presented to the rewrite valve is the URL that is used for request mapping. That URL has been + * processed to: remove path parameters; remove the query string; decode; and normalize the URL. It may + * contain literal '%', '?' and/or ';' characters at this point. + * + * The re-write rules need to be able to process URLs with literal '?' characters and add query strings + * without the two becoming confused. The re-write rules also need to be able to insert literal '%' + * characters without them being confused with %nn encoding. + * + * The re-write rules cannot insert path parameters. + * + * To meet these requirement, the URL is processed as follows. + * + * Step 1. The URL is partially re-encoded by encodeForRewrite(). This method encodes any literal '%', ';' + * and/or '?' characters in the URL using the standard %nn form. + * + * Step 2. The re-write processing runs with the provided re-write rules against the partially encoded URL. + * If a re-write rule needs to insert a literal '%', ';' or '?', it must do so in %nn encoded form. + * + * Step 3. The URL (and query string if present) is re-encoded using the re-write specific encoders + * (REWRITE_DEFAULT_ENCODER and REWRITE_QUERY_ENCODER) that behave the same was as the standard encoders + * apart from '%' being treated as a safe character. This prevents double encoding of any '%' characters + * present in the URL from steps 1 or 2. + */ + + // Step 1. Encode URL for processing by the re-write rules. + CharSequence urlRewriteEncoded = encodeForRewrite(urlDecoded); CharSequence host = request.getServerName(); boolean rewritten = false; boolean done = false; boolean qsa = false; boolean qsd = false; boolean valveSkip = false; + + // Step 2. Process the URL using the re-write rules. for (int i = 0; i < rules.length; i++) { RewriteRule rule = rules[i]; - CharSequence test = (rule.isHost()) ? host : urlDecoded; + CharSequence test = (rule.isHost()) ? host : urlRewriteEncoded; CharSequence newtest = rule.evaluate(test, resolver); if (newtest != null && !Objects.equals(test.toString(), newtest.toString())) { if (containerLog.isTraceEnabled()) { @@ -322,7 +370,7 @@ public void invoke(Request request, Response response) throws IOException, Servl if (rule.isHost()) { host = newtest; } else { - urlDecoded = newtest; + urlRewriteEncoded = newtest; } rewritten = true; } @@ -359,28 +407,30 @@ public void invoke(Request request, Response response) throws IOException, Servl if (rule.isRedirect() && newtest != null) { // Append the query string to the url if there is one and it // hasn't been rewritten - String urlStringDecoded = urlDecoded.toString(); - int index = urlStringDecoded.indexOf('?'); - String rewrittenQueryStringDecoded; + String urlStringRewriteEncoded = urlRewriteEncoded.toString(); + int index = urlStringRewriteEncoded.indexOf('?'); + String rewrittenQueryStringRewriteEncoded; if (index == -1) { - rewrittenQueryStringDecoded = null; + rewrittenQueryStringRewriteEncoded = null; } else { - rewrittenQueryStringDecoded = urlStringDecoded.substring(index + 1); - urlStringDecoded = urlStringDecoded.substring(0, index); + rewrittenQueryStringRewriteEncoded = urlStringRewriteEncoded.substring(index + 1); + urlStringRewriteEncoded = urlStringRewriteEncoded.substring(0, index); } + // Step 3. Complete the 2nd stage to encoding. StringBuilder urlStringEncoded = - new StringBuilder(URLEncoder.DEFAULT.encode(urlStringDecoded, uriCharset)); + new StringBuilder(REWRITE_DEFAULT_ENCODER.encode(urlStringRewriteEncoded, uriCharset)); + if (!qsd && originalQueryStringEncoded != null && !originalQueryStringEncoded.isEmpty()) { - if (rewrittenQueryStringDecoded == null) { + if (rewrittenQueryStringRewriteEncoded == null) { urlStringEncoded.append('?'); urlStringEncoded.append(originalQueryStringEncoded); } else { if (qsa) { // if qsa is specified append the query urlStringEncoded.append('?'); - urlStringEncoded - .append(URLEncoder.QUERY.encode(rewrittenQueryStringDecoded, uriCharset)); + urlStringEncoded.append( + REWRITE_QUERY_ENCODER.encode(rewrittenQueryStringRewriteEncoded, uriCharset)); urlStringEncoded.append('&'); urlStringEncoded.append(originalQueryStringEncoded); } else if (index == urlStringEncoded.length() - 1) { @@ -389,13 +439,14 @@ public void invoke(Request request, Response response) throws IOException, Servl urlStringEncoded.deleteCharAt(index); } else { urlStringEncoded.append('?'); - urlStringEncoded - .append(URLEncoder.QUERY.encode(rewrittenQueryStringDecoded, uriCharset)); + urlStringEncoded.append( + REWRITE_QUERY_ENCODER.encode(rewrittenQueryStringRewriteEncoded, uriCharset)); } } - } else if (rewrittenQueryStringDecoded != null) { + } else if (rewrittenQueryStringRewriteEncoded != null) { urlStringEncoded.append('?'); - urlStringEncoded.append(URLEncoder.QUERY.encode(rewrittenQueryStringDecoded, uriCharset)); + urlStringEncoded + .append(REWRITE_QUERY_ENCODER.encode(rewrittenQueryStringRewriteEncoded, uriCharset)); } // Insert the context if @@ -470,12 +521,12 @@ public void invoke(Request request, Response response) throws IOException, Servl if (rewritten) { if (!done) { // See if we need to replace the query string - String urlStringDecoded = urlDecoded.toString(); - String queryStringDecoded = null; - int queryIndex = urlStringDecoded.indexOf('?'); + String urlStringRewriteEncoded = urlRewriteEncoded.toString(); + String queryStringRewriteEncoded = null; + int queryIndex = urlStringRewriteEncoded.indexOf('?'); if (queryIndex != -1) { - queryStringDecoded = urlStringDecoded.substring(queryIndex + 1); - urlStringDecoded = urlStringDecoded.substring(0, queryIndex); + queryStringRewriteEncoded = urlStringRewriteEncoded.substring(queryIndex + 1); + urlStringRewriteEncoded = urlStringRewriteEncoded.substring(0, queryIndex); } // Save the current context path before re-writing starts String contextPath = null; @@ -489,22 +540,24 @@ public void invoke(Request request, Response response) throws IOException, Servl // This is neither decoded nor normalized chunk.append(contextPath); } - chunk.append(URLEncoder.DEFAULT.encode(urlStringDecoded, uriCharset)); + + // 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 - urlStringDecoded = RequestUtil.normalize(urlStringDecoded); + urlStringRewriteEncoded = RequestUtil.normalize(urlStringRewriteEncoded); 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(urlStringDecoded); + chunk.append(URLDecoder.decode(urlStringRewriteEncoded, uriCharset)); // Set the new Query if there is one - if (queryStringDecoded != null) { + if (queryStringRewriteEncoded != null) { request.getCoyoteRequest().queryString().setChars(MessageBytes.EMPTY_CHAR_ARRAY, 0, 0); chunk = request.getCoyoteRequest().queryString().getCharChunk(); - chunk.append(URLEncoder.QUERY.encode(queryStringDecoded, uriCharset)); + chunk.append(REWRITE_QUERY_ENCODER.encode(queryStringRewriteEncoded, uriCharset)); if (qsa && originalQueryStringEncoded != null && !originalQueryStringEncoded.isEmpty()) { chunk.append('&'); chunk.append(originalQueryStringEncoded); @@ -799,4 +852,31 @@ protected static void parseRuleFlag(String line, RewriteRule rule, String flag) throw new IllegalArgumentException(sm.getString("rewriteValve.invalidFlags", line, flag)); } } + + + private CharSequence encodeForRewrite(CharSequence input) { + StringBuilder result = null; + int pos = 0; + int mark = 0; + while (pos < input.length()) { + char c = input.charAt(pos); + if (c == '%' || c == ';' || c == '?') { + if (result == null) { + result = new StringBuilder((int) (input.length() * 1.1)); + } + result.append(input.subSequence(mark, pos)); + result.append('%'); + result.append(Character.forDigit((c >> 4) & 0xF, 16)); + result.append(Character.forDigit(c & 0xF, 16)); + mark = pos + 1; + } + pos++; + } + if (result != null) { + result.append(input.subSequence(mark, input.length())); + return result; + } else { + return input; + } + } }
test/org/apache/catalina/valves/rewrite/TestRewriteValve.java+102 −17 modified@@ -20,6 +20,7 @@ import java.io.IOException; import java.io.PrintWriter; import java.net.HttpURLConnection; +import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.HashMap; @@ -63,7 +64,7 @@ public void testNoRewrite() throws Exception { @Test public void testBackslashPercentSign() throws Exception { - doTestRewrite("RewriteRule ^(.*) /a/\\%5A", "/", "/a/%255A"); + doTestRewrite("RewriteRule ^(.*) /a/\\%5A", "/", "/a/%5A"); } @Test @@ -142,7 +143,7 @@ public void testRewriteMap09() throws Exception { @Test public void testRewriteMap10() throws Exception { - doTestRewrite("RewriteMap lc int:escape\n" + "RewriteRule ^(.*) ${lc:$1}", "/c/a%20aa", "/c/a%2520aa"); + doTestRewrite("RewriteMap lc int:escape\n" + "RewriteRule ^(.*) ${lc:$1}", "/c/a%20aa", "/c/a%20aa"); } @Test @@ -346,16 +347,16 @@ public void testNonAsciiQueryStringAndPathAndRedirect() throws Exception { public void testNonAsciiQueryStringWithB() throws Exception { doTestRewrite("RewriteRule ^/b/(.*)/id=(.*) /c?filename=$1&id=$2 [B]", "/b/file01/id=%E5%9C%A8%E7%BA%BF%E6%B5%8B%E8%AF%95", "/c", - "filename=file01&id=%25E5%259C%25A8%25E7%25BA%25BF%25E6%25B5%258B%25E8%25AF%2595"); + "filename=file01&id=%E5%9C%A8%E7%BA%BF%E6%B5%8B%E8%AF%95"); } @Test public void testNonAsciiQueryStringAndPathAndRedirectWithB() throws Exception { // Note the double encoding of the result (httpd produces the same result) doTestRewrite("RewriteRule ^/b/(.*)/(.*)/id=(.*) /c/$1?filename=$2&id=$3 [B,R]", - "/b/%E5%9C%A8%E7%BA%BF/file01/id=%E6%B5%8B%E8%AF%95", "/c/%25E5%259C%25A8%25E7%25BA%25BF", - "filename=file01&id=%25E6%25B5%258B%25E8%25AF%2595"); + "/b/%E5%9C%A8%E7%BA%BF/file01/id=%E6%B5%8B%E8%AF%95", "/c/%E5%9C%A8%E7%BA%BF", + "filename=file01&id=%E6%B5%8B%E8%AF%95"); } @@ -371,7 +372,7 @@ public void testUtf8WithBothQsFlagsNone() throws Exception { public void testUtf8WithBothQsFlagsB() throws Exception { // Note %C2%A1 == \u00A1 doTestRewrite("RewriteRule ^/b/(.*)/(.*) /c/\u00A1$1?$2 [B]", "/b/%C2%A1/id=%C2%A1?di=%C2%AE", - "/c/%C2%A1%25C2%25A1", "id=%25C2%25A1"); + "/c/%C2%A1%C2%A1", "id=%C2%A1"); } @@ -387,7 +388,7 @@ public void testUtf8WithBothQsFlagsR() throws Exception { public void testUtf8WithBothQsFlagsRB() throws Exception { // Note %C2%A1 == \u00A1 doTestRewrite("RewriteRule ^/b/(.*)/(.*) /c/\u00A1$1?$2 [R,B]", "/b/%C2%A1/id=%C2%A1?di=%C2%AE", - "/c/%C2%A1%25C2%25A1", "id=%25C2%25A1"); + "/c/%C2%A1%C2%A1", "id=%C2%A1"); } @@ -413,7 +414,7 @@ public void testUtf8WithBothQsFlagsRBNE() throws Exception { public void testUtf8WithBothQsFlagsBQSA() throws Exception { // Note %C2%A1 == \u00A1 doTestRewrite("RewriteRule ^/b/(.*)/(.*) /c/\u00A1$1?$2 [B,QSA]", "/b/%C2%A1/id=%C2%A1?di=%C2%AE", - "/c/%C2%A1%25C2%25A1", "id=%25C2%25A1&di=%C2%AE"); + "/c/%C2%A1%C2%A1", "id=%C2%A1&di=%C2%AE"); } @@ -429,7 +430,7 @@ public void testUtf8WithBothQsFlagsRQSA() throws Exception { public void testUtf8WithBothQsFlagsRBQSA() throws Exception { // Note %C2%A1 == \u00A1 doTestRewrite("RewriteRule ^/b/(.*)/(.*) /c/\u00A1$1?$2 [R,B,QSA]", "/b/%C2%A1/id=%C2%A1?di=%C2%AE", - "/c/%C2%A1%25C2%25A1", "id=%25C2%25A1&di=%C2%AE"); + "/c/%C2%A1%C2%A1", "id=%C2%A1&di=%C2%AE"); } @@ -461,7 +462,7 @@ public void testUtf8WithOriginalQsFlagsNone() throws Exception { @Test public void testUtf8WithOriginalQsFlagsB() throws Exception { // Note %C2%A1 == \u00A1 - doTestRewrite("RewriteRule ^/b/(.*) /c/\u00A1$1 [B]", "/b/%C2%A1?id=%C2%A1", "/c/%C2%A1%25C2%25A1", + doTestRewrite("RewriteRule ^/b/(.*) /c/\u00A1$1 [B]", "/b/%C2%A1?id=%C2%A1", "/c/%C2%A1%C2%A1", "id=%C2%A1"); } @@ -476,7 +477,7 @@ public void testUtf8WithOriginalQsFlagsR() throws Exception { @Test public void testUtf8WithOriginalQsFlagsRB() throws Exception { // Note %C2%A1 == \u00A1 - doTestRewrite("RewriteRule ^/b/(.*) /c/\u00A1$1 [R,B]", "/b/%C2%A1?id=%C2%A1", "/c/%C2%A1%25C2%25A1", + doTestRewrite("RewriteRule ^/b/(.*) /c/\u00A1$1 [R,B]", "/b/%C2%A1?id=%C2%A1", "/c/%C2%A1%C2%A1", "id=%C2%A1"); } @@ -510,8 +511,8 @@ public void testUtf8WithRewriteQsFlagsNone() throws Exception { @Test public void testUtf8WithRewriteQsFlagsB() throws Exception { // Note %C2%A1 == \u00A1 - doTestRewrite("RewriteRule ^/b/(.*)/(.*) /c/\u00A1$1?$2 [B]", "/b/%C2%A1/id=%C2%A1", "/c/%C2%A1%25C2%25A1", - "id=%25C2%25A1"); + doTestRewrite("RewriteRule ^/b/(.*)/(.*) /c/\u00A1$1?$2 [B]", "/b/%C2%A1/id=%C2%A1", "/c/%C2%A1%C2%A1", + "id=%C2%A1"); } @@ -534,8 +535,8 @@ public void testUtf8WithBothQsFlagsQSA() throws Exception { @Test public void testUtf8WithRewriteQsFlagsRB() throws Exception { // Note %C2%A1 == \u00A1 - doTestRewrite("RewriteRule ^/b/(.*)/(.*) /c/\u00A1$1?$2 [R,B]", "/b/%C2%A1/id=%C2%A1", "/c/%C2%A1%25C2%25A1", - "id=%25C2%25A1"); + doTestRewrite("RewriteRule ^/b/(.*)/(.*) /c/\u00A1$1?$2 [R,B]", "/b/%C2%A1/id=%C2%A1", "/c/%C2%A1%C2%A1", + "id=%C2%A1"); } @@ -575,7 +576,7 @@ public void testUtf8FlagsNone() throws Exception { @Test public void testUtf8FlagsB() throws Exception { // Note %C2%A1 == \u00A1 - doTestRewrite("RewriteRule ^/b/(.*) /c/\u00A1$1 [B]", "/b/%C2%A1", "/c/%C2%A1%25C2%25A1"); + doTestRewrite("RewriteRule ^/b/(.*) /c/\u00A1$1 [B]", "/b/%C2%A1", "/c/%C2%A1%C2%A1"); } @@ -589,7 +590,7 @@ public void testUtf8FlagsR() throws Exception { @Test public void testUtf8FlagsRB() throws Exception { // Note %C2%A1 == \u00A1 - doTestRewrite("RewriteRule ^/b/(.*) /c/\u00A1$1 [R,B]", "/b/%C2%A1", "/c/%C2%A1%25C2%25A1"); + doTestRewrite("RewriteRule ^/b/(.*) /c/\u00A1$1 [R,B]", "/b/%C2%A1", "/c/%C2%A1%C2%A1"); } @@ -784,6 +785,7 @@ public void invoke(Request request, Response response) throws IOException, Servl rewriteValve.setConfiguration(config); Tomcat.addServlet(ctx, "snoop", new SnoopServlet()); + ctx.addServletMappingDecoded("/a/Z", "snoop"); ctx.addServletMappingDecoded("/a/%5A", "snoop"); ctx.addServletMappingDecoded("/c/*", "snoop"); ctx.addServletMappingDecoded("/W/*", "snoop"); @@ -929,4 +931,87 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws Se } } } + + + @Test + public void testEncodedUriSimple() throws Exception { + doTestRewriteWithEncoding("aaa"); + } + + + @Test + public void testEncodedUriEncodedQuestionMark01() throws Exception { + doTestRewriteWithEncoding("a%3fa"); + } + + + @Test + public void testEncodedUriEncodedQuestionMark02() throws Exception { + doTestRewriteWithEncoding("%3faa"); + } + + + @Test + public void testEncodedUriEncodedQuestionMark03() throws Exception { + doTestRewriteWithEncoding("aa%3f"); + } + + + @Test + public void testEncodedUriEncodedQuestionMarkAndQueryString() throws Exception { + doTestRewriteWithEncoding("a%3fa?b=c", "a%3fa", "b=c"); + } + + + @Test + public void testEncodedUriEncodedSemicolon01() throws Exception { + doTestRewriteWithEncoding("a%3ba"); + } + + + @Test + public void testEncodedUriEncodedSemicolon02() throws Exception { + doTestRewriteWithEncoding("%3baa"); + } + + + @Test + public void testEncodedUriEncodedSemicolon03() throws Exception { + doTestRewriteWithEncoding("aa%3b"); + } + + + private void doTestRewriteWithEncoding(String segment) throws Exception { + doTestRewriteWithEncoding(segment, segment, null); + } + + private void doTestRewriteWithEncoding(String segment, String expectedSegment, String expectedQueryString) + throws Exception { + Tomcat tomcat = getTomcatInstance(); + + // No file system docBase required + Context ctx = tomcat.addContext("", null); + + RewriteValve rewriteValve = new RewriteValve(); + tomcat.getHost().getPipeline().addValve(rewriteValve); + + rewriteValve.setConfiguration("RewriteRule ^/source/(.*)$ /target/$1"); + + Tomcat.addServlet(ctx, "snoop", new SnoopServlet()); + ctx.addServletMappingDecoded("/target/*", "snoop"); + + tomcat.start(); + + ByteChunk res = new ByteChunk(); + int rc = getUrl("http://localhost:" + getPort() + "/source/" + segment, res, false); + + Assert.assertEquals(HttpServletResponse.SC_OK, rc); + + res.setCharset(StandardCharsets.UTF_8); + String body = res.toString(); + Assert.assertTrue(body, body.contains("REQUEST-URI: /target/" + expectedSegment)); + Assert.assertTrue(body, body.contains("PATH-INFO: /" + + URLDecoder.decode(expectedSegment, StandardCharsets.UTF_8))); + Assert.assertTrue(body, body.contains("REQUEST-QUERY-STRING: " + expectedQueryString)); + } }
webapps/docs/changelog.xml+5 −0 modified@@ -161,6 +161,11 @@ <code>JsonErrorReportValve</code> equivalent to the one from <code>ErrorReportValve</code>. (remm) </fix> + <fix> + Improve the handling of <code>%nn</code> URL encoding in the + RewriteValve and document how <code>%nn</code> URL encoding may be used + with rewrite rules. (markt) + </fix> </changelog> </subsection> <subsection name="Coyote">
webapps/docs/rewrite.xml+22 −0 modified@@ -56,6 +56,28 @@ </section> +<section name="Using rewrite rules with special characters"> + + <p>The URL presented to the rewrite valve is the same URL used for request + mapping with any literal <code>'%'</code>, <code>';'</code> and/or + <code>'?'</code> characters encoded in <code>%nn</code> form.</p> + + <p>A rewrite rule that wishes to insert a literal <code>'%'</code>, + <code>';'</code>, <code>'?'</code>, <code>'&'</code> or <code>'='</code> + character should do so in <code>%nn</code> form. Other characters maybe + inserted in either literal or <code>%nn</code> form.</p> + + <p>This enables the rewrite rules to: + <ul> + <li>process URLs containing literal <code>'?'</code> characters;</li> + <li>add a query string;</li> + <li>insert a literal <code>'%'</code> character without it being confused with + <code>%nn</code> encoding.</li> + </ul> + </p> + +</section> + <section name="Directives"> <p>The rewrite.config file contains a list of directives which closely
066bf6b6a15aBetter handling of URLs with literal ';' and '?'
5 files changed · +246 −55
java/org/apache/catalina/connector/CoyoteAdapter.java+9 −10 modified@@ -653,18 +653,17 @@ protected boolean postParseRequest(org.apache.coyote.Request req, Request reques } else { /* * The URI is chars or String, and has been sent using an in-memory protocol handler. The following - * assumptions are made: - req.requestURI() has been set to the 'original' non-decoded, non-normalized - * URI - req.decodedURI() has been set to the decoded, normalized form of req.requestURI() - - * 'suspicious' URI filtering - if required - has already been performed + * assumptions are made: + * + * - req.requestURI() has been set to the 'original' non-decoded, non-normalized URI that includes path + * parameters (if any) + * + * - req.decodedURI() has been set to the decoded, normalized form of req.requestURI() with any path + * parameters removed + * + * - 'suspicious' URI filtering, if required, has already been performed */ decodedURI.toChars(); - // Remove all path parameters; any needed path parameter should be set - // using the request object rather than passing it in the URL - CharChunk uriCC = decodedURI.getCharChunk(); - int semicolon = uriCC.indexOf(';'); - if (semicolon > 0) { - decodedURI.setChars(uriCC.getBuffer(), uriCC.getStart(), semicolon); - } } }
java/org/apache/catalina/valves/rewrite/RewriteValve.java+108 −28 modified@@ -21,6 +21,7 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.io.StringReader; +import java.net.URLDecoder; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -65,6 +66,24 @@ */ public class RewriteValve extends ValveBase { + private static final URLEncoder REWRITE_DEFAULT_ENCODER; + private static final URLEncoder REWRITE_QUERY_ENCODER; + + static { + /* + * See the detailed explanation of encoding/decoding during URL re-writing in the invoke() method. + * + * These encoders perform the second stage of encoding, after re-writing has completed. These rewrite specific + * encoders treat '%' as a safe character so that URLs and query strings already processed by encodeForRewrite() + * do not end up with double encoding of '%' characters. + */ + REWRITE_DEFAULT_ENCODER = (URLEncoder) URLEncoder.DEFAULT.clone(); + REWRITE_DEFAULT_ENCODER.addSafeCharacter('%'); + + REWRITE_QUERY_ENCODER = (URLEncoder) URLEncoder.QUERY.clone(); + REWRITE_QUERY_ENCODER.addSafeCharacter('%'); + } + /** * The rewrite rules that the valve will use. */ @@ -297,22 +316,51 @@ public void invoke(Request request, Response response) throws IOException, Servl invoked.set(Boolean.TRUE); - // As long as MB isn't a char sequence or affiliated, this has to be - // converted to a string + // 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(); MessageBytes urlMB = context ? request.getRequestPathMB() : request.getDecodedRequestURIMB(); urlMB.toChars(); CharSequence urlDecoded = urlMB.getCharChunk(); + + /* + * The URL presented to the rewrite valve is the URL that is used for request mapping. That URL has been + * processed to: remove path parameters; remove the query string; decode; and normalize the URL. It may + * contain literal '%', '?' and/or ';' characters at this point. + * + * The re-write rules need to be able to process URLs with literal '?' characters and add query strings + * without the two becoming confused. The re-write rules also need to be able to insert literal '%' + * characters without them being confused with %nn encoding. + * + * The re-write rules cannot insert path parameters. + * + * To meet these requirement, the URL is processed as follows. + * + * Step 1. The URL is partially re-encoded by encodeForRewrite(). This method encodes any literal '%', ';' + * and/or '?' characters in the URL using the standard %nn form. + * + * Step 2. The re-write processing runs with the provided re-write rules against the partially encoded URL. + * If a re-write rule needs to insert a literal '%', ';' or '?', it must do so in %nn encoded form. + * + * Step 3. The URL (and query string if present) is re-encoded using the re-write specific encoders + * (REWRITE_DEFAULT_ENCODER and REWRITE_QUERY_ENCODER) that behave the same was as the standard encoders + * apart from '%' being treated as a safe character. This prevents double encoding of any '%' characters + * present in the URL from steps 1 or 2. + */ + + // Step 1. Encode URL for processing by the re-write rules. + CharSequence urlRewriteEncoded = encodeForRewrite(urlDecoded); CharSequence host = request.getServerName(); boolean rewritten = false; boolean done = false; boolean qsa = false; boolean qsd = false; boolean valveSkip = false; + + // Step 2. Process the URL using the re-write rules. for (int i = 0; i < rules.length; i++) { RewriteRule rule = rules[i]; - CharSequence test = (rule.isHost()) ? host : urlDecoded; + CharSequence test = (rule.isHost()) ? host : urlRewriteEncoded; CharSequence newtest = rule.evaluate(test, resolver); if (newtest != null && !Objects.equals(test.toString(), newtest.toString())) { if (containerLog.isTraceEnabled()) { @@ -322,7 +370,7 @@ public void invoke(Request request, Response response) throws IOException, Servl if (rule.isHost()) { host = newtest; } else { - urlDecoded = newtest; + urlRewriteEncoded = newtest; } rewritten = true; } @@ -359,28 +407,30 @@ public void invoke(Request request, Response response) throws IOException, Servl if (rule.isRedirect() && newtest != null) { // Append the query string to the url if there is one and it // hasn't been rewritten - String urlStringDecoded = urlDecoded.toString(); - int index = urlStringDecoded.indexOf('?'); - String rewrittenQueryStringDecoded; + String urlStringRewriteEncoded = urlRewriteEncoded.toString(); + int index = urlStringRewriteEncoded.indexOf('?'); + String rewrittenQueryStringRewriteEncoded; if (index == -1) { - rewrittenQueryStringDecoded = null; + rewrittenQueryStringRewriteEncoded = null; } else { - rewrittenQueryStringDecoded = urlStringDecoded.substring(index + 1); - urlStringDecoded = urlStringDecoded.substring(0, index); + rewrittenQueryStringRewriteEncoded = urlStringRewriteEncoded.substring(index + 1); + urlStringRewriteEncoded = urlStringRewriteEncoded.substring(0, index); } + // Step 3. Complete the 2nd stage to encoding. StringBuilder urlStringEncoded = - new StringBuilder(URLEncoder.DEFAULT.encode(urlStringDecoded, uriCharset)); + new StringBuilder(REWRITE_DEFAULT_ENCODER.encode(urlStringRewriteEncoded, uriCharset)); + if (!qsd && originalQueryStringEncoded != null && !originalQueryStringEncoded.isEmpty()) { - if (rewrittenQueryStringDecoded == null) { + if (rewrittenQueryStringRewriteEncoded == null) { urlStringEncoded.append('?'); urlStringEncoded.append(originalQueryStringEncoded); } else { if (qsa) { // if qsa is specified append the query urlStringEncoded.append('?'); - urlStringEncoded - .append(URLEncoder.QUERY.encode(rewrittenQueryStringDecoded, uriCharset)); + urlStringEncoded.append( + REWRITE_QUERY_ENCODER.encode(rewrittenQueryStringRewriteEncoded, uriCharset)); urlStringEncoded.append('&'); urlStringEncoded.append(originalQueryStringEncoded); } else if (index == urlStringEncoded.length() - 1) { @@ -389,13 +439,14 @@ public void invoke(Request request, Response response) throws IOException, Servl urlStringEncoded.deleteCharAt(index); } else { urlStringEncoded.append('?'); - urlStringEncoded - .append(URLEncoder.QUERY.encode(rewrittenQueryStringDecoded, uriCharset)); + urlStringEncoded.append( + REWRITE_QUERY_ENCODER.encode(rewrittenQueryStringRewriteEncoded, uriCharset)); } } - } else if (rewrittenQueryStringDecoded != null) { + } else if (rewrittenQueryStringRewriteEncoded != null) { urlStringEncoded.append('?'); - urlStringEncoded.append(URLEncoder.QUERY.encode(rewrittenQueryStringDecoded, uriCharset)); + urlStringEncoded + .append(REWRITE_QUERY_ENCODER.encode(rewrittenQueryStringRewriteEncoded, uriCharset)); } // Insert the context if @@ -470,12 +521,12 @@ public void invoke(Request request, Response response) throws IOException, Servl if (rewritten) { if (!done) { // See if we need to replace the query string - String urlStringDecoded = urlDecoded.toString(); - String queryStringDecoded = null; - int queryIndex = urlStringDecoded.indexOf('?'); + String urlStringRewriteEncoded = urlRewriteEncoded.toString(); + String queryStringRewriteEncoded = null; + int queryIndex = urlStringRewriteEncoded.indexOf('?'); if (queryIndex != -1) { - queryStringDecoded = urlStringDecoded.substring(queryIndex + 1); - urlStringDecoded = urlStringDecoded.substring(0, queryIndex); + queryStringRewriteEncoded = urlStringRewriteEncoded.substring(queryIndex + 1); + urlStringRewriteEncoded = urlStringRewriteEncoded.substring(0, queryIndex); } // Save the current context path before re-writing starts String contextPath = null; @@ -489,22 +540,24 @@ public void invoke(Request request, Response response) throws IOException, Servl // This is neither decoded nor normalized chunk.append(contextPath); } - chunk.append(URLEncoder.DEFAULT.encode(urlStringDecoded, uriCharset)); + + // 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 - urlStringDecoded = RequestUtil.normalize(urlStringDecoded); + urlStringRewriteEncoded = RequestUtil.normalize(urlStringRewriteEncoded); 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(urlStringDecoded); + chunk.append(URLDecoder.decode(urlStringRewriteEncoded, uriCharset)); // Set the new Query if there is one - if (queryStringDecoded != null) { + if (queryStringRewriteEncoded != null) { request.getCoyoteRequest().queryString().setChars(MessageBytes.EMPTY_CHAR_ARRAY, 0, 0); chunk = request.getCoyoteRequest().queryString().getCharChunk(); - chunk.append(URLEncoder.QUERY.encode(queryStringDecoded, uriCharset)); + chunk.append(REWRITE_QUERY_ENCODER.encode(queryStringRewriteEncoded, uriCharset)); if (qsa && originalQueryStringEncoded != null && !originalQueryStringEncoded.isEmpty()) { chunk.append('&'); chunk.append(originalQueryStringEncoded); @@ -799,4 +852,31 @@ protected static void parseRuleFlag(String line, RewriteRule rule, String flag) throw new IllegalArgumentException(sm.getString("rewriteValve.invalidFlags", line, flag)); } } + + + private CharSequence encodeForRewrite(CharSequence input) { + StringBuilder result = null; + int pos = 0; + int mark = 0; + while (pos < input.length()) { + char c = input.charAt(pos); + if (c == '%' || c == ';' || c == '?') { + if (result == null) { + result = new StringBuilder((int) (input.length() * 1.1)); + } + result.append(input.subSequence(mark, pos)); + result.append('%'); + result.append(Character.forDigit((c >> 4) & 0xF, 16)); + result.append(Character.forDigit(c & 0xF, 16)); + mark = pos + 1; + } + pos++; + } + if (result != null) { + result.append(input.subSequence(mark, input.length())); + return result; + } else { + return input; + } + } }
test/org/apache/catalina/valves/rewrite/TestRewriteValve.java+102 −17 modified@@ -20,6 +20,7 @@ import java.io.IOException; import java.io.PrintWriter; import java.net.HttpURLConnection; +import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.HashMap; @@ -63,7 +64,7 @@ public void testNoRewrite() throws Exception { @Test public void testBackslashPercentSign() throws Exception { - doTestRewrite("RewriteRule ^(.*) /a/\\%5A", "/", "/a/%255A"); + doTestRewrite("RewriteRule ^(.*) /a/\\%5A", "/", "/a/%5A"); } @Test @@ -142,7 +143,7 @@ public void testRewriteMap09() throws Exception { @Test public void testRewriteMap10() throws Exception { - doTestRewrite("RewriteMap lc int:escape\n" + "RewriteRule ^(.*) ${lc:$1}", "/c/a%20aa", "/c/a%2520aa"); + doTestRewrite("RewriteMap lc int:escape\n" + "RewriteRule ^(.*) ${lc:$1}", "/c/a%20aa", "/c/a%20aa"); } @Test @@ -346,16 +347,16 @@ public void testNonAsciiQueryStringAndPathAndRedirect() throws Exception { public void testNonAsciiQueryStringWithB() throws Exception { doTestRewrite("RewriteRule ^/b/(.*)/id=(.*) /c?filename=$1&id=$2 [B]", "/b/file01/id=%E5%9C%A8%E7%BA%BF%E6%B5%8B%E8%AF%95", "/c", - "filename=file01&id=%25E5%259C%25A8%25E7%25BA%25BF%25E6%25B5%258B%25E8%25AF%2595"); + "filename=file01&id=%E5%9C%A8%E7%BA%BF%E6%B5%8B%E8%AF%95"); } @Test public void testNonAsciiQueryStringAndPathAndRedirectWithB() throws Exception { // Note the double encoding of the result (httpd produces the same result) doTestRewrite("RewriteRule ^/b/(.*)/(.*)/id=(.*) /c/$1?filename=$2&id=$3 [B,R]", - "/b/%E5%9C%A8%E7%BA%BF/file01/id=%E6%B5%8B%E8%AF%95", "/c/%25E5%259C%25A8%25E7%25BA%25BF", - "filename=file01&id=%25E6%25B5%258B%25E8%25AF%2595"); + "/b/%E5%9C%A8%E7%BA%BF/file01/id=%E6%B5%8B%E8%AF%95", "/c/%E5%9C%A8%E7%BA%BF", + "filename=file01&id=%E6%B5%8B%E8%AF%95"); } @@ -371,7 +372,7 @@ public void testUtf8WithBothQsFlagsNone() throws Exception { public void testUtf8WithBothQsFlagsB() throws Exception { // Note %C2%A1 == \u00A1 doTestRewrite("RewriteRule ^/b/(.*)/(.*) /c/\u00A1$1?$2 [B]", "/b/%C2%A1/id=%C2%A1?di=%C2%AE", - "/c/%C2%A1%25C2%25A1", "id=%25C2%25A1"); + "/c/%C2%A1%C2%A1", "id=%C2%A1"); } @@ -387,7 +388,7 @@ public void testUtf8WithBothQsFlagsR() throws Exception { public void testUtf8WithBothQsFlagsRB() throws Exception { // Note %C2%A1 == \u00A1 doTestRewrite("RewriteRule ^/b/(.*)/(.*) /c/\u00A1$1?$2 [R,B]", "/b/%C2%A1/id=%C2%A1?di=%C2%AE", - "/c/%C2%A1%25C2%25A1", "id=%25C2%25A1"); + "/c/%C2%A1%C2%A1", "id=%C2%A1"); } @@ -413,7 +414,7 @@ public void testUtf8WithBothQsFlagsRBNE() throws Exception { public void testUtf8WithBothQsFlagsBQSA() throws Exception { // Note %C2%A1 == \u00A1 doTestRewrite("RewriteRule ^/b/(.*)/(.*) /c/\u00A1$1?$2 [B,QSA]", "/b/%C2%A1/id=%C2%A1?di=%C2%AE", - "/c/%C2%A1%25C2%25A1", "id=%25C2%25A1&di=%C2%AE"); + "/c/%C2%A1%C2%A1", "id=%C2%A1&di=%C2%AE"); } @@ -429,7 +430,7 @@ public void testUtf8WithBothQsFlagsRQSA() throws Exception { public void testUtf8WithBothQsFlagsRBQSA() throws Exception { // Note %C2%A1 == \u00A1 doTestRewrite("RewriteRule ^/b/(.*)/(.*) /c/\u00A1$1?$2 [R,B,QSA]", "/b/%C2%A1/id=%C2%A1?di=%C2%AE", - "/c/%C2%A1%25C2%25A1", "id=%25C2%25A1&di=%C2%AE"); + "/c/%C2%A1%C2%A1", "id=%C2%A1&di=%C2%AE"); } @@ -461,7 +462,7 @@ public void testUtf8WithOriginalQsFlagsNone() throws Exception { @Test public void testUtf8WithOriginalQsFlagsB() throws Exception { // Note %C2%A1 == \u00A1 - doTestRewrite("RewriteRule ^/b/(.*) /c/\u00A1$1 [B]", "/b/%C2%A1?id=%C2%A1", "/c/%C2%A1%25C2%25A1", + doTestRewrite("RewriteRule ^/b/(.*) /c/\u00A1$1 [B]", "/b/%C2%A1?id=%C2%A1", "/c/%C2%A1%C2%A1", "id=%C2%A1"); } @@ -476,7 +477,7 @@ public void testUtf8WithOriginalQsFlagsR() throws Exception { @Test public void testUtf8WithOriginalQsFlagsRB() throws Exception { // Note %C2%A1 == \u00A1 - doTestRewrite("RewriteRule ^/b/(.*) /c/\u00A1$1 [R,B]", "/b/%C2%A1?id=%C2%A1", "/c/%C2%A1%25C2%25A1", + doTestRewrite("RewriteRule ^/b/(.*) /c/\u00A1$1 [R,B]", "/b/%C2%A1?id=%C2%A1", "/c/%C2%A1%C2%A1", "id=%C2%A1"); } @@ -510,8 +511,8 @@ public void testUtf8WithRewriteQsFlagsNone() throws Exception { @Test public void testUtf8WithRewriteQsFlagsB() throws Exception { // Note %C2%A1 == \u00A1 - doTestRewrite("RewriteRule ^/b/(.*)/(.*) /c/\u00A1$1?$2 [B]", "/b/%C2%A1/id=%C2%A1", "/c/%C2%A1%25C2%25A1", - "id=%25C2%25A1"); + doTestRewrite("RewriteRule ^/b/(.*)/(.*) /c/\u00A1$1?$2 [B]", "/b/%C2%A1/id=%C2%A1", "/c/%C2%A1%C2%A1", + "id=%C2%A1"); } @@ -534,8 +535,8 @@ public void testUtf8WithBothQsFlagsQSA() throws Exception { @Test public void testUtf8WithRewriteQsFlagsRB() throws Exception { // Note %C2%A1 == \u00A1 - doTestRewrite("RewriteRule ^/b/(.*)/(.*) /c/\u00A1$1?$2 [R,B]", "/b/%C2%A1/id=%C2%A1", "/c/%C2%A1%25C2%25A1", - "id=%25C2%25A1"); + doTestRewrite("RewriteRule ^/b/(.*)/(.*) /c/\u00A1$1?$2 [R,B]", "/b/%C2%A1/id=%C2%A1", "/c/%C2%A1%C2%A1", + "id=%C2%A1"); } @@ -575,7 +576,7 @@ public void testUtf8FlagsNone() throws Exception { @Test public void testUtf8FlagsB() throws Exception { // Note %C2%A1 == \u00A1 - doTestRewrite("RewriteRule ^/b/(.*) /c/\u00A1$1 [B]", "/b/%C2%A1", "/c/%C2%A1%25C2%25A1"); + doTestRewrite("RewriteRule ^/b/(.*) /c/\u00A1$1 [B]", "/b/%C2%A1", "/c/%C2%A1%C2%A1"); } @@ -589,7 +590,7 @@ public void testUtf8FlagsR() throws Exception { @Test public void testUtf8FlagsRB() throws Exception { // Note %C2%A1 == \u00A1 - doTestRewrite("RewriteRule ^/b/(.*) /c/\u00A1$1 [R,B]", "/b/%C2%A1", "/c/%C2%A1%25C2%25A1"); + doTestRewrite("RewriteRule ^/b/(.*) /c/\u00A1$1 [R,B]", "/b/%C2%A1", "/c/%C2%A1%C2%A1"); } @@ -784,6 +785,7 @@ public void invoke(Request request, Response response) throws IOException, Servl rewriteValve.setConfiguration(config); Tomcat.addServlet(ctx, "snoop", new SnoopServlet()); + ctx.addServletMappingDecoded("/a/Z", "snoop"); ctx.addServletMappingDecoded("/a/%5A", "snoop"); ctx.addServletMappingDecoded("/c/*", "snoop"); ctx.addServletMappingDecoded("/W/*", "snoop"); @@ -929,4 +931,87 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws Se } } } + + + @Test + public void testEncodedUriSimple() throws Exception { + doTestRewriteWithEncoding("aaa"); + } + + + @Test + public void testEncodedUriEncodedQuestionMark01() throws Exception { + doTestRewriteWithEncoding("a%3fa"); + } + + + @Test + public void testEncodedUriEncodedQuestionMark02() throws Exception { + doTestRewriteWithEncoding("%3faa"); + } + + + @Test + public void testEncodedUriEncodedQuestionMark03() throws Exception { + doTestRewriteWithEncoding("aa%3f"); + } + + + @Test + public void testEncodedUriEncodedQuestionMarkAndQueryString() throws Exception { + doTestRewriteWithEncoding("a%3fa?b=c", "a%3fa", "b=c"); + } + + + @Test + public void testEncodedUriEncodedSemicolon01() throws Exception { + doTestRewriteWithEncoding("a%3ba"); + } + + + @Test + public void testEncodedUriEncodedSemicolon02() throws Exception { + doTestRewriteWithEncoding("%3baa"); + } + + + @Test + public void testEncodedUriEncodedSemicolon03() throws Exception { + doTestRewriteWithEncoding("aa%3b"); + } + + + private void doTestRewriteWithEncoding(String segment) throws Exception { + doTestRewriteWithEncoding(segment, segment, null); + } + + private void doTestRewriteWithEncoding(String segment, String expectedSegment, String expectedQueryString) + throws Exception { + Tomcat tomcat = getTomcatInstance(); + + // No file system docBase required + Context ctx = tomcat.addContext("", null); + + RewriteValve rewriteValve = new RewriteValve(); + tomcat.getHost().getPipeline().addValve(rewriteValve); + + rewriteValve.setConfiguration("RewriteRule ^/source/(.*)$ /target/$1"); + + Tomcat.addServlet(ctx, "snoop", new SnoopServlet()); + ctx.addServletMappingDecoded("/target/*", "snoop"); + + tomcat.start(); + + ByteChunk res = new ByteChunk(); + int rc = getUrl("http://localhost:" + getPort() + "/source/" + segment, res, false); + + Assert.assertEquals(HttpServletResponse.SC_OK, rc); + + res.setCharset(StandardCharsets.UTF_8); + String body = res.toString(); + Assert.assertTrue(body, body.contains("REQUEST-URI: /target/" + expectedSegment)); + Assert.assertTrue(body, body.contains("PATH-INFO: /" + + URLDecoder.decode(expectedSegment, StandardCharsets.UTF_8))); + Assert.assertTrue(body, body.contains("REQUEST-QUERY-STRING: " + expectedQueryString)); + } }
webapps/docs/changelog.xml+5 −0 modified@@ -161,6 +161,11 @@ <code>JsonErrorReportValve</code> equivalent to the one from <code>ErrorReportValve</code>. (remm) </fix> + <fix> + Improve the handling of <code>%nn</code> URL encoding in the + RewriteValve and document how <code>%nn</code> URL encoding may be used + with rewrite rules. (markt) + </fix> </changelog> </subsection> <subsection name="Coyote">
webapps/docs/rewrite.xml+22 −0 modified@@ -56,6 +56,28 @@ </section> +<section name="Using rewrite rules with special characters"> + + <p>The URL presented to the rewrite valve is the same URL used for request + mapping with any literal <code>'%'</code>, <code>';'</code> and/or + <code>'?'</code> characters encoded in <code>%nn</code> form.</p> + + <p>A rewrite rule that wishes to insert a literal <code>'%'</code>, + <code>';'</code>, <code>'?'</code>, <code>'&'</code> or <code>'='</code> + character should do so in <code>%nn</code> form. Other characters maybe + inserted in either literal or <code>%nn</code> form.</p> + + <p>This enables the rewrite rules to: + <ul> + <li>process URLs containing literal <code>'?'</code> characters;</li> + <li>add a query string;</li> + <li>insert a literal <code>'%'</code> character without it being confused with + <code>%nn</code> encoding.</li> + </ul> + </p> + +</section> + <section name="Directives"> <p>The rewrite.config file contains a list of directives which closely
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
12- github.com/advisories/GHSA-ff77-26x5-69crghsaADVISORY
- lists.apache.org/list.htmlghsavendor-advisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2025-31651ghsaADVISORY
- www.openwall.com/lists/oss-security/2025/04/28/3ghsaWEB
- github.com/apache/tomcat/commit/066bf6b6a15a4e7e0941d4acf096841165b97098ghsaWEB
- github.com/apache/tomcat/commit/175dc75fc428930034a6c93fb52f830d955d8e64ghsaWEB
- github.com/apache/tomcat/commit/ee3ab548e92345eca0cbd1f01649eb36c6f29454ghsaWEB
- github.com/apache/tomcat/commit/fbecc915a10c5a3d634c5e2c6ced4ff479ce9953ghsaWEB
- lists.debian.org/debian-lts-announce/2025/07/msg00009.htmlghsaWEB
- tomcat.apache.org/security-10.htmlghsaWEB
- tomcat.apache.org/security-11.htmlghsaWEB
- tomcat.apache.org/security-9.htmlghsaWEB
News mentions
0No linked articles in our index yet.