-
Notifications
You must be signed in to change notification settings - Fork 5.3k
Added RateLimitFilter #607
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 jakarta.servlet.FilterChain; | ||
| import jakarta.servlet.FilterConfig; | ||
| import jakarta.servlet.GenericFilter; | ||
| import jakarta.servlet.ServletException; | ||
| import jakarta.servlet.ServletRequest; | ||
| import jakarta.servlet.ServletResponse; | ||
| import jakarta.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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. shouldnt the "key" be configurable? thinking to common cases where it often chains (order can vary):
So having it configurable enables to get this behavior without overriding it, wdyt?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I considered something similar at first, but I didn't want to over complicate the first commit. More features can be added later, for sure. |
||
|
|
||
| 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(); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For tomcat, which behind a proxy (e.g. nginx), this filter is meaningless. I think we can write common method (check whether
x-forwarded-forheader exists) that handle that case or allow user to specific a way that how to get really IP (specific header or others).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Somewhat agree. I don't recall seeing a Connector attribute (or filter) that will provide the client remoteAddr. Which can also come i handy for CDN's which send the client IP via header too. It feels like that might need to be a new feature.
If x-forwarded-for is used, we need to be wary and document its ability to be insecure too. Such as bad actors sending this header randomized. In which case, documenting that we need to document the assumption that the header is value is trusted.
So by itself
String ipAddr = request.getRemoteAddr();(I think) is OK ... if a way to implement request.getRemoteAddr() can be adjusted accordingly.The alternative is an option called ipHeader (horrible name, just a suggestion) which is the name of the header to use to get client IP address.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Personally I was seeing this as a niche feature for Tomcat standalone. If there's a proxy, then it should be that component doing any rate limiting. Or did I miss something ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If expectation is standalone, then its just a documentation update to clarifying limitations. That should (optimistically) eliminate security practitioners claiming new CVE's
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Tomcat already ships with an excellent RemoteIpFilter / RemoteIpValve and this filter does not attempt to replace them, but to work with them. The Filter documentation comes with a WARNING [1]:
Did I get that wrong or am I correct that by having the RemoteIpFilter earlier in the Filter Chain then subsequent filters will see the "translated" address when calling
request.getRemoteAddr()? cc @rmaucher @ChristopherSchultz @markt-asfThis Filter can work either in a standalone configuration, or behind a reverse proxy as long as the Remote Address is resolved using the methods mentioned above. Even with reverse proxy it is useful to have this filter in place as it adds:
enforce=false, allowing the user to configure the filter to only add a Request Attribute with the request count. That allows the application to inspect the value and make a more intelligent decision since it knows more than the web server or the filter.[1] https://github.com/apache/tomcat/pull/607/files#diff-e79a16176f7d3d059a2bf4c43d5f50e9c107e27a2511379056ceea48b578c3e5R1001
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry, I didn't look the doc carefully.
It's ok to use with
RemoteIpFilter.BTW, I think we should also copy WARNING to Javadoc of
RateLimitFilter. :)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great doc ! (of course, nobody reads it)