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:
@@ -25,6 +25,18 @@ pub fn routes() -> Router<AppState> {
|
||||
.route("/admin/mangas/:id/chapters", get(list_chapters))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct ListChaptersParams {
|
||||
#[serde(default = "default_chapter_limit")]
|
||||
pub limit: i64,
|
||||
#[serde(default)]
|
||||
pub offset: i64,
|
||||
}
|
||||
|
||||
fn default_chapter_limit() -> i64 {
|
||||
200
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct ListMangasParams {
|
||||
#[serde(default)]
|
||||
@@ -76,12 +88,23 @@ async fn list_chapters(
|
||||
State(state): State<AppState>,
|
||||
_admin: RequireAdmin,
|
||||
Path(manga_id): Path<Uuid>,
|
||||
) -> AppResult<Json<Vec<AdminChapterRow>>> {
|
||||
Query(params): Query<ListChaptersParams>,
|
||||
) -> AppResult<Json<PagedResponse<AdminChapterRow>>> {
|
||||
// Explicit existence check so a typo / deleted manga returns 404
|
||||
// rather than a misleading "no chapters" 200.
|
||||
if !repo::manga::exists(&state.db, manga_id).await? {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
let rows = repo::admin_view::list_chapters_with_sync_state(&state.db, manga_id).await?;
|
||||
Ok(Json(rows))
|
||||
// Cap at 500 to bound the per-row scalar-subquery cost on
|
||||
// long-runners with thousands of chapters; default 200 covers
|
||||
// typical browsing without paging round-trips.
|
||||
let limit = params.limit.clamp(1, 500);
|
||||
let offset = params.offset.max(0);
|
||||
let q = repo::admin_view::ListAdminChaptersQuery {
|
||||
manga_id,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
let (items, total) = repo::admin_view::list_chapters_with_sync_state(&state.db, &q).await?;
|
||||
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user