430 lines
16 KiB
Rust
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;
|
|
}
|