View Javadoc
1   /*
2    * Copyright 2012-2025 CodeLibs Project and the Others.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13   * either express or implied. See the License for the specific language
14   * governing permissions and limitations under the License.
15   */
16  package org.codelibs.fess.helper;
17  
18  import java.util.Set;
19  import java.util.concurrent.TimeUnit;
20  import java.util.concurrent.atomic.AtomicLong;
21  
22  import org.apache.logging.log4j.LogManager;
23  import org.apache.logging.log4j.Logger;
24  import org.codelibs.core.lang.StringUtil;
25  import org.codelibs.fess.mylasta.direction.FessConfig;
26  import org.codelibs.fess.util.ComponentUtil;
27  
28  import com.google.common.cache.Cache;
29  import com.google.common.cache.CacheBuilder;
30  
31  import jakarta.annotation.PostConstruct;
32  import jakarta.servlet.http.HttpServletRequest;
33  
34  /**
35   * Helper class for rate limiting functionality.
36   * Implements a sliding window algorithm for request counting
37   * and manages IP-based blocking using Guava Cache for automatic expiration.
38   */
39  public class RateLimitHelper {
40  
41      private static final Logger logger = LogManager.getLogger(RateLimitHelper.class);
42  
43      /**
44       * Request counters per IP address.
45       * Entries automatically expire after the configured window period.
46       */
47      protected Cache<String, AtomicLong> requestCounters;
48  
49      /**
50       * Blocked IPs with automatic expiration.
51       * Entries automatically expire after the configured block duration.
52       */
53      protected Cache<String, Boolean> blockedIps;
54  
55      /**
56       * Default constructor.
57       */
58      public RateLimitHelper() {
59          // nothing
60      }
61  
62      /**
63       * Initialize caches with configuration values.
64       */
65      @PostConstruct
66      public void init() {
67          if (logger.isDebugEnabled()) {
68              logger.debug("Initializing {}", this.getClass().getSimpleName());
69          }
70  
71          final FessConfig fessConfig = ComponentUtil.getFessConfig();
72          final long windowMs = fessConfig.getRateLimitWindowMsAsInteger().longValue();
73          final long blockDurationMs = fessConfig.getRateLimitBlockDurationMsAsInteger().longValue();
74  
75          requestCounters = CacheBuilder.newBuilder().expireAfterWrite(windowMs, TimeUnit.MILLISECONDS).maximumSize(10000).build();
76  
77          blockedIps = CacheBuilder.newBuilder().expireAfterWrite(blockDurationMs, TimeUnit.MILLISECONDS).maximumSize(10000).build();
78  
79          if (logger.isInfoEnabled()) {
80              logger.info("RateLimitHelper initialized: windowMs={}, blockDurationMs={}", windowMs, blockDurationMs);
81          }
82      }
83  
84      /**
85       * Check if rate limiting is enabled.
86       * @return true if rate limiting is enabled
87       */
88      public boolean isEnabled() {
89          return ComponentUtil.getFessConfig().isRateLimitEnabled();
90      }
91  
92      /**
93       * Get the client IP address from the request, considering proxy headers.
94       * Only trusts X-Forwarded-For/X-Real-IP headers when the request comes from a trusted proxy.
95       * @param request the HTTP request
96       * @return the client IP address
97       */
98      public String getClientIp(final HttpServletRequest request) {
99          final String remoteAddr = request.getRemoteAddr();
100 
101         // Only trust proxy headers if the request comes from a trusted proxy
102         if (isTrustedProxy(remoteAddr)) {
103             final String xForwardedFor = request.getHeader("X-Forwarded-For");
104             if (StringUtil.isNotBlank(xForwardedFor)) {
105                 final String clientIp = xForwardedFor.split(",")[0].trim();
106                 if (logger.isDebugEnabled()) {
107                     logger.debug("Client IP from X-Forwarded-For: clientIp={}, remoteAddr={}", clientIp, remoteAddr);
108                 }
109                 return clientIp;
110             }
111             final String xRealIp = request.getHeader("X-Real-IP");
112             if (StringUtil.isNotBlank(xRealIp)) {
113                 final String clientIp = xRealIp.trim();
114                 if (logger.isDebugEnabled()) {
115                     logger.debug("Client IP from X-Real-IP: clientIp={}, remoteAddr={}", clientIp, remoteAddr);
116                 }
117                 return clientIp;
118             }
119         }
120 
121         if (logger.isDebugEnabled()) {
122             logger.debug("Client IP from remoteAddr: ip={}", remoteAddr);
123         }
124         return remoteAddr;
125     }
126 
127     /**
128      * Check if the IP is a trusted proxy.
129      * @param ip the IP address to check
130      * @return true if the IP is a trusted proxy
131      */
132     protected boolean isTrustedProxy(final String ip) {
133         final Set<String> trustedProxies = ComponentUtil.getFessConfig().getRateLimitTrustedProxiesAsSet();
134         final boolean trusted = trustedProxies.contains(ip);
135         if (logger.isDebugEnabled() && trusted) {
136             logger.debug("Trusted proxy detected: ip={}", ip);
137         }
138         return trusted;
139     }
140 
141     /**
142      * Check if the IP is in the whitelist.
143      * @param ip the IP address to check
144      * @return true if whitelisted
145      */
146     public boolean isWhitelisted(final String ip) {
147         final Set<String> whitelist = ComponentUtil.getFessConfig().getRateLimitWhitelistIpsAsSet();
148         final boolean whitelisted = whitelist.contains(ip);
149         if (logger.isDebugEnabled() && whitelisted) {
150             logger.debug("Whitelisted IP: ip={}", ip);
151         }
152         return whitelisted;
153     }
154 
155     /**
156      * Check if the IP is blocked.
157      * @param ip the IP address to check
158      * @return true if blocked
159      */
160     public boolean isBlocked(final String ip) {
161         if (isWhitelisted(ip)) {
162             return false;
163         }
164 
165         // Check statically configured blocked IPs
166         final Set<String> blockedIpSet = ComponentUtil.getFessConfig().getRateLimitBlockedIpsAsSet();
167         if (blockedIpSet.contains(ip)) {
168             if (logger.isDebugEnabled()) {
169                 logger.debug("IP in static block list: ip={}", ip);
170             }
171             return true;
172         }
173 
174         // Check dynamically blocked IPs (Cache handles expiration automatically)
175         final Boolean blocked = blockedIps.getIfPresent(ip);
176         if (blocked != null) {
177             if (logger.isDebugEnabled()) {
178                 logger.debug("IP dynamically blocked: ip={}", ip);
179             }
180             return true;
181         }
182 
183         return false;
184     }
185 
186     /**
187      * Check if the request is allowed under rate limiting rules.
188      * @param ip the client IP address
189      * @return true if the request is allowed
190      */
191     public boolean allowRequest(final String ip) {
192         if (isWhitelisted(ip)) {
193             return true;
194         }
195 
196         final FessConfig fessConfig = ComponentUtil.getFessConfig();
197         final int maxRequests = fessConfig.getRateLimitRequestsPerWindowAsInteger();
198 
199         AtomicLong counter = requestCounters.getIfPresent(ip);
200         if (counter == null) {
201             counter = new AtomicLong(0);
202             requestCounters.put(ip, counter);
203         }
204 
205         final long count = counter.incrementAndGet();
206 
207         if (logger.isDebugEnabled()) {
208             logger.debug("Request count: ip={}, count={}, max={}", ip, count, maxRequests);
209         }
210 
211         if (count > maxRequests) {
212             blockedIps.put(ip, Boolean.TRUE);
213             logger.info("Rate limit exceeded, IP blocked: ip={}, requestCount={}", ip, count);
214             return false;
215         }
216 
217         return true;
218     }
219 
220     /**
221      * Get the Retry-After header value in seconds.
222      * @return the retry after seconds
223      */
224     public int getRetryAfterSeconds() {
225         return ComponentUtil.getFessConfig().getRateLimitRetryAfterSecondsAsInteger();
226     }
227 
228     /**
229      * Block an IP address for the specified duration.
230      * Note: The duration is ignored as the cache uses the configured block duration.
231      * @param ip the IP address to block
232      * @param durationMs the duration in milliseconds (ignored, uses configured value)
233      */
234     public void blockIp(final String ip, final long durationMs) {
235         blockedIps.put(ip, Boolean.TRUE);
236         logger.info("IP manually blocked: ip={}", ip);
237     }
238 
239     /**
240      * Unblock an IP address.
241      * @param ip the IP address to unblock
242      */
243     public void unblockIp(final String ip) {
244         blockedIps.invalidate(ip);
245         logger.info("IP unblocked: ip={}", ip);
246     }
247 
248     /**
249      * Get the number of currently blocked IPs.
250      * Note: This may include expired entries not yet evicted.
251      * @return the count of blocked IPs
252      */
253     public int getBlockedIpCount() {
254         blockedIps.cleanUp();
255         return (int) blockedIps.size();
256     }
257 
258     /**
259      * Get the number of tracked IP counters.
260      * Note: This may include expired entries not yet evicted.
261      * @return the count of tracked IPs
262      */
263     public int getTrackedIpCount() {
264         requestCounters.cleanUp();
265         return (int) requestCounters.size();
266     }
267 
268     /**
269      * Clean up expired entries.
270      * Note: Guava Cache handles expiration automatically, but this method
271      * can be called to force immediate cleanup.
272      */
273     public void cleanup() {
274         requestCounters.cleanUp();
275         blockedIps.cleanUp();
276         if (logger.isDebugEnabled()) {
277             logger.debug("Cache cleanup completed: trackedIps={}, blockedIps={}", requestCounters.size(), blockedIps.size());
278         }
279     }
280 }