fix(admin): security-audit findings — paginate chapters, lock down unchecked helper (0.41.2)
Addresses the security-audit findings on top of the admin feature stack: M1: /admin/mangas/:id/chapters now paginates (default limit 200, max 500). A long-runner with thousands of chapters would otherwise produce a multi-MB response with that many scalar subqueries per row — admin-only but a real stall risk on one expand-click. Adds explicit pagination tests for the cap and offset; frontend renders a "Showing first N of M" hint when the cap clips the result. L1: repo::user::set_is_admin renamed to set_is_admin_unchecked with a doc-comment pointing at admin_safe_set_is_admin for production use. The short name was a footgun — a future contributor reaching for it would silently bypass self-protection, the last-admin invariant, and the audit log. Used only by integration-test setup; production code goes through the admin_safe_* paths. CSRF posture: build_session_cookie carries a comment that the SameSite=Lax default is the project's CSRF defense for state-changing mutations and breaks the instant anyone adds a side-effecting GET under /admin/*. Spells out what to do then (Strict + explicit token check). Test counts: 43 backend admin tests + 12 vitest admin tests all green; svelte-check 0/0 across 446 files.
This commit is contained in:
@@ -24,7 +24,7 @@ async fn seed_admin(pool: &PgPool, app: &Router) -> (String, String) {
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
repo::user::set_is_admin(pool, u.id, true).await.unwrap();
|
||||
repo::user::set_is_admin_unchecked(pool, u.id, true).await.unwrap();
|
||||
(username, cookie)
|
||||
}
|
||||
|
||||
@@ -107,6 +107,25 @@ async fn insert_job(pool: &PgPool, payload: serde_json::Value, state: &str) {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Per-variant tests don't care about pagination — fetch the whole
|
||||
/// chapter set (up to the hard cap) and discard the total.
|
||||
async fn fetch_chapter_rows(
|
||||
pool: &PgPool,
|
||||
manga_id: Uuid,
|
||||
) -> Vec<mangalord::repo::admin_view::AdminChapterRow> {
|
||||
let (rows, _) = repo::admin_view::list_chapters_with_sync_state(
|
||||
pool,
|
||||
&repo::admin_view::ListAdminChaptersQuery {
|
||||
manga_id,
|
||||
limit: 500,
|
||||
offset: 0,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
rows
|
||||
}
|
||||
|
||||
// ---- manga sync state ------------------------------------------------------
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
@@ -257,9 +276,7 @@ async fn chapter_state_synced_when_pages_present(pool: PgPool) {
|
||||
let c = insert_chapter(&pool, m, 1, 12).await;
|
||||
insert_chapter_source(&pool, c, "ckey-1", false).await;
|
||||
|
||||
let rows = repo::admin_view::list_chapters_with_sync_state(&pool, m)
|
||||
.await
|
||||
.unwrap();
|
||||
let rows = fetch_chapter_rows(&pool, m).await;
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].id, c);
|
||||
assert_eq!(rows[0].sync_state, mangalord::domain::ChapterSyncState::Synced);
|
||||
@@ -272,9 +289,7 @@ async fn chapter_state_not_downloaded_when_page_count_zero(pool: PgPool) {
|
||||
let c = insert_chapter(&pool, m, 1, 0).await;
|
||||
insert_chapter_source(&pool, c, "ckey-1", false).await;
|
||||
|
||||
let rows = repo::admin_view::list_chapters_with_sync_state(&pool, m)
|
||||
.await
|
||||
.unwrap();
|
||||
let rows = fetch_chapter_rows(&pool, m).await;
|
||||
assert_eq!(
|
||||
rows[0].sync_state,
|
||||
mangalord::domain::ChapterSyncState::NotDownloaded
|
||||
@@ -299,9 +314,7 @@ async fn chapter_state_downloading_when_job_in_flight(pool: PgPool) {
|
||||
)
|
||||
.await;
|
||||
|
||||
let rows = repo::admin_view::list_chapters_with_sync_state(&pool, m)
|
||||
.await
|
||||
.unwrap();
|
||||
let rows = fetch_chapter_rows(&pool, m).await;
|
||||
assert_eq!(
|
||||
rows[0].sync_state,
|
||||
mangalord::domain::ChapterSyncState::Downloading
|
||||
@@ -315,9 +328,7 @@ async fn chapter_state_dropped_when_all_sources_dropped(pool: PgPool) {
|
||||
let c = insert_chapter(&pool, m, 1, 0).await;
|
||||
insert_chapter_source(&pool, c, "ckey-1", true).await;
|
||||
|
||||
let rows = repo::admin_view::list_chapters_with_sync_state(&pool, m)
|
||||
.await
|
||||
.unwrap();
|
||||
let rows = fetch_chapter_rows(&pool, m).await;
|
||||
assert_eq!(
|
||||
rows[0].sync_state,
|
||||
mangalord::domain::ChapterSyncState::Dropped
|
||||
@@ -342,9 +353,7 @@ async fn chapter_state_failed_when_most_recent_job_dead(pool: PgPool) {
|
||||
)
|
||||
.await;
|
||||
|
||||
let rows = repo::admin_view::list_chapters_with_sync_state(&pool, m)
|
||||
.await
|
||||
.unwrap();
|
||||
let rows = fetch_chapter_rows(&pool, m).await;
|
||||
assert_eq!(
|
||||
rows[0].sync_state,
|
||||
mangalord::domain::ChapterSyncState::Failed
|
||||
@@ -415,12 +424,67 @@ async fn http_list_chapters_returns_per_chapter_state(pool: PgPool) {
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
let items = body.as_array().unwrap();
|
||||
let items = body["items"].as_array().unwrap();
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0]["id"], c1.to_string());
|
||||
assert_eq!(items[0]["sync_state"], "synced");
|
||||
assert_eq!(items[1]["id"], c2.to_string());
|
||||
assert_eq!(items[1]["sync_state"], "not_downloaded");
|
||||
assert_eq!(body["page"]["total"], 2);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn http_list_chapters_caps_limit_at_500(pool: PgPool) {
|
||||
// The handler clamps limit to [1, 500] so a long-runner with
|
||||
// thousands of chapters can't be turned into a request-stall by an
|
||||
// admin (or by a curious admin tab) just clicking expand.
|
||||
let h = common::harness(pool.clone());
|
||||
let (_admin, cookie) = seed_admin(&pool, &h.app).await;
|
||||
seed_source(&pool).await;
|
||||
let m = insert_manga(&pool, "M").await;
|
||||
for n in 1..=3 {
|
||||
let _c = insert_chapter(&pool, m, n, 0).await;
|
||||
}
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get_with_cookie(
|
||||
&format!("/api/v1/admin/mangas/{m}/chapters?limit=999"),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["page"]["limit"], 500, "limit must clamp to 500");
|
||||
assert_eq!(body["items"].as_array().unwrap().len(), 3);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn http_list_chapters_paginates(pool: PgPool) {
|
||||
let h = common::harness(pool.clone());
|
||||
let (_admin, cookie) = seed_admin(&pool, &h.app).await;
|
||||
seed_source(&pool).await;
|
||||
let m = insert_manga(&pool, "M").await;
|
||||
for n in 1..=5 {
|
||||
let _c = insert_chapter(&pool, m, n, 0).await;
|
||||
}
|
||||
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::get_with_cookie(
|
||||
&format!("/api/v1/admin/mangas/{m}/chapters?limit=2&offset=2"),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let body = common::body_json(resp).await;
|
||||
let items = body["items"].as_array().unwrap();
|
||||
assert_eq!(items.len(), 2);
|
||||
// Ordered by chapter number ascending; offset=2 skips chapters 1 & 2.
|
||||
assert_eq!(items[0]["number"], 3);
|
||||
assert_eq!(items[1]["number"], 4);
|
||||
assert_eq!(body["page"]["total"], 5);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
@@ -463,9 +527,7 @@ async fn chapter_state_synced_when_pages_present_even_with_dead_job(pool: PgPool
|
||||
)
|
||||
.await;
|
||||
|
||||
let rows = repo::admin_view::list_chapters_with_sync_state(&pool, m)
|
||||
.await
|
||||
.unwrap();
|
||||
let rows = fetch_chapter_rows(&pool, m).await;
|
||||
assert_eq!(
|
||||
rows[0].sync_state,
|
||||
mangalord::domain::ChapterSyncState::Synced,
|
||||
|
||||
Reference in New Issue
Block a user