Files
PiCloud/crates/manager-core/src/auth_middleware.rs
MechaCat02 86448beb06 fix(security): server-authoritative approval gate + auth/session/file hardening
Remediate the HIGH and security-relevant findings from the 2026-07-11 audit.

H1 — the per-env approval gate is now server-authoritative. The governing
project is resolved from the target node's nearest-claimed ancestor
(`governing_env_policy`/`_tree` + `governing_project_id` +
`ProjectRepository::get_environments_by_id`), independent of the client-supplied
`[project]`. Omitting or spoofing the project block can no longer skip a gate the
owning project established; a to-create group resolves its declared parent's
chain so a fresh subtree node inherits the gate. Fails closed on any read error.

H2 — the API-key prefix slice (`&rest[..8]`) is now the boundary-safe
`rest.get(..8)`, so an attacker-supplied multibyte bearer can't panic the request
task (unauthenticated per-request DoS). Regression test added.

C1 — admin sessions gain an absolute lifetime cap (migration 0070,
`PICLOUD_SESSION_ABSOLUTE_TTL_HOURS`, default 30d): `lookup` filters it, `touch`
clamps the sliding bump to it, so a continuously-used or stolen-but-warm token
self-expires. Mirrors the data-plane app-user cap.

C2 — `Cache-Control: no-store` on the login and API-key-mint responses (the two
that return a raw credential), so a proxy/CDN/browser cache can't retain it.

B8 — file downloads are header-safe: `sanitize_stored_filename` guarantees a
valid `HeaderValue` (no panic on a control-char name) and BOTH the per-app and
group download paths now set attachment + `X-Content-Type-Options: nosniff` +
a restrictive CSP, closing a group-path stored-XSS gap.

Also folds in the server-side plan-warning plumbing (`plan_warnings`,
`PlanResult::warnings`) and the `app_only_reject` message helper that the CLI
plan-preview change builds on, plus operator security notes (reads-open shared-
topic SSE; the `--env` label is advisory, not a boundary).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:47:35 +02:00

573 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Authentication middleware — resolves the caller's `Principal` from
//! 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
//! the actual resource's `app_id`). This middleware is gate-only: it
//! ensures *some* `Principal` is attached, or returns 401.
//!
//! 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.
//!
//! 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};
use std::time::{Duration, Instant};
use axum::body::Body;
use axum::extract::{Request, State};
use axum::http::{header, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Json, Response};
use chrono::Utc;
use picloud_shared::{AdminUserId, Principal};
use serde_json::json;
use crate::admin_session_repo::AdminSessionRepository;
use crate::admin_user_repo::AdminUserRepository;
use crate::api_key_repo::{ApiKeyRepository, ApiKeyVerification};
use crate::auth::{hash_token, verify_password};
/// F-P-009: short-lived cache of resolved `(token → principal)` pairs.
/// Two reasons it's load-bearing:
/// 1. `verify_api_key` Argon2-verifies *every* candidate sharing the
/// 8-char prefix per request — caching cuts that to once per token
/// per TTL window.
/// 2. `attach_principal_if_present` runs on every data-plane request
/// including paths that may not even need authz; even the session
/// path costs three round-trips (lookup + user-get + touch).
///
/// Keyed on the hashed token (sha256 in `hash_token`); value is the
/// resolved Principal + the instant it landed in the cache. TTL is
/// short — the audit cited 60-300s.
#[derive(Debug, Default)]
pub struct PrincipalCache {
inner: Mutex<HashMap<String, (Instant, Principal)>>,
}
/// TTL on cached principals. Short enough that role / scope changes
/// take effect quickly; long enough to amortize Argon2 over many
/// adjacent requests on a hot key.
const PRINCIPAL_CACHE_TTL: Duration = Duration::from_secs(60);
/// F-S-006: maximum candidate API keys we'll Argon2-verify per
/// request. Hoisted to module scope so clippy::items_after_statements
/// is happy.
const MAX_API_KEY_CANDIDATES: usize = 16;
impl PrincipalCache {
#[must_use]
pub fn new() -> Self {
Self::default()
}
fn get(&self, token_hash: &str) -> Option<Principal> {
let mut guard = self.inner.lock().ok()?;
let entry = guard.get(token_hash)?;
if entry.0.elapsed() > PRINCIPAL_CACHE_TTL {
guard.remove(token_hash);
return None;
}
Some(entry.1.clone())
}
fn insert(&self, token_hash: String, principal: Principal) {
let Ok(mut guard) = self.inner.lock() else {
return;
};
// Lazy GC: cap unbounded growth by sweeping expired entries
// when the cache crosses an arbitrary threshold.
if guard.len() > 1024 {
let now = Instant::now();
guard.retain(|_, v| now.duration_since(v.0) <= PRINCIPAL_CACHE_TTL);
}
guard.insert(token_hash, (Instant::now(), principal));
}
/// Evict every cached principal belonging to `user_id`.
///
/// Audit 2026-06-11 (PrincipalCache revocation-lag, Medium) — the
/// revocation paths (`set_active(false)`, password change,
/// API-key delete) flip the backing DB rows, but a cached principal
/// keeps authenticating for up to [`PRINCIPAL_CACHE_TTL`] unless it
/// is evicted. Callers invoke this right after the DB mutation so a
/// just-revoked credential stops working on the next request. The
/// cache value carries the resolved [`Principal`], so we can target
/// the user without knowing which token hashes belong to them.
pub fn evict_user(&self, user_id: AdminUserId) {
let Ok(mut guard) = self.inner.lock() else {
return;
};
guard.retain(|_, (_, p)| p.user_id != user_id);
}
/// Evict a single cached entry by its token hash. Used on logout,
/// where exactly one session token is being invalidated and we
/// don't want to disturb the user's other live sessions.
pub fn evict_token(&self, token_hash: &str) {
if let Ok(mut guard) = self.inner.lock() {
guard.remove(token_hash);
}
}
}
/// 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.
pub const API_KEY_PREFIX: &str = "pic_";
/// Length of the indexed prefix portion of an API key (the 8 chars
/// immediately after `pic_`). Schema-side index is on this slice.
pub const API_KEY_PREFIX_LEN: usize = 8;
/// Shared state for auth: the user / session / API-key repos plus the
/// configured sliding session TTL. Cheap to clone (`Arc` everywhere).
#[derive(Clone)]
pub struct AuthState {
pub users: Arc<dyn AdminUserRepository>,
pub sessions: Arc<dyn AdminSessionRepository>,
pub keys: Arc<dyn ApiKeyRepository>,
pub ttl: Duration,
/// C1: absolute hard cap on an admin session's lifetime. The sliding
/// `touch` bump is clamped at `session_start + absolute_ttl`, so even a
/// continuously-used token self-expires. Set at login via the session's
/// stored `absolute_expires_at`; this value seeds that at create time.
pub absolute_ttl: Duration,
/// F-P-009 — shared cache of resolved Principals. Constructed once
/// at startup and cloned (same `Arc`) into every router state that
/// resolves or revokes credentials, so a revocation-side eviction is
/// visible to this resolve-side lookup.
pub principal_cache: Arc<PrincipalCache>,
/// Audit 2026-06-11 H-B1 — per-`(remote_ip, username)` + per-`username`
/// burst limiter for `/auth/login`. Cheap to clone (Arc).
pub login_rate_limiter: Arc<crate::login_rate_limit::LoginRateLimiter>,
/// Audit 2026-06-11 H-B1 — caps concurrent Argon2 verifies during
/// login so a credential-stuffing flurry can't park every blocking
/// worker. Sized via `PICLOUD_LOGIN_ARGON2_PARALLELISM` (default 2).
pub argon2_login_semaphore: Arc<tokio::sync::Semaphore>,
}
/// Legacy request-extension alias retained so the (only remaining)
/// handler that pulled `AuthedAdmin` out — `GET /admin/auth/me` —
/// keeps compiling during the migration. New handlers should pull
/// `Extension<Principal>` directly.
#[deprecated(note = "use Extension<Principal> directly")]
#[derive(Debug, Clone)]
pub struct AuthedAdmin {
pub id: AdminUserId,
pub username: String,
}
/// Middleware entry point. Wire with
/// `axum::middleware::from_fn_with_state(auth_state, require_authenticated)`.
/// Inserts `Principal` (and the legacy `AuthedAdmin`) as request
/// extensions on success; returns 401 on any failure mode.
pub async fn require_authenticated(
State(state): State<AuthState>,
mut req: Request<Body>,
next: Next,
) -> Response {
let Some(token) = extract_token(&req) else {
return unauthorized();
};
let principal = match resolve_principal(&state, &token).await {
Ok(Some(p)) => p,
Ok(None) => return unauthorized(),
Err(InternalError) => return internal_error(),
};
let username_for_legacy = username_for(&state, principal.user_id).await;
req.extensions_mut().insert(principal.clone());
#[allow(deprecated)]
if let Some(username) = username_for_legacy {
req.extensions_mut().insert(AuthedAdmin {
id: principal.user_id,
username,
});
}
next.run(req).await
}
/// Backwards-compatible alias — the single callsite that still names
/// `require_admin` keeps working without an immediate rename. New
/// wiring should call `require_authenticated`.
#[deprecated(note = "renamed to require_authenticated")]
pub async fn require_admin(state: State<AuthState>, req: Request<Body>, next: Next) -> Response {
require_authenticated(state, req, next).await
}
/// Opportunistic data-plane variant: always inserts an
/// `Extension<Option<Principal>>` and forwards the request. Used on
/// `/execute/{id}` and the user-route fallback, where most invocations
/// are anonymous public HTTP and the few authed ones (dashboard
/// test-runs, API keys) should still let scripts see the caller via
/// `cx.principal` once services consume it.
///
/// Failure modes — all degrade to `None` rather than rejecting:
/// * No bearer / cookie → `None`.
/// * Malformed or unknown token → `None`.
/// * DB blip while resolving → `None` (fail-open; the data plane
/// should not 500 on transient infra failures for an *optional*
/// identity check).
///
/// Admin-side routes that REQUIRE an identity keep using
/// `require_authenticated`.
pub async fn attach_principal_if_present(
State(state): State<AuthState>,
mut req: Request<Body>,
next: Next,
) -> Response {
let principal: Option<Principal> = match extract_token(&req) {
Some(token) => resolve_principal(&state, &token).await.unwrap_or(None),
None => None,
};
req.extensions_mut().insert(principal);
next.run(req).await
}
/// Decide whether the token is an API key (pic_ prefix) or a session
/// token, then resolve the corresponding `Principal`. `Ok(None)`
/// means the token was structurally valid but didn't match any active
/// credential; `Err(InternalError)` means a DB blip.
async fn resolve_principal(
state: &AuthState,
token: &str,
) -> Result<Option<Principal>, InternalError> {
// F-P-009: cache hit short-circuits Argon2 + 3 DB round-trips.
// Keyed on the hashed token so we never store raw bearer values.
let token_hash = hash_token(token);
if let Some(p) = state.principal_cache.get(&token_hash) {
return Ok(Some(p));
}
let resolved = if let Some(rest) = token.strip_prefix(API_KEY_PREFIX) {
verify_api_key(state, rest).await?
} else {
verify_session(state, token).await?
};
if let Some(p) = &resolved {
state.principal_cache.insert(token_hash, p.clone());
}
Ok(resolved)
}
async fn verify_session(
state: &AuthState,
token: &str,
) -> Result<Option<Principal>, InternalError> {
let token_hash = hash_token(token);
let lookup = match state.sessions.lookup(&token_hash).await {
Ok(Some(l)) => l,
Ok(None) => return Ok(None),
Err(err) => {
tracing::error!(?err, "admin_sessions lookup failed");
return Err(InternalError);
}
};
let user = match state.users.get(lookup.user_id).await {
Ok(Some(u)) if u.is_active => u,
Ok(_) => return Ok(None),
Err(err) => {
tracing::error!(?err, "admin_users lookup failed");
return Err(InternalError);
}
};
// Sliding-window bump — inline so a DB blip surfaces as 500 rather
// than silent stale sessions. C1: clamp the bump at the session's absolute
// cap so a continuously-used token can't slide forever.
let sliding = Utc::now() + chrono::Duration::from_std(state.ttl).unwrap_or_default();
let new_expires_at = sliding.min(lookup.absolute_expires_at);
if let Err(err) = state.sessions.touch(&token_hash, new_expires_at).await {
tracing::error!(?err, "admin_sessions touch failed");
return Err(InternalError);
}
Ok(Some(Principal {
user_id: user.id,
instance_role: user.instance_role,
scopes: None,
app_binding: None,
}))
}
/// API-key verification path. `rest` is the portion of the bearer
/// value *after* `pic_`. We slice off the first 8 chars as the
/// indexed lookup key, then Argon2id-verify each candidate's hash
/// against the full `rest`. At most one match is expected; multiple
/// candidates with the same prefix is statistically negligible but
/// handled correctly (verify each, take the first match).
async fn verify_api_key(state: &AuthState, rest: &str) -> Result<Option<Principal>, InternalError> {
if rest.len() <= API_KEY_PREFIX_LEN {
return Ok(None);
}
// `rest` is the raw, attacker-controlled bearer value after `pic_` — it is
// NOT yet validated as the base32 body a real key has, so a byte-index slice
// (`&rest[..8]`) could split a multibyte UTF-8 codepoint straddling byte 8
// and panic, aborting the request task (an unauthenticated DoS). `get(..)`
// returns `None` at a non-char-boundary, so a malformed bearer falls through
// to "no match" instead of panicking.
let Some(prefix) = rest.get(..API_KEY_PREFIX_LEN) else {
return Ok(None);
};
let mut candidates = match state.keys.find_active_by_prefix(prefix).await {
Ok(v) => v,
Err(err) => {
tracing::error!(?err, "api_keys lookup failed");
return Err(InternalError);
}
};
// F-S-006: bound the candidate set. Prefix collisions are
// statistically rare for a 32-byte random body, but the 8-char
// index space is small enough that an attacker who provisions
// many keys against one indexed prefix could amplify each public
// bearer-tagged request into M×Argon2 verifies. Cap at MAX and
// log the truncation so operators can spot it.
if candidates.len() > MAX_API_KEY_CANDIDATES {
tracing::warn!(
prefix,
total = candidates.len(),
kept = MAX_API_KEY_CANDIDATES,
"api_keys prefix-bucket overflow; truncating candidate verify set"
);
candidates.truncate(MAX_API_KEY_CANDIDATES);
}
// F-P-002: Argon2id verify is CPU-bound (~tens of ms each at OWASP
// defaults). Move it off the Tokio async worker so a hot user with
// N prefix-colliding keys doesn't park the worker for N×Argon2.
let rest_owned = rest.to_string();
let matched: Option<ApiKeyVerification> = tokio::task::spawn_blocking(move || {
candidates
.into_iter()
.find(|c| verify_password(&c.hash, &rest_owned))
})
.await
.map_err(|e| {
tracing::error!(error = ?e, "verify_api_key spawn_blocking join failed");
InternalError
})?;
let Some(matched) = matched else {
return Ok(None);
};
// Resolve the owning user. is_active = false → reject even if the
// key itself hasn't been expired yet (the expire_all_for_user
// cascade on deactivation is the primary defense; this is the
// belt-and-suspenders check at request time).
let user = match state.users.get(matched.user_id).await {
Ok(Some(u)) if u.is_active => u,
Ok(_) => return Ok(None),
Err(err) => {
tracing::error!(?err, "admin_users lookup for api key failed");
return Err(InternalError);
}
};
if let Err(err) = state.keys.touch_last_used(matched.id).await {
tracing::error!(?err, "api_keys touch_last_used failed");
// Soft-fail: a timestamp blip should not invalidate the
// request. Continue with the resolved Principal.
}
Ok(Some(Principal {
user_id: user.id,
instance_role: user.instance_role,
scopes: Some(matched.scopes),
app_binding: matched.app_id,
}))
}
/// Best-effort username lookup for the legacy `AuthedAdmin` extension.
/// Returns `None` on DB error (the caller treats `None` as "skip the
/// legacy extension"). New handlers use `Principal` and don't depend
/// on this.
async fn username_for(state: &AuthState, id: AdminUserId) -> Option<String> {
match state.users.get(id).await {
Ok(Some(u)) => Some(u.username),
Ok(None) => None,
Err(err) => {
tracing::warn!(
?err,
"username lookup for AuthedAdmin failed; skipping legacy ext"
);
None
}
}
}
/// 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> {
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;
}
Some(token.to_string())
}
/// Sentinel returned from the resolve functions when a DB error should
/// produce a 500 rather than a 401. Empty struct because the actual
/// error is already logged at the failure site.
struct InternalError;
fn unauthorized() -> Response {
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "authentication required" })),
)
.into_response()
}
fn internal_error() -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "internal error" })),
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::Request;
use picloud_shared::InstanceRole;
fn req_with_header(name: &str, value: &str) -> Request<Body> {
Request::builder()
.header(name, value)
.body(Body::empty())
.unwrap()
}
#[test]
fn extracts_bearer_token() {
let r = req_with_header("authorization", "Bearer abc123");
assert_eq!(extract_token(&r).as_deref(), Some("abc123"));
}
#[test]
fn extracts_bearer_pic_prefixed_token() {
let r = req_with_header("authorization", "Bearer pic_abcdefghIJKL");
assert_eq!(extract_token(&r).as_deref(), Some("pic_abcdefghIJKL"));
}
#[test]
fn ignores_bearer_with_no_token() {
let r = req_with_header("authorization", "Bearer ");
assert_eq!(extract_token(&r), None);
}
#[test]
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), None);
}
#[test]
fn bearer_wins_when_both_present() {
let r = Request::builder()
.header("authorization", "Bearer header-token")
.header("cookie", "picloud_session=cookie-token")
.body(Body::empty())
.unwrap();
assert_eq!(extract_token(&r).as_deref(), Some("header-token"));
}
#[test]
fn returns_none_when_no_authorization_header() {
let r = Request::builder().body(Body::empty()).unwrap();
assert_eq!(extract_token(&r), None);
}
// Round-trip test for the unused-variable to keep `Principal`
// visibly tied to InstanceRole — caught a real bug during dev when
// the field order in the struct literal had drifted.
#[test]
fn principal_construction_is_explicit() {
let p = Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Owner,
scopes: None,
app_binding: None,
};
assert_eq!(p.instance_role, InstanceRole::Owner);
assert!(p.scopes.is_none());
assert!(p.app_binding.is_none());
}
fn principal_for(user_id: AdminUserId) -> Principal {
Principal {
user_id,
instance_role: InstanceRole::Owner,
scopes: None,
app_binding: None,
}
}
#[test]
fn api_key_prefix_slice_is_char_boundary_safe() {
// H2 regression (audit 2026-07-11): `rest` is the raw bearer body after
// `pic_`, sliced BEFORE any base32 validation. A byte-index slice
// `&rest[..API_KEY_PREFIX_LEN]` panics when a multibyte codepoint
// straddles byte 8. `€` is 3 bytes, so `aaaaaa€…` puts byte 8 mid-
// codepoint — the boundary-safe `get(..)` must return None, not panic.
let malformed = "aaaaaa\u{20ac}bbbb";
assert!(malformed.len() > API_KEY_PREFIX_LEN);
assert!(malformed.get(..API_KEY_PREFIX_LEN).is_none());
// A clean ASCII body slices to exactly the indexed prefix.
assert_eq!("abcdefghXYZ".get(..API_KEY_PREFIX_LEN), Some("abcdefgh"));
}
#[test]
fn evict_user_drops_only_that_users_entries() {
// Audit 2026-06-11 (PrincipalCache revocation-lag).
let cache = PrincipalCache::new();
let alice = AdminUserId::new();
let bob = AdminUserId::new();
cache.insert("hash-alice-1".into(), principal_for(alice));
cache.insert("hash-alice-2".into(), principal_for(alice));
cache.insert("hash-bob-1".into(), principal_for(bob));
cache.evict_user(alice);
assert!(cache.get("hash-alice-1").is_none());
assert!(cache.get("hash-alice-2").is_none());
// Bob is untouched.
assert!(cache.get("hash-bob-1").is_some());
}
#[test]
fn evict_token_drops_only_that_entry() {
let cache = PrincipalCache::new();
let alice = AdminUserId::new();
cache.insert("hash-1".into(), principal_for(alice));
cache.insert("hash-2".into(), principal_for(alice));
cache.evict_token("hash-1");
// Logout drops exactly one session; the user's other live
// session stays cached.
assert!(cache.get("hash-1").is_none());
assert!(cache.get("hash-2").is_some());
}
}