fix(audit-2026-06-11/C-1): drop cookie auth on /api/v1/admin/*

Same-origin CSRF: the platform co-hosts user-route HTML on the same
origin as /api/v1/admin/*; SameSite=Lax did not block a same-origin POST
from a malicious script at /<route>, riding the admin's picloud_session
cookie. The dashboard already uses Bearer (dashboard/src/lib/auth.ts +
api.ts:495), so cookie auth was dead weight on the admin side and
exploitable.

Cuts the cookie path entirely:
- auth_middleware::extract_token is Bearer-only; cookie branch removed.
- auth_api::login no longer sets Set-Cookie.
- auth_api::logout no longer clears the cookie (the bearer token is
  still revoked by deleting the session row).
- extract_token_for_logout matches.
- SESSION_COOKIE const + PICLOUD_COOKIE_SECURE env var deleted.

Audit ref: security_audit/07_http_cors_csrf_xss.md#c07-01.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-11 20:18:55 +02:00
parent 285a76f2e2
commit b096ea9c4e
3 changed files with 51 additions and 109 deletions

View File

@@ -1,7 +1,7 @@
//! Authentication middleware — resolves the caller's `Principal` from
//! either a session cookie / Bearer session-token OR an API key
//! (`Authorization: Bearer pic_…`). Both paths converge on the same
//! request extension so downstream handlers see one shape.
//! a Bearer session-token OR an API key (`Authorization: Bearer pic_…`).
//! Both paths converge on the same request extension so downstream
//! handlers see one shape.
//!
//! Capability checks live in `crate::authz` and are called per-handler
//! (after the relevant resource is loaded, so the capability binds to
@@ -10,8 +10,14 @@
//!
//! Token discriminator: the `pic_` prefix on a Bearer value selects
//! the API-key path; anything else (raw 32-byte base64-url-encoded
//! string) takes the session path. The session cookie can only ever
//! carry a session token (cookies are never API keys).
//! string) takes the session path.
//!
//! Audit 2026-06-11 (C-1): cookie auth removed. The platform co-hosts
//! attacker-controlled user-route HTML on the same origin as the admin
//! API; `SameSite=Lax` did not block same-origin POSTs from a malicious
//! script under `/<route>`. Admin clients must send
//! `Authorization: Bearer <session_token>` (the dashboard already does;
//! see `dashboard/src/lib/api.ts`).
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
@@ -88,8 +94,6 @@ impl PrincipalCache {
}
}
pub const SESSION_COOKIE: &str = "picloud_session";
/// Prefix on the wire that selects the API-key path. The body that
/// follows is `base32(32 random bytes)`; the first 8 chars of the body
/// index into `api_keys.prefix` for verification.
@@ -355,34 +359,17 @@ async fn username_for(state: &AuthState, id: AdminUserId) -> Option<String> {
}
}
/// Pull the bearer token out of an `Authorization` header (preferred)
/// or the `picloud_session` cookie (fallback for browser clients).
/// Same shape as Phase 3a; the cookie only ever carries session
/// tokens — no `pic_` prefix expected there.
/// Pull the bearer token out of an `Authorization` header. Cookie auth
/// was removed in the audit 2026-06-11 C-1 fix; same-origin user-route
/// HTML made `SameSite=Lax` insufficient against CSRF.
fn extract_token(req: &Request<Body>) -> Option<String> {
if let Some(value) = req.headers().get(header::AUTHORIZATION) {
if let Ok(s) = value.to_str() {
if let Some(token) = s.strip_prefix("Bearer ") {
let trimmed = token.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
let value = req.headers().get(header::AUTHORIZATION)?;
let s = value.to_str().ok()?;
let token = s.strip_prefix("Bearer ")?.trim();
if token.is_empty() {
return None;
}
if let Some(value) = req.headers().get(header::COOKIE) {
if let Ok(s) = value.to_str() {
for chunk in s.split(';') {
let chunk = chunk.trim();
if let Some(rest) = chunk.strip_prefix(&format!("{SESSION_COOKIE}=")) {
if !rest.is_empty() {
return Some(rest.to_string());
}
}
}
}
}
None
Some(token.to_string())
}
/// Sentinel returned from the resolve functions when a DB error should
@@ -438,13 +425,16 @@ mod tests {
}
#[test]
fn extracts_cookie_token() {
fn cookie_alone_is_rejected_post_audit_2026_06_11() {
// C-1 closure: `picloud_session` cookie is no longer accepted as
// an auth fallback; a same-origin malicious script could ride the
// cookie via SameSite=Lax POST. Bearer-only.
let r = req_with_header("cookie", "foo=bar; picloud_session=xyz; baz=qux");
assert_eq!(extract_token(&r).as_deref(), Some("xyz"));
assert_eq!(extract_token(&r), None);
}
#[test]
fn bearer_wins_over_cookie() {
fn bearer_wins_when_both_present() {
let r = Request::builder()
.header("authorization", "Bearer header-token")
.header("cookie", "picloud_session=cookie-token")
@@ -454,7 +444,7 @@ mod tests {
}
#[test]
fn returns_none_when_neither_present() {
fn returns_none_when_no_authorization_header() {
let r = Request::builder().body(Body::empty()).unwrap();
assert_eq!(extract_token(&r), None);
}