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
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
public enum RateLimitCategory {
UPLOAD,
DOWNLOAD,
LISTING
LISTING,
SCAN
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -77,6 +79,7 @@ public Policy forCategory(RateLimitCategory category) {
case UPLOAD -> upload;
case DOWNLOAD -> download;
case LISTING -> listing;
case SCAN -> scan;
};
}

Expand All @@ -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}. */
Expand Down
2 changes: 2 additions & 0 deletions backend/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
Expand Down