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

@@ -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) {