Server: APK download rate limit 10/min per IP (D-119)
Build Kvitt images / build (push) Failing after 55s
Build Kvitt images / build (push) Failing after 55s
E-6 (/apk/files/{file_name}) gets its own per-IP bucket of 10 requests per
minute (security review L-3), keyed like the 60/min unauthenticated limit
(peer or trusted-proxy X-Forwarded-For, D-032) and answered 429
rate_limited with Retry-After. E-6 no longer counts against the 60; E-5 and
E-4 stay on the 60/min limit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -200,3 +200,67 @@ async fn ten_failed_invite_accepts_lock_the_ip_for_the_rest_of_the_hour() {
|
||||
let reply = accept(&good, "198.51.100.1:1").await;
|
||||
assert_eq!(reply.status, 200, "{}", reply.text());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apk_downloads_10_per_minute_per_ip_in_their_own_bucket() {
|
||||
let app = TestApp::empty().await;
|
||||
let dir = app.data_dir().join("apk");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("kvitt-412.apk"), b"apk").unwrap();
|
||||
std::fs::write(
|
||||
dir.join("latest.json"),
|
||||
example("E-5-apk-latest/response.json").to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let file = "/apk/files/kvitt-412.apk";
|
||||
for i in 0..10 {
|
||||
let reply = app.send(from("192.0.2.1:1", None, file, None)).await;
|
||||
assert_eq!(reply.status, 200, "download {i}");
|
||||
}
|
||||
app.clock
|
||||
.set(parse_ts(NOW).unwrap() + Duration::seconds(20));
|
||||
let limited = app.send(from("192.0.2.1:1", None, file, None)).await;
|
||||
assert_error(&limited, 429, "rate_limited");
|
||||
assert_eq!(limited.headers["retry-after"], "40");
|
||||
// Unknown files count too: the limit is per route, not per answer.
|
||||
let limited = app
|
||||
.send(from("192.0.2.1:1", None, "/apk/files/kvitt-1.apk", None))
|
||||
.await;
|
||||
assert_error(&limited, 429, "rate_limited");
|
||||
// Another client is not affected; neither are the other unauthenticated endpoints of the same client.
|
||||
let other = app.send(from("192.0.2.2:1", None, file, None)).await;
|
||||
assert_eq!(other.status, 200);
|
||||
for path in ["/apk/latest", "/i/7K3MQX9D2HPA", "/health"] {
|
||||
let reply = app.send(from("192.0.2.1:1", None, path, None)).await;
|
||||
assert_eq!(reply.status, 200, "{path}");
|
||||
}
|
||||
// Behind the trusted proxy the forwarded client is the key, as for the 60 per minute.
|
||||
for _ in 0..10 {
|
||||
app.send(from("127.0.0.9:1", Some("203.0.113.5"), file, None))
|
||||
.await;
|
||||
}
|
||||
let limited = app
|
||||
.send(from("127.0.0.9:1", Some("203.0.113.5"), file, None))
|
||||
.await;
|
||||
assert_error(&limited, 429, "rate_limited");
|
||||
// The window ends a minute after the first download.
|
||||
app.clock
|
||||
.set(parse_ts(NOW).unwrap() + Duration::seconds(60));
|
||||
let again = app.send(from("192.0.2.1:1", None, file, None)).await;
|
||||
assert_eq!(again.status, 200);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apk_downloads_do_not_use_up_the_unauthenticated_limit() {
|
||||
let app = TestApp::empty().await;
|
||||
for _ in 0..10 {
|
||||
app.send(from("192.0.2.1:1", None, "/apk/files/kvitt-1.apk", None))
|
||||
.await;
|
||||
}
|
||||
for i in 0..60 {
|
||||
let reply = app.send(from("192.0.2.1:1", None, "/health", None)).await;
|
||||
assert_eq!(reply.status, 200, "request {i}");
|
||||
}
|
||||
let limited = app.send(from("192.0.2.1:1", None, "/health", None)).await;
|
||||
assert_error(&limited, 429, "rate_limited");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user