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:
@@ -362,14 +362,12 @@ async fn csrf_rejects_cookie_auth_without_origin_or_referer(pool: PgPool) {
|
||||
// ("curl bypass"). It's now refused: an extension or no-referrer-policy
|
||||
// page can produce exactly this shape and would otherwise be a CSRF
|
||||
// vector. Real curl/script callers should authenticate with a bearer
|
||||
// token instead — see `csrf_allows_bearer_token_without_origin`.
|
||||
// token instead — see `csrf_bearer_only_request_bypasses_gate`.
|
||||
let h = harness_with_admin_origins(
|
||||
pool.clone(),
|
||||
vec!["http://localhost:3000".to_string()],
|
||||
);
|
||||
let cookie = seed_admin(&pool, &h.app).await;
|
||||
// `post_json_with_cookie_origin(..., None, None)` builds the exact "no
|
||||
// Origin, no Referer" cookie-auth shape this test pins.
|
||||
let resp = h
|
||||
.app
|
||||
.clone()
|
||||
@@ -385,6 +383,77 @@ async fn csrf_rejects_cookie_auth_without_origin_or_referer(pool: PgPool) {
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn csrf_bearer_only_request_bypasses_gate(pool: PgPool) {
|
||||
// Bearer-only (no session cookie, no Origin) bypasses the CSRF gate
|
||||
// because a browser can't set `Authorization` on a cross-site POST.
|
||||
// RequireAdmin then rejects bot tokens for admin endpoints — we
|
||||
// expect 401, not the CSRF 403. The path proves the bypass branch
|
||||
// is reachable for legitimate bot callers.
|
||||
let h = harness_with_admin_origins(
|
||||
pool.clone(),
|
||||
vec!["http://localhost:3000".to_string()],
|
||||
);
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/admin/crawler/session/clear-expired")
|
||||
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
||||
.header(axum::http::header::AUTHORIZATION, "Bearer fake-bot-token")
|
||||
.body(axum::body::Body::from("{}"))
|
||||
.unwrap();
|
||||
let resp = h.app.clone().oneshot(req).await.unwrap();
|
||||
// 401 from RequireAdmin (bad/non-admin token), NOT 403 from CSRF.
|
||||
// If the CSRF gate had fired, we'd get FORBIDDEN.
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn csrf_bearer_plus_cookie_still_enforces_gate(pool: PgPool) {
|
||||
// The cookie-ride vector: an attacker page mints `Authorization:
|
||||
// Bearer junk` on a cross-site credentialed POST. The cookie is
|
||||
// attached automatically by the browser; the bearer header alone
|
||||
// used to bypass CSRF, then `CurrentUser` authenticated the victim
|
||||
// via the cookie. Cookie precedence here means: as soon as a
|
||||
// session cookie is present, the CSRF gate fires regardless of
|
||||
// Authorization — the bearer can't whitelist the cookie ride.
|
||||
let h = harness_with_admin_origins(
|
||||
pool.clone(),
|
||||
vec!["http://localhost:3000".to_string()],
|
||||
);
|
||||
let cookie = seed_admin(&pool, &h.app).await;
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/admin/crawler/session/clear-expired")
|
||||
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
||||
.header(axum::http::header::COOKIE, cookie)
|
||||
.header(axum::http::header::AUTHORIZATION, "Bearer junk")
|
||||
.header(axum::http::header::ORIGIN, "https://evil.example.com")
|
||||
.body(axum::body::Body::from("{}"))
|
||||
.unwrap();
|
||||
let resp = h.app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn csrf_no_auth_falls_through_to_401(pool: PgPool) {
|
||||
// No cookie, no bearer — there's no authority to ride, so the CSRF
|
||||
// gate yields and lets `RequireAdmin` return the more meaningful 401.
|
||||
// Pins the carve-out that keeps anonymous curl-against-admin from
|
||||
// 403-via-CSRF (which would mislead operators about what's blocking).
|
||||
let h = harness_with_admin_origins(
|
||||
pool.clone(),
|
||||
vec!["http://localhost:3000".to_string()],
|
||||
);
|
||||
let req = axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/v1/admin/crawler/session/clear-expired")
|
||||
.header(axum::http::header::CONTENT_TYPE, "application/json")
|
||||
.body(axum::body::Body::from("{}"))
|
||||
.unwrap();
|
||||
let resp = h.app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn csrf_falls_back_to_referer_when_origin_missing(pool: PgPool) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user