fix(admin-security): close the bearer-cookie ride; require session for admin gate (0.87.10)

Two follow-ups to 0.87.2 + 0.87.3 from the adversarial review:

1. **CSRF cookie-ride.** Bearer-bypass branch checked Authorization
   header presence only — an attacker page could mint `Authorization:
   Bearer junk` and ride the still-attached session cookie. Bypass now
   requires bearer AND no cookie. Drive the cookie-name comparison off
   `SESSION_COOKIE_NAME` and split on `=` (not prefix).

2. **`require_can_edit` admin path open to bearer.** Now reads admin
   authority off an `Option<CurrentSessionUser>` extractor — None on
   bearer-only requests. Owner-match path still accepts bearer; admin
   path is cookie-gated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-22 22:28:16 +02:00
parent 747bdeda46
commit 93bb156fba
7 changed files with 220 additions and 32 deletions

View File

@@ -642,6 +642,85 @@ async fn patch_null_uploader_rejected_for_non_admin(pool: PgPool) {
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
/// The original uploader-of-record (when one exists) must keep editing
/// their own row even after admin gating tightens, so a legitimate
/// uploader on the same auth surface isn't locked out alongside drive-by
/// attackers. Re-runs the patch as the original cookie.
#[sqlx::test(migrations = "./migrations")]
async fn patch_owner_can_still_edit_after_admin_gate(pool: PgPool) {
let h = common::harness(pool.clone());
let (_, cookie) = common::register_user(&h.app).await;
let created = create_manga(&h.app, &cookie, json!({ "title": "Owner" })).await;
let id = id_of(&created);
// Row keeps uploaded_by set to the creating user (no NULL twiddle).
let resp = h
.app
.oneshot(common::patch_json_with_cookie(
&format!("/api/v1/mangas/{id}"),
json!({ "status": "completed" }),
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
/// Cover endpoints share `require_can_edit` with PATCH but write blob
/// state to `storage`, so a regression that lets non-admins through
/// `put_cover` would let any registered user replace a crawler-imported
/// cover. `delete_cover` would wipe blobs entirely. Tests both, mirroring
/// the PATCH guard tests above.
#[sqlx::test(migrations = "./migrations")]
async fn put_cover_null_uploader_rejected_for_non_admin(pool: PgPool) {
let h = common::harness(pool.clone());
let (_, cookie) = common::register_user(&h.app).await;
let created = create_manga(&h.app, &cookie, json!({ "title": "Catalog" })).await;
let id = id_of(&created);
sqlx::query("UPDATE mangas SET uploaded_by = NULL WHERE id = $1")
.bind(id)
.execute(&pool)
.await
.unwrap();
let (_, other_cookie) = common::register_user(&h.app).await;
// Tiny PNG `cover` part is what put_cover expects to look at.
let png = common::fake_png_bytes();
let mp = common::MultipartBuilder::new()
.add_file("cover", "cover.png", "image/png", &png);
let resp = h
.app
.oneshot(common::put_multipart_with_cookie(
&format!("/api/v1/mangas/{id}/cover"),
mp,
&other_cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[sqlx::test(migrations = "./migrations")]
async fn delete_cover_null_uploader_rejected_for_non_admin(pool: PgPool) {
let h = common::harness(pool.clone());
let (_, cookie) = common::register_user(&h.app).await;
let created = create_manga(&h.app, &cookie, json!({ "title": "Catalog" })).await;
let id = id_of(&created);
sqlx::query("UPDATE mangas SET uploaded_by = NULL WHERE id = $1")
.bind(id)
.execute(&pool)
.await
.unwrap();
let (_, other_cookie) = common::register_user(&h.app).await;
let resp = h
.app
.oneshot(common::delete_with_cookie(
&format!("/api/v1/mangas/{id}/cover"),
&other_cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
/// Admins CAN edit NULL-`uploaded_by` rows, since they're the operators
/// curating the crawled catalog. Mirror of the test above with the
/// caller promoted.