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:
@@ -4,6 +4,8 @@
|
||||
//! expire naturally rather than being explicitly invalidated, so other
|
||||
//! devices keep their existing logins).
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
@@ -102,9 +104,15 @@ async fn login(
|
||||
));
|
||||
}
|
||||
|
||||
let user = repo::user::find_by_username(&state.db, username)
|
||||
.await?
|
||||
.ok_or(AppError::Unauthenticated)?;
|
||||
let user = repo::user::find_by_username(&state.db, username).await?;
|
||||
let Some(user) = user else {
|
||||
// No such user. Run argon2 against a stable dummy hash so the
|
||||
// response time matches the wrong-password branch — otherwise
|
||||
// an attacker can enumerate usernames by timing the no-user
|
||||
// 401 against the wrong-password 401.
|
||||
let _ = verify_password(&input.password, dummy_password_hash());
|
||||
return Err(AppError::Unauthenticated);
|
||||
};
|
||||
if !verify_password(&input.password, &user.password_hash) {
|
||||
return Err(AppError::Unauthenticated);
|
||||
}
|
||||
@@ -113,6 +121,21 @@ async fn login(
|
||||
Ok((StatusCode::OK, jar, Json(AuthResponse { user })))
|
||||
}
|
||||
|
||||
/// Lazily-computed argon2 hash used to equalise login response time
|
||||
/// across the "no such user" and "wrong password" branches. Computing
|
||||
/// it once (on the first login of the process) is enough — the hash is
|
||||
/// never compared against a real password, only used to force argon2
|
||||
/// to do the same amount of work it would for a real verify.
|
||||
fn dummy_password_hash() -> &'static str {
|
||||
static DUMMY: OnceLock<String> = OnceLock::new();
|
||||
DUMMY
|
||||
.get_or_init(|| {
|
||||
crate::auth::password::hash_password("login-timing-equaliser")
|
||||
.expect("hash_password on a fixed input cannot fail")
|
||||
})
|
||||
.as_str()
|
||||
}
|
||||
|
||||
async fn logout(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
@@ -230,8 +253,24 @@ async fn create_token(
|
||||
Json(input): Json<CreateTokenInput>,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
let name = input.name.trim();
|
||||
// Both arms use `ValidationFailed` (422 with field details) to
|
||||
// match the structured-error shape `attach_tag` returns for the
|
||||
// same kind of free-form-identifier validation. The other
|
||||
// /auth/* handlers in this file use `InvalidInput` (400); the
|
||||
// divergence is pre-existing and would warrant a project-wide
|
||||
// pass to flip them all if the client side wants uniform per-
|
||||
// field error rendering.
|
||||
if name.is_empty() {
|
||||
return Err(AppError::InvalidInput("token name is required".into()));
|
||||
return Err(AppError::ValidationFailed {
|
||||
message: "token name is required".into(),
|
||||
details: serde_json::json!({ "name": "required" }),
|
||||
});
|
||||
}
|
||||
if name.chars().count() > 64 {
|
||||
return Err(AppError::ValidationFailed {
|
||||
message: "token name too long".into(),
|
||||
details: serde_json::json!({ "name": "max 64 characters" }),
|
||||
});
|
||||
}
|
||||
let (raw, hash) = generate_token();
|
||||
let token = repo::api_token::create(&state.db, user.id, name, &hash).await?;
|
||||
|
||||
@@ -196,16 +196,14 @@ async fn create(
|
||||
|
||||
async fn update(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(patch): Json<MangaPatch>,
|
||||
) -> AppResult<Json<MangaDetail>> {
|
||||
// TODO(auth): until uploaders are tracked (Phase 5), any signed-in
|
||||
// user can edit any manga. Restrict to uploader + admin once that
|
||||
// column lands.
|
||||
if !repo::manga::exists(&state.db, id).await? {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
require_can_edit(&state, id, user.id).await?;
|
||||
|
||||
if let Some(ref status) = patch.status {
|
||||
let trimmed = status.trim();
|
||||
@@ -269,16 +267,14 @@ async fn update(
|
||||
/// `MangaDetail`.
|
||||
async fn put_cover(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
mut multipart: Multipart,
|
||||
) -> AppResult<Json<MangaDetail>> {
|
||||
// TODO(auth): until uploaders are tracked (Phase 5), any signed-in
|
||||
// user can edit any manga's cover. Restrict to uploader + admin
|
||||
// once that column lands.
|
||||
if !repo::manga::exists(&state.db, id).await? {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
require_can_edit(&state, id, user.id).await?;
|
||||
|
||||
let mut cover: Option<UploadedImage> = None;
|
||||
while let Some(field) = next_field(&mut multipart).await? {
|
||||
@@ -320,13 +316,13 @@ async fn put_cover(
|
||||
/// with the unchanged detail.
|
||||
async fn delete_cover(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(_user): CurrentUser,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> AppResult<Json<MangaDetail>> {
|
||||
// TODO(auth): same caveat as put_cover.
|
||||
if !repo::manga::exists(&state.db, id).await? {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
require_can_edit(&state, id, user.id).await?;
|
||||
if let Some(key) = repo::manga::get(&state.db, id).await?.cover_image_path {
|
||||
match state.storage.delete(&key).await {
|
||||
Ok(()) | Err(StorageError::NotFound) => {}
|
||||
@@ -348,6 +344,7 @@ async fn attach_tag(
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<AttachTagBody>,
|
||||
) -> AppResult<(StatusCode, Json<TagRef>)> {
|
||||
validate_tag_name(&body.name)?;
|
||||
if !repo::manga::exists(&state.db, id).await? {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
@@ -394,6 +391,27 @@ async fn detach_tag(
|
||||
}
|
||||
}
|
||||
|
||||
/// Request-side validation for `POST /mangas/:id/tags` body. Mirrors
|
||||
/// the repo-level cap in `repo::tag::upsert_by_name` (max 64 chars
|
||||
/// after trim) but surfaces the failure at the handler boundary with
|
||||
/// the same envelope shape other validations use.
|
||||
fn validate_tag_name(name: &str) -> AppResult<()> {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::ValidationFailed {
|
||||
message: "tag name cannot be empty".into(),
|
||||
details: json!({ "name": "required" }),
|
||||
});
|
||||
}
|
||||
if trimmed.chars().count() > 64 {
|
||||
return Err(AppError::ValidationFailed {
|
||||
message: "tag name too long".into(),
|
||||
details: json!({ "name": "max 64 characters" }),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_new_manga(input: &NewManga) -> AppResult<()> {
|
||||
if input.title.trim().is_empty() {
|
||||
return Err(AppError::ValidationFailed {
|
||||
@@ -413,6 +431,30 @@ fn validate_new_manga(input: &NewManga) -> AppResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Authorisation gate for manga mutations. The manga is assumed to
|
||||
/// exist (the caller runs [`repo::manga::exists`] first so a missing id
|
||||
/// surfaces as `NotFound`, not `Forbidden`).
|
||||
///
|
||||
/// Rule: a non-NULL `uploaded_by` must match the current user. Legacy
|
||||
/// rows with `uploaded_by IS NULL` (pre-migration-0011) are still
|
||||
/// editable by any signed-in user — there's nobody to gate on yet, and
|
||||
/// the historical-data note in 0011 acknowledges the gap. Once an
|
||||
/// admin role lands the NULL case can flip to admin-only.
|
||||
///
|
||||
/// Returns `Forbidden` (not `NotFound`) on owner mismatch — mangas
|
||||
/// are listable via `GET /mangas`, so existence isn't a secret and
|
||||
/// the more accurate 403 is fine. This deliberately differs from
|
||||
/// `repo::collection::require_owner`, which collapses both states to
|
||||
/// `NotFound` because collections are private to a user and existence
|
||||
/// itself is information worth hiding from non-owners.
|
||||
async fn require_can_edit(state: &AppState, manga_id: Uuid, user_id: Uuid) -> AppResult<()> {
|
||||
match repo::manga::uploaded_by(&state.db, manga_id).await? {
|
||||
Some(owner) if owner != user_id => Err(AppError::Forbidden),
|
||||
// Some(owner) == user_id (good) or None (legacy row, no owner).
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn validate_genre_ids(state: &AppState, ids: &[Uuid]) -> AppResult<()> {
|
||||
if ids.is_empty() {
|
||||
return Ok(());
|
||||
|
||||
Reference in New Issue
Block a user