This is an automated email from the ASF dual-hosted git repository. markt pushed a commit to branch 9.0.x in repository https://gitbox.apache.org/repos/asf/tomcat.git
The following commit(s) were added to refs/heads/9.0.x by this push: new 8954758204 Add ParameterLimitValve: enforce request parameter limit per URL (#753) 8954758204 is described below commit 89547582045de4a88edb8a723040c55c74623f69 Author: Dimitrios Soumis <jimsou...@gmail.com> AuthorDate: Wed Jan 29 16:45:53 2025 +0200 Add ParameterLimitValve: enforce request parameter limit per URL (#753) Introduce ParameterLimitValve to enforce request parameter limits - Added `ParameterLimitValve`, a new valve that allows enforcing limits on the number of parameters in HTTP requests. - Supports defining per-URL pattern parameter limits using regular expressions. - Requests exceeding the configured limits are rejected with an HTTP 400 Bad Request error. - Configuration is possible through `context.xml` and `server.xml`, or via dynamic management. - Includes integration tests (`TestParameterLimitValve`) to validate enforcement behavior across different contexts. - Added documentation detailing the valveās attributes, configuration options, and usage examples. --- java/org/apache/catalina/connector/Request.java | 27 +- .../apache/catalina/valves/LocalStrings.properties | 6 + .../catalina/valves/ParameterLimitValve.java | 269 +++++++++++++ .../servlet/ServletRequestParametersBaseTest.java | 90 +++++ .../catalina/session/TestPersistentManager.java | 2 +- .../catalina/valves/TestParameterLimitValve.java | 437 +++++++++++++++++++++ test/org/apache/catalina/valves/TestSSLValve.java | 68 +++- webapps/docs/changelog.xml | 5 + webapps/docs/config/valve.xml | 43 ++ 9 files changed, 927 insertions(+), 20 deletions(-) diff --git a/java/org/apache/catalina/connector/Request.java b/java/org/apache/catalina/connector/Request.java index 46da139173..c717371a5a 100644 --- a/java/org/apache/catalina/connector/Request.java +++ b/java/org/apache/catalina/connector/Request.java @@ -144,6 +144,10 @@ public class Request implements HttpServletRequest { public Request(Connector connector) { this.connector = connector; + if (connector != null) { + this.maxParameterCount = connector.getMaxParameterCount(); + } + formats = new SimpleDateFormat[formatsTemplate.length]; for (int i = 0; i < formats.length; i++) { formats[i] = (SimpleDateFormat) formatsTemplate[i].clone(); @@ -440,6 +444,10 @@ public class Request implements HttpServletRequest { private HttpServletRequest applicationRequest = null; + /** + * The maximum number of request parameters + */ + private int maxParameterCount = -1; // --------------------------------------------------------- Public Methods @@ -470,6 +478,11 @@ public class Request implements HttpServletRequest { userPrincipal = null; subject = null; parametersParsed = false; + if (connector != null) { + maxParameterCount = connector.getMaxParameterCount(); + } else { + maxParameterCount = -1; + } if (parts != null) { for (Part part : parts) { try { @@ -857,6 +870,14 @@ public class Request implements HttpServletRequest { coyoteRequest.setServerPort(port); } + /** + * Set the maximum number of request parameters (GET plus POST) for a single request + * + * @param maxParameterCount The maximum number of request parameters + */ + public void setMaxParameterCount(int maxParameterCount) { + this.maxParameterCount = maxParameterCount; + } // ------------------------------------------------- ServletRequest Methods @@ -929,8 +950,8 @@ public class Request implements HttpServletRequest { * {@inheritDoc} * <p> * The attribute names returned will only be those for the attributes set via {@link #setAttribute(String, Object)}. - * Tomcat internal attributes will not be included even though they are accessible via {@link #getAttribute(String)}. - * The Tomcat internal attributes include: + * Tomcat internal attributes will not be included even though they are accessible via + * {@link #getAttribute(String)}. The Tomcat internal attributes include: * <ul> * <li>{@link Globals#DISPATCHER_TYPE_ATTR}</li> * <li>{@link Globals#DISPATCHER_REQUEST_PATH_ATTR}</li> @@ -2561,7 +2582,6 @@ public class Request implements HttpServletRequest { } } - int maxParameterCount = getConnector().getMaxParameterCount(); Parameters parameters = coyoteRequest.getParameters(); parameters.setLimit(maxParameterCount); @@ -2916,7 +2936,6 @@ public class Request implements HttpServletRequest { boolean success = false; try { // Set this every time in case limit has been changed via JMX - int maxParameterCount = getConnector().getMaxParameterCount(); if (parts != null && maxParameterCount > 0) { maxParameterCount -= parts.size(); } diff --git a/java/org/apache/catalina/valves/LocalStrings.properties b/java/org/apache/catalina/valves/LocalStrings.properties index 8cae3fbc36..fb62ba75a6 100644 --- a/java/org/apache/catalina/valves/LocalStrings.properties +++ b/java/org/apache/catalina/valves/LocalStrings.properties @@ -166,3 +166,9 @@ stuckThreadDetectionValve.interrupted=Thread interrupted after the request is fi stuckThreadDetectionValve.notifyStuckThreadCompleted=Thread [{0}] (id=[{3}]) was previously reported to be stuck but has completed. It was active for approximately [{1}] milliseconds.{2,choice,0#|0< There is/are still [{2}] thread(s) that are monitored by this Valve and may be stuck.} stuckThreadDetectionValve.notifyStuckThreadDetected=Thread [{0}] (id=[{6}]) has been active for [{1}] milliseconds (since [{2}]) to serve the same request for [{4}] and may be stuck (configured threshold for this StuckThreadDetectionValve is [{5}] seconds). There is/are [{3}] thread(s) in total that are monitored by this Valve and may be stuck. stuckThreadDetectionValve.notifyStuckThreadInterrupted=Thread [{0}] (id=[{5}]) has been interrupted because it was active for [{1}] milliseconds (since [{2}]) to serve the same request for [{3}] and was probably stuck (configured interruption threshold for this StuckThreadDetectionValve is [{4}] seconds). + +parameterLimitValve.closeError=Error closing configuration +parameterLimitValve.invalidLine=Each line must contain at least one '=' character. Invalid line [{0}] +parameterLimitValve.noConfiguration=No configuration resource found [{0}] +parameterLimitValve.readConfiguration=Read configuration from [/WEB-INF/{0}] +parameterLimitValve.readError=Error reading configuration \ No newline at end of file diff --git a/java/org/apache/catalina/valves/ParameterLimitValve.java b/java/org/apache/catalina/valves/ParameterLimitValve.java new file mode 100644 index 0000000000..0b511ffc1b --- /dev/null +++ b/java/org/apache/catalina/valves/ParameterLimitValve.java @@ -0,0 +1,269 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.catalina.valves; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; + +import javax.servlet.ServletException; + +import org.apache.catalina.Container; +import org.apache.catalina.Context; +import org.apache.catalina.LifecycleException; +import org.apache.catalina.connector.Request; +import org.apache.catalina.connector.Response; +import org.apache.juli.logging.LogFactory; +import org.apache.tomcat.util.buf.UDecoder; +import org.apache.tomcat.util.file.ConfigFileLoader; +import org.apache.tomcat.util.file.ConfigurationSource; + + +/** + * This is a concrete implementation of {@link ValveBase} that enforces a limit on the number of HTTP request + * parameters. The features of this implementation include: + * <ul> + * <li>URL-specific parameter limits that can be defined using regular expressions</li> + * <li>Configurable through Tomcat's <code>server.xml</code> or <code>context.xml</code></li> + * <li>Requires a <code>parameter_limit.config</code> file containing the URL-specific parameter limits. It must be + * placed in the Host configuration folder or in the WEB-INF folder of the web application.</li> + * </ul> + * <p> + * The default limit, specified by Connector's value, applies to all requests unless a more specific URL pattern is + * matched. URL patterns and their corresponding limits can be configured via a regular expression mapping through the + * <code>urlPatternLimits</code> attribute. + * </p> + * <p> + * The Valve checks each incoming request and enforces the appropriate limit. If a request exceeds the allowed number of + * parameters, a <code>400 Bad Request</code> response is returned. + * </p> + * <p> + * Example, configuration in <code>context.xml</code>: + * + * <pre> + * {@code + * <Context> + * <Valve className="org.apache.catalina.valves.ParameterLimitValve" + * </Context> + * } + * and in <code>parameter_limit.config</code>: + * </pre> + * + * <pre> + * {@code + * /api/.*=150 + * /admin/.*=50 + * } + * </pre> + * <p> + * The configuration allows for flexible control over different sections of your application, such as applying higher + * limits for API endpoints and stricter limits for admin areas. + * </p> + * + * @author Dimitris Soumis + */ + +public class ParameterLimitValve extends ValveBase { + + /** + * Map for URL-specific limits + */ + protected Map<Pattern,Integer> urlPatternLimits = new ConcurrentHashMap<>(); + + /** + * Relative path to the configuration file. Note: If the valve's container is a context, this will be relative to + * /WEB-INF/. + */ + protected String resourcePath = "parameter_limit.config"; + + /** + * Will be set to true if the valve is associated with a context. + */ + protected boolean context = false; + + public ParameterLimitValve() { + super(true); + } + + public String getResourcePath() { + return resourcePath; + } + + public void setResourcePath(String resourcePath) { + this.resourcePath = resourcePath; + } + + @Override + protected void initInternal() throws LifecycleException { + super.initInternal(); + containerLog = LogFactory.getLog(getContainer().getLogName() + ".parameterLimit"); + } + + @Override + protected void startInternal() throws LifecycleException { + + super.startInternal(); + + InputStream is = null; + + // Process configuration file for this valve + if (getContainer() instanceof Context) { + context = true; + String webInfResourcePath = "/WEB-INF/" + resourcePath; + is = ((Context) getContainer()).getServletContext().getResourceAsStream(webInfResourcePath); + if (containerLog.isDebugEnabled()) { + if (is == null) { + containerLog.debug(sm.getString("parameterLimitValve.noConfiguration", webInfResourcePath)); + } else { + containerLog.debug(sm.getString("parameterLimitValve.readConfiguration", webInfResourcePath)); + } + } + } else { + String resourceName = Container.getConfigPath(getContainer(), resourcePath); + try { + ConfigurationSource.Resource resource = ConfigFileLoader.getSource().getResource(resourceName); + is = resource.getInputStream(); + } catch (IOException e) { + if (containerLog.isDebugEnabled()) { + containerLog.debug(sm.getString("parameterLimitValve.noConfiguration", resourceName), e); + } + } + } + + if (is == null) { + // Will use management operations to configure the valve dynamically + return; + } + + try (InputStreamReader isr = new InputStreamReader(is, StandardCharsets.UTF_8); + BufferedReader reader = new BufferedReader(isr)) { + setUrlPatternLimits(reader); + } catch (IOException ioe) { + containerLog.error(sm.getString("parameterLimitValve.closeError"), ioe); + } finally { + try { + is.close(); + } catch (IOException e) { + containerLog.error(sm.getString("parameterLimitValve.closeError"), e); + } + } + + } + + public void setUrlPatternLimits(String urlPatternConfig) { + urlPatternLimits.clear(); + setUrlPatternLimits(new BufferedReader(new StringReader(urlPatternConfig))); + } + + /** + * Set the mapping of URL patterns to their corresponding parameter limits. The input should be provided line by + * line, where each line contains a pattern and a limit, separated by the last '='. + * <p> + * Example: + * + * <pre> + * /api/.*=50 + * /api======/.*=150 + * /urlEncoded%20api=2 + * # This is a comment + * </pre> + * + * @param reader A BufferedReader containing URL pattern to parameter limit mappings, with each pair on a separate + * line. + */ + public void setUrlPatternLimits(BufferedReader reader) { + if (containerLog == null && getContainer() != null) { + containerLog = LogFactory.getLog(getContainer().getLogName() + ".parameterLimit"); + } + try { + String line; + while ((line = reader.readLine()) != null) { + // Trim whitespace from the line + line = line.trim(); + if (line.isEmpty() || line.startsWith("#")) { + // Skip empty lines or comments + continue; + } + + int lastEqualsIndex = line.lastIndexOf('='); + if (lastEqualsIndex == -1) { + throw new IllegalArgumentException(sm.getString("parameterLimitValve.invalidLine", line)); + } + + String patternString = line.substring(0, lastEqualsIndex).trim(); + String limitString = line.substring(lastEqualsIndex + 1).trim(); + + Pattern pattern = Pattern.compile(UDecoder.URLDecode(patternString, StandardCharsets.UTF_8)); + int limit = Integer.parseInt(limitString); + if (containerLog != null && containerLog.isTraceEnabled()) { + containerLog.trace("Add pattern " + pattern + " and limit " + limit); + } + urlPatternLimits.put(pattern, Integer.valueOf(limit)); + } + } catch (IOException e) { + containerLog.error(sm.getString("parameterLimitValve.readError"), e); + } + } + + @Override + protected void stopInternal() throws LifecycleException { + super.stopInternal(); + urlPatternLimits.clear(); + } + + /** + * Checks if any of the defined patterns matches the URI of the request and if it does, enforces the corresponding + * parameter limit for the request. Then invoke the next Valve in the sequence. + * + * @param request The servlet request to be processed + * @param response The servlet response to be created + * + * @exception IOException if an input/output error occurs + * @exception ServletException if a servlet error occurs + */ + @Override + public void invoke(Request request, Response response) throws IOException, ServletException { + + if (urlPatternLimits == null || urlPatternLimits.isEmpty()) { + getNext().invoke(request, response); + return; + } + + String requestURI = context ? request.getRequestPathMB().toString() : request.getDecodedRequestURI(); + + // Iterate over the URL patterns and apply corresponding limits + for (Map.Entry<Pattern,Integer> entry : urlPatternLimits.entrySet()) { + Pattern pattern = entry.getKey(); + int limit = entry.getValue().intValue(); + + if (pattern.matcher(requestURI).matches()) { + request.setMaxParameterCount(limit); + break; + } + } + + // Invoke the next valve to continue processing the request + getNext().invoke(request, response); + } + +} diff --git a/test/javax/servlet/ServletRequestParametersBaseTest.java b/test/javax/servlet/ServletRequestParametersBaseTest.java new file mode 100644 index 0000000000..17bd42ac46 --- /dev/null +++ b/test/javax/servlet/ServletRequestParametersBaseTest.java @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package javax.servlet; + +import java.io.IOException; +import java.io.PrintWriter; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.catalina.startup.SimpleHttpClient; +import org.apache.catalina.startup.TomcatBaseTest; + +public class ServletRequestParametersBaseTest extends TomcatBaseTest { + + protected Map<String,List<String>> parseReportedParameters(SimpleHttpClient client) { + Map<String,List<String>> parameters = new LinkedHashMap<>(); + if (client.isResponse200()) { + // Response is written using "\n" so need to split on that. + String[] lines = client.getResponseBody().split("\n"); + for (String line : lines) { + // Every line should be name=value + int equalsPos = line.indexOf('='); + String name = line.substring(0, equalsPos); + String value = line.substring(equalsPos + 1); + + List<String> values = parameters.computeIfAbsent(name, k -> new ArrayList<>()); + values.add(value); + } + } + return parameters; + } + + + protected static class ParameterParsingServlet extends HttpServlet { + + private static final long serialVersionUID = 1L; + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + + resp.setContentType("text/plain"); + resp.setCharacterEncoding("UTF-8"); + PrintWriter pw = resp.getWriter(); + + Enumeration<String> names = req.getParameterNames(); + while (names.hasMoreElements()) { + String name = names.nextElement(); + for (String value : req.getParameterValues(name)) { + pw.print(name + "=" + value + '\n'); + } + } + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { + // Required parameter processing is the same as for GET + doGet(req, resp); + } + } + + + protected static class TestParameterClient extends SimpleHttpClient { + + @Override + public boolean isResponseBodyOK() { + return true; + } + } +} diff --git a/test/org/apache/catalina/session/TestPersistentManager.java b/test/org/apache/catalina/session/TestPersistentManager.java index 4aa69185d7..9d60256ca3 100644 --- a/test/org/apache/catalina/session/TestPersistentManager.java +++ b/test/org/apache/catalina/session/TestPersistentManager.java @@ -108,6 +108,7 @@ public class TestPersistentManager { context.setParent(host); Connector connector = EasyMock.createNiceMock(Connector.class); + EasyMock.replay(connector); Request req = new Request(connector) { @Override public Context getContext() { @@ -116,7 +117,6 @@ public class TestPersistentManager { }; req.setRequestedSessionId("invalidSession"); HttpServletRequest request = new RequestFacade(req); - EasyMock.replay(connector); requestCachingSessionListener.request = request; manager.setContext(context); diff --git a/test/org/apache/catalina/valves/TestParameterLimitValve.java b/test/org/apache/catalina/valves/TestParameterLimitValve.java new file mode 100644 index 0000000000..a2e5bb5afc --- /dev/null +++ b/test/org/apache/catalina/valves/TestParameterLimitValve.java @@ -0,0 +1,437 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.catalina.valves; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; + +import javax.servlet.ServletRequestParametersBaseTest; + +import org.junit.Assert; +import org.junit.Test; + +import static org.apache.catalina.startup.SimpleHttpClient.CRLF; +import org.apache.catalina.Context; +import org.apache.catalina.core.StandardContext; +import org.apache.catalina.filters.FailedRequestFilter; +import org.apache.catalina.startup.Tomcat; +import org.apache.tomcat.util.buf.ByteChunk; +import org.apache.tomcat.util.descriptor.web.FilterDef; +import org.apache.tomcat.util.descriptor.web.FilterMap; +import org.apache.tomcat.util.scan.StandardJarScanner; + + +public class TestParameterLimitValve extends ServletRequestParametersBaseTest { + + @Test + public void testSpecificUrlPatternLimit() throws Exception { + Tomcat tomcat = getTomcatInstance(); + + StandardContext ctx = (StandardContext) getProgrammaticRootContext(); + + ParameterLimitValve parameterLimitValve = new ParameterLimitValve(); + ctx.getPipeline().addValve(parameterLimitValve); + parameterLimitValve.setUrlPatternLimits("/special/.*=2"); + + Tomcat.addServlet(ctx, "snoop", new SnoopServlet()); + ctx.addServletMappingDecoded("/special/endpoint", "snoop"); + + addFailedRequestFilter(ctx); + + tomcat.start(); + + int rc = getUrl("http://localhost:" + getPort() + "/special/endpoint?param1=value1¶m2=value2¶m3=value3", + new ByteChunk(), null); + Assert.assertEquals(400, rc); + + rc = getUrl("http://localhost:" + getPort() + "/special/endpoint?param1=value1¶m2=value2", new ByteChunk(), + null); + Assert.assertEquals(200, rc); + + byte[] body = ("POST / HTTP/1.1" + CRLF + "Host: localhost:" + getPort() + CRLF + "Connection: close" + CRLF + + "Transfer-Encoding: chunked" + CRLF + "Content-Type: application/x-www-form-urlencoded" + CRLF + CRLF + + "param1=value1¶m2=value2¶m3=value3" + CRLF).getBytes(StandardCharsets.UTF_8); + + rc = postUrl(body, "http://localhost:" + getPort() + "/special/endpoint", new ByteChunk(), null); + + Assert.assertEquals(400, rc); + + body = ("POST / HTTP/1.1" + CRLF + "Host: localhost:" + getPort() + CRLF + "Connection: close" + CRLF + + "Transfer-Encoding: chunked" + CRLF + "Content-Type: application/x-www-form-urlencoded" + CRLF + CRLF + + "param1=value1¶m2=value2" + CRLF).getBytes(StandardCharsets.UTF_8); + + rc = postUrl(body, "http://localhost:" + getPort() + "/special/endpoint", new ByteChunk(), null); + + Assert.assertEquals(200, rc); + + body = ("POST / HTTP/1.1" + CRLF + "Host: localhost:" + getPort() + CRLF + "Connection: close" + CRLF + + "Transfer-Encoding: chunked" + CRLF + "Content-Type: application/x-www-form-urlencoded" + CRLF + CRLF + + "param1=value1¶m2=value2" + CRLF).getBytes(StandardCharsets.UTF_8); + + rc = postUrl(body, "http://localhost:" + getPort() + "/special/endpoint?param3=value3", new ByteChunk(), null); + + Assert.assertEquals(400, rc); + + body = ("POST / HTTP/1.1" + CRLF + "Host: localhost:" + getPort() + CRLF + "Connection: close" + CRLF + + "Transfer-Encoding: chunked" + CRLF + "Content-Type: application/x-www-form-urlencoded" + CRLF + CRLF + + "param1=value1" + CRLF).getBytes(StandardCharsets.UTF_8); + + rc = postUrl(body, "http://localhost:" + getPort() + "/special/endpoint?param2=value2", new ByteChunk(), null); + + Assert.assertEquals(200, rc); + + rc = getUrl("http://localhost:" + getPort() + "/special/endpoint", new ByteChunk(), null); + + Assert.assertEquals(200, rc); + } + + @Test + public void testMultipleEqualsPatternLimit() throws Exception { + Tomcat tomcat = getTomcatInstance(); + + StandardContext ctx = (StandardContext) getProgrammaticRootContext(); + + ParameterLimitValve parameterLimitValve = new ParameterLimitValve(); + ctx.getPipeline().addValve(parameterLimitValve); + parameterLimitValve.setUrlPatternLimits("/special====2"); + + Tomcat.addServlet(ctx, "snoop", new SnoopServlet()); + ctx.addServletMappingDecoded("/special===", "snoop"); + + addFailedRequestFilter(ctx); + + tomcat.start(); + + int rc = getUrl("http://localhost:" + getPort() + "/special===?param1=value1¶m2=value2", new ByteChunk(), + null); + + Assert.assertEquals(200, rc); + } + + @Test + public void testEncodedUrlPatternLimit() throws Exception { + Tomcat tomcat = getTomcatInstance(); + + StandardContext ctx = (StandardContext) getProgrammaticRootContext(); + + ParameterLimitValve parameterLimitValve = new ParameterLimitValve(); + ctx.getPipeline().addValve(parameterLimitValve); + parameterLimitValve.setUrlPatternLimits("/special%20endpoint=2"); + + Tomcat.addServlet(ctx, "snoop", new SnoopServlet()); + ctx.addServletMappingDecoded("/special endpoint", "snoop"); + + addFailedRequestFilter(ctx); + + tomcat.start(); + + int rc = getUrl( + "http://localhost:" + getPort() + "/special%20endpoint?param1=value1¶m2=value2¶m3=value3", + new ByteChunk(), null); + + Assert.assertEquals(400, rc); + } + + @Test + public void testMultipleSpecificUrlPatternsLimit() throws Exception { + Tomcat tomcat = getTomcatInstance(); + + tomcat.getConnector().setMaxParameterCount(2); + + StandardContext ctx = (StandardContext) getProgrammaticRootContext(); + + ParameterLimitValve parameterLimitValve = new ParameterLimitValve(); + ctx.getPipeline().addValve(parameterLimitValve); + parameterLimitValve + .setUrlPatternLimits("/special/.*=2" + CRLF + "/special2/.*=3" + CRLF + "/my/special/url1=1"); + + Tomcat.addServlet(ctx, "snoop", new SnoopServlet()); + ctx.addServletMappingDecoded("/special/endpoint", "snoop"); + ctx.addServletMappingDecoded("/special2/endpoint", "snoop"); + ctx.addServletMappingDecoded("/my/special/url1", "snoop"); + ctx.addServletMappingDecoded("/my/special/url2", "snoop"); + + addFailedRequestFilter(ctx); + + tomcat.start(); + + int rc = getUrl("http://localhost:" + getPort() + "/special/endpoint?param1=value1¶m2=value2¶m3=value3", + new ByteChunk(), null); + Assert.assertEquals(400, rc); + + rc = getUrl( + "http://localhost:" + getPort() + + "/special2/endpoint?param1=value1¶m2=value2¶m3=value3¶m4=value4", + new ByteChunk(), null); + Assert.assertEquals(400, rc); + + rc = getUrl("http://localhost:" + getPort() + "/my/special/url1?param1=value1¶m2=value2", new ByteChunk(), + null); + Assert.assertEquals(400, rc); + + rc = getUrl("http://localhost:" + getPort() + "/my/special/url2?param1=value1¶m2=value2¶m3=value3", + new ByteChunk(), null); + Assert.assertEquals(400, rc); + + rc = getUrl("http://localhost:" + getPort() + "/special/endpoint?param1=value1¶m2=value2", new ByteChunk(), + null); + Assert.assertEquals(200, rc); + + rc = getUrl("http://localhost:" + getPort() + "/special2/endpoint?param1=value1¶m2=value2¶m3=value3", + new ByteChunk(), null); + Assert.assertEquals(200, rc); + + rc = getUrl("http://localhost:" + getPort() + "/my/special/url1?param1=value1", new ByteChunk(), null); + Assert.assertEquals(200, rc); + + rc = getUrl("http://localhost:" + getPort() + "/my/special/url2?param1=value1¶m2=value2", new ByteChunk(), + null); + Assert.assertEquals(200, rc); + } + + @Test + public void testNoMatchingPatternWithConnectorLimit() throws Exception { + Tomcat tomcat = getTomcatInstance(); + + tomcat.getConnector().setMaxParameterCount(1); + + StandardContext ctx = (StandardContext) getProgrammaticRootContext(); + + ParameterLimitValve parameterLimitValve = new ParameterLimitValve(); + ctx.getPipeline().addValve(parameterLimitValve); + parameterLimitValve.setUrlPatternLimits("/special/.*=2"); + + Tomcat.addServlet(ctx, "snoop", new SnoopServlet()); + ctx.addServletMappingDecoded("/other/endpoint", "snoop"); + + addFailedRequestFilter(ctx); + + tomcat.start(); + + int rc = getUrl("http://localhost:" + getPort() + "/other/endpoint?param1=value1¶m2=value2", + new ByteChunk(), null); + + Assert.assertEquals(400, rc); + } + + @Test + public void testUrlPatternLimitsFromFile() throws Exception { + File configFile = File.createTempFile("parameter_limit", ".config"); + try (PrintWriter writer = new PrintWriter(new FileWriter(configFile))) { + writer.println("# Commented line"); + writer.println("/api/.*=2"); + writer.println("# Commented line"); + } + + Tomcat tomcat = getTomcatInstance(); + StandardContext ctx = (StandardContext) getProgrammaticRootContext(); + + ParameterLimitValve parameterLimitValve = new ParameterLimitValve(); + ctx.getPipeline().addValve(parameterLimitValve); + + try (BufferedReader reader = new BufferedReader(new FileReader(configFile))) { + parameterLimitValve.setUrlPatternLimits(reader); + } + + Tomcat.addServlet(ctx, "snoop", new SnoopServlet()); + ctx.addServletMappingDecoded("/api/test", "snoop"); + + addFailedRequestFilter(ctx); + + tomcat.start(); + + int rc = getUrl("http://localhost:" + getPort() + "/api/test?param1=value1¶m2=value2", new ByteChunk(), + null); + Assert.assertEquals(200, rc); + + rc = getUrl("http://localhost:" + getPort() + "/api/test?param1=value1¶m2=value2¶m3=value3", + new ByteChunk(), null); + Assert.assertEquals(400, rc); + } + + @Test + public void testUrlPatternLimitsWithEmptyFile() throws Exception { + File configFile = File.createTempFile("parameter_limit", ".config"); + + Tomcat tomcat = getTomcatInstance(); + StandardContext ctx = (StandardContext) getProgrammaticRootContext(); + + ParameterLimitValve parameterLimitValve = new ParameterLimitValve(); + ctx.getPipeline().addValve(parameterLimitValve); + + try (BufferedReader reader = new BufferedReader(new FileReader(configFile))) { + parameterLimitValve.setUrlPatternLimits(reader); + } + + Tomcat.addServlet(ctx, "snoop", new SnoopServlet()); + ctx.addServletMappingDecoded("/api/test", "snoop"); + + addFailedRequestFilter(ctx); + + tomcat.start(); + + int rc = getUrl("http://localhost:" + getPort() + "/api/test?param1=value1¶m2=value2", new ByteChunk(), + null); + Assert.assertEquals(200, rc); + } + + @Test + public void testUrlPatternLimitsFromFileAndProperty() throws Exception { + File configFile = File.createTempFile("parameter_limit", ".config"); + try (PrintWriter writer = new PrintWriter(new FileWriter(configFile))) { + writer.println("# Commented line"); + writer.println("/api/.*=2"); + writer.println("# Commented line"); + } + + Tomcat tomcat = getTomcatInstance(); + StandardContext ctx = (StandardContext) getProgrammaticRootContext(); + + ParameterLimitValve parameterLimitValve = new ParameterLimitValve(); + ctx.getPipeline().addValve(parameterLimitValve); + + parameterLimitValve.setUrlPatternLimits("/admin/.*=2"); + + try (BufferedReader reader = new BufferedReader(new FileReader(configFile))) { + parameterLimitValve.setUrlPatternLimits(reader); + } + + Tomcat.addServlet(ctx, "snoop", new SnoopServlet()); + ctx.addServletMappingDecoded("/api/test", "snoop"); + ctx.addServletMappingDecoded("/admin/test", "snoop"); + + addFailedRequestFilter(ctx); + + tomcat.start(); + + int rc = getUrl("http://localhost:" + getPort() + "/api/test?param1=value1¶m2=value2", new ByteChunk(), + null); + Assert.assertEquals(200, rc); + + rc = getUrl("http://localhost:" + getPort() + "/admin/test?param1=value1¶m2=value2", new ByteChunk(), null); + Assert.assertEquals(200, rc); + + rc = getUrl("http://localhost:" + getPort() + "/api/test?param1=value1¶m2=value2¶m3=value3", + new ByteChunk(), null); + Assert.assertEquals(400, rc); + + rc = getUrl("http://localhost:" + getPort() + "/admin/test?param1=value1¶m2=value2¶m3=value3", + new ByteChunk(), null); + Assert.assertEquals(400, rc); + } + + @Test + public void testServerUrlPatternLimit() throws Exception { + Tomcat tomcat = getTomcatInstance(); + + StandardContext ctx = (StandardContext) getProgrammaticRootContext(); + + ParameterLimitValve parameterLimitValve = new ParameterLimitValve(); + ctx.getParent().getPipeline().addValve(parameterLimitValve); + parameterLimitValve.setUrlPatternLimits("/.*=2"); + + Tomcat.addServlet(ctx, "snoop", new SnoopServlet()); + ctx.addServletMappingDecoded("/special/endpoint", "snoop"); + + addFailedRequestFilter(ctx); + + tomcat.start(); + + int rc = getUrl("http://localhost:" + getPort() + "/special/endpoint?param1=value1¶m2=value2¶m3=value3", + new ByteChunk(), null); + + Assert.assertEquals(400, rc); + + rc = getUrl("http://localhost:" + getPort() + "/special/endpoint?param1=value1¶m2=value2", new ByteChunk(), + null); + + Assert.assertEquals(200, rc); + } + + @Test + public void testServerAndContextUrlPatternLimit() throws Exception { + Tomcat tomcat = getTomcatInstance(); + + Context ctx1 = tomcat.addContext("context1", null); + ((StandardJarScanner) ctx1.getJarScanner()).setScanClassPath(false); + + Context ctx2 = tomcat.addContext("context2", null); + ((StandardJarScanner) ctx2.getJarScanner()).setScanClassPath(false); + + Context ctx3 = tomcat.addContext("context3", null); + ((StandardJarScanner) ctx2.getJarScanner()).setScanClassPath(false); + + ParameterLimitValve serverParameterLimitValve = new ParameterLimitValve(); + ParameterLimitValve contextParameterLimitValve = new ParameterLimitValve(); + ParameterLimitValve context3ParameterLimitValve = new ParameterLimitValve(); + + ctx1.getParent().getPipeline().addValve(serverParameterLimitValve); + + ctx1.getPipeline().addValve(contextParameterLimitValve); + ctx3.getPipeline().addValve(context3ParameterLimitValve); + + serverParameterLimitValve.setUrlPatternLimits("/.*=2"); + contextParameterLimitValve.setUrlPatternLimits("/special/.*=3"); + context3ParameterLimitValve.setUrlPatternLimits("/special/.*=1"); + + Tomcat.addServlet(ctx1, "snoop", new SnoopServlet()); + ctx1.addServletMappingDecoded("/special/endpoint", "snoop"); + + Tomcat.addServlet(ctx2, "snoop", new SnoopServlet()); + ctx2.addServletMappingDecoded("/special/endpoint", "snoop"); + + Tomcat.addServlet(ctx3, "snoop", new SnoopServlet()); + ctx3.addServletMappingDecoded("/special/endpoint", "snoop"); + + addFailedRequestFilter(ctx1); + addFailedRequestFilter(ctx2); + addFailedRequestFilter(ctx3); + + tomcat.start(); + + int rc = getUrl("http://localhost:" + getPort() + + "/context1/special/endpoint?param1=value1¶m2=value2¶m3=value3", new ByteChunk(), null); + + Assert.assertEquals(200, rc); + + rc = getUrl("http://localhost:" + getPort() + + "/context2/special/endpoint?param1=value1¶m2=value2¶m3=value3", new ByteChunk(), null); + + Assert.assertEquals(400, rc); + + rc = getUrl("http://localhost:" + getPort() + "/context3/special/endpoint?param1=value1¶m2=value2", + new ByteChunk(), null); + + Assert.assertEquals(400, rc); + } + + + private static void addFailedRequestFilter(Context context) { + FilterDef failedRequestFilter = new FilterDef(); + failedRequestFilter.setFilterName("failedRequestFilter"); + failedRequestFilter.setFilterClass(FailedRequestFilter.class.getName()); + FilterMap failedRequestFilterMap = new FilterMap(); + failedRequestFilterMap.setFilterName("failedRequestFilter"); + failedRequestFilterMap.addURLPatternDecoded("/*"); + context.addFilterDef(failedRequestFilter); + context.addFilterMap(failedRequestFilterMap); + } +} diff --git a/test/org/apache/catalina/valves/TestSSLValve.java b/test/org/apache/catalina/valves/TestSSLValve.java index 3d8df7bd01..ddd00e9736 100644 --- a/test/org/apache/catalina/valves/TestSSLValve.java +++ b/test/org/apache/catalina/valves/TestSSLValve.java @@ -21,7 +21,6 @@ import java.util.Arrays; import java.util.logging.Level; import org.junit.Assert; -import org.junit.Before; import org.junit.Test; import org.apache.catalina.Globals; @@ -36,11 +35,23 @@ public class TestSSLValve { public static class MockRequest extends Request { - public MockRequest() { - super(EasyMock.createMock(Connector.class)); + private static MockRequest single_instance = null; + + public MockRequest(Connector connector) { + super(connector); setCoyoteRequest(new org.apache.coyote.Request()); } + public static MockRequest getInstance() { + if (single_instance == null) { + Connector connector = EasyMock.createNiceMock(Connector.class); + EasyMock.replay(connector); + single_instance = new MockRequest(connector); + } + + return single_instance; + } + @Override public void setAttribute(String name, Object value) { getCoyoteRequest().getAttributes().put(name, value); @@ -91,22 +102,30 @@ public class TestSSLValve { "yoTBqEpJloWksrypqp3iL4PAL5+KkB2zp66+MVAg8LcEDFJggBBJCtv4SCWV7ZOB", "WLu8gep+XCwSn0Wb6D3eFs4DoIiMvQ6g2rS/pk7o5eWj", "-----END CERTIFICATE-----" }; - private SSLValve valve = new SSLValve(); - - private MockRequest mockRequest = new MockRequest(); - private Valve mockNext = EasyMock.createMock(Valve.class); + private final SSLValve valve = new SSLValve(); + private MockRequest mockRequest; + private final Valve mockNext = EasyMock.createMock(Valve.class); - @Before public void setUp() throws Exception { + setUp(null); + } + + public void setUp(Connector connector) throws Exception { valve.setNext(mockNext); + if (connector == null) { + mockRequest = MockRequest.getInstance(); + } else { + EasyMock.replay(connector); + mockRequest = new MockRequest(connector); + } mockNext.invoke(mockRequest, null); EasyMock.replay(mockNext); } - @Test - public void testSslHeader() { + public void testSslHeader() throws Exception { + setUp(); final String headerName = "myheader"; final String headerValue = "BASE64_HEADER_VALUE"; mockRequest.setHeader(headerName, headerValue); @@ -116,7 +135,8 @@ public class TestSSLValve { @Test - public void testSslHeaderNull() { + public void testSslHeaderNull() throws Exception { + setUp(); final String headerName = "myheader"; mockRequest.setHeader(headerName, null); @@ -125,7 +145,8 @@ public class TestSSLValve { @Test - public void testSslHeaderNullModHeader() { + public void testSslHeaderNullModHeader() throws Exception { + setUp(); final String headerName = "myheader"; final String nullModHeaderValue = "(null)"; mockRequest.setHeader(headerName, nullModHeaderValue); @@ -136,12 +157,14 @@ public class TestSSLValve { @Test public void testSslHeaderNullName() throws Exception { + setUp(); Assert.assertNull(valve.mygetHeader(mockRequest, null)); } @Test public void testSslHeaderMultiples() throws Exception { + setUp(); final String headerName = "myheader"; final String headerValue = "BASE64_HEADER_VALUE"; mockRequest.addHeader(headerName, headerValue); @@ -153,6 +176,7 @@ public class TestSSLValve { @Test public void testSslClientCertHeaderSingleSpace() throws Exception { + setUp(); String singleSpaced = certificateSingleLine(" "); mockRequest.setHeader(valve.getSslClientCertHeader(), singleSpaced); @@ -164,6 +188,7 @@ public class TestSSLValve { @Test public void testSslClientCertHeaderMultiSpace() throws Exception { + setUp(); String singleSpaced = certificateSingleLine(" "); mockRequest.setHeader(valve.getSslClientCertHeader(), singleSpaced); @@ -175,6 +200,7 @@ public class TestSSLValve { @Test public void testSslClientCertHeaderTab() throws Exception { + setUp(); String singleSpaced = certificateSingleLine("\t"); mockRequest.setHeader(valve.getSslClientCertHeader(), singleSpaced); @@ -186,6 +212,7 @@ public class TestSSLValve { @Test public void testSslClientCertHeaderEscaped() throws Exception { + setUp(); String cert = certificateEscaped(); mockRequest.setHeader(valve.getSslClientEscapedCertHeader(), cert); @@ -197,6 +224,7 @@ public class TestSSLValve { @Test public void testSslClientCertNull() throws Exception { + setUp(); TesterLogValidationFilter f = TesterLogValidationFilter.add(null, "", null, "org.apache.catalina.valves.SSLValve"); @@ -210,6 +238,7 @@ public class TestSSLValve { @Test public void testSslClientCertShorter() throws Exception { + setUp(); mockRequest.setHeader(valve.getSslClientCertHeader(), "shorter than hell"); TesterLogValidationFilter f = TesterLogValidationFilter.add(null, "", null, @@ -225,6 +254,7 @@ public class TestSSLValve { @Test public void testSslClientCertIgnoredBegin() throws Exception { + setUp(); String[] linesBegin = Arrays.copyOf(CERTIFICATE_LINES, CERTIFICATE_LINES.length); linesBegin[0] = "3fisjcme3kdsakasdfsadkafsd3"; String begin = certificateSingleLine(linesBegin, " "); @@ -238,6 +268,7 @@ public class TestSSLValve { @Test public void testSslClientCertBadFormat() throws Exception { + setUp(); String[] linesDeleted = Arrays.copyOf(CERTIFICATE_LINES, CERTIFICATE_LINES.length / 2); String deleted = certificateSingleLine(linesDeleted, " "); mockRequest.setHeader(valve.getSslClientCertHeader(), deleted); @@ -255,8 +286,10 @@ public class TestSSLValve { @Test public void testClientCertProviderNotFound() throws Exception { - EasyMock.expect(mockRequest.getConnector().getProperty("clientCertProvider")).andStubReturn("wontBeFound"); - EasyMock.replay(mockRequest.getConnector()); + Connector connector = EasyMock.createNiceMock(Connector.class); + EasyMock.expect(connector.getProperty("clientCertProvider")).andStubReturn("wontBeFound"); + setUp(connector); + mockRequest.setHeader(valve.getSslClientCertHeader(), certificateSingleLine(" ")); TesterLogValidationFilter f = TesterLogValidationFilter.add(Level.SEVERE, null, @@ -271,6 +304,7 @@ public class TestSSLValve { @Test public void testSslCipherHeaderPresent() throws Exception { + setUp(); String cipher = "ciphered-with"; mockRequest.setHeader(valve.getSslCipherHeader(), cipher); @@ -282,6 +316,7 @@ public class TestSSLValve { @Test public void testSslSessionIdHeaderPresent() throws Exception { + setUp(); String session = "ssl-session"; mockRequest.setHeader(valve.getSslSessionIdHeader(), session); @@ -293,6 +328,7 @@ public class TestSSLValve { @Test public void testSslCipherUserKeySizeHeaderPresent() throws Exception { + setUp(); Integer keySize = Integer.valueOf(452); mockRequest.setHeader(valve.getSslCipherUserKeySizeHeader(), String.valueOf(keySize)); @@ -304,12 +340,14 @@ public class TestSSLValve { @Test(expected = NumberFormatException.class) public void testSslCipherUserKeySizeHeaderBadFormat() throws Exception { + setUp(); mockRequest.setHeader(valve.getSslCipherUserKeySizeHeader(), "not-an-integer"); try { valve.invoke(mockRequest, null); } catch (NumberFormatException e) { Assert.assertNull(mockRequest.getAttribute(Globals.KEY_SIZE_ATTR)); + mockRequest.setHeader(valve.getSslCipherUserKeySizeHeader(), null); throw e; } } @@ -353,4 +391,4 @@ public class TestSSLValve { Assert.assertNotNull(certificates[0]); Assert.assertEquals(0, f.getMessageCount()); } -} \ No newline at end of file +} diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml index 25ade009f2..05a0ca4fc6 100644 --- a/webapps/docs/changelog.xml +++ b/webapps/docs/changelog.xml @@ -116,6 +116,11 @@ in a single URL segment. Based on pull request <pr>860</pr> by Chenjp. (markt) </fix> + <add> + Added support for limiting the number of parameters in HTTP requests through + the new <code>ParameterLimitValve</code>. The valve allows configurable + URL-specific limits on the number of parameters. (dsoumis) + </add> </changelog> </subsection> <subsection name="Coyote"> diff --git a/webapps/docs/config/valve.xml b/webapps/docs/config/valve.xml index 08ebb45c69..ad49c588c9 100644 --- a/webapps/docs/config/valve.xml +++ b/webapps/docs/config/valve.xml @@ -2678,6 +2678,49 @@ </section> +<section name="Parameter Limit Valve"> + + <subsection name="Introduction"> + + <p>The <strong>Parameter Limit Valve</strong> is used to limit the number of parameters allowed in HTTP requests + overriding the Connector's value. The valve can be configured with specific limits for certain URL patterns. + Requests exceeding the defined parameter limits will result in an HTTP 400 Bad Request error.</p> + + </subsection> + + <subsection name="Attributes"> + + <p>The <strong>Parameter Limit Valve</strong> supports the following + configuration attributes:</p> + + <attributes> + + <attribute name="className" required="true"> + <p>Java class name of the implementation to use. This MUST be set to + <strong>org.apache.catalina.valves.ParameterLimitValve</strong>.</p> + </attribute> + + <attribute name="resourcePath" required="false"> + <p>A file consisting of line-separated URL patterns and their respective parameter limits. + Each entry should follow the format <code>urlPattern=limit</code>. + The valve will apply the limit defined for a URL pattern when a request matches that pattern. + If no pattern matches, the Connector's limit will be used. + For example: + <code> + /api/.*=100 + /admin/.*=50 + </code> + Default value: <code>parameter_limit.config</code>. + It must be placed in the Host configuration folder or in the WEB-INF folder of the web application. + </p> + </attribute> + + </attributes> + + </subsection> + +</section> + </body> --------------------------------------------------------------------- To unsubscribe, e-mail: dev-unsubscr...@tomcat.apache.org For additional commands, e-mail: dev-h...@tomcat.apache.org