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 &quot;429 Too many requests&quot; 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 &quot;429 Too many requests&quot; 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 &quot;Too many requests&quot;.</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