diff --git a/crates/miroir-proxy/src/admin_ui.rs b/crates/miroir-proxy/src/admin_ui.rs index 47d0162..cffa446 100644 --- a/crates/miroir-proxy/src/admin_ui.rs +++ b/crates/miroir-proxy/src/admin_ui.rs @@ -7,12 +7,14 @@ use axum::{ body::Body, extract::{FromRef, State}, - http::{header, HeaderMap, StatusCode}, + http::{header, HeaderMap, HeaderValue, StatusCode}, response::Response, }; use miroir_core::config::MiroirConfig; use rust_embed::RustEmbed; +use crate::auth::build_csp_header; + // Re-export for use in the handler pub use crate::routes::admin_endpoints; @@ -81,7 +83,22 @@ where // Determine if this is a static asset (has file extension) let is_static_asset = path.contains('.'); - serve_embedded_file(path, is_static_asset) + // Build CSP header (plan §9) + let csp_value = build_csp_header( + &admin_state.config.admin_ui.csp, + &admin_state.config.admin_ui.csp_overrides, + ); + + let mut response = serve_embedded_file(path, is_static_asset)?; + + // Add CSP header to response (plan §9) + if let Ok(csp_header) = HeaderValue::from_str(&csp_value) { + response + .headers_mut() + .insert(header::CONTENT_SECURITY_POLICY, csp_header); + } + + Ok(response) } /// Serve an embedded file from the Admin UI assets. diff --git a/crates/miroir-proxy/src/auth.rs b/crates/miroir-proxy/src/auth.rs index 891e263..427e5fd 100644 --- a/crates/miroir-proxy/src/auth.rs +++ b/crates/miroir-proxy/src/auth.rs @@ -76,15 +76,15 @@ pub struct JwtClaims { } /// Key ID embedded in the JWT header to identify which secret signed it. -const KID_PRIMARY: &str = "primary"; -const KID_PREVIOUS: &str = "previous"; +pub const KID_PRIMARY: &str = "primary"; +pub const KID_PREVIOUS: &str = "previous"; /// JWT header (always HS256). #[derive(Debug, Clone, Serialize, Deserialize)] -struct JwtHeader { - alg: String, - kid: String, - typ: String, +pub struct JwtHeader { + pub alg: String, + pub kid: String, + pub typ: String, } // --------------------------------------------------------------------------- @@ -92,7 +92,7 @@ struct JwtHeader { // --------------------------------------------------------------------------- /// Encode and sign a JWT with the given secret. -fn jwt_encode(header: &JwtHeader, claims: &JwtClaims, secret: &[u8]) -> Result { +pub fn jwt_encode(header: &JwtHeader, claims: &JwtClaims, secret: &[u8]) -> Result { 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())?; @@ -2160,4 +2160,229 @@ mod tests { ); assert_eq!(verdict, AuthVerdict::Authenticated(TokenKind::Jwt)); } + + // ----------------------------------------------------------------------- + // CSRF middleware tests (plan §9, bead miroir-46p.6) + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn csrf_bypass_for_bearer_token() { + // Cookie-auth POST without X-CSRF-Token → 403 + // Cookie-auth POST with wrong token → 403 + // Bearer-auth POST without X-CSRF-Token → 200 (bearer bypasses CSRF) + // This test verifies the bypass works + let state = test_state_with_jwt(); + let csrf_state = crate::auth::CsrfState { + auth: state.clone(), + redis_store: None, + }; + + // Create a POST request with Bearer token but no CSRF token + let mut req = Request::builder() + .uri("/_miroir/admin/some-endpoint") + .method(Method::POST) + .header("Authorization", "Bearer admin-key-456") + .body(axum::body::Body::empty()) + .unwrap(); + + // Run through CSRF middleware + let response = csrf_middleware( + State(csrf_state), + req, + Next::new(|_| async { + // This should not be reached for CSRF check failure + Response::new(axum::body::Body::from("should not reach")) + }), + ) + .await; + + // Bearer token should bypass CSRF check - response should not be a CSRF error + // (Note: this will still fail auth later, but CSRF middleware should pass) + assert_ne!(response.status(), StatusCode::FORBIDDEN); + } + + #[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:")); + } } diff --git a/crates/miroir-proxy/src/routes/session.rs b/crates/miroir-proxy/src/routes/session.rs index 3ba879d..0a50a5e 100644 --- a/crates/miroir-proxy/src/routes/session.rs +++ b/crates/miroir-proxy/src/routes/session.rs @@ -10,7 +10,7 @@ use axum::{ extract::{Extension, FromRef, Path, State}, - http::{HeaderMap, StatusCode}, + http::{HeaderMap, HeaderValue, StatusCode}, response::{IntoResponse, Response}, Json, }; @@ -18,6 +18,7 @@ use miroir_core::task_store::{NewAdminSession, TaskStore}; use serde::{Deserialize, Serialize}; use tracing::{info, warn}; +use crate::admin_session::{seal_session, COOKIE_NAME}; use crate::auth::{build_csp_header, generate_csrf_token, validate_origin, AdminSessionId}; use super::admin_endpoints::AppState; @@ -86,7 +87,7 @@ pub async fn admin_login( State(state): State, headers: HeaderMap, Json(body): Json, -) -> Result, (StatusCode, String)> +) -> Result where S: Clone + Send + Sync + 'static, AppState: FromRef, @@ -171,11 +172,34 @@ where "admin login successful" ); - Ok(Json(AdminLoginResponse { + // Seal the session ID for the cookie (plan §9) + let sealed = seal_session(&session_id, &state.seal_key).map_err(|e| { + warn!(error = %e, "failed to seal admin session"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to create session".into(), + ) + })?; + + // Build the Set-Cookie header with HttpOnly, Secure, SameSite=Strict (plan §9) + let cookie_value = format!( + "{}={}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age={}", + COOKIE_NAME, sealed, config.admin_ui.session_ttl_s + ); + + // Build response with Set-Cookie header and JSON body + let response_body = AdminLoginResponse { session_id, csrf_token, expires_at, - })) + }; + + let mut response = Json(response_body).into_response(); + if let Ok(cookie_header) = HeaderValue::from_str(&cookie_value) { + response.headers_mut().insert("Set-Cookie", cookie_header); + } + + Ok(response) } /// GET /_miroir/admin/session - validate admin session and refresh CSRF token.