You are viewing a plain text version of this content. The canonical link for it is here.
Posted to dev@tomcat.apache.org by is...@apache.org on 2023/05/06 19:45:30 UTC
[tomcat] branch 9.0.x updated: Added RateLimitFilter
This is an automated email from the ASF dual-hosted git repository.
isapir 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 836f603dd8 Added RateLimitFilter
836f603dd8 is described below
commit 836f603dd8518cd9d6dea482a6c32dba04bf8917
Author: Igal Sapir <is...@apache.org>
AuthorDate: Sat May 6 12:45:11 2023 -0700
Added RateLimitFilter
---
.../catalina/filters/LocalStrings.properties | 3 +
.../apache/catalina/filters/RateLimitFilter.java | 227 +++++++++++++++++++++
.../apache/catalina/util/TimeBucketCounter.java | 213 +++++++++++++++++++
.../catalina/filters/TestRateLimitFilter.java | 198 ++++++++++++++++++
.../catalina/util/TestTimeBucketCounter.java | 78 +++++++
webapps/docs/config/filter.xml | 130 +++++++++++-
6 files changed, 847 insertions(+), 2 deletions(-)
diff --git a/java/org/apache/catalina/filters/LocalStrings.properties b/java/org/apache/catalina/filters/LocalStrings.properties
index 31f7bd0acd..cd5a52366e 100644
--- a/java/org/apache/catalina/filters/LocalStrings.properties
+++ b/java/org/apache/catalina/filters/LocalStrings.properties
@@ -52,6 +52,9 @@ http.403=Access to the specified resource [{0}] has been forbidden.
httpHeaderSecurityFilter.clickjack.invalid=An invalid value [{0}] was specified for the anti click-jacking header
httpHeaderSecurityFilter.committed=Unable to add HTTP headers since response is already committed on entry to the HTTP header security Filter
+rateLimitFilter.initialized=RateLimitFilter [{0}] initialized with [{1}] requests per [{2}] seconds. Actual is [{3}] per [{4}] milliseconds. {5}.
+rateLimitFilter.maxRequestsExceeded=[{0}] [{1}] Requests from [{2}] have exceeded the maximum allowed of [{3}] in a [{4}] second window.
+
remoteCidrFilter.invalid=Invalid configuration provided for [{0}]. See previous messages for details.
remoteCidrFilter.noRemoteIp=Client does not have an IP address. Request denied.
diff --git a/java/org/apache/catalina/filters/RateLimitFilter.java b/java/org/apache/catalina/filters/RateLimitFilter.java
new file mode 100644
index 0000000000..00d4fad683
--- /dev/null
+++ b/java/org/apache/catalina/filters/RateLimitFilter.java
@@ -0,0 +1,227 @@
+/*
+ * 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.filters;
+
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.GenericFilter;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.catalina.util.TimeBucketCounter;
+import org.apache.juli.logging.Log;
+import org.apache.juli.logging.LogFactory;
+import org.apache.tomcat.util.res.StringManager;
+
+import java.io.IOException;
+
+/**
+ * <p>Servlet filter that can help mitigate Denial of Service
+ * (DoS) and Brute Force attacks by limiting the number of a requests that are
+ * allowed from a single IP address within a time window (also referred
+ * to as a time bucket), e.g. 300 Requests per 60 seconds.</p>
+ *
+ * <p>The filter works by incrementing a counter in a time bucket for each IP
+ * address, and if the counter exceeds the allowed limit then further requests
+ * from that IP are dropped with a "429 Too many requests" response
+ * until the bucket time ends and a new bucket starts.</p>
+ *
+ * <p>The filter is optimized for efficiency and low overhead, so it converts
+ * some configured values to more efficient values. For example, a configuration
+ * of a 60 seconds time bucket is converted to 65.536 seconds. That allows
+ * for very fast bucket calculation using bit shift arithmetic. In order to remain
+ * true to the user intent, the configured number of requests is then multiplied
+ * by the same ratio, so a configuration of 100 Requests per 60 seconds, has the
+ * real values of 109 Requests per 65 seconds.</p>
+ *
+ * <p>It is common to set up different restrictions for different URIs.
+ * For example, a login page or authentication script is typically expected
+ * to get far less requests than the rest of the application, so you can add
+ * a filter definition that would allow only 5 requests per 15 seconds and map
+ * those URIs to it.</p>
+ *
+ * <p>You can set <code>enforce</code> to <code>false</code>
+ * to disable the termination of requests that exceed the allowed limit. Then
+ * your application code can inspect the Request Attribute
+ * <code>org.apache.catalina.filters.RateLimitFilter.Count</code> and decide
+ * how to handle the request based on other information that it has, e.g. allow
+ * more requests to certain users based on roles, etc.</p>
+ *
+ * <p><strong>WARNING:</strong> if Tomcat is behind a reverse proxy then you must
+ * make sure that the Rate Limit Filter sees the client IP address, so if for
+ * example you are using the <a href="#Remote_IP_Filter">Remote IP Filter</a>,
+ * then the filter mapping for the Rate Limit Filter must come <em>after</em>
+ * the mapping of the Remote IP Filter to ensure that each request has its IP
+ * address resolved before the Rate Limit Filter is applied. Failure to do so
+ * will count requests from different IPs in the same bucket and will result in
+ * a self inflicted DoS attack.</p>
+ */
+public class RateLimitFilter extends GenericFilter {
+
+ /**
+ * default duration in seconds
+ */
+ public static final int DEFAULT_BUCKET_DURATION = 60;
+
+ /**
+ * default number of requests per duration
+ */
+ public static final int DEFAULT_BUCKET_REQUESTS = 300;
+
+ /**
+ * default value for enforce
+ */
+ public static final boolean DEFAULT_ENFORCE = true;
+
+ /**
+ * default status code to return if requests per duration exceeded
+ */
+ public static final int DEFAULT_STATUS_CODE = 429;
+
+ /**
+ * default status message to return if requests per duration exceeded
+ */
+ public static final String DEFAULT_STATUS_MESSAGE = "Too many requests";
+
+ /**
+ * request attribute that will contain the number of requests per duration
+ */
+ public static final String RATE_LIMIT_ATTRIBUTE_COUNT = "org.apache.catalina.filters.RateLimitFilter.Count";
+
+ /**
+ * init-param to set the bucket duration in seconds
+ */
+ public static final String PARAM_BUCKET_DURATION = "bucketDuration";
+
+ /**
+ * init-param to set the bucket number of requests
+ */
+ public static final String PARAM_BUCKET_REQUESTS = "bucketRequests";
+
+ /**
+ * init-param to set the enforce flag
+ */
+ public static final String PARAM_ENFORCE = "enforce";
+
+ /**
+ * init-param to set a custom status code if requests per duration exceeded
+ */
+ public static final String PARAM_STATUS_CODE = "statusCode";
+
+ /**
+ * init-param to set a custom status message if requests per duration exceeded
+ */
+ public static final String PARAM_STATUS_MESSAGE = "statusMessage";
+
+ TimeBucketCounter bucketCounter;
+
+ private int actualRequests;
+
+ private int bucketRequests = DEFAULT_BUCKET_REQUESTS;
+
+ private int bucketDuration = DEFAULT_BUCKET_DURATION;
+
+ private boolean enforce = DEFAULT_ENFORCE;
+
+ private int statusCode = DEFAULT_STATUS_CODE;
+
+ private String statusMessage = DEFAULT_STATUS_MESSAGE;
+
+ private transient Log log = LogFactory.getLog(RateLimitFilter.class);
+
+ private static final StringManager sm = StringManager.getManager(RateLimitFilter.class);
+
+ /**
+ * @return the actual maximum allowed requests per time bucket
+ */
+ public int getActualRequests() {
+ return actualRequests;
+ }
+
+ /**
+ * @return the actual duration of a time bucket in milliseconds
+ */
+ public int getActualDurationInSeconds() {
+ return bucketCounter.getActualDuration() / 1000;
+ }
+
+ @Override
+ public void init() throws ServletException {
+
+ FilterConfig config = getFilterConfig();
+
+ String param;
+ param = config.getInitParameter(PARAM_BUCKET_DURATION);
+ if (param != null)
+ bucketDuration = Integer.parseInt(param);
+
+ param = config.getInitParameter(PARAM_BUCKET_REQUESTS);
+ if (param != null)
+ bucketRequests = Integer.parseInt(param);
+
+ param = config.getInitParameter(PARAM_ENFORCE);
+ if (param != null)
+ enforce = Boolean.parseBoolean(param);
+
+ param = config.getInitParameter(PARAM_STATUS_CODE);
+ if (param != null)
+ statusCode = Integer.parseInt(param);
+
+ param = config.getInitParameter(PARAM_STATUS_MESSAGE);
+ if (param != null)
+ statusMessage = param;
+
+ bucketCounter = new TimeBucketCounter(bucketDuration);
+
+ actualRequests = (int) Math.round(bucketCounter.getRatio() * bucketRequests);
+
+ log.info(sm.getString("rateLimitFilter.initialized",
+ super.getFilterName(), bucketRequests, bucketDuration, getActualRequests(),
+ getActualDurationInSeconds(), (!enforce ? "Not " : "") + "enforcing")
+ );
+ }
+
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+ throws IOException, ServletException {
+
+ String ipAddr = request.getRemoteAddr();
+ int reqCount = bucketCounter.increment(ipAddr);
+
+ request.setAttribute(RATE_LIMIT_ATTRIBUTE_COUNT, reqCount);
+
+ if (enforce && (reqCount > actualRequests)) {
+
+ ((HttpServletResponse) response).sendError(statusCode, statusMessage);
+ log.warn(sm.getString("rateLimitFilter.maxRequestsExceeded",
+ super.getFilterName(), reqCount, ipAddr, getActualRequests(), getActualDurationInSeconds())
+ );
+
+ return;
+ }
+
+ chain.doFilter(request, response);
+ }
+
+ @Override
+ public void destroy() {
+ this.bucketCounter.destroy();
+ super.destroy();
+ }
+}
diff --git a/java/org/apache/catalina/util/TimeBucketCounter.java b/java/org/apache/catalina/util/TimeBucketCounter.java
new file mode 100644
index 0000000000..70bb09f122
--- /dev/null
+++ b/java/org/apache/catalina/util/TimeBucketCounter.java
@@ -0,0 +1,213 @@
+/*
+ * 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.util;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * this class maintains a thread safe hash map that has timestamp-based buckets
+ * followed by a string for a key, and a counter for a value. each time the
+ * increment() method is called it adds the key if it does not exist, increments
+ * its value and returns it.
+ *
+ * a maintenance thread cleans up keys that are prefixed by previous timestamp
+ * buckets.
+ */
+public class TimeBucketCounter {
+
+ /**
+ * Map to hold the buckets
+ */
+ private final ConcurrentHashMap<String, AtomicInteger> map = new ConcurrentHashMap<>();
+
+ /**
+ * Milliseconds bucket size as a Power of 2 for bit shift math, e.g.
+ * 16 for 65_536ms which is about 1:05 minute
+ */
+ private final int numBits;
+
+ /**
+ * ratio of actual duration to config duration
+ */
+ private final double ratio;
+
+ /**
+ * flag for the maintenance thread
+ */
+ volatile boolean isRunning = false;
+
+ /**
+ *
+ * @param bucketDuration duration in seconds, e.g. for 1 minute pass 60
+ */
+ public TimeBucketCounter(int bucketDuration) {
+
+ int durationMillis = bucketDuration * 1000;
+
+ int bits = 0;
+ int pof2 = nextPowerOf2(durationMillis);
+ int bitCheck = pof2;
+ while (bitCheck > 1) {
+ bitCheck = pof2 >> ++bits;
+ }
+
+ this.numBits = bits;
+
+ this.ratio = ratioToPowerOf2(durationMillis);
+
+ int cleanupsPerBucketDuration = (durationMillis >= 60_000) ? 6 : 3;
+ Thread mt = new MaintenanceThread(durationMillis / cleanupsPerBucketDuration);
+ mt.start();
+ }
+
+ /**
+ * increments the counter for the passed identifier in the current time
+ * bucket and returns the new value
+ *
+ * @param identifier an identifier for which we want to maintain count, e.g. IP Address
+ * @return the count within the current time bucket
+ */
+ public final int increment(String identifier) {
+ String key = getCurrentBucketPrefix() + "-" + identifier;
+ AtomicInteger ai = map.computeIfAbsent(key, v -> new AtomicInteger());
+ return ai.incrementAndGet();
+ }
+
+ /**
+ * calculates the current time bucket prefix by shifting bits for fast
+ * division, e.g. shift 16 bits is the same as dividing by 65,536 which is
+ * about 1:05m
+ */
+ public final int getCurrentBucketPrefix() {
+ return (int) (System.currentTimeMillis() >> this.numBits);
+ }
+
+ /**
+ *
+ * @return
+ */
+ public int getNumBits() {
+ return numBits;
+ }
+
+ /**
+ * the actual duration may differ from the configured duration because
+ * it is set to the next power of 2 value in order to perform very fast
+ * bit shift arithmetic
+ *
+ * @return the actual bucket duration in milliseconds
+ */
+ public int getActualDuration() {
+ return (int) Math.pow(2, getNumBits());
+ }
+
+ /**
+ * returns the ratio between the configured duration param and the
+ * actual duration which will be set to the next power of 2. we then
+ * multiply the configured requests param by the same ratio in order
+ * to compensate for the added time, if any
+ *
+ * @return the ratio, e.g. 1.092 if the actual duration is 65_536 for
+ * the configured duration of 60_000
+ */
+ public double getRatio() {
+ return ratio;
+ }
+
+ /**
+ * returns the ratio to the next power of 2 so that we can adjust the value
+ *
+ * @param value
+ * @return
+ */
+ static double ratioToPowerOf2(int value) {
+ double nextPO2 = nextPowerOf2(value);
+ return Math.round((1000 * nextPO2 / value)) / 1000d;
+ }
+
+ /**
+ * returns the next power of 2 given a value, e.g. 256 for 250,
+ * or 1024, for 1000
+ *
+ * @param value
+ * @return
+ */
+ static int nextPowerOf2(int value) {
+ int valueOfHighestBit = Integer.highestOneBit(value);
+ if (valueOfHighestBit == value)
+ return value;
+
+ return valueOfHighestBit << 1;
+ }
+
+ /**
+ * when we want to test a full bucket duration we need to sleep until the
+ * next bucket starts
+ *
+ * @return the number of milliseconds until the next bucket
+ */
+ public long getMillisUntilNextBucket() {
+ long millis = System.currentTimeMillis();
+ long nextTimeBucketMillis = ((millis + (long) Math.pow(2, numBits)) >> numBits) << numBits;
+ long delta = nextTimeBucketMillis - millis;
+ return delta;
+ }
+
+ /**
+ * sets isRunning to false to terminate the maintenance thread
+ */
+ public void destroy() {
+ this.isRunning = false;
+ }
+
+ /**
+ * this class runs a background thread to clean up old keys from the map
+ */
+ class MaintenanceThread extends Thread {
+
+ final long sleeptime;
+
+ public MaintenanceThread(long sleeptime) {
+ super.setDaemon(true);
+ this.sleeptime = sleeptime;
+ }
+
+ @Override
+ public void start() {
+ isRunning = true;
+ super.start();
+ }
+
+ @Override
+ public void run() {
+
+ while (isRunning) {
+ String currentBucketPrefix = String.valueOf(getCurrentBucketPrefix());
+ ConcurrentHashMap.KeySetView<String, AtomicInteger> keys = map.keySet();
+
+ // remove obsolete keys
+ keys.removeIf(k -> !k.startsWith(currentBucketPrefix));
+
+ try {
+ Thread.sleep(sleeptime);
+ } catch (InterruptedException e) {}
+ }
+ }
+ }
+}
diff --git a/test/org/apache/catalina/filters/TestRateLimitFilter.java b/test/org/apache/catalina/filters/TestRateLimitFilter.java
new file mode 100644
index 0000000000..444d1d7e8d
--- /dev/null
+++ b/test/org/apache/catalina/filters/TestRateLimitFilter.java
@@ -0,0 +1,198 @@
+/*
+ * 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.filters;
+
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletContext;
+import javax.servlet.ServletException;
+import org.apache.catalina.Context;
+import org.apache.catalina.LifecycleException;
+import org.apache.catalina.startup.Tomcat;
+import org.apache.catalina.startup.TomcatBaseTest;
+import org.apache.catalina.filters.TestRemoteIpFilter.MockFilterChain;
+import org.apache.catalina.filters.TestRemoteIpFilter.MockHttpServletRequest;
+import org.apache.tomcat.unittest.TesterResponse;
+import org.apache.tomcat.unittest.TesterServletContext;
+import org.apache.tomcat.util.descriptor.web.FilterDef;
+import org.apache.tomcat.util.descriptor.web.FilterMap;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.util.Enumeration;
+import java.util.Map;
+
+public class TestRateLimitFilter extends TomcatBaseTest {
+
+ @Test
+ public void TestRateLimitWith4Clients() throws Exception {
+
+ int bucketRequests = 40;
+ int bucketDuration = 4;
+
+ FilterDef filterDef = new FilterDef();
+ filterDef.addInitParameter(RateLimitFilter.PARAM_BUCKET_REQUESTS, String.valueOf(bucketRequests));
+ filterDef.addInitParameter(RateLimitFilter.PARAM_BUCKET_DURATION, String.valueOf(bucketDuration));
+
+ Tomcat tomcat = getTomcatInstance();
+ Context root = tomcat.addContext("", TEMP_DIR);
+ tomcat.start();
+
+ MockFilterChain filterChain = new MockFilterChain();
+ RateLimitFilter rateLimitFilter = testRateLimitFilter(filterDef, root);
+
+ int allowedRequests = (int) Math.round(rateLimitFilter.bucketCounter.getRatio() * bucketRequests);
+
+ long sleepTime = rateLimitFilter.bucketCounter.getMillisUntilNextBucket();
+ System.out.printf("Sleeping %d millis for the next time bucket to start\n", sleepTime);
+ Thread.sleep(sleepTime);
+
+ TestClient tc1 = new TestClient(rateLimitFilter, filterChain, "10.20.20.5", 200, 5);
+ TestClient tc2 = new TestClient(rateLimitFilter, filterChain, "10.20.20.10", 200, 10);
+
+ TestClient tc3 = new TestClient(rateLimitFilter, filterChain, "10.20.20.20", 200, 20);
+ TestClient tc4 = new TestClient(rateLimitFilter, filterChain, "10.20.20.40", 200, 40);
+
+ Thread.sleep(5000);
+
+ Assert.assertEquals(200, tc1.results[24]); // only 25 requests made, all allowed
+
+ Assert.assertEquals(200, tc2.results[49]); // only 25 requests made, all allowed
+
+ Assert.assertEquals(200, tc3.results[allowedRequests - 1]); // first allowedRequests allowed
+ Assert.assertEquals(429, tc3.results[allowedRequests]); // subsequent requests dropped
+
+ Assert.assertEquals(200, tc4.results[allowedRequests - 1]); // first allowedRequests allowed
+ Assert.assertEquals(429, tc4.results[allowedRequests]); // subsequent requests dropped
+ }
+
+ private RateLimitFilter testRateLimitFilter(FilterDef filterDef, Context root)
+ throws LifecycleException, IOException, ServletException {
+
+ RateLimitFilter rateLimitFilter = new RateLimitFilter();
+ filterDef.setFilterClass(RateLimitFilter.class.getName());
+ filterDef.setFilter(rateLimitFilter);
+ filterDef.setFilterName(RateLimitFilter.class.getName());
+ root.addFilterDef(filterDef);
+
+ FilterMap filterMap = new FilterMap();
+ filterMap.setFilterName(RateLimitFilter.class.getName());
+ filterMap.addURLPatternDecoded("*");
+ root.addFilterMap(filterMap);
+
+ FilterConfig filterConfig = generateFilterConfig(filterDef);
+
+ rateLimitFilter.init(filterConfig);
+
+ return rateLimitFilter;
+ //*/
+ }
+
+ static class TestClient extends Thread {
+ RateLimitFilter filter;
+ FilterChain filterChain;
+ String ip;
+
+ int requests;
+ int sleep;
+
+ int[] results;
+
+ TestClient(RateLimitFilter filter, FilterChain filterChain, String ip, int requests, int rps) {
+ this.filter = filter;
+ this.filterChain = filterChain;
+ this.ip = ip;
+ this.requests = requests;
+ this.sleep = 1000 / rps;
+ this.results = new int[requests];
+ super.setDaemon(true);
+ super.start();
+ }
+
+ @Override
+ public void run() {
+ try {
+ for (int i = 0; i < requests; i++) {
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ request.setRemoteAddr(ip);
+ TesterResponse response = new TesterResponseWithStatus();
+ response.setRequest(request);
+ filter.doFilter(request, response, filterChain);
+ results[i] = response.getStatus();
+ System.out.printf("%s %s: %s %d\n", ip, Instant.now(), i + 1, response.getStatus());
+ Thread.sleep(sleep);
+ }
+ }
+ catch (Exception ex) {
+ ex.printStackTrace();
+ }
+ }
+ }
+
+ static class TesterResponseWithStatus extends TesterResponse {
+
+ int status = 200;
+ String message = "OK";
+
+ @Override
+ public void sendError(int status, String message) throws IOException {
+ this.status = status;
+ this.message = message;
+ }
+
+ @Override
+ public int getStatus() {
+ return status;
+ }
+ }
+
+ private static FilterConfig generateFilterConfig(FilterDef filterDef) {
+
+ TesterServletContext mockServletContext = new TesterServletContext();
+ Map<String, String> parameters = filterDef.getParameterMap();
+
+ FilterConfig filterConfig = new FilterConfig() {
+
+ @Override
+ public String getFilterName() {
+ return "rate-limit-filter";
+ }
+
+ @Override
+ public ServletContext getServletContext() {
+ return mockServletContext;
+ }
+
+ @Override
+ public String getInitParameter(String name) {
+
+ return parameters.get(name);
+ }
+
+ @Override
+ public Enumeration<String> getInitParameterNames() {
+ return null;
+ }
+ };
+
+ return filterConfig;
+ }
+
+}
diff --git a/test/org/apache/catalina/util/TestTimeBucketCounter.java b/test/org/apache/catalina/util/TestTimeBucketCounter.java
new file mode 100644
index 0000000000..bfaca1e6f4
--- /dev/null
+++ b/test/org/apache/catalina/util/TestTimeBucketCounter.java
@@ -0,0 +1,78 @@
+/*
+ * 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.util;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+public class TestTimeBucketCounter {
+
+ final static double DELTA = 0.001;
+
+ @Test
+ public void testNextPowerOf2() {
+ Assert.assertEquals(128, TimeBucketCounter.nextPowerOf2(100));
+ Assert.assertEquals(128, TimeBucketCounter.nextPowerOf2(127));
+ Assert.assertEquals(128, TimeBucketCounter.nextPowerOf2(128));
+ Assert.assertEquals(256, TimeBucketCounter.nextPowerOf2(250));
+ Assert.assertEquals(1024, TimeBucketCounter.nextPowerOf2(1000));
+ Assert.assertEquals(1024, TimeBucketCounter.nextPowerOf2(1023));
+ Assert.assertEquals(2048, TimeBucketCounter.nextPowerOf2(1025));
+ }
+
+ @Test
+ public void testCalcRatioToNextPowerOf2() {
+ Assert.assertEquals(256 / 256d, TimeBucketCounter.ratioToPowerOf2(256), DELTA);
+ Assert.assertEquals(256 / 200d, TimeBucketCounter.ratioToPowerOf2(200), DELTA);
+ Assert.assertEquals(65_536 / 60_000d, TimeBucketCounter.ratioToPowerOf2(60_000), DELTA);
+ }
+
+ @Test
+ public void testTimeBucketCounter() {
+ TimeBucketCounter tbc = new TimeBucketCounter(60);
+ Assert.assertEquals(16, tbc.getNumBits());
+ Assert.assertEquals(1.092, tbc.getRatio(), DELTA);
+ }
+
+ @Test
+ public void testGetMillisUntilNextBucket() throws InterruptedException {
+ long millis;
+ int tb1, tb2;
+
+ TimeBucketCounter tbc = new TimeBucketCounter(2);
+ tb1 = tbc.getCurrentBucketPrefix();
+ millis = tbc.getMillisUntilNextBucket();
+
+ // sleep millis and get bucket
+ Thread.sleep(millis);
+ tb2 = tbc.getCurrentBucketPrefix();
+
+ // ensure the new time bucket is one more than the previous one
+ Assert.assertEquals(1, tb2 - tb1);
+
+ tb1 = tb2;
+ millis = tbc.getMillisUntilNextBucket();
+
+ // sleep again
+ Thread.sleep(millis);
+ tb2 = tbc.getCurrentBucketPrefix();
+
+ // ensure again
+ Assert.assertEquals(1, tb2 - tb1);
+ }
+}
diff --git a/webapps/docs/config/filter.xml b/webapps/docs/config/filter.xml
index ec50d4a86a..0327dd7fde 100644
--- a/webapps/docs/config/filter.xml
+++ b/webapps/docs/config/filter.xml
@@ -962,6 +962,132 @@ FINE: Request "/docs/config/manager.html" with response status "200"
</section>
+
+<section name="Rate Limit Filter">
+
+ <subsection name="Introduction">
+
+ <p>The <strong>Rate Limit Filter</strong> can help mitigate Denial of Service
+ (DoS) and Brute Force attacks by limiting the number of a requests that are
+ allowed from a single IP address within a time window (also referred
+ to as a time bucket), e.g. 300 Requests per 60 seconds.</p>
+
+ <p>The filter works by incrementing a counter in a time bucket for each IP
+ address, and if the counter exceeds the allowed limit then further requests
+ from that IP are dropped with a "429 Too many requests" response
+ until the bucket time ends and a new bucket starts.</p>
+
+ <p>The filter is optimized for efficiency and low overhead, so it converts
+ some configured values to more efficient values. For example, a configuration
+ of a 60 seconds time bucket is converted to 65.536 seconds. That allows
+ for very fast bucket calculation using bit shift arithmetic. In order to remain
+ true to the user intent, the configured number of requests is then multiplied
+ by the same ratio, so a configuration of 100 Requests per 60 seconds, has the
+ real values of 109 Requests per 65 seconds.</p>
+
+ <p>It is common to set up different restrictions for different URIs.
+ For example, a login page or authentication script is typically expected
+ to get far less requests than the rest of the application, so you can add
+ a filter definition that would allow only 5 requests per 15 seconds and map
+ those URIs to it.</p>
+
+ <p>You can set <code>enforce</code> to <code>false</code>
+ to disable the termination of requests that exceed the allowed limit. Then
+ your application code can inspect the Request Attribute
+ <code>org.apache.catalina.filters.RateLimitFilter.Count</code> and decide
+ how to handle the request based on other information that it has, e.g. allow
+ more requests to certain users based on roles, etc.</p>
+
+ <p><strong>WARNING:</strong> if Tomcat is behind a reverse proxy then you must
+ make sure that the Rate Limit Filter sees the client IP address, so if for
+ example you are using the <a href="#Remote_IP_Filter">Remote IP Filter</a>,
+ then the filter mapping for the Rate Limit Filter must come <em>after</em>
+ the mapping of the Remote IP Filter to ensure that each request has its IP
+ address resolved before the Rate Limit Filter is applied. Failure to do so
+ will count requests from different IPs in the same bucket and will result in
+ a self inflicted DoS attack.</p>
+
+ </subsection>
+
+ <subsection name="Filter Class Name">
+
+ <p>The filter class name for the Remote Address Filter is
+ <strong><code>org.apache.catalina.filters.RateLimitFilter</code>
+ </strong>.</p>
+
+ </subsection>
+
+ <subsection name="Initialisation parameters">
+
+ <p>The <strong>Rate Limit Filter</strong> supports the following
+ initialisation parameters:</p>
+
+ <attributes>
+
+ <attribute name="bucketDuration" required="false">
+ <p>The number of seconds in a time bucket. Default is <code>60</code>.</p>
+ </attribute>
+
+ <attribute name="bucketRequests" required="false">
+ <p>The number of requests that are allowed in a time bucket.
+ Default is <code>300</code>.</p>
+ </attribute>
+
+ <attribute name="enforce" required="false">
+ <p>Set to false to allow requests through even when they exceed
+ the maximum allowed per time window. Your application code can
+ still inspect the Request Attribute
+ org.apache.catalina.filters.RateLimitFilter.Count to retrieve
+ the number of Requests made from that IP within the time window.
+ Default is <code>true</code>.</p>
+ </attribute>
+
+ <attribute name="statusCode" required="false">
+ <p>The status code to return when a request is dropped.
+ Default is <code>429</code>.</p>
+ </attribute>
+
+ <attribute name="statusMessage" required="false">
+ <p>The status message to return when a request is dropped.
+ Default is "Too many requests".</p>
+ </attribute>
+
+ </attributes>
+
+ </subsection>
+
+ <subsection name="Example">
+ <p>Set the site rate limit to 300 Requests per minute (default):</p>
+<source><![CDATA[ <filter>
+ <filter-name>RateLimitFilter Global</filter-name>
+ <filter-class>org.apache.catalina.filters.RateLimitFilter</filter-class>
+ </filter>
+
+ <filter-mapping>
+ <filter-name>RateLimitFilter Global</filter-name>
+ <url-pattern>*</url-pattern>
+ </filter-mapping>]]></source>
+
+ <p>Set the /auth/* scripts rate limit to 20 Requests per minute:</p>
+<source><![CDATA[ <filter>
+ <filter-name>RateLimitFilter Login</filter-name>
+ <filter-class>org.apache.catalina.filters.RateLimitFilter</filter-class>
+ <init-param>
+ <param-name>bucketRequests</param-name>
+ <param-value>20</param-value>
+ </init-param>
+ </filter>
+
+ <filter-mapping>
+ <filter-name>RateLimitFilter Login</filter-name>
+ <url-pattern>/auth/*</url-pattern>
+ </filter-mapping>]]></source>
+
+ </subsection>
+
+</section>
+
+
<section name="Remote Address Filter">
<subsection name="Introduction">
@@ -1732,12 +1858,12 @@ org.apache.catalina.filters.RequestDumperFilter.handlers = \
<section name="Session Initializer Filter">
<subsection name="Introduction">
- <p>The Session Initializer Filter initializes the <code>javax.servlet.http.HttpSession</code>
+ <p>The Session Initializer Filter initializes the <code>jakarta.servlet.http.HttpSession</code>
before the Request is processed. This is required for JSR-356 compliant WebSocket implementations,
if the <code>HttpSession</code> is needed during the HandShake phase.</p>
<p>The Java API for WebSocket does not mandate that an <code>HttpSession</code> would
- be initialized upon request, and thus <code>javax.servlet.http.HttpServletRequest</code>'s
+ be initialized upon request, and thus <code>jakarta.servlet.http.HttpServletRequest</code>'s
<code>getSession()</code> returns <code>null</code> if the <code>HttpSession</code> was not
initialized in advance.</p>
---------------------------------------------------------------------
To unsubscribe, e-mail: dev-unsubscribe@tomcat.apache.org
For additional commands, e-mail: dev-help@tomcat.apache.org