feat: add anonymous download rate limiting (#76)

This commit is contained in:
Xudong Sun 2026-03-18 11:31:10 +08:00 committed by GitHub
parent 8fd9e364fd
commit c2284d8acf
8 changed files with 423 additions and 19 deletions

View file

@ -0,0 +1,38 @@
package com.iflytek.skillhub.config;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "skillhub.ratelimit.download")
public class DownloadRateLimitProperties {
private String anonymousCookieName = "skillhub_anon_dl";
private Duration anonymousCookieMaxAge = Duration.ofDays(30);
private String anonymousCookieSecret = "change-me-in-production";
public String getAnonymousCookieName() {
return anonymousCookieName;
}
public void setAnonymousCookieName(String anonymousCookieName) {
this.anonymousCookieName = anonymousCookieName;
}
public Duration getAnonymousCookieMaxAge() {
return anonymousCookieMaxAge;
}
public void setAnonymousCookieMaxAge(Duration anonymousCookieMaxAge) {
this.anonymousCookieMaxAge = anonymousCookieMaxAge;
}
public String getAnonymousCookieSecret() {
return anonymousCookieSecret;
}
public void setAnonymousCookieSecret(String anonymousCookieSecret) {
this.anonymousCookieSecret = anonymousCookieSecret;
}
}

View file

@ -24,6 +24,7 @@ import java.util.Map;
public class RequestLoggingFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
private static final int MAX_LOG_BODY_LENGTH = 512;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
@ -86,7 +87,7 @@ public class RequestLoggingFilter extends OncePerRequestFilter {
byte[] buf = request.getContentAsByteArray();
if (buf.length > 0) {
try {
return new String(buf, request.getCharacterEncoding());
return truncateBody(new String(buf, request.getCharacterEncoding()));
} catch (UnsupportedEncodingException e) {
return "[unknown encoding]";
}
@ -98,11 +99,19 @@ public class RequestLoggingFilter extends OncePerRequestFilter {
byte[] buf = response.getContentAsByteArray();
if (buf.length > 0) {
try {
return new String(buf, response.getCharacterEncoding());
return truncateBody(new String(buf, response.getCharacterEncoding()));
} catch (UnsupportedEncodingException e) {
return "[unknown encoding]";
}
}
return null;
}
private String truncateBody(String body) {
if (body == null || body.length() <= MAX_LOG_BODY_LENGTH) {
return body;
}
return body.substring(0, MAX_LOG_BODY_LENGTH)
+ "... [truncated, original length=" + body.length() + "]";
}
}

View file

@ -0,0 +1,131 @@
package com.iflytek.skillhub.ratelimit;
import com.iflytek.skillhub.config.DownloadRateLimitProperties;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.Arrays;
import java.util.Base64;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Component;
@Component
public class AnonymousDownloadIdentityService {
private static final String COOKIE_VERSION = "v1";
private static final SecureRandom RANDOM = new SecureRandom();
private final DownloadRateLimitProperties properties;
private final ClientIpResolver clientIpResolver;
public AnonymousDownloadIdentityService(DownloadRateLimitProperties properties,
ClientIpResolver clientIpResolver) {
this.properties = properties;
this.clientIpResolver = clientIpResolver;
}
public AnonymousDownloadIdentity resolve(HttpServletRequest request, HttpServletResponse response) {
String ip = clientIpResolver.resolve(request);
String cookieId = extractValidCookieId(request);
if (cookieId == null) {
cookieId = generateId();
response.addHeader("Set-Cookie", buildCookie(cookieId, request).toString());
}
return new AnonymousDownloadIdentity(hash(ip), hash(cookieId));
}
private String extractValidCookieId(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return null;
}
return Arrays.stream(cookies)
.filter(cookie -> properties.getAnonymousCookieName().equals(cookie.getName()))
.map(Cookie::getValue)
.map(this::parseAndVerify)
.filter(value -> value != null && !value.isBlank())
.findFirst()
.orElse(null);
}
private String parseAndVerify(String cookieValue) {
if (cookieValue == null) {
return null;
}
String[] parts = cookieValue.split("\\.", 3);
if (parts.length != 3 || !COOKIE_VERSION.equals(parts[0])) {
return null;
}
byte[] expected = sign(parts[1]);
byte[] actual;
try {
actual = Base64.getUrlDecoder().decode(parts[2]);
} catch (IllegalArgumentException ex) {
return null;
}
return MessageDigest.isEqual(expected, actual) ? parts[1] : null;
}
private ResponseCookie buildCookie(String cookieId, HttpServletRequest request) {
Duration maxAge = properties.getAnonymousCookieMaxAge();
return ResponseCookie.from(properties.getAnonymousCookieName(), encodeCookieValue(cookieId))
.httpOnly(true)
.secure(isSecure(request))
.sameSite("Lax")
.path("/")
.maxAge(maxAge)
.build();
}
private boolean isSecure(HttpServletRequest request) {
if (request.isSecure()) {
return true;
}
String forwardedProto = request.getHeader("X-Forwarded-Proto");
return forwardedProto != null && forwardedProto.equalsIgnoreCase("https");
}
private String encodeCookieValue(String id) {
return COOKIE_VERSION + "." + id + "." + Base64.getUrlEncoder().withoutPadding().encodeToString(sign(id));
}
private byte[] sign(String value) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(properties.getAnonymousCookieSecret().getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return mac.doFinal(value.getBytes(StandardCharsets.UTF_8));
} catch (GeneralSecurityException ex) {
throw new IllegalStateException("Failed to sign anonymous download cookie", ex);
}
}
private String generateId() {
byte[] bytes = new byte[16];
RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
private String hash(String raw) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest(raw.getBytes(StandardCharsets.UTF_8));
StringBuilder builder = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
builder.append(String.format("%02x", b));
}
return builder.toString();
} catch (GeneralSecurityException ex) {
throw new IllegalStateException("Failed to hash anonymous download identity", ex);
}
}
public record AnonymousDownloadIdentity(String ipHash, String cookieHash) {
}
}

View file

@ -0,0 +1,54 @@
package com.iflytek.skillhub.ratelimit;
import jakarta.servlet.http.HttpServletRequest;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.stereotype.Component;
@Component
public class ClientIpResolver {
private static final Pattern FORWARDED_FOR_PATTERN = Pattern.compile("for=\"?\\[?([^;,\"]+)\\]?\"?");
public String resolve(HttpServletRequest request) {
String forwarded = trimToNull(request.getHeader("Forwarded"));
if (forwarded != null) {
Matcher matcher = FORWARDED_FOR_PATTERN.matcher(forwarded);
if (matcher.find()) {
return normalizeCandidate(matcher.group(1));
}
}
String xForwardedFor = trimToNull(request.getHeader("X-Forwarded-For"));
if (xForwardedFor != null) {
return normalizeCandidate(xForwardedFor.split(",")[0]);
}
String xRealIp = trimToNull(request.getHeader("X-Real-IP"));
if (xRealIp != null) {
return normalizeCandidate(xRealIp);
}
return normalizeCandidate(request.getRemoteAddr());
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() || "unknown".equalsIgnoreCase(trimmed) ? null : trimmed;
}
private String normalizeCandidate(String candidate) {
String normalized = trimToNull(candidate);
if (normalized == null) {
return "unknown";
}
int zoneIndex = normalized.indexOf('%');
if (zoneIndex >= 0) {
normalized = normalized.substring(0, zoneIndex);
}
return normalized;
}
}

View file

@ -15,13 +15,19 @@ import org.springframework.web.servlet.HandlerInterceptor;
public class RateLimitInterceptor implements HandlerInterceptor {
private final RateLimiter rateLimiter;
private final ClientIpResolver clientIpResolver;
private final AnonymousDownloadIdentityService anonymousDownloadIdentityService;
private final ApiResponseFactory apiResponseFactory;
private final ObjectMapper objectMapper;
public RateLimitInterceptor(RateLimiter rateLimiter,
ClientIpResolver clientIpResolver,
AnonymousDownloadIdentityService anonymousDownloadIdentityService,
ApiResponseFactory apiResponseFactory,
ObjectMapper objectMapper) {
this.rateLimiter = rateLimiter;
this.clientIpResolver = clientIpResolver;
this.anonymousDownloadIdentityService = anonymousDownloadIdentityService;
this.apiResponseFactory = apiResponseFactory;
this.objectMapper = objectMapper;
}
@ -46,12 +52,9 @@ public class RateLimitInterceptor implements HandlerInterceptor {
// Get limit based on authentication status
int limit = isAuthenticated ? rateLimit.authenticated() : rateLimit.anonymous();
// Build rate limit key
String identifier = isAuthenticated ? "user:" + userId : "ip:" + getClientIp(request);
String key = "ratelimit:" + rateLimit.category() + ":" + identifier;
// Check rate limit
boolean allowed = rateLimiter.tryAcquire(key, limit, rateLimit.windowSeconds());
boolean allowed = isAuthenticated
? rateLimiter.tryAcquire("ratelimit:" + rateLimit.category() + ":user:" + userId, limit, rateLimit.windowSeconds())
: checkAnonymousLimit(request, response, rateLimit, limit);
if (!allowed) {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
@ -64,18 +67,32 @@ public class RateLimitInterceptor implements HandlerInterceptor {
return true;
}
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
private boolean checkAnonymousLimit(HttpServletRequest request,
HttpServletResponse response,
RateLimit rateLimit,
int limit) {
if (!"download".equals(rateLimit.category())) {
return rateLimiter.tryAcquire(
"ratelimit:" + rateLimit.category() + ":ip:" + clientIpResolver.resolve(request),
limit,
rateLimit.windowSeconds()
);
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
AnonymousDownloadIdentityService.AnonymousDownloadIdentity identity =
anonymousDownloadIdentityService.resolve(request, response);
boolean ipAllowed = rateLimiter.tryAcquire(
"ratelimit:download:ip:" + identity.ipHash(),
limit,
rateLimit.windowSeconds()
);
if (!ipAllowed) {
return false;
}
// Take first IP if multiple
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
return rateLimiter.tryAcquire(
"ratelimit:download:anon:" + identity.cookieHash(),
limit,
rateLimit.windowSeconds()
);
}
}

View file

@ -92,6 +92,11 @@ skillhub:
weight: 0.35
candidate-multiplier: 8
max-candidates: 120
ratelimit:
download:
anonymous-cookie-name: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_NAME:skillhub_anon_dl}
anonymous-cookie-max-age: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_MAX_AGE:P30D}
anonymous-cookie-secret: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET:change-me-in-production}
publish:
max-file-count: 100
max-single-file-size: 1048576 # 1MB

View file

@ -0,0 +1,102 @@
package com.iflytek.skillhub.controller.portal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.iflytek.skillhub.TestRedisConfig;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.skill.service.SkillDownloadService;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.ratelimit.RateLimiter;
import java.io.ByteArrayInputStream;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Import(TestRedisConfig.class)
class DownloadRateLimitControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private SkillQueryService skillQueryService;
@MockBean
private SkillDownloadService skillDownloadService;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@MockBean
private DeviceAuthService deviceAuthService;
@MockBean
private RateLimiter rateLimiter;
@Test
void anonymousDownloadUsesIpAndSignedCookieBuckets() throws Exception {
given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willReturn(true);
given(skillDownloadService.downloadVersion("global", "demo-skill", "1.0.0", null, Map.of()))
.willReturn(new SkillDownloadService.DownloadResult(
new ByteArrayInputStream("zip".getBytes()),
"demo-skill-1.0.0.zip",
3L,
"application/zip",
null
));
var result = mockMvc.perform(get("/api/v1/skills/global/demo-skill/versions/1.0.0/download")
.header("X-Forwarded-For", "203.0.113.10"))
.andExpect(status().isOk())
.andExpect(header().string("Content-Disposition", "attachment; filename=\"demo-skill-1.0.0.zip\""))
.andExpect(header().exists("Set-Cookie"))
.andReturn();
assertThat(result.getResponse().getHeaders("Set-Cookie"))
.anySatisfy(cookie -> {
assertThat(cookie).contains("skillhub_anon_dl=");
assertThat(cookie).contains("HttpOnly");
assertThat(cookie).contains("SameSite=Lax");
});
ArgumentCaptor<String> keyCaptor = ArgumentCaptor.forClass(String.class);
verify(rateLimiter, times(2)).tryAcquire(keyCaptor.capture(), anyInt(), anyInt());
assertThat(keyCaptor.getAllValues()).anyMatch(key -> key.startsWith("ratelimit:download:ip:"));
assertThat(keyCaptor.getAllValues()).anyMatch(key -> key.startsWith("ratelimit:download:anon:"));
}
@Test
void anonymousDownloadReturnsTooManyRequestsWhenIpBucketIsExceeded() throws Exception {
given(rateLimiter.tryAcquire(anyString(), anyInt(), anyInt())).willAnswer(invocation ->
((String) invocation.getArgument(0)).startsWith("ratelimit:download:ip:") ? false : true);
mockMvc.perform(get("/api/v1/skills/global/demo-skill/versions/1.0.0/download")
.header("X-Forwarded-For", "203.0.113.10"))
.andExpect(status().isTooManyRequests())
.andExpect(jsonPath("$.code").value(429));
verify(skillDownloadService, never()).downloadVersion(anyString(), anyString(), anyString(), anyString(), any());
}
}

View file

@ -0,0 +1,48 @@
package com.iflytek.skillhub.filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(OutputCaptureExtension.class)
class RequestLoggingFilterTest {
@Test
void doFilterInternal_truncatesLongRequestAndResponseBodiesInLogs(CapturedOutput output)
throws ServletException, IOException {
RequestLoggingFilter filter = new RequestLoggingFilter();
String longBody = "x".repeat(5_000);
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/test");
request.setCharacterEncoding(StandardCharsets.UTF_8.name());
request.setContentType("application/json");
request.setContent(longBody.getBytes(StandardCharsets.UTF_8));
MockHttpServletResponse response = new MockHttpServletResponse();
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
FilterChain filterChain = (req, res) -> {
req.getReader().lines().count();
res.setContentType("application/json");
res.getWriter().write(longBody);
};
filter.doFilter(request, response, filterChain);
assertThat(output).contains("Request Body: " + "x".repeat(512) + "... [truncated, original length=5000]");
assertThat(output).contains("Response Body: " + "x".repeat(512) + "... [truncated, original length=5000]");
assertThat(output).doesNotContain("Request Body: " + longBody);
assertThat(output).doesNotContain("Response Body: " + longBody);
assertThat(response.getContentAsString()).isEqualTo(longBody);
}
}