fix: reject a malformed reenqueue body instead of running the full scope
Option<Json<T>> collapses a malformed body to None, so a bad reenqueue body silently ran the default full "All" scope (audit). Parse raw bytes: empty = default All, malformed = 422. Tested both. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.128.16"
|
version = "0.128.17"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.128.16"
|
version = "0.128.17"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -328,10 +328,21 @@ fn ensure_enabled(state: &AppState) -> AppResult<()> {
|
|||||||
async fn reenqueue(
|
async fn reenqueue(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
admin: RequireAdmin,
|
admin: RequireAdmin,
|
||||||
body: Option<Json<ReenqueueBody>>,
|
raw: axum::body::Bytes,
|
||||||
) -> AppResult<Json<ReenqueueResponse>> {
|
) -> AppResult<Json<ReenqueueResponse>> {
|
||||||
ensure_enabled(&state)?;
|
ensure_enabled(&state)?;
|
||||||
let body = body.map(|b| b.0).unwrap_or_default();
|
// An ABSENT/empty body means the default "All" scope. A PRESENT body must be
|
||||||
|
// valid JSON — `Option<Json<T>>` would silently collapse a malformed body to
|
||||||
|
// None and run the full-library default the caller never intended, so parse
|
||||||
|
// the raw bytes ourselves and 422 on a real parse error.
|
||||||
|
let body: ReenqueueBody = if raw.iter().all(u8::is_ascii_whitespace) {
|
||||||
|
ReenqueueBody::default()
|
||||||
|
} else {
|
||||||
|
serde_json::from_slice(&raw).map_err(|e| AppError::ValidationFailed {
|
||||||
|
message: "reenqueue body is not valid JSON".into(),
|
||||||
|
details: json!({ "body": e.to_string() }),
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
|
||||||
// Resolve the scope. manga_id and chapter_id are mutually exclusive;
|
// Resolve the scope. manga_id and chapter_id are mutually exclusive;
|
||||||
// an unknown target is a 404 rather than a silent zero-enqueue.
|
// an unknown target is a 404 rather than a silent zero-enqueue.
|
||||||
|
|||||||
@@ -189,6 +189,51 @@ async fn reenqueue_returns_503_when_analysis_disabled(pool: PgPool) {
|
|||||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn reenqueue_rejects_a_malformed_body(pool: PgPool) {
|
||||||
|
// A present-but-malformed JSON body must be a 422 — NOT silently coerced to
|
||||||
|
// an absent body, which would run the default full "All" scope the caller
|
||||||
|
// never asked for and enqueue jobs across the whole library.
|
||||||
|
let h = common::harness_with_analysis(pool.clone());
|
||||||
|
let cookie = seed_admin(&pool, &h.app).await;
|
||||||
|
seed_pages(&pool, 2).await;
|
||||||
|
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.clone()
|
||||||
|
.oneshot(common::post_raw_with_cookie(
|
||||||
|
"/api/v1/admin/analysis/reenqueue",
|
||||||
|
"{ not valid json",
|
||||||
|
&cookie,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
assert_eq!(analyze_job_count(&pool).await, 0, "a rejected body enqueues nothing");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn reenqueue_treats_empty_body_as_default_all_scope(pool: PgPool) {
|
||||||
|
// An absent/empty body is still valid: it means the default "All" scope.
|
||||||
|
let h = common::harness_with_analysis(pool.clone());
|
||||||
|
let cookie = seed_admin(&pool, &h.app).await;
|
||||||
|
seed_pages(&pool, 2).await;
|
||||||
|
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.clone()
|
||||||
|
.oneshot(common::post_raw_with_cookie(
|
||||||
|
"/api/v1/admin/analysis/reenqueue",
|
||||||
|
"",
|
||||||
|
&cookie,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = common::body_json(resp).await;
|
||||||
|
assert_eq!(body["enqueued"], 2, "empty body backfills the whole library");
|
||||||
|
}
|
||||||
|
|
||||||
#[sqlx::test(migrations = "./migrations")]
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
async fn reenqueue_backfills_existing_pages(pool: PgPool) {
|
async fn reenqueue_backfills_existing_pages(pool: PgPool) {
|
||||||
let h = common::harness_with_analysis(pool.clone());
|
let h = common::harness_with_analysis(pool.clone());
|
||||||
|
|||||||
@@ -496,6 +496,20 @@ pub fn post_json_with_cookie(
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like [`post_json_with_cookie`] but sends a raw (possibly malformed) body,
|
||||||
|
/// for exercising body-parse error handling. Content-Type stays JSON and the
|
||||||
|
/// CSRF Origin is attached so the request reaches the handler.
|
||||||
|
pub fn post_raw_with_cookie(uri: &str, body: &str, cookie: &str) -> Request<Body> {
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri(uri)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json")
|
||||||
|
.header(header::COOKIE, cookie)
|
||||||
|
.header(header::ORIGIN, TEST_ORIGIN)
|
||||||
|
.body(Body::from(body.to_string()))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
/// Same as [`post_json_with_cookie`] but also attaches `Origin` (and
|
/// Same as [`post_json_with_cookie`] but also attaches `Origin` (and
|
||||||
/// optionally `Referer`) headers. Used by the admin CSRF tests to drive
|
/// optionally `Referer`) headers. Used by the admin CSRF tests to drive
|
||||||
/// the cross-origin reject + allowed-origin accept paths.
|
/// the cross-origin reject + allowed-origin accept paths.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.128.16",
|
"version": "0.128.17",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user