use super::*; use crate::{ config::{self, Config, ValidationPolicy}, db::Store, }; use axum::body::Body; use http::Request; use tower::ServiceExt; async fn fixture() -> (Router, Store, tempfile::TempDir, String, String) { fixture_with_config(Config::default()).await } async fn fixture_with_config( config: Config, ) -> (Router, Store, tempfile::TempDir, String, String) { let dir = tempfile::tempdir().unwrap(); let policy = ValidationPolicy { tls_addr: None, cert_dir: None, data_dir: None, proxy_addr: "127.0.0.1:8080".parse().unwrap(), admin_addr: "127.0.0.1:9090".parse().unwrap(), }; let store = Store::open(&dir.path().join("db"), &config).await.unwrap(); store .initialize_admin("owner", "unused-test-hash", "test") .await .unwrap(); let auth = auth::Auth::new(store.clone(), dir.path().to_path_buf()) .await .unwrap(); let issued = store .create_session(1, "browser", None) .await .unwrap() .unwrap(); let snapshot = config::compile(config, 0, &policy).unwrap(); let state = App::new( store.clone(), snapshot, policy, auth, 10, Duration::from_secs(30), Duration::from_secs(30), Duration::from_secs(300), ); ( router(state), store, dir, issued.secret, issued.session.csrf, ) } async fn fixture_with_certificate_storage( config: Config, ) -> (Router, Store, tempfile::TempDir, String, String) { let dir = tempfile::tempdir().unwrap(); let certificate_directory = dir.path().join("certs"); crate::tls::initialize_default(&certificate_directory).unwrap(); let policy = ValidationPolicy { tls_addr: None, cert_dir: Some(certificate_directory), data_dir: None, proxy_addr: "127.0.0.1:8080".parse().unwrap(), admin_addr: "127.0.0.1:9090".parse().unwrap(), }; let store = Store::open(&dir.path().join("db"), &config).await.unwrap(); store .initialize_admin("owner", "unused-test-hash", "test") .await .unwrap(); let auth = auth::Auth::new(store.clone(), dir.path().to_path_buf()) .await .unwrap(); let issued = store .create_session(1, "browser", None) .await .unwrap() .unwrap(); let snapshot = config::compile(config, 0, &policy).unwrap(); let state = App::new( store.clone(), snapshot, policy, auth, 10, Duration::from_secs(30), Duration::from_secs(30), Duration::from_secs(300), ); ( router(state), store, dir, issued.secret, issued.session.csrf, ) } fn request(path: &str) -> http::request::Builder { Request::builder() .uri(path) .header("host", "127.0.0.1:9090") } #[test] fn administration_authority_uses_http2_pseudo_authority_without_legacy_host() { let request = Request::builder() .version(Version::HTTP_2) .uri("https://192.0.2.10:9090/healthz") .body(Body::empty()) .unwrap(); assert_eq!( admin_request_authority(&request, true).unwrap(), "192.0.2.10:9090" ); let conflicting = Request::builder() .version(Version::HTTP_2) .uri("https://192.0.2.10:9090/healthz") .header(HOST, "panel.example.test:9090") .body(Body::empty()) .unwrap(); assert!(admin_request_authority(&conflicting, true).is_err()); let h1_absolute = Request::builder() .uri("https://192.0.2.10:9090/healthz") .header(HOST, "192.0.2.10:9090") .body(Body::empty()) .unwrap(); assert!(admin_request_authority(&h1_absolute, true).is_err()); } #[tokio::test] async fn administration_https_health_accepts_http2_authority_without_host_header() { let (router, _store, _dir, _secret, _csrf) = fixture().await; let request = Request::builder() .version(Version::HTTP_2) .uri("https://127.0.0.1:9090/healthz") .extension(Transport { tls: true }) .body(Body::empty()) .unwrap(); assert_eq!( router.oneshot(request).await.unwrap().status(), StatusCode::OK ); } #[test] fn tls_admin_accepts_dns_without_allowlist_but_keeps_explicit_lists_strict() { let wildcard: std::net::SocketAddr = "0.0.0.0:9090".parse().unwrap(); assert!(admin_host_allowed("127.0.0.1:9090", wildcard, true, &[])); assert!(admin_host_allowed("192.0.2.10:9090", wildcard, true, &[])); assert!(admin_host_allowed( "example.test:9090", wildcard, true, &["example.test".into()] )); assert!(admin_host_allowed("example.test:9090", wildcard, true, &[])); assert!(!admin_host_allowed( "example.test:9090", wildcard, false, &[] )); assert!(!admin_host_allowed("0.0.0.0:9091", wildcard, true, &[])); assert!(!admin_host_allowed("0.0.0.0:9090", wildcard, true, &[])); assert!(admin_host_allowed("localhost:9090", wildcard, true, &[])); let concrete: std::net::SocketAddr = "192.0.2.10:9090".parse().unwrap(); assert!(admin_host_allowed("localhost:9090", concrete, true, &[])); assert!(admin_host_allowed( "localhost:9090", concrete, true, &["localhost".into()] )); assert!(!admin_host_allowed( "other.test:9090", concrete, true, &["localhost".into()] )); } #[test] fn reverse_proxy_dns_allowlist_uses_external_port_and_keeps_direct_port_available() { let wildcard: std::net::SocketAddr = "0.0.0.0:9090".parse().unwrap(); let allowed = vec!["panel.example.test".to_owned()]; assert!(admin_host_allowed( "panel.example.test", wildcard, false, &allowed )); assert!(admin_host_allowed( "panel.example.test:443", wildcard, true, &allowed )); assert!(admin_host_allowed( "panel.example.test:8443", wildcard, true, &allowed )); assert!(admin_host_allowed( "192.0.2.10:9090", wildcard, false, &allowed )); assert!(admin_host_allowed( "localhost:9090", wildcard, false, &allowed )); assert!(!admin_host_allowed( "192.0.2.10:443", wildcard, true, &allowed )); assert!(!admin_host_allowed( "other.example.test:443", wildcard, true, &allowed )); } #[test] fn origin_matching_normalizes_default_ports_without_weakening_host_checks() { assert!(admin_origin_matches( "https://panel.example.test", "https", "panel.example.test:443" )); assert!(admin_origin_matches( "https://PANEL.EXAMPLE.TEST:443/", "https", "panel.example.test" )); assert!(!admin_origin_matches( "https://panel.example.test:8443", "https", "panel.example.test" )); assert!(!admin_origin_matches( "http://panel.example.test", "https", "panel.example.test" )); } #[test] fn admin_origin_diagnostics_redact_non_origin_values() { let mut headers = HeaderMap::new(); assert_eq!(diagnostic_admin_origin(&headers), "(absent)"); headers.insert(ORIGIN, HeaderValue::from_static("https://panel.example.test")); assert_eq!(diagnostic_admin_origin(&headers), "https://panel.example.test"); headers.insert(ORIGIN, HeaderValue::from_static("https://user:password@panel.example.test")); assert_eq!(diagnostic_admin_origin(&headers), "(invalid)"); headers.insert(ORIGIN, HeaderValue::from_static("https://panel.example.test/?token=secret")); assert_eq!(diagnostic_admin_origin(&headers), "(invalid)"); headers.insert("x-forwarded-host", HeaderValue::from_static("other.example.test")); assert_eq!(diagnostic_forwarded_header(&headers, "x-forwarded-host", false), "(untrusted)"); assert_eq!(diagnostic_forwarded_header(&headers, "x-forwarded-host", true), "other.example.test"); } #[tokio::test] async fn unauthorized_browser_origins_remain_rejected_for_session_checks() { let (router, store, _dir, secret, _) = fixture().await; for (origin, fetch_site) in [ (Some("https://attacker.example"), None), (Some("null"), None), (None, Some("cross-site")), ] { let mut request = Request::builder() .uri("/api/auth/session") .header("host", "127.0.0.1:9090") .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)); if let Some(origin) = origin { request = request.header(ORIGIN, origin); } if let Some(site) = fetch_site { request = request.header("sec-fetch-site", site); } let response = router.clone().oneshot(request.body(Body::empty()).unwrap()).await.unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); } // Origin on an ordinary same-origin GET may be absent, or may match. for origin in [None, Some("http://127.0.0.1:9090")] { let mut request = Request::builder() .uri("/api/auth/session") .header("host", "127.0.0.1:9090") .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)); if let Some(origin) = origin { request = request.header(ORIGIN, origin); } assert_eq!(router.clone().oneshot(request.body(Body::empty()).unwrap()).await.unwrap().status(), StatusCode::OK); } store.close().await; } #[tokio::test] async fn invalid_origin_on_document_is_visible_but_still_forbidden() { let (router, store, _dir, _secret, _) = fixture().await; let response = router.oneshot( Request::builder() .uri("/overview") .header("host", "127.0.0.1:9090") .header("origin", "null") .header("accept", "text/html,application/xhtml+xml") .body(Body::empty()) .unwrap(), ).await.unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); assert!(response.headers().get(http::header::CONTENT_TYPE).unwrap().to_str().unwrap().starts_with("text/html")); let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); assert!(String::from_utf8_lossy(&body).contains("Could not open the panel")); store.close().await; } #[tokio::test] async fn trusted_https_reverse_proxy_accepts_external_origin_and_refreshes_secure_cookie() { let mut config = Config::default(); config.administration.allowed_hosts = vec!["panel.example.test".into()]; let (router, store, _dir, secret, _) = fixture_with_config(config).await; let response = router .oneshot( Request::builder() .uri("/api/status") .header("host", "127.0.0.1:9090") .header("x-forwarded-host", "panel.example.test") .header("x-forwarded-proto", "https") .header("origin", "https://panel.example.test") .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)) .extension(PeerAddr("127.0.0.1:45000".parse().unwrap())) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let refreshed = response .headers() .get_all(SET_COOKIE) .iter() .filter_map(|value| value.to_str().ok()) .find(|value| value.starts_with(auth::COOKIE_NAME)) .expect("session cookie refreshed"); assert!(refreshed.contains("; Secure")); store.close().await; } #[tokio::test] async fn https_redirect_router_preserves_authority_path_and_query() { let request = Request::builder() .uri("/api/status?view=compact") .header("host", "panel.example.test:9090") .body(Body::empty()) .unwrap(); let response = https_redirect_router().oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT); assert_eq!( response.headers().get(http::header::LOCATION).unwrap(), "https://panel.example.test:9090/api/status?view=compact" ); assert_eq!( response.headers().get(http::header::CACHE_CONTROL).unwrap(), "no-store" ); } #[tokio::test] async fn status_exposes_runtime_memory_accounting_and_cold_limits() { let (router, store, _dir, secret, _) = fixture().await; let response = router .oneshot( request("/api/status") .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = json_body(response).await; assert_eq!(body["transform_resources"]["memory_budget_bytes"], 128 * 1024 * 1024); assert_eq!(body["transform_resources"]["max_inflight"], 32); assert_eq!(body["memory_accounting"]["transform_budget_bytes"], 128 * 1024 * 1024); assert_eq!(body["memory_accounting"]["cache_hot_limit_bytes"], 256 * 1024 * 1024); assert_eq!(body["cache"]["cold_limit_bytes"], 4_u64 * 1024 * 1024 * 1024); assert_eq!(body["cache"]["cold_limit_entries"], 50_000); assert_eq!(body["memory_accounting"]["non_cache_rss_includes_transform"], true); store.close().await; } #[tokio::test] async fn rejects_legacy_token_rebinding_and_cross_origin() { let (router, store, _dir, secret, _) = fixture().await; let old = request("/api/status") .header("authorization", format!("Bearer {}", "a".repeat(64))) .body(Body::empty()) .unwrap(); assert_eq!( router.clone().oneshot(old).await.unwrap().status(), StatusCode::UNAUTHORIZED ); let evil = Request::builder() .uri("/healthz") .header("host", "evil.test:9090") .body(Body::empty()) .unwrap(); assert_eq!( router.clone().oneshot(evil).await.unwrap().status(), StatusCode::FORBIDDEN ); let req = request("/api/status") .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)) .header("origin", "http://evil.test") .body(Body::empty()) .unwrap(); assert_eq!( router.clone().oneshot(req).await.unwrap().status(), StatusCode::FORBIDDEN ); let req = request("/api/status") .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)) .body(Body::empty()) .unwrap(); assert_eq!(router.oneshot(req).await.unwrap().status(), StatusCode::OK); store.close().await; }