ClientAddressRateLimiter.java
/*
* Copyright (c) 2007-2026 MetaSolutions AB
*
* Licensed 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.entrystore.rest.springboot.service;
import com.github.benmanes.caffeine.cache.Ticker;
import com.google.common.base.CharMatcher;
import com.google.common.net.InetAddresses;
import org.jetbrains.annotations.Nullable;
import java.net.InetAddress;
import java.time.Duration;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* A rate limiter keyed by client address, {@code request.getRemoteAddr()}. Behind
* {@code server.forward-headers-strategy: framework} that is the unvalidated {@code Forwarded: for=} or
* leftmost {@code X-Forwarded-For} value. An IP address counts against the budget of its canonical text, so
* every spelling of one address shares a budget, and a port sent with it is ignored. Any other value, such as
* RFC 7239 {@code unknown}, an obfuscated identifier like {@code _hidden}, an IPv6 address with a zone or a
* malformed header, counts against {@link #UNKNOWN_KEY}. A key is therefore an IP address or
* {@link #UNKNOWN_KEY}, never the text a client sent.
*/
public abstract class ClientAddressRateLimiter extends FixedWindowRateLimiter {
private static final int MAX_ADDRESS_LENGTH = "[0000:0000:0000:0000:0000:ffff:255.255.255.255]:65535".length();
private static final CharMatcher ADDRESS_CHARS = CharMatcher.anyOf("0123456789abcdefABCDEF.:[]");
/** {@code [v6]:port} (group 1), or {@code v4:port} in optional brackets (group 2). */
private static final Pattern WITH_PORT = Pattern.compile("(\\[[0-9a-fA-F:.]+]):\\d{1,5}|\\[?([0-9.]+):\\d{1,5}]?");
protected ClientAddressRateLimiter(int max, Duration window, String rateLimitName, Ticker ticker) {
super(max, window, rateLimitName, ticker);
}
protected ClientAddressRateLimiter(int max, Duration window, String rateLimitName, Ticker ticker,
boolean warnWhenDisabled) {
super(max, window, rateLimitName, ticker, warnWhenDisabled);
}
/** Spring's forwarded-header handling and Jetty both bracket IPv6, which {@code ofLiteral} accepts. */
@Override
protected final String permitKey(@Nullable String address) {
// ofLiteral throws twice per rejected value and looks up a named IPv6 zone in the network interfaces
if (address == null || address.length() > MAX_ADDRESS_LENGTH || !ADDRESS_CHARS.matchesAllOf(address)) {
return UNKNOWN_KEY;
}
// Spring keeps a port sent in X-Forwarded-For (Azure Application Gateway) and brackets "v4:port"
Matcher withPort = WITH_PORT.matcher(address);
String literal = !withPort.matches() ? address
: withPort.group(1) != null ? withPort.group(1) : withPort.group(2);
try {
return InetAddresses.toAddrString(InetAddress.ofLiteral(literal));
} catch (IllegalArgumentException e) {
return UNKNOWN_KEY;
}
}
}