diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/RateLimitGuard.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/RateLimitGuard.java new file mode 100644 index 000000000..af2eb0de4 --- /dev/null +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/RateLimitGuard.java @@ -0,0 +1,84 @@ +/* + * This file is part of Player Analytics (Plan). + * + * Plan is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License v3 as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Plan is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Plan. If not, see . + */ +package com.djrapitops.plan.delivery.webserver; + +import com.djrapitops.plan.utilities.dev.Untrusted; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +/** + * Simple guard against DDoS attacks to single endpoint. + *

+ * This only protects against a DDoS that doesn't follow redirects. + * + * @author AuroraLS3 + */ +public class RateLimitGuard { + + private static final int ATTEMPT_LIMIT = 30; + private final Cache requests = Caffeine.newBuilder() + .expireAfterWrite(120, TimeUnit.SECONDS) + .build(); + private final Cache lastRequestPath = Caffeine.newBuilder() + .expireAfterWrite(120, TimeUnit.SECONDS) + .build(); + + public boolean shouldPreventRequest(@Untrusted String accessor) { + Integer attempts = requests.getIfPresent(accessor); + if (attempts == null) return false; + // Too many attempts, forbid further attempts. + return attempts >= ATTEMPT_LIMIT; + } + + public void increaseAttemptCount(@Untrusted String requestPath, @Untrusted String accessor) { + String previous = lastRequestPath.getIfPresent(accessor); + if (!Objects.equals(previous, requestPath)) { + resetAttemptCount(accessor); + } + + Integer attempts = requests.getIfPresent(accessor); + if (attempts == null) { + attempts = 0; + } + + lastRequestPath.put(accessor, requestPath); + requests.put(accessor, attempts + 1); + } + + public void resetAttemptCount(@Untrusted String accessor) { + // previous request changed + requests.cleanUp(); + requests.invalidate(accessor); + } + + public static class Disabled extends RateLimitGuard { + @Override + public boolean shouldPreventRequest(String accessor) { + return false; + } + + @Override + public void increaseAttemptCount(String requestPath, String accessor) { /* Disabled */ } + + @Override + public void resetAttemptCount(String accessor) { /* Disabled */ } + } + +} diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/ResponseFactory.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/ResponseFactory.java index 5a20d0ff5..1ed008067 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/ResponseFactory.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/ResponseFactory.java @@ -475,6 +475,14 @@ public class ResponseFactory { .build(); } + public Response failedRateLimit403() { + return Response.builder() + .setMimeType(MimeType.HTML) + .setContent("

403 Forbidden

You are being rate-limited.

") + .setStatus(403) + .build(); + } + public Response ipWhitelist403(@Untrusted String accessor) { return Response.builder() .setMimeType(MimeType.HTML) diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/http/InternalRequest.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/http/InternalRequest.java index 3c73a1ce8..63c839c5a 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/http/InternalRequest.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/http/InternalRequest.java @@ -81,4 +81,6 @@ public interface InternalRequest { } return authenticationExtractor.extractAuthentication(this); } + + String getRequestedPath(); } diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/http/JettyInternalRequest.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/http/JettyInternalRequest.java index f7f08d56c..52855fdd8 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/http/JettyInternalRequest.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/http/JettyInternalRequest.java @@ -129,6 +129,11 @@ public class JettyInternalRequest implements InternalRequest { return baseRequest.getRequestURI(); } + @Override + public String getRequestedPath() { + return baseRequest.getHttpURI().getDecodedPath(); + } + @Override public String toString() { return "JettyInternalRequest{" + diff --git a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/http/RequestHandler.java b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/http/RequestHandler.java index 97ce55c92..f2b49a3ae 100644 --- a/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/http/RequestHandler.java +++ b/Plan/common/src/main/java/com/djrapitops/plan/delivery/webserver/http/RequestHandler.java @@ -19,6 +19,7 @@ package com.djrapitops.plan.delivery.webserver.http; import com.djrapitops.plan.delivery.web.resolver.Response; import com.djrapitops.plan.delivery.web.resolver.request.Request; import com.djrapitops.plan.delivery.webserver.PassBruteForceGuard; +import com.djrapitops.plan.delivery.webserver.RateLimitGuard; import com.djrapitops.plan.delivery.webserver.ResponseFactory; import com.djrapitops.plan.delivery.webserver.ResponseResolver; import com.djrapitops.plan.delivery.webserver.auth.FailReason; @@ -40,13 +41,15 @@ public class RequestHandler { private final ResponseResolver responseResolver; private final PassBruteForceGuard bruteForceGuard; + private final RateLimitGuard rateLimitGuard; private final AccessLogger accessLogger; @Inject - public RequestHandler(WebserverConfiguration webserverConfiguration, ResponseFactory responseFactory, ResponseResolver responseResolver, AccessLogger accessLogger) { + public RequestHandler(WebserverConfiguration webserverConfiguration, ResponseFactory responseFactory, ResponseResolver responseResolver, RateLimitGuard rateLimitGuard, AccessLogger accessLogger) { this.webserverConfiguration = webserverConfiguration; this.responseFactory = responseFactory; this.responseResolver = responseResolver; + this.rateLimitGuard = rateLimitGuard; this.accessLogger = accessLogger; bruteForceGuard = new PassBruteForceGuard(); @@ -64,11 +67,17 @@ public class RequestHandler { .warnAboutWhitelistBlock(accessAddress, internalRequest.getRequestedURIString()); response = responseFactory.ipWhitelist403(accessAddress); } else { - try { - request = internalRequest.toRequest(); - response = attemptToResolve(request, accessAddress); - } catch (WebUserAuthException thrownByAuthentication) { - response = processFailedAuthentication(internalRequest, accessAddress, thrownByAuthentication); + String requestedPath = internalRequest.getRequestedPath(); + rateLimitGuard.increaseAttemptCount(requestedPath, accessAddress); + if (rateLimitGuard.shouldPreventRequest(accessAddress)) { + response = responseFactory.failedRateLimit403(); + } else { + try { + request = internalRequest.toRequest(); + response = attemptToResolve(request, accessAddress); + } catch (WebUserAuthException thrownByAuthentication) { + response = processFailedAuthentication(internalRequest, accessAddress, thrownByAuthentication); + } } }