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:
@@ -166,11 +166,23 @@ pub struct AdminChapterRow {
|
||||
pub latest_seen_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ListAdminChaptersQuery {
|
||||
pub manga_id: Uuid,
|
||||
pub limit: i64,
|
||||
pub offset: i64,
|
||||
}
|
||||
|
||||
/// Paginated chapter list with derived sync state. Pagination is non-
|
||||
/// optional — long-runners can have thousands of chapters and the
|
||||
/// per-row scalar subqueries make the unbounded variant a real
|
||||
/// stall risk even behind an admin guard. Returns the page slice plus
|
||||
/// the unfiltered total so the UI can render "showing N of M".
|
||||
pub async fn list_chapters_with_sync_state(
|
||||
pool: &PgPool,
|
||||
manga_id: Uuid,
|
||||
) -> AppResult<Vec<AdminChapterRow>> {
|
||||
let rows: Vec<AdminChapterRow> = sqlx::query_as(
|
||||
q: &ListAdminChaptersQuery,
|
||||
) -> AppResult<(Vec<AdminChapterRow>, i64)> {
|
||||
let items: Vec<AdminChapterRow> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
c.id, c.manga_id, c.number, c.title, c.page_count, c.created_at,
|
||||
@@ -202,10 +214,19 @@ pub async fn list_chapters_with_sync_state(
|
||||
FROM chapters c
|
||||
WHERE c.manga_id = $1
|
||||
ORDER BY c.number ASC
|
||||
LIMIT $2 OFFSET $3
|
||||
"#,
|
||||
)
|
||||
.bind(manga_id)
|
||||
.bind(q.manga_id)
|
||||
.bind(q.limit)
|
||||
.bind(q.offset)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows)
|
||||
|
||||
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM chapters WHERE manga_id = $1")
|
||||
.bind(q.manga_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok((items, total))
|
||||
}
|
||||
|
||||
@@ -114,7 +114,12 @@ pub async fn list_with_total(
|
||||
Ok((items, total))
|
||||
}
|
||||
|
||||
pub async fn set_is_admin(pool: &PgPool, id: Uuid, value: bool) -> AppResult<()> {
|
||||
/// Raw `is_admin` update with no safety checks, no audit log, and no
|
||||
/// advisory lock. Exists only as a test setup helper for the admin-
|
||||
/// feature integration suite — production code MUST go through
|
||||
/// [`admin_safe_set_is_admin`], which enforces self-protection, the
|
||||
/// last-admin invariant, and the audit log atomically.
|
||||
pub async fn set_is_admin_unchecked(pool: &PgPool, id: Uuid, value: bool) -> AppResult<()> {
|
||||
sqlx::query("UPDATE users SET is_admin = $1 WHERE id = $2")
|
||||
.bind(value)
|
||||
.bind(id)
|
||||
|
||||
Reference in New Issue
Block a user