This is an automated email from the ASF dual-hosted git repository. isapir pushed a commit to branch 8.5.x in repository https://gitbox.apache.org/repos/asf/tomcat.git
The following commit(s) were added to refs/heads/8.5.x by this push: new 39f5f38a53 Added RateLimitFilter 39f5f38a53 is described below commit 39f5f38a5303b8456a730abe94eeb2513f67c6fb Author: Igal Sapir <isa...@apache.org> AuthorDate: Sat May 6 15:10:24 2023 -0700 Added RateLimitFilter --- .../catalina/filters/LocalStrings.properties | 3 + .../apache/catalina/filters/RateLimitFilter.java | 228 +++++++++++++++++++++ .../apache/catalina/util/TimeBucketCounter.java | 226 ++++++++++++++++++++ .../catalina/filters/TestRateLimitFilter.java | 197 ++++++++++++++++++ .../catalina/util/TestTimeBucketCounter.java | 78 +++++++ webapps/docs/config/filter.xml | 130 +++++++++++- 6 files changed, 860 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..9a4ec0c397 --- /dev/null +++ b/java/org/apache/catalina/filters/RateLimitFilter.java @@ -0,0 +1,228 @@ +/* + * 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 java.io.IOException; +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +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; + + +/** + * <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 implements Filter { + + /** + * 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); + + private String filterName; + + /** + * @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(FilterConfig config) throws ServletException { + + filterName = config.getFilterName(); + + 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", + filterName, 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", + filterName, reqCount, ipAddr, getActualRequests(), getActualDurationInSeconds()) + ); + + return; + } + + chain.doFilter(request, response); + } + + @Override + public void destroy() { + this.bucketCounter.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..7f689caa78 --- /dev/null +++ b/java/org/apache/catalina/util/TimeBucketCounter.java @@ -0,0 +1,226 @@ +/* + * 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; +import java.util.Set; + +/** + * 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.get(key); + if (ai == null) { + // there is a small chance of a benign data race where we might not count a request or + // two but as a tradeoff in favor of performance we do not synchronize this operation + ai = new AtomicInteger(); + map.putIfAbsent(key, ai); + } + + 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()); + Set<String> keys = map.keySet(); + + for (String k : keys) { + if (!k.startsWith(currentBucketPrefix)) { + // the key is obsolete, remove it + keys.remove((k)); + } + } + + 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..cc19e4a885 --- /dev/null +++ b/test/org/apache/catalina/filters/TestRateLimitFilter.java @@ -0,0 +1,197 @@ +/* + * 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 java.io.IOException; +import java.util.Date; +import java.util.Enumeration; +import java.util.Map; +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; + +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, new Date(), 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) { + + final TesterServletContext mockServletContext = new TesterServletContext(); + final 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-unsubscr...@tomcat.apache.org For additional commands, e-mail: dev-h...@tomcat.apache.org