bugfix: security & correctness bundle (0.34.1)
Five fixes bundled into one release: - preserve user-attached tags across crawler upserts (repo::crawler::sync_tags now scopes to added_by IS NULL; orphaned attachments from deleted users are reaped as crawler-owned) - gate manga PATCH and cover endpoints on uploaded_by (require_can_edit in api::mangas; non-NULL uploaded_by must match the caller) - equalise login response time across user-existence branches (run argon2 against a OnceLock-cached dummy hash on the no-user branch so timing doesn't leak username existence) - crawler download defences (SSRF allowlist of host literals including IPv4-mapped IPv6 ranges, 32 MiB streamed size cap, reject non-whitelisted image types, three-way chapter-probe classifier replaces the binary #avatar_menu check) - tighten validation and clean up dead unload path (attach_tag + create_token enforce 64-char caps; LocalStorage rejects NUL bytes explicitly; reader flushFinalProgress drops the always-405 sendBeacon path) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -567,6 +567,91 @@ async fn user_a_cannot_delete_user_b_token(pool: PgPool) {
|
||||
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
/// Username enumeration via login response time: an attacker probes
|
||||
/// for valid usernames by measuring how long /auth/login takes. Before
|
||||
/// the equalisation fix, the no-user branch returned 401 in <1 ms
|
||||
/// while the wrong-password branch took ~50-100 ms (the argon2 verify
|
||||
/// cost). This test asserts the no-user branch now spends at least
|
||||
/// some meaningful fraction of the wrong-password branch's time.
|
||||
///
|
||||
/// Tolerance is intentionally loose so CI variance doesn't flap the
|
||||
/// test. The unequalised gap is large enough (~50x) that even a noisy
|
||||
/// CI run with a 5x slack still catches it.
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn login_no_user_branch_runs_argon2_for_timing_equalisation(pool: PgPool) {
|
||||
use std::time::Instant;
|
||||
|
||||
let h = common::harness(pool);
|
||||
|
||||
// Register the victim user so the wrong-password branch has a real
|
||||
// argon2 hash to verify against.
|
||||
let _ = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::post_json(
|
||||
"/api/v1/auth/register",
|
||||
json!({ "username": "victim", "password": "hunter2hunter2" }),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Warm-up: first login of the process initialises the dummy hash
|
||||
// lazily. Skip that cost when measuring.
|
||||
let _ = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::post_json(
|
||||
"/api/v1/auth/login",
|
||||
json!({ "username": "victim", "password": "wrong" }),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let _ = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::post_json(
|
||||
"/api/v1/auth/login",
|
||||
json!({ "username": "ghost", "password": "wrong" }),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Median-of-N is more stable than a single sample.
|
||||
async fn sample_min(
|
||||
app: &axum::Router,
|
||||
username: &str,
|
||||
n: u32,
|
||||
) -> std::time::Duration {
|
||||
let mut samples = Vec::with_capacity(n as usize);
|
||||
for _ in 0..n {
|
||||
let req = common::post_json(
|
||||
"/api/v1/auth/login",
|
||||
json!({ "username": username, "password": "wrong-guess" }),
|
||||
);
|
||||
let t = Instant::now();
|
||||
let resp = app.clone().oneshot(req).await.unwrap();
|
||||
let d = t.elapsed();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
samples.push(d);
|
||||
}
|
||||
// Use the minimum: it's the floor that argon2 takes, robust
|
||||
// against unrelated stalls (DB connection acquisition, etc.).
|
||||
*samples.iter().min().unwrap()
|
||||
}
|
||||
|
||||
let wrong_pwd = sample_min(&h.app, "victim", 3).await;
|
||||
let no_user = sample_min(&h.app, "ghost", 3).await;
|
||||
|
||||
// 5x slack: argon2 dominates both branches, so they should be
|
||||
// within an order of magnitude. Unequalised, no_user would be
|
||||
// ~50-100x faster. Asserting "no_user >= wrong_pwd / 5" catches
|
||||
// the bug without being flaky in CI.
|
||||
assert!(
|
||||
no_user * 5 >= wrong_pwd,
|
||||
"login timing leaks user existence: no_user={no_user:?}, wrong_pwd={wrong_pwd:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn delete_unknown_token_is_404(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
@@ -581,3 +666,27 @@ async fn delete_unknown_token_is_404(pool: PgPool) {
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
/// Bot token names are user-supplied free-form strings; a 10 MB name
|
||||
/// was accepted before. Cap at 64 chars to match the other free-form
|
||||
/// identifier caps (tags, collection names). The response uses
|
||||
/// `ValidationFailed` (422 with per-field details) so clients can
|
||||
/// render the same shape they already handle for `attach_tag`.
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn create_token_rejects_name_over_64_chars(pool: PgPool) {
|
||||
let h = common::harness(pool);
|
||||
let (_, cookie) = common::register_user(&h.app).await;
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
"/api/v1/auth/tokens",
|
||||
json!({ "name": "x".repeat(65) }),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["error"]["code"], "validation_failed");
|
||||
assert!(body["error"]["details"]["name"].is_string());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user