diff --git a/backend/src/main/java/cloudpage/ratelimit/RateLimitCategory.java b/backend/src/main/java/cloudpage/ratelimit/RateLimitCategory.java index 4b28941c..57d7a6fa 100644 --- a/backend/src/main/java/cloudpage/ratelimit/RateLimitCategory.java +++ b/backend/src/main/java/cloudpage/ratelimit/RateLimitCategory.java @@ -4,5 +4,6 @@ public enum RateLimitCategory { UPLOAD, DOWNLOAD, - LISTING + LISTING, + SCAN } diff --git a/backend/src/main/java/cloudpage/ratelimit/RateLimitFilter.java b/backend/src/main/java/cloudpage/ratelimit/RateLimitFilter.java index 6aff4186..322203d9 100644 --- a/backend/src/main/java/cloudpage/ratelimit/RateLimitFilter.java +++ b/backend/src/main/java/cloudpage/ratelimit/RateLimitFilter.java @@ -17,8 +17,8 @@ import org.springframework.web.filter.OncePerRequestFilter; /** - * Applies per-user / per-IP (and optional instance-wide) rate limits to the file upload, download - * and listing endpoints. + * Applies per-user / per-IP (and optional instance-wide) rate limits to the file upload, download, + * listing and virus scan start endpoints. * *

Runs right after {@link cloudpage.security.JwtAuthFilter} so the authenticated principal, when * present, is used as the bucket key (the client IP is used as a fallback). Requests that exceed @@ -89,6 +89,9 @@ private RateLimitCategory categoryFor(HttpServletRequest request) { if ("POST".equals(method) && "/api/files/upload".equals(path)) { return RateLimitCategory.UPLOAD; } + if ("POST".equals(method) && "/api/files/scan".equals(path)) { + return RateLimitCategory.SCAN; + } if ("GET".equals(method) && ("/api/files/download".equals(path) || "/api/files/view".equals(path) diff --git a/backend/src/main/java/cloudpage/ratelimit/RateLimitProperties.java b/backend/src/main/java/cloudpage/ratelimit/RateLimitProperties.java index 9649fd0a..bd700e18 100644 --- a/backend/src/main/java/cloudpage/ratelimit/RateLimitProperties.java +++ b/backend/src/main/java/cloudpage/ratelimit/RateLimitProperties.java @@ -59,12 +59,14 @@ public static class Tier { private Policy upload = new Policy(); private Policy download = new Policy(); private Policy listing = new Policy(); + private Policy scan = new Policy(); static Tier defaults() { Tier tier = new Tier(); tier.upload = new Policy(30, Duration.ofMinutes(1)); tier.download = new Policy(120, Duration.ofMinutes(1)); tier.listing = new Policy(240, Duration.ofMinutes(1)); + tier.scan = new Policy(1, Duration.ofMinutes(5)); return tier; } @@ -77,6 +79,7 @@ public Policy forCategory(RateLimitCategory category) { case UPLOAD -> upload; case DOWNLOAD -> download; case LISTING -> listing; + case SCAN -> scan; }; } @@ -103,6 +106,14 @@ public Policy getListing() { public void setListing(Policy listing) { this.listing = listing; } + + public Policy getScan() { + return scan; + } + + public void setScan(Policy scan) { + this.scan = scan; + } } /** A token-bucket policy: {@code capacity} tokens, fully refilled every {@code refillPeriod}. */ diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 2e098b91..0b80f9a6 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -35,6 +35,8 @@ cloudpage.rate-limit.per-client.download.capacity=120 cloudpage.rate-limit.per-client.download.refill-period=1m cloudpage.rate-limit.per-client.listing.capacity=240 cloudpage.rate-limit.per-client.listing.refill-period=1m +cloudpage.rate-limit.per-client.scan.capacity=1 +cloudpage.rate-limit.per-client.scan.refill-period=5m # Folder virus scanning (async, ClamAV clamd over INSTREAM). Disabled by default so a deployment # without a clamd daemon does not attempt network connections. Enable it and point host/port at a # reachable clamd instance to turn it on. diff --git a/backend/src/test/java/cloudpage/ratelimit/RateLimitFilterTest.java b/backend/src/test/java/cloudpage/ratelimit/RateLimitFilterTest.java index a8e14fb9..9489daf3 100644 --- a/backend/src/test/java/cloudpage/ratelimit/RateLimitFilterTest.java +++ b/backend/src/test/java/cloudpage/ratelimit/RateLimitFilterTest.java @@ -163,4 +163,45 @@ void unauthenticatedRequestsAreKeyedByIp() throws Exception { assertEquals(200, otherResponse.getStatus()); assertNotNull(otherChain.getRequest()); } + + @Test + void scanStartIsLimitedButStatusPollingIsNot() throws Exception { + RateLimitProperties properties = new RateLimitProperties(); + properties.getPerClient().setScan(new RateLimitProperties.Policy(1, Duration.ofMinutes(5))); + RateLimitFilter filter = filterWith(properties); + authenticate("alice"); + + filter.doFilter( + request("POST", "/api/files/scan"), new MockHttpServletResponse(), new MockFilterChain()); + + MockHttpServletResponse limitedResponse = new MockHttpServletResponse(); + MockFilterChain limitedChain = new MockFilterChain(); + filter.doFilter(request("POST", "/api/files/scan"), limitedResponse, limitedChain); + + assertEquals(429, limitedResponse.getStatus()); + assertNull(limitedChain.getRequest()); + assertTrue(limitedResponse.getContentAsString().contains("\"category\":\"SCAN\"")); + assertNotNull(limitedResponse.getHeader(HttpHeaders.RETRY_AFTER)); + + MockHttpServletResponse pollingResponse = new MockHttpServletResponse(); + MockFilterChain pollingChain = new MockFilterChain(); + filter.doFilter(request("GET", "/api/files/scan/job-1"), pollingResponse, pollingChain); + + assertEquals(200, pollingResponse.getStatus()); + assertNotNull(pollingChain.getRequest()); + } + + @Test + void nonPositiveScanCapacityDisablesScanLimit() throws Exception { + RateLimitProperties properties = new RateLimitProperties(); + properties.getPerClient().setScan(new RateLimitProperties.Policy(0, Duration.ofMinutes(5))); + RateLimitFilter filter = filterWith(properties); + authenticate("alice"); + + for (int i = 0; i < 5; i++) { + MockFilterChain chain = new MockFilterChain(); + filter.doFilter(request("POST", "/api/files/scan"), new MockHttpServletResponse(), chain); + assertNotNull(chain.getRequest()); + } + } } diff --git a/backend/src/test/java/cloudpage/ratelimit/RateLimitServiceTest.java b/backend/src/test/java/cloudpage/ratelimit/RateLimitServiceTest.java index 4c50a65b..2db0ed74 100644 --- a/backend/src/test/java/cloudpage/ratelimit/RateLimitServiceTest.java +++ b/backend/src/test/java/cloudpage/ratelimit/RateLimitServiceTest.java @@ -25,6 +25,14 @@ private RateLimitProperties perClientUpload(int capacity, Duration refillPeriod) return properties; } + @Test + void scanDefaultsToOneRequestEveryFiveMinutes() { + RateLimitProperties.Policy scan = new RateLimitProperties().getPerClient().getScan(); + + assertEquals(1, scan.getCapacity()); + assertEquals(Duration.ofMinutes(5), scan.getRefillPeriod()); + } + @Test void allowsUpToCapacityThenThrottles() { RateLimitService service = serviceWith(perClientUpload(3, Duration.ofMinutes(1)));