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:
@@ -8,7 +8,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::api::pagination::PagedResponse;
|
||||
use crate::app::AppState;
|
||||
use crate::auth::extractor::CurrentUser;
|
||||
use crate::auth::extractor::{CurrentSessionUser, CurrentUser};
|
||||
use crate::domain::manga::{MangaCard, MangaDetail, MangaPatch, NewManga};
|
||||
use crate::domain::patch::Patch;
|
||||
use crate::domain::tag::TagRef;
|
||||
@@ -229,13 +229,14 @@ async fn create(
|
||||
async fn update(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
session: Option<CurrentSessionUser>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(patch): Json<MangaPatch>,
|
||||
) -> AppResult<Json<MangaDetail>> {
|
||||
if !repo::manga::exists(&state.db, id).await? {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
require_can_edit(&state, id, user.id, user.is_admin).await?;
|
||||
require_can_edit(&state, id, user.id, admin_via_session(&session)).await?;
|
||||
|
||||
if let Some(ref status) = patch.status {
|
||||
let trimmed = status.trim();
|
||||
@@ -300,13 +301,14 @@ async fn update(
|
||||
async fn put_cover(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
session: Option<CurrentSessionUser>,
|
||||
Path(id): Path<Uuid>,
|
||||
mut multipart: Multipart,
|
||||
) -> AppResult<Json<MangaDetail>> {
|
||||
if !repo::manga::exists(&state.db, id).await? {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
require_can_edit(&state, id, user.id, user.is_admin).await?;
|
||||
require_can_edit(&state, id, user.id, admin_via_session(&session)).await?;
|
||||
|
||||
let mut cover: Option<UploadedImage> = None;
|
||||
while let Some(field) = next_field(&mut multipart).await? {
|
||||
@@ -349,12 +351,13 @@ async fn put_cover(
|
||||
async fn delete_cover(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
session: Option<CurrentSessionUser>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> AppResult<Json<MangaDetail>> {
|
||||
if !repo::manga::exists(&state.db, id).await? {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
require_can_edit(&state, id, user.id, user.is_admin).await?;
|
||||
require_can_edit(&state, id, user.id, admin_via_session(&session)).await?;
|
||||
if let Some(key) = repo::manga::get(&state.db, id).await?.cover_image_path {
|
||||
match state.storage.delete(&key).await {
|
||||
Ok(()) | Err(StorageError::NotFound) => {}
|
||||
@@ -468,15 +471,23 @@ fn validate_new_manga(input: &NewManga) -> AppResult<()> {
|
||||
/// surfaces as `NotFound`, not `Forbidden`).
|
||||
///
|
||||
/// Rule: a non-NULL `uploaded_by` must match the current user, OR the
|
||||
/// caller is an admin. Rows with `uploaded_by IS NULL` (crawler-imported
|
||||
/// + legacy pre-0011) are admin-only.
|
||||
/// caller is an admin **via a session cookie**. Rows with
|
||||
/// `uploaded_by IS NULL` (crawler-imported + legacy pre-0011) are
|
||||
/// admin-via-session-only.
|
||||
///
|
||||
/// Why: every crawler row has NULL `uploaded_by` (see
|
||||
/// `repo::crawler::upsert_manga`). The earlier "any signed-in user can
|
||||
/// edit a NULL row" rule meant `register → PATCH /api/v1/mangas/<id>`
|
||||
/// rewrote the catalog for free, and `delete_cover` removed blobs from
|
||||
/// `storage`. Now that the admin role exists (migration 0018), gate
|
||||
/// crawler/legacy rows on it.
|
||||
/// Why "via session": [`crate::auth::extractor`] documents that admin
|
||||
/// authority is unreachable via bearer tokens — a leaked bot token
|
||||
/// must not yield admin powers. Keep that invariant by reading the
|
||||
/// `is_admin_session` flag from the optional `CurrentSessionUser`
|
||||
/// extractor (`None` ⇒ caller authenticated via bearer ⇒ admin path
|
||||
/// is closed) rather than from `User.is_admin` (which is true whichever
|
||||
/// way the user authenticated).
|
||||
///
|
||||
/// Why NULL is admin-only: every crawler row has NULL `uploaded_by`
|
||||
/// (see `repo::crawler::upsert_manga`). The earlier "any signed-in
|
||||
/// user can edit a NULL row" rule meant `register → PATCH
|
||||
/// /api/v1/mangas/<id>` rewrote the catalog for free, and
|
||||
/// `delete_cover` removed blobs from `storage`.
|
||||
///
|
||||
/// Returns `Forbidden` (not `NotFound`) on owner mismatch — mangas
|
||||
/// are listable via `GET /mangas`, so existence isn't a secret and
|
||||
@@ -488,17 +499,28 @@ async fn require_can_edit(
|
||||
state: &AppState,
|
||||
manga_id: Uuid,
|
||||
user_id: Uuid,
|
||||
is_admin: bool,
|
||||
is_admin_session: bool,
|
||||
) -> AppResult<()> {
|
||||
match repo::manga::uploaded_by(&state.db, manga_id).await? {
|
||||
Some(owner) if owner == user_id => Ok(()),
|
||||
Some(_) if is_admin => Ok(()),
|
||||
Some(_) if is_admin_session => Ok(()),
|
||||
Some(_) => Err(AppError::Forbidden),
|
||||
None if is_admin => Ok(()),
|
||||
None if is_admin_session => Ok(()),
|
||||
None => Err(AppError::Forbidden),
|
||||
}
|
||||
}
|
||||
|
||||
/// True iff the caller authenticated via session cookie AND is an admin.
|
||||
/// Used to gate the admin-only branches of [`require_can_edit`]. The
|
||||
/// `Option<CurrentSessionUser>` extractor returns `None` on a bearer-only
|
||||
/// request, so admin authority over bot tokens never composes here.
|
||||
fn admin_via_session(session: &Option<CurrentSessionUser>) -> bool {
|
||||
session
|
||||
.as_ref()
|
||||
.map(|CurrentSessionUser(u)| u.is_admin)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn validate_genre_ids(state: &AppState, ids: &[Uuid]) -> AppResult<()> {
|
||||
if ids.is_empty() {
|
||||
return Ok(());
|
||||
|
||||
@@ -1089,27 +1089,45 @@ async fn admin_csrf_guard(
|
||||
return Ok(next.run(req).await);
|
||||
}
|
||||
let headers = req.headers();
|
||||
// Bearer-token requests are non-browser bot callers and bypass the
|
||||
// gate entirely (the Authorization header is never set by an
|
||||
// attacker page).
|
||||
|
||||
// Detect both auth modes BEFORE choosing a bypass. The previous
|
||||
// version short-circuited on "is_bearer" regardless of cookie state,
|
||||
// which let an attacker page do a credentialed cross-site POST with
|
||||
// a forged `Authorization: Bearer junk` header: the header existed,
|
||||
// CSRF bypassed, then the auth extractor authenticated the victim
|
||||
// via the session cookie. Cookie precedence here re-anchors the
|
||||
// gate to the actual authority being ridden.
|
||||
// Drive the parse off the auth-module constant so a rename of
|
||||
// `SESSION_COOKIE_NAME` propagates here instead of silently
|
||||
// reopening the cookie-ride. Split on `=` (not a prefix match) so
|
||||
// an attacker can't shadow with `mangalord_session_x=` etc.
|
||||
let has_session_cookie = headers
|
||||
.get(axum::http::header::COOKIE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|raw| {
|
||||
raw.split(';').any(|p| {
|
||||
p.trim_start().split('=').next()
|
||||
== Some(crate::auth::extractor::SESSION_COOKIE_NAME)
|
||||
})
|
||||
});
|
||||
|
||||
// Bearer-token-only requests are bot callers and bypass the gate —
|
||||
// a browser can't set Authorization on a cross-site POST. But ONLY
|
||||
// when no session cookie is also attached; with both present we
|
||||
// must treat the request as cookie-auth to defeat the cookie-ride.
|
||||
let is_bearer = headers
|
||||
.get(axum::http::header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| v.trim_start().to_ascii_lowercase().starts_with("bearer "));
|
||||
if is_bearer {
|
||||
if is_bearer && !has_session_cookie {
|
||||
return Ok(next.run(req).await);
|
||||
}
|
||||
// No session cookie either → let the auth extractor return a clean 401
|
||||
|
||||
// No auth context at all → let the auth extractor return a clean 401
|
||||
// ("log in first"). A no-auth request can't be a CSRF vector — there's
|
||||
// no authority to ride. Without this, an anonymous curl POST to an
|
||||
// admin endpoint surfaces 403 with our CSRF message instead of the
|
||||
// expected 401, which is confusing for both operators and tests.
|
||||
let has_session_cookie = headers
|
||||
.get(axum::http::header::COOKIE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|raw| raw.split(';').any(|p| {
|
||||
p.trim_start().starts_with("mangalord_session=")
|
||||
}));
|
||||
if !has_session_cookie {
|
||||
return Ok(next.run(req).await);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user