Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions java/org/apache/catalina/filters/LocalStrings.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
227 changes: 227 additions & 0 deletions java/org/apache/catalina/filters/RateLimitFilter.java
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 &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();
Copy link
Member

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-for header exists) that handle that case or allow user to specific a way that how to get really IP (specific header or others).

Copy link

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.

Copy link
Contributor

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 ?

Copy link

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

Copy link
Member Author

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]:

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 Remote IP Filter,
then the filter mapping for the Rate Limit Filter must come after
the mapping of the Remote IP Filter to ensure that each request has its IP
address resolved before the Rate Limit Filter is applied

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-asf

This 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:

  1. Configuration via web.xml which might be more convenient for those who are more familiar with Servlet containers than with web servers
  2. A lenient mode in a way of 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

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Filter documentation comes with a WARNING [1]:

Sorry, I didn't look the doc carefully.

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()

It's ok to use with RemoteIpFilter.

BTW, I think we should also copy WARNING to Javadoc of RateLimitFilter. :)

Copy link
Contributor

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)

int reqCount = bucketCounter.increment(ipAddr);
Copy link
Contributor

Choose a reason for hiding this comment

The 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):

  1. global (key=constant=app)
  2. user (key=req.principal.name)
  3. ip

So having it configurable enables to get this behavior without overriding it, wdyt?

Copy link
Member Author

Choose a reason for hiding this comment

The 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();
}
}
Loading