Server: APK download rate limit 10/min per IP (D-119)
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:
2026-10-19 11:15:26 +02:00
parent 3ef2c15764
commit 96e4cbdf4e
2 changed files with 115 additions and 2 deletions
@@ -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");
}