Files
RustProxy/src/admin/tests/part_01.rs
T
2026-10-11 12:21:26 +02:00

430 lines
16 KiB
Rust

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;
}