Skip to content

Commit 3a54a0b

Browse files
authored
feat: add anonymous download rate limiting (#76)
1 parent 4bb01d9 commit 3a54a0b

File tree

8 files changed

+423
-19
lines changed

8 files changed

+423
-19
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.iflytek.skillhub.config;
2+
3+
import java.time.Duration;
4+
import org.springframework.boot.context.properties.ConfigurationProperties;
5+
import org.springframework.stereotype.Component;
6+
7+
@Component
8+
@ConfigurationProperties(prefix = "skillhub.ratelimit.download")
9+
public class DownloadRateLimitProperties {
10+
11+
private String anonymousCookieName = "skillhub_anon_dl";
12+
private Duration anonymousCookieMaxAge = Duration.ofDays(30);
13+
private String anonymousCookieSecret = "change-me-in-production";
14+
15+
public String getAnonymousCookieName() {
16+
return anonymousCookieName;
17+
}
18+
19+
public void setAnonymousCookieName(String anonymousCookieName) {
20+
this.anonymousCookieName = anonymousCookieName;
21+
}
22+
23+
public Duration getAnonymousCookieMaxAge() {
24+
return anonymousCookieMaxAge;
25+
}
26+
27+
public void setAnonymousCookieMaxAge(Duration anonymousCookieMaxAge) {
28+
this.anonymousCookieMaxAge = anonymousCookieMaxAge;
29+
}
30+
31+
public String getAnonymousCookieSecret() {
32+
return anonymousCookieSecret;
33+
}
34+
35+
public void setAnonymousCookieSecret(String anonymousCookieSecret) {
36+
this.anonymousCookieSecret = anonymousCookieSecret;
37+
}
38+
}

server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
public class RequestLoggingFilter extends OncePerRequestFilter {
2525

2626
private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
27+
private static final int MAX_LOG_BODY_LENGTH = 512;
2728

2829
@Override
2930
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
@@ -86,7 +87,7 @@ private String getRequestBody(ContentCachingRequestWrapper request) {
8687
byte[] buf = request.getContentAsByteArray();
8788
if (buf.length > 0) {
8889
try {
89-
return new String(buf, request.getCharacterEncoding());
90+
return truncateBody(new String(buf, request.getCharacterEncoding()));
9091
} catch (UnsupportedEncodingException e) {
9192
return "[unknown encoding]";
9293
}
@@ -98,11 +99,19 @@ private String getResponseBody(ContentCachingResponseWrapper response) {
9899
byte[] buf = response.getContentAsByteArray();
99100
if (buf.length > 0) {
100101
try {
101-
return new String(buf, response.getCharacterEncoding());
102+
return truncateBody(new String(buf, response.getCharacterEncoding()));
102103
} catch (UnsupportedEncodingException e) {
103104
return "[unknown encoding]";
104105
}
105106
}
106107
return null;
107108
}
109+
110+
private String truncateBody(String body) {
111+
if (body == null || body.length() <= MAX_LOG_BODY_LENGTH) {
112+
return body;
113+
}
114+
return body.substring(0, MAX_LOG_BODY_LENGTH)
115+
+ "... [truncated, original length=" + body.length() + "]";
116+
}
108117
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package com.iflytek.skillhub.ratelimit;
2+
3+
import com.iflytek.skillhub.config.DownloadRateLimitProperties;
4+
import jakarta.servlet.http.Cookie;
5+
import jakarta.servlet.http.HttpServletRequest;
6+
import jakarta.servlet.http.HttpServletResponse;
7+
import java.nio.charset.StandardCharsets;
8+
import java.security.GeneralSecurityException;
9+
import java.security.MessageDigest;
10+
import java.security.SecureRandom;
11+
import java.time.Duration;
12+
import java.util.Arrays;
13+
import java.util.Base64;
14+
import javax.crypto.Mac;
15+
import javax.crypto.spec.SecretKeySpec;
16+
import org.springframework.http.ResponseCookie;
17+
import org.springframework.stereotype.Component;
18+
19+
@Component
20+
public class AnonymousDownloadIdentityService {
21+
22+
private static final String COOKIE_VERSION = "v1";
23+
private static final SecureRandom RANDOM = new SecureRandom();
24+
25+
private final DownloadRateLimitProperties properties;
26+
private final ClientIpResolver clientIpResolver;
27+
28+
public AnonymousDownloadIdentityService(DownloadRateLimitProperties properties,
29+
ClientIpResolver clientIpResolver) {
30+
this.properties = properties;
31+
this.clientIpResolver = clientIpResolver;
32+
}
33+
34+
public AnonymousDownloadIdentity resolve(HttpServletRequest request, HttpServletResponse response) {
35+
String ip = clientIpResolver.resolve(request);
36+
String cookieId = extractValidCookieId(request);
37+
if (cookieId == null) {
38+
cookieId = generateId();
39+
response.addHeader("Set-Cookie", buildCookie(cookieId, request).toString());
40+
}
41+
return new AnonymousDownloadIdentity(hash(ip), hash(cookieId));
42+
}
43+
44+
private String extractValidCookieId(HttpServletRequest request) {
45+
Cookie[] cookies = request.getCookies();
46+
if (cookies == null) {
47+
return null;
48+
}
49+
return Arrays.stream(cookies)
50+
.filter(cookie -> properties.getAnonymousCookieName().equals(cookie.getName()))
51+
.map(Cookie::getValue)
52+
.map(this::parseAndVerify)
53+
.filter(value -> value != null && !value.isBlank())
54+
.findFirst()
55+
.orElse(null);
56+
}
57+
58+
private String parseAndVerify(String cookieValue) {
59+
if (cookieValue == null) {
60+
return null;
61+
}
62+
String[] parts = cookieValue.split("\\.", 3);
63+
if (parts.length != 3 || !COOKIE_VERSION.equals(parts[0])) {
64+
return null;
65+
}
66+
byte[] expected = sign(parts[1]);
67+
byte[] actual;
68+
try {
69+
actual = Base64.getUrlDecoder().decode(parts[2]);
70+
} catch (IllegalArgumentException ex) {
71+
return null;
72+
}
73+
return MessageDigest.isEqual(expected, actual) ? parts[1] : null;
74+
}
75+
76+
private ResponseCookie buildCookie(String cookieId, HttpServletRequest request) {
77+
Duration maxAge = properties.getAnonymousCookieMaxAge();
78+
return ResponseCookie.from(properties.getAnonymousCookieName(), encodeCookieValue(cookieId))
79+
.httpOnly(true)
80+
.secure(isSecure(request))
81+
.sameSite("Lax")
82+
.path("/")
83+
.maxAge(maxAge)
84+
.build();
85+
}
86+
87+
private boolean isSecure(HttpServletRequest request) {
88+
if (request.isSecure()) {
89+
return true;
90+
}
91+
String forwardedProto = request.getHeader("X-Forwarded-Proto");
92+
return forwardedProto != null && forwardedProto.equalsIgnoreCase("https");
93+
}
94+
95+
private String encodeCookieValue(String id) {
96+
return COOKIE_VERSION + "." + id + "." + Base64.getUrlEncoder().withoutPadding().encodeToString(sign(id));
97+
}
98+
99+
private byte[] sign(String value) {
100+
try {
101+
Mac mac = Mac.getInstance("HmacSHA256");
102+
mac.init(new SecretKeySpec(properties.getAnonymousCookieSecret().getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
103+
return mac.doFinal(value.getBytes(StandardCharsets.UTF_8));
104+
} catch (GeneralSecurityException ex) {
105+
throw new IllegalStateException("Failed to sign anonymous download cookie", ex);
106+
}
107+
}
108+
109+
private String generateId() {
110+
byte[] bytes = new byte[16];
111+
RANDOM.nextBytes(bytes);
112+
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
113+
}
114+
115+
private String hash(String raw) {
116+
try {
117+
MessageDigest digest = MessageDigest.getInstance("SHA-256");
118+
byte[] bytes = digest.digest(raw.getBytes(StandardCharsets.UTF_8));
119+
StringBuilder builder = new StringBuilder(bytes.length * 2);
120+
for (byte b : bytes) {
121+
builder.append(String.format("%02x", b));
122+
}
123+
return builder.toString();
124+
} catch (GeneralSecurityException ex) {
125+
throw new IllegalStateException("Failed to hash anonymous download identity", ex);
126+
}
127+
}
128+
129+
public record AnonymousDownloadIdentity(String ipHash, String cookieHash) {
130+
}
131+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package com.iflytek.skillhub.ratelimit;
2+
3+
import jakarta.servlet.http.HttpServletRequest;
4+
import java.util.regex.Matcher;
5+
import java.util.regex.Pattern;
6+
import org.springframework.stereotype.Component;
7+
8+
@Component
9+
public class ClientIpResolver {
10+
11+
private static final Pattern FORWARDED_FOR_PATTERN = Pattern.compile("for=\"?\\[?([^;,\"]+)\\]?\"?");
12+
13+
public String resolve(HttpServletRequest request) {
14+
String forwarded = trimToNull(request.getHeader("Forwarded"));
15+
if (forwarded != null) {
16+
Matcher matcher = FORWARDED_FOR_PATTERN.matcher(forwarded);
17+
if (matcher.find()) {
18+
return normalizeCandidate(matcher.group(1));
19+
}
20+
}
21+
22+
String xForwardedFor = trimToNull(request.getHeader("X-Forwarded-For"));
23+
if (xForwardedFor != null) {
24+
return normalizeCandidate(xForwardedFor.split(",")[0]);
25+
}
26+
27+
String xRealIp = trimToNull(request.getHeader("X-Real-IP"));
28+
if (xRealIp != null) {
29+
return normalizeCandidate(xRealIp);
30+
}
31+
32+
return normalizeCandidate(request.getRemoteAddr());
33+
}
34+
35+
private String trimToNull(String value) {
36+
if (value == null) {
37+
return null;
38+
}
39+
String trimmed = value.trim();
40+
return trimmed.isEmpty() || "unknown".equalsIgnoreCase(trimmed) ? null : trimmed;
41+
}
42+
43+
private String normalizeCandidate(String candidate) {
44+
String normalized = trimToNull(candidate);
45+
if (normalized == null) {
46+
return "unknown";
47+
}
48+
int zoneIndex = normalized.indexOf('%');
49+
if (zoneIndex >= 0) {
50+
normalized = normalized.substring(0, zoneIndex);
51+
}
52+
return normalized;
53+
}
54+
}

server/skillhub-app/src/main/java/com/iflytek/skillhub/ratelimit/RateLimitInterceptor.java

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,19 @@
1515
public class RateLimitInterceptor implements HandlerInterceptor {
1616

1717
private final RateLimiter rateLimiter;
18+
private final ClientIpResolver clientIpResolver;
19+
private final AnonymousDownloadIdentityService anonymousDownloadIdentityService;
1820
private final ApiResponseFactory apiResponseFactory;
1921
private final ObjectMapper objectMapper;
2022

2123
public RateLimitInterceptor(RateLimiter rateLimiter,
24+
ClientIpResolver clientIpResolver,
25+
AnonymousDownloadIdentityService anonymousDownloadIdentityService,
2226
ApiResponseFactory apiResponseFactory,
2327
ObjectMapper objectMapper) {
2428
this.rateLimiter = rateLimiter;
29+
this.clientIpResolver = clientIpResolver;
30+
this.anonymousDownloadIdentityService = anonymousDownloadIdentityService;
2531
this.apiResponseFactory = apiResponseFactory;
2632
this.objectMapper = objectMapper;
2733
}
@@ -46,12 +52,9 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons
4652
// Get limit based on authentication status
4753
int limit = isAuthenticated ? rateLimit.authenticated() : rateLimit.anonymous();
4854

49-
// Build rate limit key
50-
String identifier = isAuthenticated ? "user:" + userId : "ip:" + getClientIp(request);
51-
String key = "ratelimit:" + rateLimit.category() + ":" + identifier;
52-
53-
// Check rate limit
54-
boolean allowed = rateLimiter.tryAcquire(key, limit, rateLimit.windowSeconds());
55+
boolean allowed = isAuthenticated
56+
? rateLimiter.tryAcquire("ratelimit:" + rateLimit.category() + ":user:" + userId, limit, rateLimit.windowSeconds())
57+
: checkAnonymousLimit(request, response, rateLimit, limit);
5558

5659
if (!allowed) {
5760
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
@@ -64,18 +67,32 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons
6467
return true;
6568
}
6669

67-
private String getClientIp(HttpServletRequest request) {
68-
String ip = request.getHeader("X-Forwarded-For");
69-
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
70-
ip = request.getHeader("X-Real-IP");
71-
}
72-
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
73-
ip = request.getRemoteAddr();
70+
private boolean checkAnonymousLimit(HttpServletRequest request,
71+
HttpServletResponse response,
72+
RateLimit rateLimit,
73+
int limit) {
74+
if (!"download".equals(rateLimit.category())) {
75+
return rateLimiter.tryAcquire(
76+
"ratelimit:" + rateLimit.category() + ":ip:" + clientIpResolver.resolve(request),
77+
limit,
78+
rateLimit.windowSeconds()
79+
);
7480
}
75-
// Take first IP if multiple
76-
if (ip != null && ip.contains(",")) {
77-
ip = ip.split(",")[0].trim();
81+
82+
AnonymousDownloadIdentityService.AnonymousDownloadIdentity identity =
83+
anonymousDownloadIdentityService.resolve(request, response);
84+
boolean ipAllowed = rateLimiter.tryAcquire(
85+
"ratelimit:download:ip:" + identity.ipHash(),
86+
limit,
87+
rateLimit.windowSeconds()
88+
);
89+
if (!ipAllowed) {
90+
return false;
7891
}
79-
return ip;
92+
return rateLimiter.tryAcquire(
93+
"ratelimit:download:anon:" + identity.cookieHash(),
94+
limit,
95+
rateLimit.windowSeconds()
96+
);
8097
}
8198
}

server/skillhub-app/src/main/resources/application.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@ skillhub:
9292
weight: 0.35
9393
candidate-multiplier: 8
9494
max-candidates: 120
95+
ratelimit:
96+
download:
97+
anonymous-cookie-name: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_NAME:skillhub_anon_dl}
98+
anonymous-cookie-max-age: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_MAX_AGE:P30D}
99+
anonymous-cookie-secret: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET:change-me-in-production}
95100
publish:
96101
max-file-count: 100
97102
max-single-file-size: 1048576 # 1MB

0 commit comments

Comments
 (0)