fix(server): login timing pad, 409 on register race, avatar magic-byte check
Test / test (pull_request) Failing after 17m26s
Test / test (pull_request) Failing after 17m26s
Closes the three actionable server Low findings from the 2026-07-06 review: - #139: unknown-username logins now verify against a static bcrypt dummy hash so both failure paths pay the same cost — response timing no longer reveals which usernames exist. - #140: register maps a unique-constraint violation to UsernameTaken (409) — the SELECT pre-check stays as the friendly fast path, the constraint is the arbiter for the concurrent case. - #141: avatar uploads must start with the magic bytes of the declared image type; the stored extension (which decides how the file is re-served) is now backed by content, not the Content-Type header. Unit tests: real/spoofed/truncated signatures for all four formats, and the dummy hash's validity at BCRYPT_COST. Closes #139 Closes #140 Closes #141 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -74,6 +74,15 @@ struct UserRow {
|
|||||||
/// bcrypt work factor. Cost 12 ≈ 300 ms on modern hardware — balances security against registration latency.
|
/// bcrypt work factor. Cost 12 ≈ 300 ms on modern hardware — balances security against registration latency.
|
||||||
pub const BCRYPT_COST: u32 = 12;
|
pub const BCRYPT_COST: u32 = 12;
|
||||||
|
|
||||||
|
/// Static bcrypt hash used to equalise login timing when the username does
|
||||||
|
/// not exist (issue #139: user-enumeration timing oracle). Both login paths
|
||||||
|
/// must pay the same bcrypt cost; this hash is verified against when there
|
||||||
|
/// is no real one. Computed once at first use with the same [`BCRYPT_COST`]
|
||||||
|
/// as real hashes. `None` only if bcrypt itself fails on a constant input —
|
||||||
|
/// in that case the dummy verify is skipped rather than panicking.
|
||||||
|
static DUMMY_PASSWORD_HASH: std::sync::LazyLock<Option<String>> =
|
||||||
|
std::sync::LazyLock::new(|| hash("ferrous-dummy-timing-pad", BCRYPT_COST).ok());
|
||||||
|
|
||||||
async fn hash_password(password: String) -> Result<String, AppError> {
|
async fn hash_password(password: String) -> Result<String, AppError> {
|
||||||
tokio::task::spawn_blocking(move || hash(password, BCRYPT_COST))
|
tokio::task::spawn_blocking(move || hash(password, BCRYPT_COST))
|
||||||
.await
|
.await
|
||||||
@@ -208,6 +217,10 @@ pub async fn register(
|
|||||||
let password_hash = hash_password(body.password).await?;
|
let password_hash = hash_password(body.password).await?;
|
||||||
let now = Utc::now().to_rfc3339();
|
let now = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
// The SELECT above is a friendly fast path; the UNIQUE constraint is the
|
||||||
|
// real arbiter. A concurrent registration that slips between the two
|
||||||
|
// surfaces as a unique violation here — map it to the same 409 the
|
||||||
|
// fast path produces instead of a raw 500 (issue #140).
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)",
|
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)",
|
||||||
user_id,
|
user_id,
|
||||||
@@ -216,7 +229,11 @@ pub async fn register(
|
|||||||
now
|
now
|
||||||
)
|
)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await
|
||||||
|
.map_err(|e| match &e {
|
||||||
|
sqlx::Error::Database(db) if db.is_unique_violation() => AppError::UsernameTaken,
|
||||||
|
_ => AppError::from(e),
|
||||||
|
})?;
|
||||||
|
|
||||||
let access_token = make_access_token(&user_id, &state.jwt_secret)?;
|
let access_token = make_access_token(&user_id, &state.jwt_secret)?;
|
||||||
let (refresh_token, refresh_jti) = make_refresh_token(&user_id, &state.jwt_secret)?;
|
let (refresh_token, refresh_jti) = make_refresh_token(&user_id, &state.jwt_secret)?;
|
||||||
@@ -242,7 +259,16 @@ pub async fn login(
|
|||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let row = row.ok_or(AppError::InvalidCredentials)?;
|
let Some(row) = row else {
|
||||||
|
// Unknown username: burn a bcrypt verify against a static dummy hash
|
||||||
|
// so this path costs the same as a wrong-password attempt. Returning
|
||||||
|
// immediately here would let response timing reveal which usernames
|
||||||
|
// exist (issue #139).
|
||||||
|
if let Some(dummy) = DUMMY_PASSWORD_HASH.as_ref() {
|
||||||
|
let _ = verify_password(body.password, dummy.clone()).await;
|
||||||
|
}
|
||||||
|
return Err(AppError::InvalidCredentials);
|
||||||
|
};
|
||||||
let row_id = row
|
let row_id = row
|
||||||
.id
|
.id
|
||||||
.ok_or_else(|| AppError::Internal("user id missing".into()))?;
|
.ok_or_else(|| AppError::Internal("user id missing".into()))?;
|
||||||
@@ -362,6 +388,22 @@ const AVATAR_MAX_BYTES: usize = 1024 * 1024;
|
|||||||
///
|
///
|
||||||
/// The `Content-Type` header must be one of `image/jpeg`, `image/png`,
|
/// The `Content-Type` header must be one of `image/jpeg`, `image/png`,
|
||||||
/// `image/webp`, or `image/gif`. The previous avatar file is replaced in-place.
|
/// `image/webp`, or `image/gif`. The previous avatar file is replaced in-place.
|
||||||
|
/// Returns `true` when `body` begins with the magic bytes of the image type
|
||||||
|
/// implied by `ext` (the extension derived from the declared Content-Type).
|
||||||
|
///
|
||||||
|
/// Avatars are re-served under the stored extension, so the extension must be
|
||||||
|
/// derived from the content itself — a client header is not evidence of what
|
||||||
|
/// the bytes are (issue #141).
|
||||||
|
fn magic_bytes_match(ext: &str, body: &[u8]) -> bool {
|
||||||
|
match ext {
|
||||||
|
"png" => body.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]),
|
||||||
|
"jpg" => body.starts_with(&[0xFF, 0xD8, 0xFF]),
|
||||||
|
"gif" => body.starts_with(b"GIF87a") || body.starts_with(b"GIF89a"),
|
||||||
|
"webp" => body.len() >= 12 && body.starts_with(b"RIFF") && &body[8..12] == b"WEBP",
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn upload_avatar(
|
pub async fn upload_avatar(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
user: AuthenticatedUser,
|
user: AuthenticatedUser,
|
||||||
@@ -387,6 +429,14 @@ pub async fn upload_avatar(
|
|||||||
if body.len() > AVATAR_MAX_BYTES {
|
if body.len() > AVATAR_MAX_BYTES {
|
||||||
return Err(AppError::BadRequest("avatar must be ≤ 1 MB".into()));
|
return Err(AppError::BadRequest("avatar must be ≤ 1 MB".into()));
|
||||||
}
|
}
|
||||||
|
// The stored extension decides how the file is re-served, so it must be
|
||||||
|
// backed by the bytes, not just the client's Content-Type header
|
||||||
|
// (issue #141).
|
||||||
|
if !magic_bytes_match(ext, &body) {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"avatar bytes do not match the declared image type".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Write to avatars/ directory, replacing any previous file for this user.
|
// Write to avatars/ directory, replacing any previous file for this user.
|
||||||
tokio::fs::create_dir_all("avatars")
|
tokio::fs::create_dir_all("avatars")
|
||||||
@@ -587,6 +637,46 @@ mod tests {
|
|||||||
assert!(username_chars_ok(""));
|
assert!(username_chars_ok(""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn magic_bytes_match_accepts_real_signatures() {
|
||||||
|
assert!(magic_bytes_match(
|
||||||
|
"png",
|
||||||
|
&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0x00]
|
||||||
|
));
|
||||||
|
assert!(magic_bytes_match("jpg", &[0xFF, 0xD8, 0xFF, 0xE0, 0x00]));
|
||||||
|
assert!(magic_bytes_match("gif", b"GIF89a\x00\x00"));
|
||||||
|
assert!(magic_bytes_match("gif", b"GIF87a\x00\x00"));
|
||||||
|
assert!(magic_bytes_match("webp", b"RIFF\x00\x00\x00\x00WEBPVP8 "));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn magic_bytes_match_rejects_mismatched_or_bogus_content() {
|
||||||
|
// HTML declared as PNG — the exact spoof the check exists to stop.
|
||||||
|
assert!(!magic_bytes_match("png", b"<html><script>"));
|
||||||
|
// Real PNG bytes declared as JPEG: extension must match the bytes.
|
||||||
|
assert!(!magic_bytes_match(
|
||||||
|
"jpg",
|
||||||
|
&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]
|
||||||
|
));
|
||||||
|
// Truncated / empty bodies never match.
|
||||||
|
assert!(!magic_bytes_match("png", &[0x89, b'P']));
|
||||||
|
assert!(!magic_bytes_match("webp", b"RIFF1234WEB"));
|
||||||
|
assert!(!magic_bytes_match("gif", b""));
|
||||||
|
// Unknown extensions are never accepted.
|
||||||
|
assert!(!magic_bytes_match("svg", b"<svg xmlns="));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dummy_password_hash_is_available_and_verifiable() {
|
||||||
|
// The login timing pad (issue #139) must be a real bcrypt hash so the
|
||||||
|
// unknown-user path pays a genuine verify at BCRYPT_COST.
|
||||||
|
let dummy = DUMMY_PASSWORD_HASH
|
||||||
|
.as_ref()
|
||||||
|
.expect("bcrypt of a constant input must succeed");
|
||||||
|
assert!(verify("ferrous-dummy-timing-pad", dummy).unwrap_or(false));
|
||||||
|
assert!(!verify("wrong-password", dummy).unwrap_or(true));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn username_chars_ok_rejects_unicode_letters() {
|
fn username_chars_ok_rejects_unicode_letters() {
|
||||||
// Non-ASCII characters must be rejected even if they look like letters.
|
// Non-ASCII characters must be rejected even if they look like letters.
|
||||||
|
|||||||
Reference in New Issue
Block a user