miroir/crates/miroir-proxy/src/auth.rs
jedarden d10a9ac1fd fix(clippy): resolve unused type parameter, variables, and functions
- Remove unused type parameter S from explain_search function
- Add peer-discovery feature to miroir-proxy Cargo.toml
- Fix unused variables by prefixing with underscore
- Add #[allow(dead_code)] to modules with unused public API functions

Resolves clippy -D warnings for lib and binary targets.
2026-05-26 01:44:28 -04:00

2402 lines
83 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.

//! Bearer-token dispatch per plan §5 rules 05.
//!
//! Three token types can appear on `Authorization: Bearer <value>` simultaneously:
//! the `master_key`, the `admin_key`, and a search UI JWT. Miroir resolves them
//! deterministically in the order specified by §5.
//!
//! JWT signing-secret rotation (plan §9):
//! - Primary secret (`SEARCH_UI_JWT_SECRET`) signs new tokens; `kid` header identifies it.
//! - Optional previous secret (`SEARCH_UI_JWT_SECRET_PREVIOUS`) is present only during
//! the rotation overlap window. Validation accepts either secret.
#![allow(dead_code)]
use axum::{
extract::{Request, State},
http::{HeaderMap, Method},
middleware::Next,
response::{IntoResponse, Response},
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use dashmap::DashMap;
use hmac::{Hmac, Mac};
use miroir_core::{task_store::TaskStore, MeilisearchError, MiroirCode};
use prometheus::Counter;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::sync::Arc;
use subtle::ConstantTimeEq;
use crate::admin_session::{self, SealKey};
type HmacSha256 = Hmac<Sha256>;
/// Extension carried in the request after successful cookie unseal.
/// Handlers extract this to look up the session in the task store.
#[derive(Debug, Clone)]
pub struct AdminSessionId(pub String);
/// Extension carried in the request after successful JWT validation.
/// Handlers extract this to access JWT claims like injected_filter.
#[derive(Debug, Clone)]
pub struct JwtClaimsExtension(pub JwtClaims);
/// State for CSRF middleware, combining AuthState with task store access.
#[derive(Clone)]
pub struct CsrfState {
pub auth: AuthState,
pub redis_store: Option<miroir_core::task_store::RedisTaskStore>,
}
// ---------------------------------------------------------------------------
// JWT claims (plan §13.21)
// ---------------------------------------------------------------------------
/// Claims embedded in a search UI JWT session token (plan §13.21).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JwtClaims {
/// Issuer — always "miroir".
pub iss: String,
/// Subject — "search-ui-session" or user identifier in oauth_proxy mode.
pub sub: String,
/// Index this token grants access to.
pub idx: String,
/// Granted scope — array of allowed action names.
pub scope: Vec<String>,
/// Issued-at timestamp (seconds since epoch).
pub iat: u64,
/// Expiration timestamp (seconds since epoch).
pub exp: u64,
/// Optional injected filter for oauth_proxy mode (plan §13.21).
/// When present, this filter is ANDed with any user-supplied filter.
pub injected_filter: Option<String>,
/// User identifier from oauth_proxy headers (for observability).
pub user: Option<String>,
/// Groups from oauth_proxy headers (for observability).
pub groups: Option<Vec<String>>,
}
/// Key ID embedded in the JWT header to identify which secret signed it.
pub const KID_PRIMARY: &str = "primary";
#[allow(dead_code)]
pub const KID_PREVIOUS: &str = "previous";
/// JWT header (always HS256).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JwtHeader {
pub alg: String,
pub kid: String,
pub typ: String,
}
// ---------------------------------------------------------------------------
// Minimal HS256 JWT encode / decode (no external JWT crate needed)
// ---------------------------------------------------------------------------
/// Encode and sign a JWT with the given secret.
pub fn jwt_encode(header: &JwtHeader, claims: &JwtClaims, secret: &[u8]) -> Result<String, String> {
let header_json = serde_json::to_string(header).map_err(|e| e.to_string())?;
let claims_json = serde_json::to_string(claims).map_err(|e| e.to_string())?;
let header_b64 = URL_SAFE_NO_PAD.encode(header_json.as_bytes());
let payload_b64 = URL_SAFE_NO_PAD.encode(claims_json.as_bytes());
let signing_input = format!("{header_b64}.{payload_b64}");
let mut mac = HmacSha256::new_from_slice(secret).map_err(|e| format!("HMAC init: {e}"))?;
mac.update(signing_input.as_bytes());
let sig = mac.finalize().into_bytes();
let sig_b64 = URL_SAFE_NO_PAD.encode(sig);
Ok(format!("{header_b64}.{payload_b64}.{sig_b64}"))
}
/// Decode and verify a JWT with the given secret. Returns (header, claims).
pub fn jwt_decode(
token: &str,
secret: &[u8],
) -> Result<(JwtHeader, JwtClaims), JwtValidationError> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return Err(JwtValidationError::Malformed);
}
let header_bytes = URL_SAFE_NO_PAD
.decode(parts[0])
.map_err(|_| JwtValidationError::Malformed)?;
let header: JwtHeader =
serde_json::from_slice(&header_bytes).map_err(|_| JwtValidationError::Malformed)?;
if header.alg != "HS256" {
return Err(JwtValidationError::Malformed);
}
// Verify signature
let signing_input = format!("{}.{}", parts[0], parts[1]);
let mut mac = HmacSha256::new_from_slice(secret).unwrap();
mac.update(signing_input.as_bytes());
let expected_sig = mac.finalize().into_bytes();
let actual_sig = URL_SAFE_NO_PAD
.decode(parts[2])
.map_err(|_| JwtValidationError::InvalidSignature)?;
use subtle::ConstantTimeEq as _;
let sig_valid: bool = actual_sig.ct_eq(&expected_sig).into();
if !sig_valid {
return Err(JwtValidationError::InvalidSignature);
}
// Decode claims
let claims_bytes = URL_SAFE_NO_PAD
.decode(parts[1])
.map_err(|_| JwtValidationError::Malformed)?;
let claims: JwtClaims =
serde_json::from_slice(&claims_bytes).map_err(|_| JwtValidationError::Malformed)?;
// Check expiration with 30s leeway
let now = epoch_seconds();
if claims.exp + 30 < now {
return Err(JwtValidationError::Expired);
}
Ok((header, claims))
}
/// Decode and verify a JWT, trying both primary and previous secrets.
///
/// First tries the primary secret from the given environment variable name.
/// If that fails, tries the previous secret from the previous_env name.
/// Returns the claims on success, or the first error if both fail.
pub fn jwt_decode_with_fallback(
token: &str,
primary_env: &str,
previous_env: &str,
) -> Result<JwtClaims, JwtValidationError> {
// Try primary secret
if let Ok(primary_secret) = std::env::var(primary_env) {
if let Ok((_, claims)) = jwt_decode(token, primary_secret.as_bytes()) {
return Ok(claims);
}
}
// Try previous secret
if let Ok(previous_secret) = std::env::var(previous_env) {
if let Ok((_, claims)) = jwt_decode(token, previous_secret.as_bytes()) {
return Ok(claims);
}
}
// Both failed - return error from primary attempt
let primary_secret = std::env::var(primary_env).unwrap_or_default();
jwt_decode(token, primary_secret.as_bytes()).map(|(_, claims)| claims)
}
// ---------------------------------------------------------------------------
// Auth state (shared via axum State)
// ---------------------------------------------------------------------------
/// Configuration needed by the bearer-token dispatch chain.
#[derive(Clone)]
pub struct AuthState {
pub master_key: String,
pub admin_key: String,
/// HMAC secret for signing/validating search UI JWTs (primary).
pub jwt_primary: Option<String>,
/// Optional previous secret active during rotation overlap window.
pub jwt_previous: Option<String>,
/// Key for sealing/unsealing admin session cookies (XChaCha20-Poly1305).
pub seal_key: SealKey,
/// In-memory set of revoked admin session IDs (populated on logout, Pub/Sub).
pub revoked_sessions: Arc<DashMap<String, ()>>,
/// Counter for revoked admin sessions (miroir_admin_session_revoked_total).
pub admin_session_revoked_total: Counter,
}
impl std::fmt::Debug for AuthState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthState")
.field("master_key", &"[redacted]")
.field("admin_key", &"[redacted]")
.field("jwt_primary", &self.jwt_primary.as_ref().map(|_| "[set]"))
.field("jwt_previous", &self.jwt_previous.as_ref().map(|_| "[set]"))
.field("seal_key", &self.seal_key)
.field("revoked_sessions", &self.revoked_sessions.len())
.finish_non_exhaustive()
}
}
// ---------------------------------------------------------------------------
// JWT signing / validation helpers
// ---------------------------------------------------------------------------
impl AuthState {
/// Create a new signed JWT session token for the given index (plan §13.21).
/// Always signs with the primary secret; `kid` header identifies it.
/// Scope defaults to ["search", "multi_search", "beacon"] for search UI sessions.
#[allow(clippy::too_many_arguments)]
pub fn sign_jwt(
&self,
sub: &str,
idx: &str,
scope: &[&str],
ttl_s: u64,
injected_filter: Option<String>,
user: Option<String>,
groups: Option<Vec<String>>,
) -> Option<String> {
let secret = self.jwt_primary.as_ref()?;
let now = epoch_seconds();
let claims = JwtClaims {
iss: "miroir".to_string(),
sub: sub.to_string(),
idx: idx.to_string(),
scope: scope.iter().map(|s| s.to_string()).collect(),
iat: now,
exp: now + ttl_s,
injected_filter,
user,
groups,
};
let header = JwtHeader {
alg: "HS256".to_string(),
kid: KID_PRIMARY.to_string(),
typ: "JWT".to_string(),
};
jwt_encode(&header, &claims, secret.as_bytes()).ok()
}
/// Validate a JWT string against either the primary or previous secret.
/// Returns the parsed claims if validation succeeds, or an error.
pub fn validate_jwt(&self, token: &str) -> Result<JwtClaims, JwtValidationError> {
// Try primary secret first
if let Some(ref secret) = self.jwt_primary {
match jwt_decode(token, secret.as_bytes()) {
Ok((_header, claims)) => return Ok(claims),
Err(JwtValidationError::Expired) => return Err(JwtValidationError::Expired),
_ => {} // signature mismatch — try previous
}
}
// Try previous secret (rotation overlap window)
if let Some(ref secret) = self.jwt_previous {
if secret.is_empty() {
return Err(JwtValidationError::PreviousSecretEmpty);
}
match jwt_decode(token, secret.as_bytes()) {
Ok((_header, claims)) => return Ok(claims),
Err(JwtValidationError::Expired) => return Err(JwtValidationError::Expired),
_ => {} // signature mismatch
}
}
Err(JwtValidationError::InvalidSignature)
}
}
/// Errors returned by JWT validation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JwtValidationError {
/// Token structure is invalid or alg is not HS256.
Malformed,
/// HMAC signature did not match any loaded secret.
InvalidSignature,
/// Token has expired.
Expired,
/// `SEARCH_UI_JWT_SECRET_PREVIOUS` is set to the empty string (leak response).
PreviousSecretEmpty,
/// Token scope does not permit this (method, path) or idx claim mismatch.
ScopeDenied,
}
impl std::fmt::Display for JwtValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
JwtValidationError::Malformed => write!(f, "malformed JWT token"),
JwtValidationError::InvalidSignature => write!(f, "invalid JWT signature"),
JwtValidationError::Expired => write!(f, "JWT token expired"),
JwtValidationError::PreviousSecretEmpty => write!(f, "previous JWT secret is empty"),
JwtValidationError::ScopeDenied => write!(f, "JWT scope denied"),
}
}
}
impl std::error::Error for JwtValidationError {}
// ---------------------------------------------------------------------------
// CSRF token generation (plan §9)
// ---------------------------------------------------------------------------
/// Generate a cryptographically random CSRF token.
/// Returns a URL-safe base64-encoded 32-byte token.
pub fn generate_csrf_token() -> String {
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
/// Extract the CSRF token from the `X-CSRF-Token` header.
pub fn extract_csrf_token(headers: &HeaderMap) -> Option<String> {
headers.get("X-CSRF-Token")?.to_str().ok().map(String::from)
}
/// Constant-time comparison of CSRF tokens.
pub fn constant_time_csrf_compare(token: &str, expected: &str) -> bool {
constant_time_compare(token.as_bytes(), expected.as_bytes())
}
/// Validate a CSRF token against the expected session token.
/// Returns Ok(()) if the token matches, or a CsrfMismatch error.
#[allow(dead_code)]
pub fn validate_csrf_token(provided: &str, expected: &str) -> Result<(), MiroirCode> {
if constant_time_csrf_compare(provided, expected) {
Ok(())
} else {
Err(MiroirCode::CsrfMismatch)
}
}
// ---------------------------------------------------------------------------
// Origin validation (plan §9)
// ---------------------------------------------------------------------------
/// Result of origin validation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OriginVerdict {
/// Origin is allowed.
Allowed,
/// Origin is not in the allowed list.
Forbidden,
/// No Origin/Referer header present (for same-origin requests).
Missing,
}
/// Validate the Origin header against the allowed origins list.
/// Handles special "same-origin" value by comparing against the Host header.
pub fn validate_origin(
headers: &HeaderMap,
allowed_origins: &[String],
is_same_origin_by_default: bool,
) -> OriginVerdict {
// Try Origin header first (preferred for POST/DELETE/PUT)
let origin = headers.get("origin").and_then(|h| h.to_str().ok());
// Fall back to Referer header (for navigational requests)
let referer = origin.or_else(|| headers.get("referer").and_then(|h| h.to_str().ok()));
let provided_origin = match referer {
Some(o) => o,
None => {
// No Origin or Referer header - for same-origin requests, this is acceptable
return if is_same_origin_by_default {
OriginVerdict::Missing
} else {
OriginVerdict::Forbidden
};
}
};
// Strip path from Referer to get origin
let provided_origin = if let Some(ref_hdr) = headers.get("referer") {
if let Ok(ref_val) = ref_hdr.to_str() {
// Find the first '/' after "https://" (skip the first 8 chars: "https://")
if let Some(idx) = ref_val
.chars()
.enumerate()
.skip(8)
.find(|(_, c)| *c == '/')
.map(|(i, _)| i)
{
&ref_val[..idx]
} else {
ref_val
}
} else {
provided_origin
}
} else {
provided_origin
};
// Check against allowed origins
for allowed in allowed_origins {
// Special "same-origin" value - compare against Host header
if allowed == "same-origin" {
if let Some(host) = headers.get("host").and_then(|h| h.to_str().ok()) {
// Construct origin from scheme (https) and host
let same_origin = format!("https://{host}");
if provided_origin == same_origin || provided_origin == host {
return OriginVerdict::Allowed;
}
}
} else if allowed == "*" {
// Wildcard allows any origin
return OriginVerdict::Allowed;
} else if provided_origin == allowed {
return OriginVerdict::Allowed;
}
}
OriginVerdict::Forbidden
}
// ---------------------------------------------------------------------------
// CSP header builder (plan §9)
// ---------------------------------------------------------------------------
/// Build a CSP header value by merging base template with overrides.
/// Overrides are merged additively - they never replace the base template.
pub fn build_csp_header(
base_template: &str,
overrides: &miroir_core::config::CspOverridesConfig,
) -> String {
let mut directives: Vec<(String, Vec<String>)> = base_template
.split(';')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|directive| {
let parts: Vec<&str> = directive.splitn(2, ' ').collect();
if parts.len() == 2 {
(parts[0].to_lowercase(), vec![parts[1].to_string()])
} else {
(parts[0].to_lowercase(), vec![])
}
})
.collect();
// Helper to merge overrides into a directive
let merge_into =
|directives: &mut Vec<(String, Vec<String>)>, name: &str, values: &[String]| {
if values.is_empty() {
return;
}
let name_lower = name.to_lowercase();
if let Some(entry) = directives.iter_mut().find(|(n, _)| n == &name_lower) {
// Append to existing directive
entry.1.extend(values.iter().cloned());
entry.1.dedup(); // Remove duplicates
} else {
// Add new directive
directives.push((name_lower, values.to_vec()));
}
};
// Merge each override category
merge_into(&mut directives, "script-src", &overrides.script_src);
merge_into(&mut directives, "img-src", &overrides.img_src);
merge_into(&mut directives, "connect-src", &overrides.connect_src);
// Rebuild CSP string
directives
.into_iter()
.map(|(name, values)| {
if values.is_empty() {
name
} else {
format!("{} {}", name, values.join(" "))
}
})
.collect::<Vec<_>>()
.join("; ")
}
// ---------------------------------------------------------------------------
// Dispatch verdict
// ---------------------------------------------------------------------------
/// Result of the bearer-token dispatch chain.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthVerdict {
/// Request is dispatch-exempt (rule 0); handler decides auth.
Exempt,
/// Authenticated with the given token kind.
Authenticated(TokenKind),
/// Bearer token looked like a JWT but failed validation (rule 1).
JwtInvalid,
/// JWT was signature-valid but scope was insufficient (rule 1).
JwtScopeDenied,
/// No matching key / missing Authorization (rule 4).
InvalidAuth,
}
/// Which key or token type satisfied authentication.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TokenKind {
MasterKey,
AdminKey,
/// JWT validated against primary or previous secret.
Jwt,
/// Admin session cookie — sealed session ID validated against task store.
AdminSession,
}
impl AuthVerdict {
pub fn is_allowed(&self) -> bool {
matches!(self, AuthVerdict::Exempt | AuthVerdict::Authenticated(_))
}
}
// ---------------------------------------------------------------------------
// Rule 0 — dispatch-exempt check
// ---------------------------------------------------------------------------
/// Returns true when `(method, path)` is in the exhaustive dispatch-exempt list
/// (plan §5 rule 5). Exempt endpoints run their handler directly; rules 14
/// are never consulted.
pub fn is_dispatch_exempt(method: &Method, path: &str) -> bool {
// `GET /health` — unauthenticated liveness probe (Meilisearch-compatible)
if method == Method::GET && path == "/health" {
return true;
}
// `GET /version` — unauthenticated version endpoint (Meilisearch-compatible)
if method == Method::GET && path == "/version" {
return true;
}
// `GET /_miroir/ready` — unauthenticated readiness probe (plan §10)
if method == Method::GET && path == "/_miroir/ready" {
return true;
}
// `GET /_miroir/ui/search/locale/*` — unauthenticated public locale fetch
if method == Method::GET {
if let Some(rest) = path.strip_prefix("/_miroir/ui/search/locale/") {
// Must have at least one path segment after the prefix
return !rest.is_empty() && !rest.contains("//");
}
}
// `POST /_miroir/admin/login` — credentials in body
if method == Method::POST && path == "/_miroir/admin/login" {
return true;
}
// `GET /_miroir/ui/search/{index}/session` — auth per search_ui.auth.mode
if method == Method::GET {
if let Some(rest) = path.strip_prefix("/_miroir/ui/search/") {
let segments: Vec<&str> = rest.split('/').collect();
if segments.len() == 2 && segments[1] == "session" && !segments[0].is_empty() {
return true;
}
}
}
// `GET /ui/search/{index}` — public SPA entry point
if method == Method::GET {
if let Some(rest) = path.strip_prefix("/ui/search/") {
// Single non-empty segment (the index name)
return !rest.is_empty() && !rest.contains('/');
}
}
false
}
// ---------------------------------------------------------------------------
// Rule 1 — JWT-shape probe
// ---------------------------------------------------------------------------
/// Returns true if `token` has the structural shape of a JWT (three
/// dot-separated base64url segments).
pub fn probe_jwt_shape(token: &str) -> bool {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return false;
}
// Each segment should be non-empty and look like base64url
parts.iter().all(|s| {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '=')
})
}
// ---------------------------------------------------------------------------
// Helper — admin path check
// ---------------------------------------------------------------------------
/// Returns true if `path` starts with `/_miroir/` (admin surface).
pub fn is_admin_path(path: &str) -> bool {
path.starts_with("/_miroir/")
}
// ---------------------------------------------------------------------------
// Constant-time opaque key comparison
// ---------------------------------------------------------------------------
/// Constant-time comparison of an opaque token against an expected key.
/// Prevents timing side-channels on secret key values.
pub fn constant_time_compare(token: &[u8], expected: &[u8]) -> bool {
token.ct_eq(expected).into()
}
// ---------------------------------------------------------------------------
// Scope and index validation (plan §13.21 defense-in-depth)
// ---------------------------------------------------------------------------
/// Action name for a given (method, path) combination per plan §13.21.
/// Returns the scope action name if the path is a search UI endpoint, None otherwise.
fn action_for_method_path(method: &Method, path: &str) -> Option<&'static str> {
// POST /indexes/{idx}/search → "search"
if method == Method::POST {
if let Some(rest) = path.strip_prefix("/indexes/") {
if let Some(idx_rest) = rest.strip_suffix("/search") {
// Ensure the middle part is a valid index uid (non-empty, no slashes)
if !idx_rest.is_empty() && !idx_rest.contains('/') {
return Some("search");
}
}
}
}
// POST /multi-search → "multi_search"
if method == Method::POST && path == "/multi-search" {
return Some("multi_search");
}
// POST /_miroir/ui/search/{idx}/beacon → "beacon"
if method == Method::POST {
if let Some(rest) = path.strip_prefix("/_miroir/ui/search/") {
if let Some(idx_rest) = rest.strip_suffix("/beacon") {
if !idx_rest.is_empty() && !idx_rest.contains('/') {
return Some("beacon");
}
}
}
}
None
}
/// Validate JWT scope and index claims against the request (plan §13.21).
/// Returns Ok(()) if the (method, path) is allowed by the scope and idx claim,
/// or Err(JwtScopeDenied) if the validation fails.
pub fn validate_jwt_scope(
claims: &JwtClaims,
method: &Method,
path: &str,
) -> Result<(), JwtValidationError> {
// Determine the required action for this (method, path)
let Some(required_action) = action_for_method_path(method, path) else {
// This endpoint doesn't require scope validation
return Ok(());
};
// Check if the required action is in the scope
if !claims.scope.contains(&required_action.to_string()) {
return Err(JwtValidationError::ScopeDenied);
}
// For multi_search, we need to validate that every sub-query's indexUid matches idx
// This is handled later in the request handler since we need to parse the body.
// For search and beacon, validate the index in the path matches the claim.
if required_action == "search" || required_action == "beacon" {
let expected_idx = &claims.idx;
let actual_idx = if required_action == "search" {
// Extract index from /indexes/{idx}/search
path.strip_prefix("/indexes/")
.and_then(|rest| rest.strip_suffix("/search"))
} else {
// Extract index from /_miroir/ui/search/{idx}/beacon
path.strip_prefix("/_miroir/ui/search/")
.and_then(|rest| rest.strip_suffix("/beacon"))
};
if actual_idx != Some(expected_idx) {
return Err(JwtValidationError::ScopeDenied);
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Core dispatch — rules 04
// ---------------------------------------------------------------------------
/// Execute the full bearer-token dispatch chain for a request.
///
/// `bearer_token` is the raw value after stripping `"Bearer "` from the
/// `Authorization` header (may be `None` if the header is absent).
pub fn dispatch_bearer(
method: &Method,
path: &str,
bearer_token: Option<&str>,
state: &AuthState,
) -> AuthVerdict {
// Rule 0 — dispatch-exempt endpoints skip all auth checks
if is_dispatch_exempt(method, path) {
return AuthVerdict::Exempt;
}
let token = match bearer_token {
Some(t) => t,
None => return AuthVerdict::InvalidAuth, // Rule 4 — missing auth
};
// Rule 1 — JWT-shape probe, then full validation including scope check
if probe_jwt_shape(token) {
match state.validate_jwt(token) {
Ok(claims) => {
// Defense-in-depth: validate scope and index claims (plan §13.21)
match validate_jwt_scope(&claims, method, path) {
Ok(()) => return AuthVerdict::Authenticated(TokenKind::Jwt),
Err(JwtValidationError::ScopeDenied) => {
return AuthVerdict::JwtScopeDenied;
}
Err(_) => return AuthVerdict::JwtInvalid,
}
}
Err(JwtValidationError::PreviousSecretEmpty) => {
return AuthVerdict::JwtInvalid;
}
Err(_) => {
return AuthVerdict::JwtInvalid;
}
}
}
// Rule 2 — admin-path opaque-token match
if is_admin_path(path) {
if constant_time_compare(token.as_bytes(), state.admin_key.as_bytes()) {
return AuthVerdict::Authenticated(TokenKind::AdminKey);
}
return AuthVerdict::InvalidAuth; // Rule 4
}
// Rule 3 — master-key match (non-admin paths)
if constant_time_compare(token.as_bytes(), state.master_key.as_bytes()) {
return AuthVerdict::Authenticated(TokenKind::MasterKey);
}
// Rule 4 — mismatch
AuthVerdict::InvalidAuth
}
// ---------------------------------------------------------------------------
// X-Admin-Key short-circuit
// ---------------------------------------------------------------------------
/// Check the `X-Admin-Key` header for admin endpoints.
/// Returns `true` if the header is present and matches `admin_key`.
/// Evaluated independently of the bearer chain — short-circuits directly.
pub fn check_x_admin_key(headers: &HeaderMap, admin_key: &[u8]) -> bool {
match headers.get("X-Admin-Key").and_then(|v| v.to_str().ok()) {
Some(key) => constant_time_compare(key.as_bytes(), admin_key),
None => false,
}
}
// ---------------------------------------------------------------------------
// Axum middleware
// ---------------------------------------------------------------------------
/// Extract the bearer token from `Authorization: Bearer <value>`.
fn extract_bearer(headers: &HeaderMap) -> Option<&str> {
let auth = headers.get("authorization")?.to_str().ok()?;
auth.strip_prefix("Bearer ")
}
/// Extract the sealed admin session cookie value from the Cookie header.
pub fn extract_admin_session_cookie(headers: &HeaderMap) -> Option<String> {
let cookie_header = headers.get("cookie")?.to_str().ok()?;
for pair in cookie_header.split(';') {
let pair = pair.trim();
if let Some(value) = pair.strip_prefix(&format!("{}=", admin_session::COOKIE_NAME)) {
return Some(value.to_string());
}
}
None
}
/// Unseal an admin session cookie, returning the session ID.
pub fn unseal_admin_cookie(
cookie_value: &str,
key: &SealKey,
) -> Result<String, admin_session::SealError> {
admin_session::unseal_session(cookie_value, key)
}
/// Axum middleware implementing the bearer-token dispatch chain (plan §5).
pub async fn auth_middleware(State(state): State<AuthState>, req: Request, next: Next) -> Response {
let method = req.method().clone();
let path = req.uri().path().to_string();
// Rule 0 — dispatch-exempt: skip everything, handler decides auth
if is_dispatch_exempt(&method, &path) {
return next.run(req).await;
}
// X-Admin-Key short-circuit for admin endpoints
if is_admin_path(&path) && check_x_admin_key(req.headers(), state.admin_key.as_bytes()) {
return next.run(req).await;
}
// Admin session cookie check for admin endpoints (plan §9, §13.19).
// If a sealed admin session cookie is present, unseal it and authenticate.
// Revoked sessions are rejected immediately.
if is_admin_path(&path) {
if let Some(cookie_value) = extract_admin_session_cookie(req.headers()) {
match unseal_admin_cookie(&cookie_value, &state.seal_key) {
Ok(session_id) => {
// Check revocation cache (populated on logout + Pub/Sub).
if state.revoked_sessions.contains_key(&session_id) {
return MeilisearchError::new(
MiroirCode::InvalidAuth,
"Admin session has been revoked.",
)
.into_response();
}
let mut req = req;
req.extensions_mut().insert(AdminSessionId(session_id));
return next.run(req).await;
}
Err(e) => {
// Cookie tampering or wrong seal key (e.g. cross-pod key
// mismatch in HA). Log a warning so operators can diagnose
// ADMIN_SESSION_SEAL_KEY divergence across pods.
tracing::warn!(
path = %path,
error = %e,
"admin session cookie unseal failed — tampered cookie or cross-pod key mismatch"
);
}
}
}
}
// Extract bearer token
let bearer = extract_bearer(req.headers());
// Run the dispatch chain
let verdict = dispatch_bearer(&method, &path, bearer, &state);
match verdict {
AuthVerdict::Authenticated(TokenKind::Jwt) => {
// JWT validated successfully - extract claims and attach to request
if let Some(token) = bearer {
if let Ok(claims) = state.validate_jwt(token) {
let mut req = req;
req.extensions_mut().insert(JwtClaimsExtension(claims));
return next.run(req).await;
}
}
// Shouldn't reach here if dispatch returned Jwt, but handle gracefully
MeilisearchError::new(
MiroirCode::JwtInvalid,
"The provided JWT is invalid or expired.",
)
.into_response()
}
AuthVerdict::Authenticated(_) | AuthVerdict::Exempt => next.run(req).await,
AuthVerdict::JwtInvalid => MeilisearchError::new(
MiroirCode::JwtInvalid,
"The provided JWT is invalid or expired.",
)
.into_response(),
AuthVerdict::JwtScopeDenied => MeilisearchError::new(
MiroirCode::JwtScopeDenied,
"The provided JWT does not grant access to this resource.",
)
.into_response(),
AuthVerdict::InvalidAuth => MeilisearchError::new(
MiroirCode::InvalidAuth,
"The provided authorization is invalid.",
)
.into_response(),
}
}
// ---------------------------------------------------------------------------
// CSRF validation middleware (plan §9)
// ---------------------------------------------------------------------------
/// CSRF middleware that validates `X-CSRF-Token` on state-changing requests.
///
/// Bypasses CSRF check when:
/// - Request is authenticated via Bearer token (not admin session cookie)
/// - X-Admin-Key header is present
/// - Request method is safe (GET, HEAD, OPTIONS)
/// - Path is dispatch-exempt
///
/// For admin session cookie auth, requires `X-CSRF-Token` header to match
/// the token stored in the session.
pub async fn csrf_middleware(State(state): State<CsrfState>, req: Request, next: Next) -> Response {
let method = req.method().clone();
let path = req.uri().path().to_string();
// Skip CSRF for safe methods
if matches!(method, Method::GET | Method::HEAD | Method::OPTIONS) {
return next.run(req).await;
}
// Skip CSRF for non-admin paths
if !is_admin_path(&path) {
return next.run(req).await;
}
// Skip CSRF for dispatch-exempt endpoints
if is_dispatch_exempt(&method, &path) {
return next.run(req).await;
}
// Skip CSRF if X-Admin-Key is present (bypasses CSRF)
if check_x_admin_key(req.headers(), state.auth.admin_key.as_bytes()) {
return next.run(req).await;
}
// Check if authenticated via admin session cookie
let has_session_cookie = extract_admin_session_cookie(req.headers()).is_some();
let has_bearer_token = extract_bearer(req.headers()).is_some();
// CSRF only applies to session-cookie auth, not bearer tokens
if !has_session_cookie || has_bearer_token {
return next.run(req).await;
}
// Extract CSRF token from header
let csrf_token = match extract_csrf_token(req.headers()) {
Some(token) => token,
None => {
return MeilisearchError::new(
MiroirCode::MissingCsrf,
"CSRF token is required for state-changing requests.",
)
.into_response();
}
};
// Get session ID from extensions (set by auth_middleware)
let session_id = match req.extensions().get::<AdminSessionId>() {
Some(id) => id.0.clone(),
None => {
// Session cookie was present but auth_middleware didn't set AdminSessionId
// This means the session was invalid/expired/revoked
return MeilisearchError::new(
MiroirCode::InvalidAuth,
"Admin session is invalid or expired.",
)
.into_response();
}
};
// Validate CSRF token against session
let Some(redis_store) = state.redis_store.as_ref() else {
return MeilisearchError::new(
MiroirCode::InvalidAuth,
"Admin sessions require Redis task store.",
)
.into_response();
};
let session = match redis_store.get_admin_session(&session_id) {
Ok(Some(s)) => s,
Ok(None) => {
return MeilisearchError::new(MiroirCode::InvalidAuth, "Admin session not found.")
.into_response();
}
Err(e) => {
tracing::warn!(error = %e, session_prefix = &session_id[..session_id.len().min(8)], "failed to get admin session for CSRF validation");
return MeilisearchError::new(MiroirCode::InvalidAuth, "Failed to validate session.")
.into_response();
}
};
// Check if revoked
if session.revoked {
return MeilisearchError::new(MiroirCode::InvalidAuth, "Admin session has been revoked.")
.into_response();
}
// Check expiration
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
if session.expires_at < now {
return MeilisearchError::new(MiroirCode::InvalidAuth, "Admin session has expired.")
.into_response();
}
// Constant-time compare CSRF tokens
if !constant_time_csrf_compare(&csrf_token, &session.csrf_token) {
return MeilisearchError::new(
MiroirCode::CsrfMismatch,
"CSRF token does not match the session token.",
)
.into_response();
}
next.run(req).await
}
// ---------------------------------------------------------------------------
// Rate-limit hook types (Phase 2 in-memory stub, Phase 6 multi-pod)
// ---------------------------------------------------------------------------
/// Rate-limit bucket key types wired into the dispatch chain.
/// Phase 2 keeps these as in-memory counters; Phase 6 will back them
/// with the task store (Redis/SQLite).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum RateLimitBucket {
/// `miroir:ratelimit:adminlogin:<ip>`
AdminLogin(String),
/// `miroir:ratelimit:searchui:<ip>`
SearchUi(String),
}
/// In-memory rate limiter (Phase 2 stub). Always returns `Ok(())` — actual
/// enforcement is deferred to Phase 6 multi-pod. The hook is wired here so
/// handlers can call `limiter.check()` without cfg-gating.
#[derive(Debug, Clone, Default)]
pub struct RateLimiter;
impl RateLimiter {
#[allow(clippy::result_unit_err)]
pub fn check(&self, _bucket: &RateLimitBucket) -> Result<(), ()> {
Ok(()) // Phase 2: always allow
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn epoch_seconds() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn test_key() -> SealKey {
SealKey::from_bytes([42u8; 32])
}
fn test_state() -> AuthState {
AuthState {
master_key: "master-key-123".to_string(),
admin_key: "admin-key-456".to_string(),
jwt_primary: None,
jwt_previous: None,
seal_key: test_key(),
revoked_sessions: Arc::new(DashMap::new()),
admin_session_revoked_total: Counter::with_opts(prometheus::Opts::new(
"test_revoked_total",
"test",
))
.unwrap(),
}
}
fn test_state_with_jwt() -> AuthState {
AuthState {
master_key: "master-key-123".to_string(),
admin_key: "admin-key-456".to_string(),
jwt_primary: Some("test-secret-primary-key-32byte".to_string()),
jwt_previous: None,
seal_key: test_key(),
revoked_sessions: Arc::new(DashMap::new()),
admin_session_revoked_total: Counter::with_opts(prometheus::Opts::new(
"test_revoked_total",
"test",
))
.unwrap(),
}
}
fn test_state_with_dual_jwt() -> AuthState {
AuthState {
master_key: "master-key-123".to_string(),
admin_key: "admin-key-456".to_string(),
jwt_primary: Some("test-secret-primary-key-32byte".to_string()),
jwt_previous: Some("test-secret-previous-key-32byte".to_string()),
seal_key: test_key(),
revoked_sessions: Arc::new(DashMap::new()),
admin_session_revoked_total: Counter::with_opts(prometheus::Opts::new(
"test_revoked_total",
"test",
))
.unwrap(),
}
}
// -----------------------------------------------------------------------
// Rule 0 — dispatch-exempt tests
// -----------------------------------------------------------------------
#[test]
fn get_metrics_requires_admin_key() {
assert!(!is_dispatch_exempt(&Method::GET, "/_miroir/metrics"));
assert!(!is_dispatch_exempt(&Method::POST, "/_miroir/metrics"));
}
#[test]
fn exempt_get_locale_star() {
assert!(is_dispatch_exempt(
&Method::GET,
"/_miroir/ui/search/locale/en-US"
));
assert!(is_dispatch_exempt(
&Method::GET,
"/_miroir/ui/search/locale/fr"
));
}
#[test]
fn exempt_get_locale_no_variant_not_exempt() {
assert!(!is_dispatch_exempt(
&Method::GET,
"/_miroir/ui/search/locale/"
));
}
#[test]
fn exempt_post_admin_login() {
assert!(is_dispatch_exempt(&Method::POST, "/_miroir/admin/login"));
}
#[test]
fn exempt_get_admin_login_not_exempt() {
assert!(!is_dispatch_exempt(&Method::GET, "/_miroir/admin/login"));
}
#[test]
fn exempt_get_session() {
assert!(is_dispatch_exempt(
&Method::GET,
"/_miroir/ui/search/products/session"
));
}
#[test]
fn exempt_get_session_no_index_not_exempt() {
assert!(!is_dispatch_exempt(
&Method::GET,
"/_miroir/ui/search//session"
));
}
#[test]
fn exempt_get_search_ui_spa() {
assert!(is_dispatch_exempt(&Method::GET, "/ui/search/products"));
}
#[test]
fn exempt_get_search_ui_no_index_not_exempt() {
assert!(!is_dispatch_exempt(&Method::GET, "/ui/search/"));
}
#[test]
fn exempt_post_search_ui_not_exempt() {
assert!(!is_dispatch_exempt(&Method::POST, "/ui/search/products"));
}
#[test]
fn exempt_non_matching_path_not_exempt() {
assert!(!is_dispatch_exempt(&Method::GET, "/indexes/products"));
assert!(!is_dispatch_exempt(&Method::POST, "/indexes"));
assert!(!is_dispatch_exempt(&Method::GET, "/_miroir/other"));
}
#[test]
fn exempt_get_miroir_ready() {
assert!(is_dispatch_exempt(&Method::GET, "/_miroir/ready"));
assert!(!is_dispatch_exempt(&Method::POST, "/_miroir/ready"));
}
#[test]
fn exempt_get_health() {
assert!(is_dispatch_exempt(&Method::GET, "/health"));
assert!(!is_dispatch_exempt(&Method::POST, "/health"));
}
#[test]
fn exempt_get_version() {
assert!(is_dispatch_exempt(&Method::GET, "/version"));
assert!(!is_dispatch_exempt(&Method::POST, "/version"));
}
// -----------------------------------------------------------------------
// Rule 0 — exempt endpoints skip auth entirely
// -----------------------------------------------------------------------
#[test]
fn exempt_endpoint_ignores_admin_key() {
let state = test_state();
let verdict = dispatch_bearer(
&Method::POST,
"/_miroir/admin/login",
Some("admin-key-456"),
&state,
);
assert_eq!(verdict, AuthVerdict::Exempt);
}
#[test]
fn exempt_locale_ignores_all_tokens() {
let state = test_state();
let verdict = dispatch_bearer(
&Method::GET,
"/_miroir/ui/search/locale/en-US",
Some("master-key-123"),
&state,
);
assert_eq!(verdict, AuthVerdict::Exempt);
}
#[test]
fn exempt_session_ignores_all_tokens() {
let state = test_state();
let verdict = dispatch_bearer(
&Method::GET,
"/_miroir/ui/search/products/session",
Some("admin-key-456"),
&state,
);
assert_eq!(verdict, AuthVerdict::Exempt);
}
#[test]
fn exempt_spa_ignores_all_tokens() {
let state = test_state();
let verdict = dispatch_bearer(&Method::GET, "/ui/search/products", None, &state);
assert_eq!(verdict, AuthVerdict::Exempt);
}
#[test]
fn exempt_ready_ignores_all_tokens() {
let state = test_state();
let verdict = dispatch_bearer(&Method::GET, "/_miroir/ready", Some("bogus-token"), &state);
assert_eq!(verdict, AuthVerdict::Exempt);
}
#[test]
fn exempt_health_ignores_all_tokens() {
let state = test_state();
let verdict = dispatch_bearer(&Method::GET, "/health", Some("bogus-token"), &state);
assert_eq!(verdict, AuthVerdict::Exempt);
}
#[test]
fn exempt_health_with_no_token() {
let state = test_state();
let verdict = dispatch_bearer(&Method::GET, "/health", None, &state);
assert_eq!(verdict, AuthVerdict::Exempt);
}
#[test]
fn exempt_version_ignores_all_tokens() {
let state = test_state();
let verdict = dispatch_bearer(&Method::GET, "/version", Some("bogus-token"), &state);
assert_eq!(verdict, AuthVerdict::Exempt);
}
#[test]
fn exempt_version_with_no_token() {
let state = test_state();
let verdict = dispatch_bearer(&Method::GET, "/version", None, &state);
assert_eq!(verdict, AuthVerdict::Exempt);
}
// -----------------------------------------------------------------------
// /_miroir/metrics requires admin key (not exempt)
// -----------------------------------------------------------------------
#[test]
fn metrics_requires_admin_key() {
let state = test_state();
let verdict = dispatch_bearer(
&Method::GET,
"/_miroir/metrics",
Some("admin-key-456"),
&state,
);
assert_eq!(verdict, AuthVerdict::Authenticated(TokenKind::AdminKey));
}
#[test]
fn metrics_rejects_master_key() {
let state = test_state();
let verdict = dispatch_bearer(
&Method::GET,
"/_miroir/metrics",
Some("master-key-123"),
&state,
);
assert_eq!(verdict, AuthVerdict::InvalidAuth);
}
#[test]
fn metrics_rejects_missing_auth() {
let state = test_state();
let verdict = dispatch_bearer(&Method::GET, "/_miroir/metrics", None, &state);
assert_eq!(verdict, AuthVerdict::InvalidAuth);
}
// -----------------------------------------------------------------------
// Rule 1 — JWT-shape probe
// -----------------------------------------------------------------------
#[test]
fn jwt_shape_probe_accepts_valid_shape() {
assert!(probe_jwt_shape(
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc123"
));
}
#[test]
fn jwt_shape_probe_rejects_two_parts() {
assert!(!probe_jwt_shape("part1.part2"));
}
#[test]
fn jwt_shape_probe_rejects_four_parts() {
assert!(!probe_jwt_shape("a.b.c.d"));
}
#[test]
fn jwt_shape_probe_rejects_empty() {
assert!(!probe_jwt_shape(""));
}
#[test]
fn jwt_shape_probe_rejects_opaque_token() {
assert!(!probe_jwt_shape("admin-key-456"));
}
#[test]
fn jwt_on_non_admin_path_with_no_secret_returns_jwt_invalid() {
let state = test_state(); // no JWT secrets configured
let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc123";
let verdict = dispatch_bearer(&Method::GET, "/indexes/products", Some(jwt), &state);
assert_eq!(verdict, AuthVerdict::JwtInvalid);
}
#[test]
fn jwt_on_admin_path_returns_jwt_invalid() {
let state = test_state();
let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc123";
let verdict = dispatch_bearer(
&Method::GET,
"/_miroir/some/admin/endpoint",
Some(jwt),
&state,
);
assert_eq!(verdict, AuthVerdict::JwtInvalid);
}
// -----------------------------------------------------------------------
// JWT signing and validation — primary secret
// -----------------------------------------------------------------------
#[test]
fn sign_and_validate_primary_jwt() {
let state = test_state_with_jwt();
let token = state
.sign_jwt("user1", "products", &["search"], 900, None, None, None)
.unwrap();
let claims = state.validate_jwt(&token).unwrap();
assert_eq!(claims.sub, "user1");
assert_eq!(claims.idx, "products");
assert_eq!(claims.scope, vec!["search"]);
}
#[test]
fn signed_jwt_authenticates_via_dispatch() {
let state = test_state_with_jwt();
let token = state
.sign_jwt("user1", "products", &["search"], 900, None, None, None)
.unwrap();
let verdict = dispatch_bearer(&Method::GET, "/indexes/products", Some(&token), &state);
assert_eq!(verdict, AuthVerdict::Authenticated(TokenKind::Jwt));
}
#[test]
fn expired_jwt_returns_jwt_invalid() {
let state = test_state_with_jwt();
let now = epoch_seconds();
let claims = JwtClaims {
iss: "miroir".to_string(),
sub: "user1".to_string(),
idx: "products".to_string(),
scope: vec!["search".to_string()],
iat: now - 3600,
exp: now - 100, // expired well beyond 30s leeway
injected_filter: None,
user: None,
groups: None,
};
let header = JwtHeader {
alg: "HS256".to_string(),
kid: KID_PRIMARY.to_string(),
typ: "JWT".to_string(),
};
let token = jwt_encode(
&header,
&claims,
state.jwt_primary.as_ref().unwrap().as_bytes(),
)
.unwrap();
let result = state.validate_jwt(&token);
assert_eq!(result, Err(JwtValidationError::Expired));
}
#[test]
fn tampered_signature_returns_invalid_signature() {
let state = test_state_with_jwt();
let mut token = state
.sign_jwt("user1", "products", &["search"], 900, None, None, None)
.unwrap();
// Tamper with the signature
let parts: Vec<&str> = token.split('.').collect();
token = format!("{}.{}.tampered_sig", parts[0], parts[1]);
let result = state.validate_jwt(&token);
assert_eq!(result, Err(JwtValidationError::InvalidSignature));
}
// -----------------------------------------------------------------------
// JWT dual-secret rotation validation
// -----------------------------------------------------------------------
#[test]
fn rotation_old_token_validates_via_previous_secret() {
let primary = "test-secret-primary-key-32byte";
let previous = "test-secret-previous-key-32byte";
// Sign token with the previous secret
let now = epoch_seconds();
let claims = JwtClaims {
iss: "miroir".to_string(),
sub: "user1".to_string(),
idx: "products".to_string(),
scope: vec!["search".to_string()],
iat: now,
exp: now + 900,
injected_filter: None,
user: None,
groups: None,
};
let header = JwtHeader {
alg: "HS256".to_string(),
kid: KID_PREVIOUS.to_string(),
typ: "JWT".to_string(),
};
let old_token = jwt_encode(&header, &claims, previous.as_bytes()).unwrap();
// Simulate rotation — new primary, old primary as previous
let state = AuthState {
master_key: "m".to_string(),
admin_key: "a".to_string(),
jwt_primary: Some(primary.to_string()),
jwt_previous: Some(previous.to_string()),
seal_key: test_key(),
revoked_sessions: Arc::new(DashMap::new()),
admin_session_revoked_total: Counter::with_opts(prometheus::Opts::new(
"test_revoked_total",
"test",
))
.unwrap(),
};
// Old token should still validate via previous secret
let validated = state.validate_jwt(&old_token).unwrap();
assert_eq!(validated.sub, "user1");
// And dispatch should authenticate it
let verdict = dispatch_bearer(&Method::GET, "/indexes/products", Some(&old_token), &state);
assert_eq!(verdict, AuthVerdict::Authenticated(TokenKind::Jwt));
}
#[test]
fn rotation_new_token_validates_via_primary_secret() {
let state = test_state_with_dual_jwt();
let new_token = state
.sign_jwt("user2", "orders", &["search"], 900, None, None, None)
.unwrap();
let validated = state.validate_jwt(&new_token).unwrap();
assert_eq!(validated.sub, "user2");
assert_eq!(validated.idx, "orders");
}
#[test]
fn rotation_wrong_secret_returns_invalid_signature() {
let state = AuthState {
master_key: "m".to_string(),
admin_key: "a".to_string(),
jwt_primary: Some("correct-secret-key-32bytes-long!!!".to_string()),
jwt_previous: Some("previous-secret-key-32bytes-long!!".to_string()),
seal_key: test_key(),
revoked_sessions: Arc::new(DashMap::new()),
admin_session_revoked_total: Counter::with_opts(prometheus::Opts::new(
"test_revoked_total",
"test",
))
.unwrap(),
};
// Token signed with a completely different secret
let now = epoch_seconds();
let claims = JwtClaims {
iss: "miroir".to_string(),
sub: "user1".to_string(),
idx: "products".to_string(),
scope: vec!["search".to_string()],
iat: now,
exp: now + 900,
injected_filter: None,
user: None,
groups: None,
};
let header = JwtHeader {
alg: "HS256".to_string(),
kid: KID_PRIMARY.to_string(),
typ: "JWT".to_string(),
};
let token = jwt_encode(
&header,
&claims,
"wrong-secret-key-32bytes-long!!!!".as_bytes(),
)
.unwrap();
let result = state.validate_jwt(&token);
assert_eq!(result, Err(JwtValidationError::InvalidSignature));
}
#[test]
fn leak_response_empty_previous_rejects_old_tokens() {
let state = AuthState {
master_key: "m".to_string(),
admin_key: "a".to_string(),
jwt_primary: Some("new-primary-secret-key-32bytes!!".to_string()),
jwt_previous: Some(String::new()), // empty = leak response
seal_key: test_key(),
revoked_sessions: Arc::new(DashMap::new()),
admin_session_revoked_total: Counter::with_opts(prometheus::Opts::new(
"test_revoked_total",
"test",
))
.unwrap(),
};
let result = state.validate_jwt("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.fake");
assert_eq!(result, Err(JwtValidationError::PreviousSecretEmpty));
}
#[test]
fn rotation_after_step5_steady_state_previous_removed() {
let primary = "final-primary-secret-key-32bytes";
let state = AuthState {
master_key: "m".to_string(),
admin_key: "a".to_string(),
jwt_primary: Some(primary.to_string()),
jwt_previous: None,
seal_key: test_key(),
revoked_sessions: Arc::new(DashMap::new()),
admin_session_revoked_total: Counter::with_opts(prometheus::Opts::new(
"test_revoked_total",
"test",
))
.unwrap(),
};
// Tokens signed with current primary work
let token = state
.sign_jwt("user1", "products", &["search"], 900, None, None, None)
.unwrap();
assert!(state.validate_jwt(&token).is_ok());
// Old tokens signed with now-removed previous fail
let old_claims = JwtClaims {
iss: "miroir".to_string(),
sub: "user1".to_string(),
idx: "products".to_string(),
scope: vec!["search".to_string()],
iat: epoch_seconds() - 100,
exp: epoch_seconds() + 800,
injected_filter: None,
user: None,
groups: None,
};
let old_header = JwtHeader {
alg: "HS256".to_string(),
kid: KID_PREVIOUS.to_string(),
typ: "JWT".to_string(),
};
let old_token = jwt_encode(
&old_header,
&old_claims,
"old-previous-secret-now-removed".as_bytes(),
)
.unwrap();
assert!(state.validate_jwt(&old_token).is_err());
}
// -----------------------------------------------------------------------
// End-to-end rotation scenario
// -----------------------------------------------------------------------
#[test]
fn full_rotation_e2e() {
let secret_v1 = "version-1-secret-key-32bytes-long!";
let secret_v2 = "version-2-secret-key-32bytes-long!";
// Pre-rotation: only v1
let pre = AuthState {
master_key: "m".into(),
admin_key: "a".into(),
jwt_primary: Some(secret_v1.into()),
jwt_previous: None,
seal_key: test_key(),
revoked_sessions: Arc::new(DashMap::new()),
admin_session_revoked_total: Counter::with_opts(prometheus::Opts::new(
"test_revoked_total",
"test",
))
.unwrap(),
};
let token_v1 = pre
.sign_jwt("alice", "idx", &["search"], 900, None, None, None)
.unwrap();
assert!(pre.validate_jwt(&token_v1).is_ok());
// During rotation: v2 primary, v1 previous
let during = AuthState {
master_key: "m".into(),
admin_key: "a".into(),
jwt_primary: Some(secret_v2.into()),
jwt_previous: Some(secret_v1.into()),
seal_key: test_key(),
revoked_sessions: Arc::new(DashMap::new()),
admin_session_revoked_total: Counter::with_opts(prometheus::Opts::new(
"test_revoked_total",
"test",
))
.unwrap(),
};
// Old token still validates
assert!(during.validate_jwt(&token_v1).is_ok());
// New tokens work too
let token_v2 = during
.sign_jwt("bob", "idx", &["search"], 900, None, None, None)
.unwrap();
assert!(during.validate_jwt(&token_v2).is_ok());
// Post-rotation: only v2
let post = AuthState {
master_key: "m".into(),
admin_key: "a".into(),
jwt_primary: Some(secret_v2.into()),
jwt_previous: None,
seal_key: test_key(),
revoked_sessions: Arc::new(DashMap::new()),
admin_session_revoked_total: Counter::with_opts(prometheus::Opts::new(
"test_revoked_total",
"test",
))
.unwrap(),
};
// New token still works
assert!(post.validate_jwt(&token_v2).is_ok());
// Old token is rejected (signed with v1, no previous loaded)
assert!(post.validate_jwt(&token_v1).is_err());
}
// -----------------------------------------------------------------------
// Rule 2 — admin-path opaque-token match (admin_key only)
// -----------------------------------------------------------------------
#[test]
fn admin_path_matches_admin_key() {
let state = test_state();
let verdict = dispatch_bearer(
&Method::GET,
"/_miroir/some/endpoint",
Some("admin-key-456"),
&state,
);
assert_eq!(verdict, AuthVerdict::Authenticated(TokenKind::AdminKey));
}
#[test]
fn admin_path_rejects_master_key() {
let state = test_state();
let verdict = dispatch_bearer(
&Method::GET,
"/_miroir/some/endpoint",
Some("master-key-123"),
&state,
);
assert_eq!(verdict, AuthVerdict::InvalidAuth);
}
#[test]
fn admin_path_rejects_wrong_key() {
let state = test_state();
let verdict = dispatch_bearer(
&Method::GET,
"/_miroir/some/endpoint",
Some("wrong-key"),
&state,
);
assert_eq!(verdict, AuthVerdict::InvalidAuth);
}
#[test]
fn admin_path_rejects_missing_auth() {
let state = test_state();
let verdict = dispatch_bearer(&Method::GET, "/_miroir/some/endpoint", None, &state);
assert_eq!(verdict, AuthVerdict::InvalidAuth);
}
// -----------------------------------------------------------------------
// Rule 3 — master-key match (non-admin paths only)
// -----------------------------------------------------------------------
#[test]
fn non_admin_path_matches_master_key() {
let state = test_state();
let verdict = dispatch_bearer(
&Method::POST,
"/indexes/products/documents",
Some("master-key-123"),
&state,
);
assert_eq!(verdict, AuthVerdict::Authenticated(TokenKind::MasterKey));
}
#[test]
fn non_admin_path_rejects_admin_key() {
let state = test_state();
let verdict = dispatch_bearer(
&Method::POST,
"/indexes/products/documents",
Some("admin-key-456"),
&state,
);
assert_eq!(verdict, AuthVerdict::InvalidAuth);
}
#[test]
fn non_admin_path_rejects_wrong_key() {
let state = test_state();
let verdict = dispatch_bearer(
&Method::POST,
"/indexes/products/documents",
Some("wrong-key"),
&state,
);
assert_eq!(verdict, AuthVerdict::InvalidAuth);
}
#[test]
fn non_admin_path_rejects_missing_auth() {
let state = test_state();
let verdict = dispatch_bearer(&Method::POST, "/indexes/products/documents", None, &state);
assert_eq!(verdict, AuthVerdict::InvalidAuth);
}
// -----------------------------------------------------------------------
// Rule 4 — missing auth → 401 miroir_invalid_auth
// -----------------------------------------------------------------------
#[test]
fn missing_auth_on_gated_endpoint_returns_invalid_auth() {
let state = test_state();
let verdict = dispatch_bearer(&Method::POST, "/indexes", None, &state);
assert_eq!(verdict, AuthVerdict::InvalidAuth);
}
// -----------------------------------------------------------------------
// X-Admin-Key short-circuit
// -----------------------------------------------------------------------
#[test]
fn x_admin_key_matches_admin_key() {
let mut headers = HeaderMap::new();
headers.insert("X-Admin-Key", "admin-key-456".parse().unwrap());
assert!(check_x_admin_key(&headers, b"admin-key-456"));
}
#[test]
fn x_admin_key_rejects_wrong_key() {
let mut headers = HeaderMap::new();
headers.insert("X-Admin-Key", "wrong-key".parse().unwrap());
assert!(!check_x_admin_key(&headers, b"admin-key-456"));
}
#[test]
fn x_admin_key_missing_header() {
let headers = HeaderMap::new();
assert!(!check_x_admin_key(&headers, b"admin-key-456"));
}
// -----------------------------------------------------------------------
// Constant-time comparison
// -----------------------------------------------------------------------
#[test]
fn constant_time_eq_matching() {
assert!(constant_time_compare(b"hello", b"hello"));
}
#[test]
fn constant_time_eq_not_matching() {
assert!(!constant_time_compare(b"hello", b"world"));
}
#[test]
fn constant_time_eq_different_lengths() {
assert!(!constant_time_compare(b"short", b"much-longer-value"));
}
#[test]
fn constant_time_eq_empty() {
assert!(constant_time_compare(b"", b""));
}
/// Timing-injection harness: verify no measurable delta between
/// "all bytes wrong" and "one byte wrong" comparisons at the same length.
#[test]
fn constant_time_no_timing_leak() {
use std::time::Instant;
let expected = b"admin-key-456";
let all_wrong = b"xxxxxxxxxxxxx";
let one_wrong = b"admin-key-457";
let iterations = 100_000u64;
let start = Instant::now();
for _ in 0..iterations {
let _ = constant_time_compare(all_wrong, expected);
}
let all_wrong_duration = start.elapsed();
let start = Instant::now();
for _ in 0..iterations {
let _ = constant_time_compare(one_wrong, expected);
}
let one_wrong_duration = start.elapsed();
let ratio = all_wrong_duration.as_secs_f64() / one_wrong_duration.as_secs_f64();
assert!(
ratio > 0.5 && ratio < 2.0,
"Timing ratio {ratio} suggests non-constant-time comparison: all_wrong={all_wrong_duration:?}, one_wrong={one_wrong_duration:?}",
);
}
// -----------------------------------------------------------------------
// Bearer extraction
// -----------------------------------------------------------------------
#[test]
fn extract_bearer_valid() {
let mut headers = HeaderMap::new();
headers.insert("authorization", "Bearer my-token".parse().unwrap());
assert_eq!(extract_bearer(&headers), Some("my-token"));
}
#[test]
fn extract_bearer_missing_header() {
let headers = HeaderMap::new();
assert_eq!(extract_bearer(&headers), None);
}
#[test]
fn extract_bearer_wrong_scheme() {
let mut headers = HeaderMap::new();
headers.insert("authorization", "Basic dXNlcjpwYXNz".parse().unwrap());
assert_eq!(extract_bearer(&headers), None);
}
// -----------------------------------------------------------------------
// Admin path detection
// -----------------------------------------------------------------------
#[test]
fn admin_path_detected() {
assert!(is_admin_path("/_miroir/metrics"));
assert!(is_admin_path("/_miroir/admin/login"));
assert!(is_admin_path("/_miroir/ui/search/locale/en"));
}
#[test]
fn non_admin_path_not_detected() {
assert!(!is_admin_path("/indexes/products"));
assert!(!is_admin_path("/search/products"));
assert!(!is_admin_path("/health"));
}
// -----------------------------------------------------------------------
// Rate limiter stub
// -----------------------------------------------------------------------
#[test]
fn rate_limiter_always_allows() {
let limiter = RateLimiter;
assert!(limiter
.check(&RateLimitBucket::AdminLogin("127.0.0.1".into()))
.is_ok());
assert!(limiter
.check(&RateLimitBucket::SearchUi("10.0.0.1".into()))
.is_ok());
}
// -----------------------------------------------------------------------
// AuthVerdict helpers
// -----------------------------------------------------------------------
#[test]
fn verdict_is_allowed() {
assert!(AuthVerdict::Exempt.is_allowed());
assert!(AuthVerdict::Authenticated(TokenKind::MasterKey).is_allowed());
assert!(AuthVerdict::Authenticated(TokenKind::AdminKey).is_allowed());
assert!(!AuthVerdict::JwtInvalid.is_allowed());
assert!(!AuthVerdict::JwtScopeDenied.is_allowed());
assert!(!AuthVerdict::InvalidAuth.is_allowed());
}
// -----------------------------------------------------------------------
// Integration-style: all exempt endpoints have test coverage
// -----------------------------------------------------------------------
#[test]
fn all_rule5_exempt_endpoints_covered() {
let cases = vec![
(Method::GET, "/_miroir/ui/search/locale/en-US"),
(Method::GET, "/_miroir/ui/search/locale/fr"),
(Method::POST, "/_miroir/admin/login"),
(Method::GET, "/_miroir/ui/search/products/session"),
(Method::GET, "/_miroir/ui/search/users/session"),
(Method::GET, "/ui/search/products"),
(Method::GET, "/ui/search/users"),
];
for (method, path) in cases {
assert!(
is_dispatch_exempt(&method, path),
"Expected ({method}, {path}) to be dispatch-exempt",
);
}
}
// -----------------------------------------------------------------------
// Scope and index validation tests (plan §13.21)
// -----------------------------------------------------------------------
#[test]
fn scope_validation_allows_search_on_matching_index() {
let claims = JwtClaims {
iss: "miroir".to_string(),
sub: "user1".to_string(),
idx: "products".to_string(),
scope: vec![
"search".to_string(),
"multi_search".to_string(),
"beacon".to_string(),
],
iat: epoch_seconds(),
exp: epoch_seconds() + 900,
injected_filter: None,
user: None,
groups: None,
};
let result = validate_jwt_scope(&claims, &Method::POST, "/indexes/products/search");
assert!(result.is_ok());
}
#[test]
fn scope_validation_denies_search_on_different_index() {
let claims = JwtClaims {
iss: "miroir".to_string(),
sub: "user1".to_string(),
idx: "products".to_string(),
scope: vec![
"search".to_string(),
"multi_search".to_string(),
"beacon".to_string(),
],
iat: epoch_seconds(),
exp: epoch_seconds() + 900,
injected_filter: None,
user: None,
groups: None,
};
let result = validate_jwt_scope(&claims, &Method::POST, "/indexes/orders/search");
assert_eq!(result, Err(JwtValidationError::ScopeDenied));
}
#[test]
fn scope_validation_denies_missing_scope_action() {
let claims = JwtClaims {
iss: "miroir".to_string(),
sub: "user1".to_string(),
idx: "products".to_string(),
scope: vec!["beacon".to_string()], // missing "search"
iat: epoch_seconds(),
exp: epoch_seconds() + 900,
injected_filter: None,
user: None,
groups: None,
};
let result = validate_jwt_scope(&claims, &Method::POST, "/indexes/products/search");
assert_eq!(result, Err(JwtValidationError::ScopeDenied));
}
#[test]
fn scope_validation_allows_multi_search() {
let claims = JwtClaims {
iss: "miroir".to_string(),
sub: "user1".to_string(),
idx: "products".to_string(),
scope: vec![
"search".to_string(),
"multi_search".to_string(),
"beacon".to_string(),
],
iat: epoch_seconds(),
exp: epoch_seconds() + 900,
injected_filter: None,
user: None,
groups: None,
};
let result = validate_jwt_scope(&claims, &Method::POST, "/multi-search");
assert!(result.is_ok());
}
#[test]
fn scope_validation_denies_multi_search_without_scope() {
let claims = JwtClaims {
iss: "miroir".to_string(),
sub: "user1".to_string(),
idx: "products".to_string(),
scope: vec!["search".to_string()], // missing "multi_search"
iat: epoch_seconds(),
exp: epoch_seconds() + 900,
injected_filter: None,
user: None,
groups: None,
};
let result = validate_jwt_scope(&claims, &Method::POST, "/multi-search");
assert_eq!(result, Err(JwtValidationError::ScopeDenied));
}
#[test]
fn scope_validation_allows_beacon_on_matching_index() {
let claims = JwtClaims {
iss: "miroir".to_string(),
sub: "user1".to_string(),
idx: "products".to_string(),
scope: vec![
"search".to_string(),
"multi_search".to_string(),
"beacon".to_string(),
],
iat: epoch_seconds(),
exp: epoch_seconds() + 900,
injected_filter: None,
user: None,
groups: None,
};
let result =
validate_jwt_scope(&claims, &Method::POST, "/_miroir/ui/search/products/beacon");
assert!(result.is_ok());
}
#[test]
fn scope_validation_denies_beacon_on_different_index() {
let claims = JwtClaims {
iss: "miroir".to_string(),
sub: "user1".to_string(),
idx: "products".to_string(),
scope: vec![
"search".to_string(),
"multi_search".to_string(),
"beacon".to_string(),
],
iat: epoch_seconds(),
exp: epoch_seconds() + 900,
injected_filter: None,
user: None,
groups: None,
};
let result = validate_jwt_scope(&claims, &Method::POST, "/_miroir/ui/search/orders/beacon");
assert_eq!(result, Err(JwtValidationError::ScopeDenied));
}
#[test]
fn scope_validation_skips_non_scoped_endpoints() {
let claims = JwtClaims {
iss: "miroir".to_string(),
sub: "user1".to_string(),
idx: "products".to_string(),
scope: vec!["search".to_string()],
iat: epoch_seconds(),
exp: epoch_seconds() + 900,
injected_filter: None,
user: None,
groups: None,
};
// Endpoints that don't require scope validation should pass
assert!(validate_jwt_scope(&claims, &Method::GET, "/indexes/products").is_ok());
assert!(validate_jwt_scope(&claims, &Method::POST, "/indexes/products/documents").is_ok());
assert!(validate_jwt_scope(&claims, &Method::GET, "/_miroir/admin/settings").is_ok());
}
#[test]
fn dispatch_with_jwt_scope_denied_returns_scope_denied_verdict() {
let state = test_state_with_jwt();
let token = state
.sign_jwt("user1", "products", &["search"], 900, None, None, None)
.unwrap();
// Token should be valid, but trying to use it on a different index should fail
let verdict = dispatch_bearer(
&Method::POST,
"/indexes/orders/search",
Some(&token),
&state,
);
assert_eq!(verdict, AuthVerdict::JwtScopeDenied);
}
#[test]
fn dispatch_with_jwt_correct_index_and_scope_succeeds() {
let state = test_state_with_jwt();
let token = state
.sign_jwt(
"user1",
"products",
&["search", "multi_search", "beacon"],
900,
None,
None,
None,
)
.unwrap();
let verdict = dispatch_bearer(
&Method::POST,
"/indexes/products/search",
Some(&token),
&state,
);
assert_eq!(verdict, AuthVerdict::Authenticated(TokenKind::Jwt));
}
// -----------------------------------------------------------------------
// CSRF middleware tests (plan §9, bead miroir-46p.6)
// -----------------------------------------------------------------------
// Note: The CSRF middleware bypass for bearer tokens is tested via integration
// tests. Unit testing the full middleware chain is complex due to axum's Next type.
// The helper functions below are tested individually.
#[test]
fn csrf_token_extraction() {
let mut headers = HeaderMap::new();
headers.insert("X-CSRF-Token", "test-token-123".parse().unwrap());
let token = extract_csrf_token(&headers);
assert_eq!(token, Some("test-token-123".to_string()));
}
#[test]
fn csrf_token_missing() {
let headers = HeaderMap::new();
let token = extract_csrf_token(&headers);
assert_eq!(token, None);
}
#[test]
fn csrf_constant_time_compare() {
assert!(constant_time_csrf_compare("same-token", "same-token"));
assert!(!constant_time_csrf_compare("different", "tokens"));
}
#[test]
fn csrf_token_generation() {
let token1 = generate_csrf_token();
let token2 = generate_csrf_token();
// Tokens should be different (random)
assert_ne!(token1, token2);
// Tokens should be base64-like (alphanumeric + -_)
assert!(token1
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
}
#[test]
fn validate_csrf_token_matches() {
let token = "test-csrf-token";
let expected = "test-csrf-token";
assert!(validate_csrf_token(token, expected).is_ok());
}
#[test]
fn validate_csrf_token_mismatch() {
let token = "test-csrf-token";
let expected = "different-token";
assert!(validate_csrf_token(token, expected).is_err());
}
// -----------------------------------------------------------------------
// Origin validation tests (plan §9, bead miroir-46p.6)
// -----------------------------------------------------------------------
#[test]
fn origin_allowed_same_origin() {
let mut headers = HeaderMap::new();
headers.insert("Host", "admin.example.com".parse().unwrap());
headers.insert("Origin", "https://admin.example.com".parse().unwrap());
let allowed = vec!["same-origin".to_string()];
let verdict = validate_origin(&headers, &allowed, true);
assert_eq!(verdict, OriginVerdict::Allowed);
}
#[test]
fn origin_allowed_specific_origin() {
let mut headers = HeaderMap::new();
headers.insert("Origin", "https://admin.example.com".parse().unwrap());
let allowed = vec!["https://admin.example.com".to_string()];
let verdict = validate_origin(&headers, &allowed, false);
assert_eq!(verdict, OriginVerdict::Allowed);
}
#[test]
fn origin_forbidden_not_in_list() {
let mut headers = HeaderMap::new();
headers.insert("Origin", "https://evil.com".parse().unwrap());
let allowed = vec!["https://admin.example.com".to_string()];
let verdict = validate_origin(&headers, &allowed, false);
assert_eq!(verdict, OriginVerdict::Forbidden);
}
#[test]
fn origin_missing_same_origin_by_default() {
let headers = HeaderMap::new(); // No Origin header
let allowed = vec!["same-origin".to_string()];
let verdict = validate_origin(&headers, &allowed, true);
assert_eq!(verdict, OriginVerdict::Missing);
}
#[test]
fn origin_forbidden_when_missing_and_not_default() {
let headers = HeaderMap::new(); // No Origin header
let allowed = vec!["https://admin.example.com".to_string()];
let verdict = validate_origin(&headers, &allowed, false);
assert_eq!(verdict, OriginVerdict::Forbidden);
}
#[test]
fn origin_allowed_wildcard() {
let mut headers = HeaderMap::new();
headers.insert("Origin", "https://any-origin.com".parse().unwrap());
let allowed = vec!["*".to_string()];
let verdict = validate_origin(&headers, &allowed, false);
assert_eq!(verdict, OriginVerdict::Allowed);
}
#[test]
fn origin_referer_fallback() {
let mut headers = HeaderMap::new();
headers.insert("Referer", "https://admin.example.com/path".parse().unwrap());
let allowed = vec!["https://admin.example.com".to_string()];
let verdict = validate_origin(&headers, &allowed, false);
assert_eq!(verdict, OriginVerdict::Allowed);
}
// -----------------------------------------------------------------------
// CSP header builder tests (plan §9, bead miroir-46p.6)
// -----------------------------------------------------------------------
#[test]
fn csp_base_template() {
let base = "default-src 'self'; script-src 'self'";
let overrides = miroir_core::config::CspOverridesConfig::default();
let csp = build_csp_header(base, &overrides);
assert_eq!(csp, "default-src 'self'; script-src 'self'");
}
#[test]
fn csp_override_script_src() {
let base = "default-src 'self'; script-src 'self'";
let overrides = miroir_core::config::CspOverridesConfig {
script_src: vec!["https://cdn.example.com".to_string()],
..Default::default()
};
let csp = build_csp_header(base, &overrides);
assert!(csp.contains("script-src 'self' https://cdn.example.com"));
}
#[test]
fn csp_override_multiple_sources() {
let base = "default-src 'self'; connect-src 'self'";
let overrides = miroir_core::config::CspOverridesConfig {
connect_src: vec![
"https://api.example.com".to_string(),
"https://cdn.example.com".to_string(),
],
..Default::default()
};
let csp = build_csp_header(base, &overrides);
assert!(csp.contains("connect-src 'self' https://api.example.com https://cdn.example.com"));
}
#[test]
fn csp_override_additive() {
let base = "default-src 'self'; script-src 'self'";
let overrides = miroir_core::config::CspOverridesConfig {
script_src: vec!["https://cdn.example.com".to_string()],
img_src: vec!["data:".to_string()],
..Default::default()
};
let csp = build_csp_header(base, &overrides);
// Base template should be preserved, overrides added
assert!(csp.contains("script-src 'self' https://cdn.example.com"));
assert!(csp.contains("img-src data:"));
}
}