3359 lines
125 KiB
Rust
3359 lines
125 KiB
Rust
mod tests {
|
|
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;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn login_bundle_is_public_but_cache_mutations_require_session_and_csrf() {
|
|
let (router, store, _dir, secret, csrf) = fixture().await;
|
|
let public = request("/assets/login-langs.json")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
assert_eq!(
|
|
router.clone().oneshot(public).await.unwrap().status(),
|
|
StatusCode::OK
|
|
);
|
|
let anonymous = request("/api/cache").body(Body::empty()).unwrap();
|
|
assert_eq!(
|
|
router.clone().oneshot(anonymous).await.unwrap().status(),
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
let anonymous = request("/api/traffic").body(Body::empty()).unwrap();
|
|
assert_eq!(
|
|
router.clone().oneshot(anonymous).await.unwrap().status(),
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
let anonymous = request("/api/transform").body(Body::empty()).unwrap();
|
|
assert_eq!(
|
|
router.clone().oneshot(anonymous).await.unwrap().status(),
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
for valid in [false, true] {
|
|
let req = request("/api/cache/purge")
|
|
.method("POST")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header(
|
|
"x-csrf-token",
|
|
if valid { csrf.as_str() } else { "invalid" },
|
|
)
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let expected = if valid {
|
|
StatusCode::OK
|
|
} else {
|
|
StatusCode::FORBIDDEN
|
|
};
|
|
assert_eq!(
|
|
router.clone().oneshot(req).await.unwrap().status(),
|
|
expected
|
|
);
|
|
}
|
|
let cleanup = request("/api/cache/cold/cleanup")
|
|
.method("POST")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", "invalid")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(r#"{"oldest":1,"hits_zero":false}"#))
|
|
.unwrap();
|
|
assert_eq!(
|
|
router.clone().oneshot(cleanup).await.unwrap().status(),
|
|
StatusCode::FORBIDDEN
|
|
);
|
|
let cleanup_status = request("/api/cache/cold/cleanup/1")
|
|
.header("cookie", &cookie)
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
assert_eq!(
|
|
router
|
|
.clone()
|
|
.oneshot(cleanup_status)
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::NOT_FOUND
|
|
);
|
|
store.close().await;
|
|
}
|
|
#[tokio::test]
|
|
async fn selective_cache_invalidation_api_manages_bans_and_rejects_wide_rules_without_confirmation(
|
|
) {
|
|
let (router, store, _dir, secret, csrf) = fixture().await;
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
let mutate = |path: &str, method: &str, body: Body| {
|
|
request(path)
|
|
.method(method)
|
|
.header("content-type", "application/json")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.body(body)
|
|
.unwrap()
|
|
};
|
|
|
|
let create = router
|
|
.clone()
|
|
.oneshot(mutate(
|
|
"/api/cache/invalidate",
|
|
"POST",
|
|
Body::from(
|
|
r#"{"operation":"ban","selector":"exact","value":"/asset.js?v=1","host":"example.test","ttl_seconds":60}"#,
|
|
),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(create.status(), StatusCode::OK);
|
|
let created = json_body(create).await;
|
|
let ban_id = created["result"]["ban_id"].as_u64().expect("ban id");
|
|
assert_eq!(created["result"]["status"], "active");
|
|
assert_eq!(created["bans"].as_array().unwrap().len(), 1);
|
|
|
|
let list = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/cache/bans")
|
|
.header("cookie", &cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(list.status(), StatusCode::OK);
|
|
assert_eq!(json_body(list).await["bans"].as_array().unwrap().len(), 1);
|
|
|
|
let wide = router
|
|
.clone()
|
|
.oneshot(mutate(
|
|
"/api/cache/invalidate",
|
|
"POST",
|
|
Body::from(r#"{"operation":"purge","selector":"regex","value":".*"}"#),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(wide.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
|
|
let remove = router
|
|
.clone()
|
|
.oneshot(mutate(
|
|
&format!("/api/cache/bans/{ban_id}"),
|
|
"DELETE",
|
|
Body::empty(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(remove.status(), StatusCode::OK);
|
|
assert!(json_body(remove).await["bans"]
|
|
.as_array()
|
|
.unwrap()
|
|
.is_empty());
|
|
|
|
let missing = router
|
|
.clone()
|
|
.oneshot(mutate("/api/cache/bans/999999", "DELETE", Body::empty()))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
|
store.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn section_updates_are_revisioned_and_do_not_require_full_config_payloads() {
|
|
let (router, store, _dir, secret, csrf) = fixture().await;
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
let logging = Config::default().logging;
|
|
let body = serde_json::to_vec(&json!({
|
|
"expected_revision": 0,
|
|
"value": logging,
|
|
}))
|
|
.unwrap();
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/config/logging")
|
|
.method("PUT")
|
|
.header("content-type", "application/json")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.body(Body::from(body))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
assert_eq!(json_body(response).await["revision"], 1);
|
|
|
|
let http2 = crate::settings::Http2Connections {
|
|
idle_timeout_seconds: 120,
|
|
max_connection_lifetime_seconds: 7_200,
|
|
drain_timeout_seconds: 15,
|
|
};
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/config/http2")
|
|
.method("PUT")
|
|
.header("content-type", "application/json")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.body(Body::from(
|
|
serde_json::to_vec(&json!({"expected_revision":1,"value":http2})).unwrap(),
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let updated = json_body(response).await;
|
|
assert_eq!(updated["revision"], 2);
|
|
assert_eq!(
|
|
updated["config"]["http2"]["max_connection_lifetime_seconds"],
|
|
7_200
|
|
);
|
|
|
|
let stale = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/config/logging")
|
|
.method("PUT")
|
|
.header("content-type", "application/json")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.body(Body::from(
|
|
serde_json::to_vec(
|
|
&json!({"expected_revision":0,"value":Config::default().logging}),
|
|
)
|
|
.unwrap(),
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(stale.status(), StatusCode::CONFLICT);
|
|
|
|
let unknown = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/config/not-a-section")
|
|
.method("PUT")
|
|
.header("content-type", "application/json")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.body(Body::from(r#"{"expected_revision":2,"value":{}}"#))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(unknown.status(), StatusCode::NOT_FOUND);
|
|
store.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn logging_section_persists_error_log_settings_atomically() {
|
|
let (router, store, dir, secret, csrf) = fixture().await;
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
let logging = Config::default().logging;
|
|
let mut error_log = Config::default().error_log;
|
|
error_log.enabled = true;
|
|
error_log.path = "/tmp/rustproxy-error.log".into();
|
|
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/config/logging")
|
|
.method("PUT")
|
|
.header("content-type", "application/json")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.body(Body::from(
|
|
serde_json::to_vec(&json!({
|
|
"expected_revision": 0,
|
|
"value": {
|
|
"logging": logging,
|
|
"error_log": error_log,
|
|
},
|
|
}))
|
|
.unwrap(),
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let updated = json_body(response).await;
|
|
assert_eq!(updated["revision"], 1);
|
|
assert_eq!(updated["config"]["error_log"]["enabled"], true);
|
|
assert_eq!(
|
|
updated["config"]["error_log"]["path"],
|
|
"/tmp/rustproxy-error.log"
|
|
);
|
|
|
|
let configuration = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/config")
|
|
.header("cookie", &cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let persisted = json_body(configuration).await;
|
|
assert_eq!(persisted["config"]["error_log"]["enabled"], true);
|
|
assert_eq!(
|
|
persisted["config"]["error_log"]["path"],
|
|
"/tmp/rustproxy-error.log"
|
|
);
|
|
drop(router);
|
|
store.close().await;
|
|
let reopened = Store::open(&dir.path().join("db"), &Config::default())
|
|
.await
|
|
.unwrap();
|
|
let persisted = reopened.load().await.unwrap();
|
|
assert_eq!(persisted.revision, 1);
|
|
assert!(persisted.config.error_log.enabled);
|
|
assert_eq!(persisted.config.error_log.path, "/tmp/rustproxy-error.log");
|
|
reopened.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn error_log_is_not_a_standalone_configuration_section() {
|
|
let (router, store, _dir, secret, csrf) = fixture().await;
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
let response = router
|
|
.oneshot(
|
|
request("/api/config/error_log")
|
|
.method("PUT")
|
|
.header("content-type", "application/json")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.body(Body::from(r#"{"expected_revision":0,"value":{}}"#))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
store.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn default_pages_section_accepts_the_standard_value_payload() {
|
|
let (router, store, _dir, secret, csrf) = fixture().await;
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
let pages = Config::default().pages;
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/config/pages")
|
|
.method("PUT")
|
|
.header("content-type", "application/json")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.body(Body::from(
|
|
serde_json::to_vec(&json!({
|
|
"expected_revision": 0,
|
|
"value": pages,
|
|
}))
|
|
.unwrap(),
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
assert_eq!(json_body(response).await["revision"], 1);
|
|
store.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn panel_subpage_routes_survive_browser_refresh() {
|
|
let (router, store, _dir, secret, _) = fixture().await;
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
for path in [
|
|
"/overview",
|
|
"/vhosts",
|
|
"/web-server",
|
|
"/docker-vhosts",
|
|
"/redirects",
|
|
"/404-hosts",
|
|
"/streams",
|
|
"/acl-profiles",
|
|
"/configuration",
|
|
"/tls",
|
|
"/traffic",
|
|
"/cache",
|
|
"/transform",
|
|
"/logging",
|
|
"/logs",
|
|
"/settings",
|
|
] {
|
|
let public = router
|
|
.clone()
|
|
.oneshot(request(path).body(Body::empty()).unwrap())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(public.status(), StatusCode::OK, "anonymous {path}");
|
|
let public_body = axum::body::to_bytes(public.into_body(), 100_000)
|
|
.await
|
|
.unwrap();
|
|
assert!(std::str::from_utf8(&public_body)
|
|
.unwrap()
|
|
.contains(&format!("/assets/login_{}.js", super::asset_build_id())));
|
|
|
|
let private = router
|
|
.clone()
|
|
.oneshot(
|
|
request(path)
|
|
.header("cookie", &cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(private.status(), StatusCode::OK, "authenticated {path}");
|
|
let private_body = axum::body::to_bytes(private.into_body(), 2 * 1024 * 1024)
|
|
.await
|
|
.unwrap();
|
|
assert!(std::str::from_utf8(&private_body)
|
|
.unwrap()
|
|
.contains(&format!("/assets/app_{}.js", super::asset_build_id())));
|
|
}
|
|
store.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn csrf_required_and_logout_revokes_cookie() {
|
|
let (router, store, _dir, secret, csrf) = fixture().await;
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
let req = request("/api/auth/logout")
|
|
.method("POST")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
assert_eq!(
|
|
router.clone().oneshot(req).await.unwrap().status(),
|
|
StatusCode::FORBIDDEN
|
|
);
|
|
let req = request("/api/auth/logout")
|
|
.method("POST")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", csrf)
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
assert_eq!(
|
|
router.clone().oneshot(req).await.unwrap().status(),
|
|
StatusCode::OK
|
|
);
|
|
let req = request("/api/status")
|
|
.header("cookie", &cookie)
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
assert_eq!(
|
|
router.oneshot(req).await.unwrap().status(),
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
store.close().await;
|
|
}
|
|
#[tokio::test]
|
|
async fn upload_and_scoped_queries_keep_control_plane_guards() {
|
|
let (router, store, _dir, secret, csrf) = fixture().await;
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
for (path, body) in [
|
|
(
|
|
"/api/certificates/upload",
|
|
r#"{"cert_pem":"test","key_pem":"test","hosts":["app.localhost"]}"#,
|
|
),
|
|
(
|
|
"/api/certificates/deploy",
|
|
r#"{"certificate_id":"shared","cert_pem":"test","key_pem":"test"}"#,
|
|
),
|
|
] {
|
|
for (authenticated, valid_csrf, expected) in
|
|
[(false, false, 401), (true, false, 403), (true, true, 409)]
|
|
{
|
|
let mut req = request(path)
|
|
.method("POST")
|
|
.header("content-type", "application/json")
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1");
|
|
if authenticated {
|
|
req = req.header("cookie", &cookie);
|
|
}
|
|
if valid_csrf {
|
|
req = req.header("x-csrf-token", &csrf);
|
|
}
|
|
assert_eq!(
|
|
router
|
|
.clone()
|
|
.oneshot(req.body(Body::from(body)).unwrap())
|
|
.await
|
|
.unwrap()
|
|
.status()
|
|
.as_u16(),
|
|
expected
|
|
);
|
|
}
|
|
}
|
|
for (path, expected) in [
|
|
("/api/logs?vhost=demo&limit=8", 200),
|
|
("/api/logs?cache=BAD", 400),
|
|
("/api/file-logs", 200),
|
|
("/api/file-logs/query?source=all&limit=8", 200),
|
|
("/api/file-logs/query?limit=0", 400),
|
|
("/api/traffic", 200),
|
|
("/api/cache", 200),
|
|
("/api/transform", 200),
|
|
] {
|
|
let req = request(path)
|
|
.header("cookie", &cookie)
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
assert_eq!(
|
|
router.clone().oneshot(req).await.unwrap().status().as_u16(),
|
|
expected
|
|
);
|
|
}
|
|
let req = request("/api/cache/purge?vhost=missing")
|
|
.method("POST")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
assert_eq!(
|
|
router.oneshot(req).await.unwrap().status(),
|
|
StatusCode::NOT_FOUND
|
|
);
|
|
store.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn certificate_reload_keeps_configuration_revision_stable() {
|
|
let (router, store, _dir, secret, csrf) = fixture().await;
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/certificates/reload")
|
|
.method("POST")
|
|
.header("content-type", "application/json")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.body(Body::from("{}"))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload = json_body(response).await;
|
|
assert_eq!(payload["reloaded"], true);
|
|
assert_eq!(payload["revision"], 0);
|
|
|
|
let configuration = router
|
|
.oneshot(
|
|
request("/api/config")
|
|
.header("cookie", &cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(json_body(configuration).await["revision"], 0);
|
|
store.close().await;
|
|
}
|
|
#[tokio::test]
|
|
async fn manual_certificate_can_be_reloaded_by_id_without_revision_change() {
|
|
let mut config = Config::default();
|
|
config.tls.certificates.push(crate::tls::Certificate {
|
|
issuer: None,
|
|
id: "manual-file".into(),
|
|
covered_hosts: Vec::new(),
|
|
hosts: Vec::new(),
|
|
cert_file: "default.pem".into(),
|
|
key_file: "default.key".into(),
|
|
});
|
|
let (router, store, _dir, secret, csrf) = fixture_with_certificate_storage(config).await;
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/certificates/manual-file/reload")
|
|
.method("POST")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.header("content-type", "application/json")
|
|
.body(Body::from("{}"))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let payload = json_body(response).await;
|
|
assert_eq!(payload["certificate_id"], "manual-file");
|
|
assert_eq!(payload["revision"], 0);
|
|
assert_eq!(payload["reloaded"], true);
|
|
|
|
let api_token = "c".repeat(64);
|
|
store
|
|
.upsert_certificate_api_credential(
|
|
"file-renewer",
|
|
&crate::auth::secret_hash(&api_token),
|
|
"external file renewal hook",
|
|
false,
|
|
&["manual-file".to_owned()],
|
|
"owner",
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let external = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/certificates/manual-file/reload")
|
|
.method("POST")
|
|
.header("authorization", format!("Bearer {api_token}"))
|
|
.header("x-rustproxy-certificate-key", "file-renewer")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from("{}"))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(external.status(), StatusCode::OK);
|
|
assert_eq!(json_body(external).await["certificate_id"], "manual-file");
|
|
store.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn certificate_api_tokens_are_scoped_commented_and_individually_revocable() {
|
|
let (router, store, _dir, secret, csrf) = fixture().await;
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
let token = "a".repeat(64);
|
|
let token_two = "b".repeat(64);
|
|
let invalid_key = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/certificate-api")
|
|
.method("PUT")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
json!({"key_id":"x","token":token,"certificate_ids":["shared-cert"]})
|
|
.to_string(),
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(invalid_key.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
|
|
for (key_id, value, comment) in [
|
|
("certbot-main", token.as_str(), "production certbot"),
|
|
("deploy-hook", token_two.as_str(), "secondary deploy hook"),
|
|
] {
|
|
let rotated = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/certificate-api")
|
|
.method("PUT")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
json!({
|
|
"key_id":key_id,
|
|
"token":value,
|
|
"comment":comment,
|
|
"certificate_ids":[if key_id == "certbot-main" { "shared-cert" } else { "deploy-cert" }],
|
|
"allow_deploy": key_id == "deploy-hook"
|
|
}).to_string(),
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(rotated.status(), StatusCode::OK);
|
|
let rotated = json_body(rotated).await;
|
|
assert_eq!(rotated["configured"], true);
|
|
assert_eq!(rotated["credential"]["key_id"], key_id);
|
|
assert_eq!(rotated["credential"]["comment"], comment);
|
|
assert_eq!(
|
|
rotated["credential"]["allow_deploy"],
|
|
key_id == "deploy-hook"
|
|
);
|
|
assert_eq!(
|
|
rotated["credential"]["certificate_ids"]
|
|
.as_array()
|
|
.unwrap()
|
|
.len(),
|
|
1
|
|
);
|
|
assert_eq!(rotated["token"], value);
|
|
}
|
|
|
|
let status = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/certificate-api")
|
|
.header("cookie", &cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let status = json_body(status).await;
|
|
assert_eq!(status["configured"], true);
|
|
assert_eq!(status["credentials"].as_array().unwrap().len(), 2);
|
|
assert!(status.get("token").is_none());
|
|
|
|
let external = |path: &'static str, key_id: &'static str, value: String| {
|
|
request(path)
|
|
.method("POST")
|
|
.header("authorization", format!("Bearer {value}"))
|
|
.header("x-rustproxy-certificate-key", key_id)
|
|
.header("content-type", "application/json")
|
|
.body(Body::from("{}"))
|
|
.unwrap()
|
|
};
|
|
// Certificate API credentials cannot trigger an all-certificate reload.
|
|
let response = router
|
|
.clone()
|
|
.oneshot(external(
|
|
"/api/certificates/reload",
|
|
"certbot-main",
|
|
token.clone(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
// The allowlisted ID passes the auth gate and reaches the handler; this
|
|
// fixture intentionally has no such configured certificate, so it is 404.
|
|
let response = router
|
|
.clone()
|
|
.oneshot(external(
|
|
"/api/certificates/shared-cert/reload",
|
|
"certbot-main",
|
|
token.clone(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
// A reload-only token cannot use the deploy endpoint at all.
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/certificates/deploy")
|
|
.method("POST")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.header("x-rustproxy-certificate-key", "certbot-main")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from("{}"))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
// A different ID is rejected by the credential grant before the handler.
|
|
let response = router
|
|
.clone()
|
|
.oneshot(external(
|
|
"/api/certificates/deploy-cert/reload",
|
|
"certbot-main",
|
|
token.clone(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
let response = router
|
|
.clone()
|
|
.oneshot(external(
|
|
"/api/certificates/deploy-cert/reload",
|
|
"deploy-hook",
|
|
token_two.clone(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/certificates/deploy")
|
|
.method("POST")
|
|
.header("authorization", format!("Bearer {token_two}"))
|
|
.header("x-rustproxy-certificate-key", "deploy-hook")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
json!({"certificate_id":"shared-cert","cert_pem":"bad","key_pem":"bad"})
|
|
.to_string(),
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
|
|
let missing_key = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/certificates/reload")
|
|
.method("POST")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from("{}"))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(missing_key.status(), StatusCode::UNAUTHORIZED);
|
|
|
|
let forbidden_scope = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/config")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.header("x-rustproxy-certificate-key", "certbot-main")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(forbidden_scope.status(), StatusCode::UNAUTHORIZED);
|
|
|
|
let revoked = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/certificate-api/certbot-main")
|
|
.method("DELETE")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(revoked.status(), StatusCode::OK);
|
|
let response = router
|
|
.clone()
|
|
.oneshot(external(
|
|
"/api/certificates/shared-cert/reload",
|
|
"certbot-main",
|
|
token.clone(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
let response = router
|
|
.clone()
|
|
.oneshot(external(
|
|
"/api/certificates/deploy-cert/reload",
|
|
"deploy-hook",
|
|
token_two.clone(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
|
|
let revoked_all = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/certificate-api")
|
|
.method("DELETE")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(revoked_all.status(), StatusCode::OK);
|
|
let response = router
|
|
.oneshot(external(
|
|
"/api/certificates/deploy-cert/reload",
|
|
"deploy-hook",
|
|
token_two,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
store.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn certificate_api_settings_read_edit_and_regenerate_without_exposing_existing_token() {
|
|
let (router, store, _dir, secret, csrf) = fixture().await;
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
let existing_token = "C".repeat(64);
|
|
let admin = |method: Method, path: &str, body: serde_json::Value| {
|
|
request(path)
|
|
.method(method)
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(body.to_string()))
|
|
.unwrap()
|
|
};
|
|
let external = |path: &str, token: &str| {
|
|
request(path)
|
|
.method("POST")
|
|
.header("authorization", format!("Bearer {token}"))
|
|
.header("x-rustproxy-certificate-key", "certbot-prod")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from("{}"))
|
|
.unwrap()
|
|
};
|
|
let created = router.clone().oneshot(admin(
|
|
Method::PUT,
|
|
"/api/certificate-api",
|
|
json!({
|
|
"key_id": "certbot-prod", "token": existing_token,
|
|
"comment": "first comment", "certificate_ids": ["first-cert"], "allow_deploy": false
|
|
}),
|
|
)).await.unwrap();
|
|
assert_eq!(created.status(), StatusCode::OK);
|
|
let created = json_body(created).await;
|
|
assert_eq!(created["token"], existing_token);
|
|
let before_hash: String = sqlx::query_scalar(
|
|
"SELECT token_hash FROM certificate_api_credentials WHERE key_id = 'certbot-prod'",
|
|
)
|
|
.fetch_one(&store.pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(before_hash, auth::secret_hash(&existing_token));
|
|
|
|
let detail = router
|
|
.clone()
|
|
.oneshot(admin(
|
|
Method::GET,
|
|
"/api/certificate-api/certbot-prod",
|
|
json!({}),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(detail.status(), StatusCode::OK);
|
|
let detail = json_body(detail).await;
|
|
assert_eq!(detail["credential"]["comment"], "first comment");
|
|
assert_eq!(
|
|
detail["credential"]["certificate_ids"],
|
|
json!(["first-cert"])
|
|
);
|
|
assert!(detail.get("token").is_none());
|
|
assert!(detail["credential"].get("token").is_none());
|
|
assert!(detail["credential"].get("token_hash").is_none());
|
|
let created_at = detail["credential"]["created_at"].clone();
|
|
|
|
// A bearer-only automation client cannot read or modify management records.
|
|
let denied = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/certificate-api/certbot-prod")
|
|
.header("authorization", format!("Bearer {existing_token}"))
|
|
.header("x-rustproxy-certificate-key", "certbot-prod")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(denied.status(), StatusCode::UNAUTHORIZED);
|
|
let no_csrf = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/certificate-api/certbot-prod/regenerate")
|
|
.method("POST")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(no_csrf.status(), StatusCode::FORBIDDEN);
|
|
|
|
let bad_edit = router.clone().oneshot(admin(
|
|
Method::PATCH, "/api/certificate-api/certbot-prod",
|
|
json!({"comment":"changed", "certificate_ids":["other-cert"], "allow_deploy":true, "token":"should-not-be-accepted"}),
|
|
)).await.unwrap();
|
|
assert!(bad_edit.status().is_client_error());
|
|
let wildcard = router
|
|
.clone()
|
|
.oneshot(admin(
|
|
Method::PATCH,
|
|
"/api/certificate-api/certbot-prod",
|
|
json!({"comment":"changed", "certificate_ids":["*"], "allow_deploy":true}),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(wildcard.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
|
|
// An edit changes only metadata and grants, not the current bearer secret.
|
|
let edited = router.clone().oneshot(admin(
|
|
Method::PATCH, "/api/certificate-api/certbot-prod",
|
|
json!({"comment":"updated owner", "certificate_ids":["new-cert", "new-cert"], "allow_deploy":true}),
|
|
)).await.unwrap();
|
|
assert_eq!(edited.status(), StatusCode::OK);
|
|
let edited = json_body(edited).await;
|
|
assert_eq!(edited["credential"]["comment"], "updated owner");
|
|
assert_eq!(edited["credential"]["certificate_ids"], json!(["new-cert"]));
|
|
assert_eq!(edited["credential"]["allow_deploy"], true);
|
|
assert_eq!(edited["credential"]["created_at"], created_at);
|
|
assert!(edited.get("token").is_none());
|
|
let hash_after_edit: String = sqlx::query_scalar(
|
|
"SELECT token_hash FROM certificate_api_credentials WHERE key_id = 'certbot-prod'",
|
|
)
|
|
.fetch_one(&store.pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(hash_after_edit, before_hash);
|
|
let allowed = router
|
|
.clone()
|
|
.oneshot(external(
|
|
"/api/certificates/new-cert/reload",
|
|
&existing_token,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(allowed.status(), StatusCode::NOT_FOUND);
|
|
let old_id = router
|
|
.clone()
|
|
.oneshot(external(
|
|
"/api/certificates/first-cert/reload",
|
|
&existing_token,
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(old_id.status(), StatusCode::FORBIDDEN);
|
|
|
|
let regenerated = router
|
|
.clone()
|
|
.oneshot(admin(
|
|
Method::POST,
|
|
"/api/certificate-api/certbot-prod/regenerate",
|
|
json!({}),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(regenerated.status(), StatusCode::OK);
|
|
let regenerated = json_body(regenerated).await;
|
|
let new_token = regenerated["token"].as_str().unwrap().to_owned();
|
|
assert!(new_token.len() >= 32);
|
|
assert_ne!(new_token, existing_token);
|
|
assert_eq!(regenerated["credential"]["comment"], "updated owner");
|
|
assert_eq!(regenerated["credential"]["allow_deploy"], true);
|
|
assert_eq!(
|
|
regenerated["credential"]["certificate_ids"],
|
|
json!(["new-cert"])
|
|
);
|
|
assert!(regenerated["credential"]["last_used_at"].is_null());
|
|
let raw: String = sqlx::query_scalar(
|
|
"SELECT token_hash FROM certificate_api_credentials WHERE key_id = 'certbot-prod'",
|
|
)
|
|
.fetch_one(&store.pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(raw, auth::secret_hash(&new_token));
|
|
assert_ne!(raw, new_token);
|
|
assert_ne!(raw, before_hash);
|
|
assert_eq!(
|
|
router
|
|
.clone()
|
|
.oneshot(external(
|
|
"/api/certificates/new-cert/reload",
|
|
&existing_token,
|
|
))
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
assert_eq!(
|
|
router
|
|
.clone()
|
|
.oneshot(external("/api/certificates/new-cert/reload", &new_token,))
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::NOT_FOUND
|
|
);
|
|
|
|
let after = router
|
|
.clone()
|
|
.oneshot(admin(
|
|
Method::GET,
|
|
"/api/certificate-api/certbot-prod",
|
|
json!({}),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let after = json_body(after).await;
|
|
assert!(after.get("token").is_none());
|
|
assert!(after["credential"].get("token").is_none());
|
|
assert!(after["credential"].get("token_hash").is_none());
|
|
let missing = router
|
|
.clone()
|
|
.oneshot(admin(
|
|
Method::POST,
|
|
"/api/certificate-api/missing-key/regenerate",
|
|
json!({}),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
|
let audit: Vec<String> = sqlx::query_scalar(
|
|
"SELECT action FROM admin_audit WHERE action LIKE 'certificate_api.%' ORDER BY id",
|
|
)
|
|
.fetch_all(&store.pool)
|
|
.await
|
|
.unwrap();
|
|
assert!(audit.iter().any(|entry| entry == "certificate_api.edit"));
|
|
assert!(audit
|
|
.iter()
|
|
.any(|entry| entry == "certificate_api.regenerate"));
|
|
store.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn admin_preferences_are_account_scoped_and_csrf_guarded() {
|
|
let (router, store, _dir, secret, csrf) = fixture().await;
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/preferences")
|
|
.header("cookie", &cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let initial = json_body(response).await;
|
|
assert_eq!(
|
|
initial["live_log_columns"].as_array().unwrap().len(),
|
|
LIVE_LOG_COLUMNS.len()
|
|
);
|
|
assert_eq!(
|
|
initial["refresh"],
|
|
json!({
|
|
"live_enabled":true, "overview_seconds":2, "traffic_seconds":5,
|
|
"cache_seconds":5, "system_info_seconds":60
|
|
})
|
|
);
|
|
assert_eq!(initial["theme"], "dark");
|
|
assert_eq!(initial["language"], "auto");
|
|
assert_eq!(initial["ui_scale"], 100);
|
|
assert_eq!(initial["time_format"], "24h");
|
|
assert_eq!(initial["chart_style"], "default");
|
|
assert_eq!(initial["button_style"], "normal");
|
|
assert_eq!(initial["corner_style"], "default");
|
|
assert_eq!(
|
|
initial["file_log_columns"].as_array().unwrap().len(),
|
|
default_file_log_columns().len()
|
|
);
|
|
assert_eq!(
|
|
initial["backend_table_columns"].as_array().unwrap().len(),
|
|
BACKEND_TABLE_COLUMNS.len()
|
|
);
|
|
assert_eq!(initial["backend_table_sort"], "vhost");
|
|
assert_eq!(initial["backend_table_descending"], false);
|
|
|
|
assert!(store
|
|
.save_admin_preferences("owner", r#"{"live_log_columns":["time","status"]}"#)
|
|
.await
|
|
.unwrap());
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/preferences")
|
|
.header("cookie", &cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let legacy = json_body(response).await;
|
|
assert_eq!(legacy["live_log_columns"], json!(["time", "status"]));
|
|
assert_eq!(legacy["refresh"], initial["refresh"]);
|
|
assert_eq!(legacy["theme"], "dark");
|
|
assert_eq!(legacy["language"], "auto");
|
|
assert_eq!(legacy["ui_scale"], 100);
|
|
assert_eq!(legacy["time_format"], "24h");
|
|
assert_eq!(legacy["chart_style"], "default");
|
|
assert_eq!(legacy["button_style"], "normal");
|
|
assert_eq!(legacy["corner_style"], "default");
|
|
assert_eq!(
|
|
legacy["file_log_columns"],
|
|
json!(default_file_log_columns())
|
|
);
|
|
assert_eq!(
|
|
legacy["backend_table_columns"].as_array().unwrap().len(),
|
|
BACKEND_TABLE_COLUMNS.len()
|
|
);
|
|
assert_eq!(legacy["backend_table_sort"], "vhost");
|
|
assert_eq!(legacy["backend_table_descending"], false);
|
|
|
|
let selected = json!({
|
|
"refresh":{"live_enabled":false,"overview_seconds":10,"traffic_seconds":0,"cache_seconds":3,"system_info_seconds":300},
|
|
"live_log_columns":["time","request","status","upstream_error"],
|
|
"theme":"light",
|
|
"language":"pl_pl",
|
|
"ui_scale":70,
|
|
"time_format":"12h",
|
|
"chart_style":"neon",
|
|
"button_style":"compact",
|
|
"corner_style":"rounded",
|
|
"table_density":"ultra",
|
|
"dashboard_density":"ultra",
|
|
"dashboard_widgets":["errors","requests","active","bandwidth","cache","memory","websocket","http2","http3","tls","http_protocol","websocket_protocol"],
|
|
"dashboard_hidden_widgets":["http3","tls"],
|
|
"file_log_columns":["time","event","client_ip","record"],
|
|
"backend_table_columns":["vhost","backend","status","requests","latency"],
|
|
"backend_table_sort":"requests",
|
|
"backend_table_descending":true
|
|
});
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/preferences")
|
|
.method("PUT")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(selected.to_string()))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
assert_eq!(json_body(response).await, selected);
|
|
|
|
let saved: Value =
|
|
serde_json::from_str(&store.admin_preferences("owner").await.unwrap().unwrap())
|
|
.unwrap();
|
|
assert_eq!(saved, selected);
|
|
// Preferences must also survive a subsequent authenticated read.
|
|
let persisted = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/preferences")
|
|
.header("cookie", &cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let mut upgraded = selected.clone();
|
|
for id in [
|
|
"stream_overview",
|
|
"stream_traffic",
|
|
"stream_sessions",
|
|
"stream_backend_health",
|
|
"backend_health",
|
|
"backend_activity",
|
|
"backend_errors",
|
|
"backend_latency",
|
|
"backend_traffic",
|
|
"backend_protocols",
|
|
"backend_requests",
|
|
"backend_capacity",
|
|
"backend_http5xx",
|
|
"backend_timeouts",
|
|
"backend_network",
|
|
"backend_retries",
|
|
"backend_queue",
|
|
"backend_rotations",
|
|
] {
|
|
upgraded["dashboard_widgets"]
|
|
.as_array_mut()
|
|
.unwrap()
|
|
.push(json!(id));
|
|
upgraded["dashboard_hidden_widgets"]
|
|
.as_array_mut()
|
|
.unwrap()
|
|
.push(json!(id));
|
|
}
|
|
assert_eq!(json_body(persisted).await, upgraded);
|
|
// Wrong allowlist value is rejected even if the rest is valid.
|
|
let mut invalid = selected.clone();
|
|
invalid["refresh"]["traffic_seconds"] = json!(4);
|
|
let rejected = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/preferences")
|
|
.method("PUT")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(invalid.to_string()))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/preferences")
|
|
.method("PUT")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(selected.to_string()))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/preferences")
|
|
.method("PUT")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(r#"{"live_log_columns":[]}"#))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/preferences")
|
|
.method("PUT")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"live_log_columns":["time"],"theme":"sepia"}"#,
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/preferences")
|
|
.method("PUT")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"live_log_columns":["time"],"ui_scale":150}"#,
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
|
|
let response = router
|
|
.oneshot(
|
|
request("/api/preferences")
|
|
.method("PUT")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"live_log_columns":["time"],"backend_table_columns":["unknown"]}"#,
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
store.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn acme_and_cache_inspection_require_existing_admin_guards() {
|
|
let (router, store, _dir, secret, csrf) = fixture().await;
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
for path in ["/api/acme", "/api/cache?offset=0&limit=2"] {
|
|
let req = request(path).body(Body::empty()).unwrap();
|
|
assert_eq!(
|
|
router.clone().oneshot(req).await.unwrap().status(),
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
let req = request(path)
|
|
.header("cookie", &cookie)
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
assert_eq!(
|
|
router.clone().oneshot(req).await.unwrap().status(),
|
|
StatusCode::OK
|
|
);
|
|
}
|
|
for (authenticated, valid_csrf, expected) in
|
|
[(false, false, 401), (true, false, 403), (true, true, 409)]
|
|
{
|
|
let mut req = request("/api/acme/issue")
|
|
.method("POST")
|
|
.header("content-type", "application/json")
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1");
|
|
if authenticated {
|
|
req = req.header("cookie", &cookie);
|
|
}
|
|
if valid_csrf {
|
|
req = req.header("x-csrf-token", &csrf);
|
|
}
|
|
// This fixture has no certificate directory: valid auth reaches the
|
|
// 409 precondition, never a child process, DNS update or CA request.
|
|
let response = router
|
|
.clone()
|
|
.oneshot(req.body(Body::from("{}")).unwrap())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status().as_u16(), expected);
|
|
}
|
|
let req = request("/api/cache?vhost=missing")
|
|
.header("cookie", &cookie)
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
assert_eq!(
|
|
router.oneshot(req).await.unwrap().status(),
|
|
StatusCode::NOT_FOUND
|
|
);
|
|
store.close().await;
|
|
}
|
|
|
|
async fn json_body(response: Response) -> Value {
|
|
let bytes = axum::body::to_bytes(response.into_body(), 2 * 1024 * 1024)
|
|
.await
|
|
.unwrap();
|
|
serde_json::from_slice(&bytes).unwrap()
|
|
}
|
|
#[tokio::test]
|
|
async fn external_certificate_deploy_creates_updates_and_noops() {
|
|
let (router, store, dir, secret, csrf) =
|
|
fixture_with_certificate_storage(Config::default()).await;
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
let first = rcgen::generate_simple_self_signed(vec!["app.example.test".into()]).unwrap();
|
|
let first_body = json!({
|
|
"certificate_id": "opnsense-cert",
|
|
"cert_pem": first.cert.pem(),
|
|
"key_pem": first.signing_key.serialize_pem(),
|
|
})
|
|
.to_string();
|
|
|
|
let deploy = |body: String| {
|
|
request("/api/certificates/deploy")
|
|
.method("POST")
|
|
.header("content-type", "application/json")
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("cookie", &cookie)
|
|
.header("x-csrf-token", &csrf)
|
|
.body(Body::from(body))
|
|
.unwrap()
|
|
};
|
|
|
|
let response = router
|
|
.clone()
|
|
.oneshot(deploy(first_body.clone()))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let created = json_body(response).await;
|
|
assert_eq!(created["operation"], "created");
|
|
assert_eq!(created["created"], true);
|
|
assert_eq!(created["changed"], true);
|
|
let first_revision = created["revision"].as_u64().unwrap();
|
|
let first_cert_file = created["cert_file"].as_str().unwrap().to_owned();
|
|
let first_key_file = created["key_file"].as_str().unwrap().to_owned();
|
|
assert!(dir.path().join("certs").join(&first_cert_file).is_file());
|
|
assert!(dir.path().join("certs").join(&first_key_file).is_file());
|
|
|
|
let response = router.clone().oneshot(deploy(first_body)).await.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let unchanged = json_body(response).await;
|
|
assert_eq!(unchanged["operation"], "unchanged");
|
|
assert_eq!(unchanged["created"], false);
|
|
assert_eq!(unchanged["changed"], false);
|
|
assert_eq!(unchanged["revision"].as_u64(), Some(first_revision));
|
|
assert_eq!(
|
|
unchanged["cert_file"].as_str(),
|
|
Some(first_cert_file.as_str())
|
|
);
|
|
assert_eq!(
|
|
unchanged["key_file"].as_str(),
|
|
Some(first_key_file.as_str())
|
|
);
|
|
|
|
let second = rcgen::generate_simple_self_signed(vec!["app.example.test".into()]).unwrap();
|
|
let second_body = json!({
|
|
"certificate_id": "opnsense-cert",
|
|
"cert_pem": second.cert.pem(),
|
|
"key_pem": second.signing_key.serialize_pem(),
|
|
})
|
|
.to_string();
|
|
let response = router.oneshot(deploy(second_body)).await.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let updated = json_body(response).await;
|
|
assert_eq!(updated["operation"], "updated");
|
|
assert_eq!(updated["created"], false);
|
|
assert_eq!(updated["changed"], true);
|
|
assert!(updated["revision"].as_u64().unwrap() > first_revision);
|
|
assert_ne!(
|
|
updated["cert_file"].as_str(),
|
|
Some(first_cert_file.as_str())
|
|
);
|
|
assert_ne!(updated["key_file"].as_str(), Some(first_key_file.as_str()));
|
|
assert!(!dir.path().join("certs").join(&first_cert_file).exists());
|
|
assert!(!dir.path().join("certs").join(&first_key_file).exists());
|
|
store.close().await;
|
|
}
|
|
#[tokio::test]
|
|
async fn anonymous_operational_paths_fail_closed_before_extractors() {
|
|
let (router, store, _dir, _, _) = fixture().await;
|
|
for path in [
|
|
"/api/status",
|
|
"/api/system-info",
|
|
"/api/config",
|
|
"/api/config/history",
|
|
"/api/backends",
|
|
"/api/docker",
|
|
"/api/traffic",
|
|
"/api/cache?limit=invalid",
|
|
"/api/logs?limit=invalid",
|
|
"/api/live-logs",
|
|
"/api/file-logs",
|
|
"/api/file-logs/query?limit=invalid",
|
|
"/api/certificate-api",
|
|
"/api/preferences",
|
|
"/api/audit",
|
|
"/api/events",
|
|
"/api/certificates",
|
|
"/api/acme",
|
|
"/api/auth/session",
|
|
"/api/metrics",
|
|
"/metrics",
|
|
"/docs/openapi.json",
|
|
"/docs/content.json",
|
|
"/docs/init.js",
|
|
"/docs/style.css",
|
|
"/docs/swagger-ui-bundle.js",
|
|
"/docs/swagger-ui.css",
|
|
"/docs/licenses.txt",
|
|
"/assets/langs.json",
|
|
"/assets/app.js",
|
|
"/assets/management.js",
|
|
"/assets/drafts.js",
|
|
"/assets/integrations.js",
|
|
"/assets/traffic-panel.js",
|
|
"/assets/cache-panel.js",
|
|
"/assets/transform-panel.js",
|
|
"/assets/vhosts.js",
|
|
"/assets/docker.js",
|
|
"/assets/streams.js",
|
|
"/assets/redirects.js",
|
|
"/assets/host404.js",
|
|
"/assets/settings.js",
|
|
"/assets/file-logs.js",
|
|
"/assets/acme.js",
|
|
"/api/not-a-route",
|
|
"/not-a-route",
|
|
] {
|
|
let response = router
|
|
.clone()
|
|
.oneshot(request(path).body(Body::empty()).unwrap())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{path}");
|
|
assert_eq!(response.headers()["cache-control"], "no-store");
|
|
assert!(!response
|
|
.headers()
|
|
.contains_key("access-control-allow-origin"));
|
|
assert!(!response.headers().contains_key("server"));
|
|
assert_eq!(
|
|
json_body(response).await,
|
|
json!({"error":"auth.required"}),
|
|
"{path}"
|
|
);
|
|
}
|
|
for (path, method) in [
|
|
("/api/config", "PUT"),
|
|
("/api/config/cache", "PUT"),
|
|
("/api/config/history/restore", "POST"),
|
|
("/api/acme/issue", "POST"),
|
|
("/api/acme/renew", "POST"),
|
|
("/api/docker/refresh", "POST"),
|
|
("/api/geoip/update", "POST"),
|
|
("/api/cache/purge", "POST"),
|
|
("/api/cache/invalidate", "POST"),
|
|
("/api/cache/bans", "GET"),
|
|
("/api/cache/bans/1", "DELETE"),
|
|
("/api/tunnels", "GET"),
|
|
("/api/tunnels/1/kill", "POST"),
|
|
("/api/cache/cold/cleanup", "POST"),
|
|
("/api/cache/cold/cleanup/1", "GET"),
|
|
("/api/certificates/upload", "POST"),
|
|
("/api/certificates/deploy", "POST"),
|
|
("/api/certificates/reload", "POST"),
|
|
("/api/certificates/manual-file/reload", "POST"),
|
|
("/api/certificate-api", "PUT"),
|
|
("/api/certificate-api/certbot-main", "DELETE"),
|
|
("/api/certificate-api", "DELETE"),
|
|
("/api/basic-auth/hash", "POST"),
|
|
("/api/auth/password", "POST"),
|
|
("/api/auth/logout", "POST"),
|
|
("/api/auth/logout-all", "POST"),
|
|
("/api/config", "OPTIONS"),
|
|
("/api/config", "DELETE"),
|
|
("/api/auth/login", "GET"),
|
|
("/api/auth/setup", "GET"),
|
|
("/healthz", "POST"),
|
|
("/docs/openapi.json", "HEAD"),
|
|
("/docs/content.json", "HEAD"),
|
|
] {
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request(path)
|
|
.method(method)
|
|
.header("content-type", "application/json")
|
|
.body(Body::from("malformed secret payload"))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"{method} {path}"
|
|
);
|
|
if method != "HEAD" {
|
|
assert_eq!(json_body(response).await, json!({"error":"auth.required"}));
|
|
}
|
|
}
|
|
store.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn docs_require_the_same_session_and_do_not_redirect_schema_fetches() {
|
|
let (router, store, _dir, secret, _) = fixture().await;
|
|
for path in ["/docs", "/docs/"] {
|
|
let response = router
|
|
.clone()
|
|
.oneshot(request(path).body(Body::empty()).unwrap())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
|
assert_eq!(response.headers()["location"], "/?next=docs");
|
|
}
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
for path in [
|
|
"/",
|
|
"/docs",
|
|
"/docs/",
|
|
"/docs/licenses.txt",
|
|
"/docs/content.json",
|
|
&format!("/assets/app_{}.js", super::asset_build_id()),
|
|
] {
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request(path)
|
|
.header("cookie", &cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK, "{path}");
|
|
assert_eq!(response.headers()["x-frame-options"], "DENY");
|
|
assert_eq!(response.headers()["cache-control"], "no-store");
|
|
}
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/docs/openapi.json")
|
|
.header("cookie", &cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let spec = json_body(response).await;
|
|
assert_eq!(spec["openapi"], "3.0.3");
|
|
assert_eq!(spec["info"]["version"], env!("CARGO_PKG_VERSION"));
|
|
assert_eq!(
|
|
spec["components"]["securitySchemes"]["AdminSession"]["in"],
|
|
"cookie"
|
|
);
|
|
assert!(spec["paths"].get("/api/config").is_some());
|
|
assert!(spec["paths"].get("/api/config/history/restore").is_some());
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/docs/content.json")
|
|
.header("cookie", &cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let docs = json_body(response).await;
|
|
let documents = docs["documents"].as_array().expect("documentation bundle");
|
|
assert!(documents
|
|
.iter()
|
|
.any(|document| document["path"] == "docs/README.md"));
|
|
assert!(documents
|
|
.iter()
|
|
.any(|document| document["path"] == "docs/WEB_SERVER.md"));
|
|
assert!(documents
|
|
.iter()
|
|
.any(|document| document["path"] == "docs/PHP_FPM.md"));
|
|
assert!(documents
|
|
.iter()
|
|
.any(|document| document["path"] == "docs/SECURITY_AUDIT_0.31.0.md"));
|
|
assert!(documents
|
|
.iter()
|
|
.any(|document| document["path"] == "docs/TEST_REPORT.md"));
|
|
store.close().await;
|
|
}
|
|
#[tokio::test]
|
|
async fn credentials_in_query_or_duplicate_cookies_cannot_authorize() {
|
|
let (router, store, _dir, secret, _) = fixture().await;
|
|
for cookie in [
|
|
String::new(),
|
|
format!("{}=invalid", auth::COOKIE_NAME),
|
|
format!(
|
|
"{}={secret}; {}={secret}",
|
|
auth::COOKIE_NAME,
|
|
auth::COOKIE_NAME
|
|
),
|
|
] {
|
|
let path = format!("/api/config?token={secret}&session={secret}");
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request(&path)
|
|
.header("cookie", cookie)
|
|
.header("authorization", format!("Bearer {secret}"))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
store.close().await;
|
|
}
|
|
#[tokio::test]
|
|
async fn duplicate_csrf_or_marker_is_rejected_and_session_survives() {
|
|
let (router, store, _dir, secret, csrf) = fixture().await;
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
for duplicate in ["x-csrf-token", "x-rustproxy-request"] {
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/auth/logout")
|
|
.method("POST")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.header(
|
|
duplicate,
|
|
if duplicate == "x-csrf-token" {
|
|
csrf.as_str()
|
|
} else {
|
|
"1"
|
|
},
|
|
)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
let response = router
|
|
.oneshot(
|
|
request("/api/auth/session")
|
|
.header("cookie", cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
store.close().await;
|
|
}
|
|
#[tokio::test]
|
|
async fn malformed_anonymous_json_never_reflects_submitted_values() {
|
|
let (router, store, _dir, _, _) = fixture().await;
|
|
for path in ["/api/auth/login", "/api/auth/setup"] {
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request(path)
|
|
.method("POST")
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
r#"{"username":{"untrusted":"DO_NOT_REFLECT_ME"}}"#,
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
assert_eq!(
|
|
json_body(response).await,
|
|
json!({"error":"auth.invalid_request"})
|
|
);
|
|
}
|
|
store.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn retained_configuration_revision_can_be_restored_as_a_new_revision() {
|
|
let (router, store, _dir, secret, csrf) = fixture().await;
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
|
|
let mut first = Config::default();
|
|
first.server_header.value = "first".into();
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/config")
|
|
.method("PUT")
|
|
.header("content-type", "application/json")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.body(Body::from(
|
|
json!({"expected_revision":0,"config":first}).to_string(),
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
assert_eq!(json_body(response).await["revision"], 1);
|
|
|
|
let mut second = Config::default();
|
|
second.server_header.value = "second".into();
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/config")
|
|
.method("PUT")
|
|
.header("content-type", "application/json")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.body(Body::from(
|
|
json!({"expected_revision":1,"config":second}).to_string(),
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
assert_eq!(json_body(response).await["revision"], 2);
|
|
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/config/history/restore")
|
|
.method("POST")
|
|
.header("content-type", "application/json")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.body(Body::from(
|
|
json!({"revision":1,"expected_revision":2}).to_string(),
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let restored = json_body(response).await;
|
|
assert_eq!(restored["revision"], 3);
|
|
assert_eq!(restored["config"]["server_header"]["value"], "first");
|
|
|
|
let audit = store.recent_audit().await.unwrap();
|
|
assert_eq!(audit[0].actor, "owner");
|
|
assert_eq!(audit[0].action, "config.restore");
|
|
assert_eq!(audit[0].detail, "source_revision=1 revision=3");
|
|
let detail = store.configuration_revision(3).await.unwrap().unwrap();
|
|
assert_eq!(detail.config.server_header.value, "first");
|
|
assert_eq!(
|
|
detail.previous_config.unwrap().server_header.value,
|
|
"second"
|
|
);
|
|
store.close().await;
|
|
}
|
|
|
|
#[test]
|
|
fn stored_log_inventory_keeps_existing_files_visible_when_writer_is_disabled() {
|
|
use std::io::Write;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("access.jsonl");
|
|
let mut file = std::fs::File::create(&path).unwrap();
|
|
writeln!(file, r#"{{"event":"request","status":200}}"#).unwrap();
|
|
|
|
let mut config = Config::default();
|
|
config.logging.file.enabled = false;
|
|
config.logging.file.per_vhost = false;
|
|
config.logging.file.path = path.to_string_lossy().into_owned();
|
|
let sources = configured_file_log_sources(&config);
|
|
let access = sources.iter().find(|source| source.id == "access").unwrap();
|
|
assert!(!access.enabled);
|
|
assert!(access.exists);
|
|
assert!(access.size > 0);
|
|
assert_eq!(access.path, path);
|
|
}
|
|
|
|
#[test]
|
|
fn stored_log_tail_enforces_line_budget() {
|
|
use std::io::Write;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let path = dir.path().join("access.jsonl");
|
|
let mut file = std::fs::File::create(&path).unwrap();
|
|
for id in 0..10 {
|
|
writeln!(file, r#"{{"time_ms":{id},"event":"request"}}"#).unwrap();
|
|
}
|
|
drop(file);
|
|
|
|
let source = source_from_path(
|
|
"access".into(),
|
|
"Access log".into(),
|
|
"access".into(),
|
|
"access",
|
|
path,
|
|
false,
|
|
"JSONL",
|
|
);
|
|
let (lines, _bytes, truncated) = read_tail(&source, 1024 * 1024, 3).unwrap();
|
|
assert_eq!(lines.len(), 3);
|
|
assert!(lines[0].contains(r#""time_ms":9"#));
|
|
assert!(lines[2].contains(r#""time_ms":7"#));
|
|
assert!(truncated);
|
|
}
|
|
|
|
#[test]
|
|
fn stored_log_query_rejects_oversized_source_identifier() {
|
|
let query = FileLogQuery {
|
|
source: Some("x".repeat(193)),
|
|
..FileLogQuery::default()
|
|
};
|
|
assert!(validate_file_log_query(&query).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn stored_log_normalizes_mapped_client_ip_and_searches_diagnostics() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let source = source_from_path(
|
|
"vhost:app".into(),
|
|
"app.jsonl".into(),
|
|
"vhost:app".into(),
|
|
"access",
|
|
dir.path().join("app.jsonl"),
|
|
true,
|
|
"JSONL",
|
|
);
|
|
let mut format = crate::logging::JsonFormat::default();
|
|
format.fields.remove("client_ip");
|
|
format
|
|
.fields
|
|
.insert("remote_addr".into(), "client_ip".into());
|
|
let record = normalized_record(
|
|
&source,
|
|
r#"{"event":"protocol_error","remote_addr":"192.0.2.10","reason":"tls_handshake_error","error":"HTTP parser error","sni":"app.example.test","matched_by":"sni","vhost":"app"}"#,
|
|
&format,
|
|
);
|
|
assert_eq!(record["client_ip"], "192.0.2.10");
|
|
assert_eq!(record["sni"], "app.example.test");
|
|
|
|
let query = FileLogQuery::default();
|
|
assert!(record_matches(
|
|
&record,
|
|
&query,
|
|
&parse_file_log_search("error").unwrap()
|
|
));
|
|
assert!(record_matches(
|
|
&record,
|
|
&query,
|
|
&parse_file_log_search(r#"error:"HTTP parser""#).unwrap()
|
|
));
|
|
assert!(record_matches(
|
|
&record,
|
|
&query,
|
|
&parse_file_log_search("sni:app.example.test AND !country:Poland").unwrap()
|
|
));
|
|
assert!(!record_matches(
|
|
&record,
|
|
&query,
|
|
&parse_file_log_search("sni:missing").unwrap()
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn stored_log_search_supports_field_aliases_negation_and_numeric_filters() {
|
|
let record = json!({
|
|
"method":"GET",
|
|
"country":"Germany",
|
|
"status":503,
|
|
"path":"/api/users"
|
|
});
|
|
assert!(record_matches_search(
|
|
&record,
|
|
&parse_file_log_search("req: GET AND !country: Poland AND status:>=500").unwrap()
|
|
));
|
|
assert!(record_matches_search(
|
|
&record,
|
|
&parse_file_log_search("status:5xx path:/api/*").unwrap()
|
|
));
|
|
assert!(!record_matches_search(
|
|
&record,
|
|
&parse_file_log_search("country:Poland").unwrap()
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn stored_log_raw_error_parser_extracts_basic_fields() {
|
|
let record = parse_raw_error_record(
|
|
"2026-10-05T19:00:00.000Z ERROR rust_proxy::http upstream failed client_ip=192.0.2.10 status=502 error=connection reset",
|
|
);
|
|
assert_eq!(record["timestamp"], "2026-10-05T19:00:00.000Z");
|
|
assert_eq!(record["level"], "ERROR");
|
|
assert_eq!(record["target"], "rust_proxy::http");
|
|
assert_eq!(record["message"], "upstream failed");
|
|
assert_eq!(record["client_ip"], "192.0.2.10");
|
|
assert_eq!(record["status"], "502");
|
|
assert_eq!(record["error"], "connection reset");
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn stored_log_reader_refuses_symlink_targets() {
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let real = dir.path().join("real.jsonl");
|
|
let link = dir.path().join("link.jsonl");
|
|
std::fs::write(&real, b"{}\n").unwrap();
|
|
symlink(&real, &link).unwrap();
|
|
assert!(open_file_log(&link).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn stored_log_tail_reads_rotated_gzip_and_inventory_discovers_it() {
|
|
use std::io::Write;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let active = dir.path().join("access.jsonl");
|
|
std::fs::write(&active, b"{\"event\":\"request\",\"time_ms\":3}\n").unwrap();
|
|
let rotated = dir.path().join("access.jsonl.1.gz");
|
|
let file = std::fs::File::create(&rotated).unwrap();
|
|
let mut encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
|
|
writeln!(encoder, r#"{{"event":"request","time_ms":1}}"#).unwrap();
|
|
writeln!(encoder, r#"{{"event":"request","time_ms":2}}"#).unwrap();
|
|
encoder.finish().unwrap();
|
|
|
|
let source = source_from_path(
|
|
"access:gz:access.jsonl.1.gz".into(),
|
|
"Access log · access.jsonl.1.gz".into(),
|
|
"access".into(),
|
|
"access",
|
|
rotated.clone(),
|
|
true,
|
|
"JSONL",
|
|
);
|
|
assert!(source.compressed);
|
|
let (lines, bytes, truncated) = read_tail(&source, FILE_LOG_SCAN_BYTES_TOTAL, 10).unwrap();
|
|
assert_eq!(bytes, std::fs::metadata(&rotated).unwrap().len());
|
|
assert!(!truncated);
|
|
assert_eq!(lines.len(), 2);
|
|
assert!(lines[0].contains(r#""time_ms":2"#));
|
|
|
|
let mut config = Config::default();
|
|
config.logging.file.per_vhost = false;
|
|
config.logging.file.path = active.to_string_lossy().into_owned();
|
|
let sources = configured_file_log_sources(&config);
|
|
assert!(sources
|
|
.iter()
|
|
.any(|item| item.path == rotated && item.compressed));
|
|
}
|
|
|
|
#[test]
|
|
fn stored_log_per_vhost_inventory_groups_active_and_rotated_files() {
|
|
use std::io::Write;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let active = dir.path().join("app.jsonl");
|
|
std::fs::write(&active, b"{\"event\":\"request\"}\n").unwrap();
|
|
let rotated = dir.path().join("app.jsonl.1.gz");
|
|
let file = std::fs::File::create(&rotated).unwrap();
|
|
let mut encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
|
|
writeln!(encoder, r#"{{"event":"request"}}"#).unwrap();
|
|
encoder.finish().unwrap();
|
|
|
|
let mut config = Config::default();
|
|
config.logging.file.per_vhost = true;
|
|
config.logging.file.directory = dir.path().to_string_lossy().into_owned();
|
|
let sources = configured_file_log_sources(&config);
|
|
let app = sources
|
|
.iter()
|
|
.filter(|source| source.group_id == "vhost:app")
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(app.len(), 2);
|
|
assert!(app.iter().any(|source| source.id == "vhost:app"));
|
|
assert!(app.iter().any(|source| source.compressed));
|
|
}
|
|
|
|
#[test]
|
|
fn stored_log_aggregate_values_support_scalars_and_bounded_arrays() {
|
|
let value = json!(["BadBot/1", "BadBot/1", 503, true, {"ignored":"object"}]);
|
|
let mut values = Vec::new();
|
|
file_log_aggregate_values(&value, &mut values);
|
|
assert_eq!(
|
|
values,
|
|
vec![
|
|
"BadBot/1".to_string(),
|
|
"BadBot/1".to_string(),
|
|
"503".to_string(),
|
|
"true".to_string()
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn stored_log_query_validates_aggregation_controls() {
|
|
assert!(validate_file_log_query(&FileLogQuery {
|
|
aggregate_field: Some("user_agent".into()),
|
|
aggregate_min_count: Some(2),
|
|
aggregate_limit: Some(50),
|
|
..FileLogQuery::default()
|
|
})
|
|
.is_ok());
|
|
assert!(validate_file_log_query(&FileLogQuery {
|
|
aggregate_field: Some("bad field".into()),
|
|
..FileLogQuery::default()
|
|
})
|
|
.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn stored_logs_sort_globally_before_pagination_and_keep_missing_last() {
|
|
let original = vec![
|
|
json!({"time_ms": 200, "status": 500, "nested": {"count": 9}, "method": "POST", "path": "/z"}),
|
|
json!({"time_ms": 300, "status": 200, "nested": {"count": 2}, "method": "GET", "path": "/a"}),
|
|
json!({"time_ms": 100, "status": 404, "nested": {"count": 5}, "method": "GET", "path": "/b"}),
|
|
json!({"time_ms": 400, "nested": {}, "message": "missing status"}),
|
|
];
|
|
let mut records = original.clone();
|
|
sort_file_log_records(&mut records, "status", false);
|
|
assert_eq!(
|
|
records
|
|
.iter()
|
|
.map(|v| v["status"].as_u64())
|
|
.collect::<Vec<_>>(),
|
|
vec![Some(200), Some(404), Some(500), None]
|
|
);
|
|
// Page two must reflect the sorted complete scan, not a local page sort.
|
|
assert_eq!(records[1]["status"], 404);
|
|
sort_file_log_records(&mut records, "status", true);
|
|
assert_eq!(
|
|
records
|
|
.iter()
|
|
.map(|v| v["status"].as_u64())
|
|
.collect::<Vec<_>>(),
|
|
vec![Some(500), Some(404), Some(200), None]
|
|
);
|
|
sort_file_log_records(&mut records, "nested.count", false);
|
|
assert_eq!(
|
|
records
|
|
.iter()
|
|
.map(|v| v["nested"]["count"].as_u64())
|
|
.collect::<Vec<_>>(),
|
|
vec![Some(2), Some(5), Some(9), None]
|
|
);
|
|
sort_file_log_records(&mut records, "time", true);
|
|
assert_eq!(
|
|
records
|
|
.iter()
|
|
.map(|v| v["time_ms"].as_u64())
|
|
.collect::<Vec<_>>(),
|
|
vec![Some(400), Some(300), Some(200), Some(100)]
|
|
);
|
|
sort_file_log_records(&mut records, "record", false);
|
|
assert_eq!(records[0]["path"], "/a");
|
|
assert_eq!(records[3]["path"], "/z");
|
|
assert!(validate_file_log_query(&FileLogQuery {
|
|
sort_by: Some("bad field".into()),
|
|
..FileLogQuery::default()
|
|
})
|
|
.is_err());
|
|
assert!(validate_file_log_query(&FileLogQuery {
|
|
sort_by: Some("nested.count".into()),
|
|
..FileLogQuery::default()
|
|
})
|
|
.is_ok());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn only_current_build_fingerprinted_javascript_and_css_are_served() {
|
|
let (router, store, _dir, secret, _) = fixture().await;
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
let id = super::asset_build_id();
|
|
for (name, suffix) in [
|
|
("/assets/app", "js"),
|
|
("/assets/host404", "js"),
|
|
("/assets/style", "css"),
|
|
("/assets/login", "js"),
|
|
("/docs/init", "js"),
|
|
("/docs/style", "css"),
|
|
("/docs/swagger-ui-bundle", "js"),
|
|
("/docs/swagger-ui", "css"),
|
|
] {
|
|
let legacy = format!("{name}.{suffix}");
|
|
let wrong = format!("{name}_0000000000000000.{suffix}");
|
|
let current = format!("{name}_{id}.{suffix}");
|
|
for path in [legacy, wrong] {
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request(&path)
|
|
.header("cookie", &cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}");
|
|
}
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request(¤t)
|
|
.header("cookie", &cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK, "{current}");
|
|
}
|
|
store.close().await;
|
|
}
|
|
#[tokio::test]
|
|
async fn public_document_and_discovery_contain_only_login_information() {
|
|
let (router, store, _dir, _, _) = fixture().await;
|
|
for (path, expected) in [
|
|
("/healthz", json!({"status":"ok"})),
|
|
("/api/auth/status", json!({"setup_required":false})),
|
|
] {
|
|
let response = router
|
|
.clone()
|
|
.oneshot(request(path).body(Body::empty()).unwrap())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
assert_eq!(json_body(response).await, expected);
|
|
}
|
|
let response = router
|
|
.clone()
|
|
.oneshot(request("/").body(Body::empty()).unwrap())
|
|
.await
|
|
.unwrap();
|
|
let bytes = axum::body::to_bytes(response.into_body(), 100_000)
|
|
.await
|
|
.unwrap();
|
|
let text = std::str::from_utf8(&bytes).unwrap();
|
|
assert!(text.contains(&format!("/assets/login_{}.js", super::asset_build_id())));
|
|
for absent in [
|
|
"/assets/app.js",
|
|
"id=\"workspace\"",
|
|
"loginLead",
|
|
"config-editor",
|
|
"version",
|
|
] {
|
|
assert!(!text.contains(absent), "unexpected public content {absent}");
|
|
}
|
|
let response = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/assets/login-langs.json")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let bundle = json_body(response).await;
|
|
let keys: Vec<String> = serde_json::from_str(include_str!("web/login-keys.json")).unwrap();
|
|
for language in bundle["languages"].as_array().unwrap() {
|
|
assert!(language["translations"]
|
|
.as_object()
|
|
.unwrap()
|
|
.keys()
|
|
.all(|key| keys.contains(key)));
|
|
assert!(language["translations"].get("cacheTitle").is_none());
|
|
}
|
|
store.close().await;
|
|
}
|
|
|
|
#[test]
|
|
fn directory_inventory_retains_newest_with_deterministic_ties() {
|
|
let candidates = (0..10_000).map(|index| {
|
|
let name = format!("{index:05}.jsonl.gz");
|
|
(
|
|
name.clone(),
|
|
std::path::PathBuf::from(name),
|
|
(index / 10) as u64,
|
|
)
|
|
});
|
|
let files = newest_log_files(candidates, 5);
|
|
assert_eq!(files.len(), 5);
|
|
let names: Vec<_> = files.iter().map(|entry| entry.0.as_str()).collect();
|
|
assert_eq!(
|
|
names,
|
|
[
|
|
"09990.jsonl.gz",
|
|
"09991.jsonl.gz",
|
|
"09992.jsonl.gz",
|
|
"09993.jsonl.gz",
|
|
"09994.jsonl.gz"
|
|
]
|
|
);
|
|
assert!(newest_log_files(std::iter::once(("x".into(), "x".into(), 1)), 0).is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn system_info_and_compact_live_status_reuse_existing_admin_session() {
|
|
let (router, store, _dir, secret, _) = fixture().await;
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
let info = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/system-info")
|
|
.header("cookie", &cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(info.status(), StatusCode::OK);
|
|
let info = json_body(info).await;
|
|
assert!(info.get("system").is_some());
|
|
assert!(info.get("runtime").is_some());
|
|
assert!(info.get("memory_accounting").is_some());
|
|
assert!(info.get("config").is_none());
|
|
let live = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/status?view=live")
|
|
.header("cookie", &cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(live.status(), StatusCode::OK);
|
|
let live = json_body(live).await;
|
|
assert!(live.get("metrics").is_some());
|
|
assert!(live.get("backend_pools").is_some());
|
|
assert!(live["listeners"].get("active_endpoints").is_some());
|
|
assert!(live.get("system").is_none());
|
|
assert!(live.get("memory_accounting").is_none());
|
|
assert!(live.get("administration").is_none());
|
|
let complete = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/status")
|
|
.header("cookie", &cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(complete.status(), StatusCode::OK);
|
|
assert!(json_body(complete).await.get("system").is_some());
|
|
let invalid = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/status?view=unknown")
|
|
.header("cookie", &cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(invalid.status(), StatusCode::BAD_REQUEST);
|
|
let bad_sse = router
|
|
.oneshot(
|
|
request("/api/events?interval=4")
|
|
.header("cookie", &cookie)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(bad_sse.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
store.close().await;
|
|
}
|
|
|
|
#[test]
|
|
fn defaults_and_partial_legacy_refresh_documents() {
|
|
let previous = serde_json::to_value(AdminPreferences::default()).unwrap();
|
|
let mut legacy = previous.as_object().unwrap().clone();
|
|
legacy.remove("refresh");
|
|
let loaded: AdminPreferences = serde_json::from_value(Value::Object(legacy)).unwrap();
|
|
assert_eq!(loaded.refresh, RefreshPreferences::default());
|
|
let partial: RefreshPreferences = serde_json::from_str(r#"{"traffic_seconds":0}"#).unwrap();
|
|
assert_eq!(partial.traffic_seconds, 0);
|
|
assert_eq!(partial.system_info_seconds, 60);
|
|
assert!(validate_admin_preferences(&loaded).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_values_are_allowlisted() {
|
|
let mut value = RefreshPreferences::default();
|
|
for seconds in [0, 4, 11, 65000] {
|
|
value.overview_seconds = seconds;
|
|
assert!(validate_refresh_preferences(&value).is_err());
|
|
}
|
|
value.overview_seconds = 1;
|
|
for seconds in [1, 2, 4, 31] {
|
|
value.traffic_seconds = seconds;
|
|
assert!(validate_refresh_preferences(&value).is_err());
|
|
}
|
|
value.traffic_seconds = 0;
|
|
value.cache_seconds = 0;
|
|
value.system_info_seconds = 0;
|
|
assert!(validate_refresh_preferences(&value).is_ok());
|
|
value.system_info_seconds = 16;
|
|
assert!(validate_refresh_preferences(&value).is_err());
|
|
assert!(serde_json::from_str::<RefreshPreferences>(r#"{"unexpected":1}"#).is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cache_system_events_and_static_private_assets_reject_anonymous_requests() {
|
|
let (router, store, _dir, secret, csrf) = fixture().await;
|
|
for path in [
|
|
"/api/status",
|
|
"/api/status?view=live",
|
|
"/api/system-info",
|
|
"/api/config",
|
|
"/api/preferences",
|
|
"/api/cache",
|
|
"/api/cache/bans",
|
|
"/api/audit",
|
|
"/api/logs",
|
|
"/api/traffic",
|
|
"/api/events",
|
|
"/api/live-logs",
|
|
"/api/file-logs",
|
|
"/api/certificate-api",
|
|
"/docs/openapi.json",
|
|
"/assets/app.js",
|
|
"/assets/cache-panel.js",
|
|
"/assets/langs.json",
|
|
] {
|
|
let response = router
|
|
.clone()
|
|
.oneshot(request(path).body(Body::empty()).unwrap())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"anonymous GET {path}"
|
|
);
|
|
assert_eq!(response.headers().get("x-frame-options").unwrap(), "DENY");
|
|
assert!(response.headers().get("content-security-policy").is_some());
|
|
}
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
for (method, path) in [
|
|
("POST", "/api/cache/purge"),
|
|
("POST", "/api/cache/invalidate"),
|
|
("DELETE", "/api/cache/bans/1"),
|
|
("PUT", "/api/config/cache"),
|
|
("PUT", "/api/preferences"),
|
|
("POST", "/api/auth/logout-all"),
|
|
] {
|
|
let no_session = request(path).method(method).body(Body::empty()).unwrap();
|
|
let response = router.clone().oneshot(no_session).await.unwrap();
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"anonymous {method} {path}"
|
|
);
|
|
let without_csrf = request(path)
|
|
.method(method)
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = router.clone().oneshot(without_csrf).await.unwrap();
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::FORBIDDEN,
|
|
"CSRF {method} {path}"
|
|
);
|
|
}
|
|
// The valid session and CSRF continue to work for a benign mutation,
|
|
// proving the failed requests above did not revoke the cookie.
|
|
let response = router
|
|
.oneshot(
|
|
request("/api/auth/logout")
|
|
.method("POST")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "http://127.0.0.1:9090")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", &csrf)
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
store.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn admin_origin_host_and_fetch_metadata_are_checked_even_with_valid_cookie() {
|
|
let (router, store, _dir, secret, _csrf) = fixture().await;
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
let evil_origin = request("/api/status")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "https://another.example")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = router.clone().oneshot(evil_origin).await.unwrap();
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
|
|
let mut repeated_host = request("/api/status")
|
|
.header("cookie", &cookie)
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
repeated_host
|
|
.headers_mut()
|
|
.append("host", "other.example".parse().unwrap());
|
|
let response = router.clone().oneshot(repeated_host).await.unwrap();
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
|
|
let cross_site = request("/api/status")
|
|
.header("cookie", &cookie)
|
|
.header("sec-fetch-site", "cross-site")
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = router.clone().oneshot(cross_site).await.unwrap();
|
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
|
store.close().await;
|
|
}
|
|
|
|
#[test]
|
|
fn pinned_admin_proxy_proto_is_bounded_and_requires_external_https() {
|
|
let mut headers = HeaderMap::new();
|
|
assert!(pinned_admin_proto_compatible(&headers));
|
|
headers.insert("x-forwarded-proto", HeaderValue::from_static("https"));
|
|
assert!(pinned_admin_proto_compatible(&headers));
|
|
headers.insert("x-forwarded-proto", HeaderValue::from_static("https, http"));
|
|
assert!(pinned_admin_proto_compatible(&headers));
|
|
headers.append("x-forwarded-proto", HeaderValue::from_static("http"));
|
|
assert!(pinned_admin_proto_compatible(&headers));
|
|
for bad in [
|
|
"http",
|
|
"http, https",
|
|
"https, ftp",
|
|
"https,",
|
|
",https",
|
|
"HTTPS",
|
|
"https, http, http, http, http, http, http, http, http",
|
|
] {
|
|
headers.remove("x-forwarded-proto");
|
|
headers.insert("x-forwarded-proto", HeaderValue::from_str(bad).unwrap());
|
|
assert!(
|
|
!pinned_admin_proto_compatible(&headers),
|
|
"must reject {bad}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pinned_panel_origin_survives_multi_hop_proxy_without_relaxing_csrf() {
|
|
let mut config = Config::default();
|
|
config.administration.public_origin = Some("https://panel.example.test".into());
|
|
let (router, store, _dir, secret, csrf) = fixture_with_config(config).await;
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
// Without an explicit origin pin, 0.31.3 rejected this session GET as
|
|
// external_scheme although the browser address remained correct.
|
|
let result = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/auth/session")
|
|
.header("cookie", &cookie)
|
|
.header("x-forwarded-host", "panel.example.test")
|
|
.header("x-forwarded-proto", "https, http")
|
|
.header("sec-fetch-site", "same-origin")
|
|
.extension(PeerAddr("127.0.0.1:45000".parse().unwrap()))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.status(), StatusCode::OK);
|
|
let session_cookie = result
|
|
.headers()
|
|
.get_all(SET_COOKIE)
|
|
.iter()
|
|
.filter_map(|v| v.to_str().ok())
|
|
.find(|v| v.starts_with(auth::COOKIE_NAME))
|
|
.unwrap();
|
|
assert!(session_cookie.contains("; Secure"));
|
|
|
|
// The incoming admin Host may itself be the pinned public DNS name.
|
|
// There is no separate allowed_hosts entry in the saved configuration.
|
|
let pinned_host = Request::builder()
|
|
.uri("/api/auth/session")
|
|
.header("host", "panel.example.test")
|
|
.header("cookie", &cookie)
|
|
.header("x-forwarded-host", "panel.example.test")
|
|
.header("x-forwarded-proto", "https, http")
|
|
.extension(PeerAddr("127.0.0.1:45003".parse().unwrap()))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
assert_eq!(
|
|
router.clone().oneshot(pinned_host).await.unwrap().status(),
|
|
StatusCode::OK
|
|
);
|
|
|
|
// Explicit Origin, CSRF token and app request marker remain mandatory
|
|
// for a state-changing request even when XFP contains multiple hops.
|
|
let unauthorized = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/auth/logout")
|
|
.method("POST")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "https://panel.example.test")
|
|
.header("x-forwarded-host", "panel.example.test")
|
|
.header("x-forwarded-proto", "https, http")
|
|
.extension(PeerAddr("127.0.0.1:45001".parse().unwrap()))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(unauthorized.status(), StatusCode::FORBIDDEN);
|
|
let authorized = router
|
|
.oneshot(
|
|
request("/api/auth/logout")
|
|
.method("POST")
|
|
.header("cookie", &cookie)
|
|
.header("origin", "https://panel.example.test")
|
|
.header("x-rustproxy-request", "1")
|
|
.header("x-csrf-token", csrf)
|
|
.header("x-forwarded-host", "panel.example.test")
|
|
.header("x-forwarded-proto", "https, http")
|
|
.extension(PeerAddr("127.0.0.1:45002".parse().unwrap()))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(authorized.status(), StatusCode::OK);
|
|
store.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pinned_panel_origin_rejects_untrusted_proxy_host_and_origin() {
|
|
let mut config = Config::default();
|
|
config.administration.public_origin = Some("https://panel.example.test".into());
|
|
let (router, store, _dir, secret, _) = fixture_with_config(config).await;
|
|
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
|
for (xfh, xfp, origin, fetch_site) in [
|
|
(
|
|
"evil.example.test",
|
|
"https, http",
|
|
"https://panel.example.test",
|
|
"same-origin",
|
|
),
|
|
(
|
|
"other@panel.example.test",
|
|
"https, http",
|
|
"https://panel.example.test",
|
|
"same-origin",
|
|
),
|
|
(
|
|
"panel.example.test",
|
|
"http",
|
|
"https://panel.example.test",
|
|
"same-origin",
|
|
),
|
|
(
|
|
"panel.example.test",
|
|
"http, https",
|
|
"https://panel.example.test",
|
|
"same-origin",
|
|
),
|
|
(
|
|
"panel.example.test",
|
|
"https, http",
|
|
"https://evil.example.test",
|
|
"same-origin",
|
|
),
|
|
("panel.example.test", "https, http", "null", "same-origin"),
|
|
(
|
|
"panel.example.test",
|
|
"https, http",
|
|
"https://panel.example.test",
|
|
"cross-site",
|
|
),
|
|
] {
|
|
let result = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/auth/session")
|
|
.header("cookie", &cookie)
|
|
.header("x-forwarded-host", xfh)
|
|
.header("x-forwarded-proto", xfp)
|
|
.header("origin", origin)
|
|
.header("sec-fetch-site", fetch_site)
|
|
.extension(PeerAddr("127.0.0.1:45000".parse().unwrap()))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.status(), StatusCode::FORBIDDEN,
|
|
"must reject forwarded-host={xfh}, proto={xfp}, origin={origin}, fetch={fetch_site}");
|
|
}
|
|
// Credential-bearing certificate automation does not inherit the
|
|
// browser-origin override, even through the same trusted admin socket.
|
|
let automation = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/certificates/deploy")
|
|
.method("POST")
|
|
.header(CERTIFICATE_API_KEY_HEADER, "svc-key")
|
|
.header("x-forwarded-host", "panel.example.test")
|
|
.header("x-forwarded-proto", "https, http")
|
|
.extension(PeerAddr("127.0.0.1:45000".parse().unwrap()))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(automation.status(), StatusCode::FORBIDDEN);
|
|
|
|
let untrusted = router
|
|
.clone()
|
|
.oneshot(
|
|
request("/api/auth/session")
|
|
.header("cookie", &cookie)
|
|
.header("x-forwarded-host", "panel.example.test")
|
|
.header("x-forwarded-proto", "https, http")
|
|
.header("origin", "https://panel.example.test")
|
|
.extension(PeerAddr("192.0.2.42:44000".parse().unwrap()))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(untrusted.status(), StatusCode::FORBIDDEN);
|
|
|
|
let mut duplicated = request("/api/auth/session")
|
|
.header("cookie", &cookie)
|
|
.header("x-forwarded-host", "panel.example.test")
|
|
.header("x-forwarded-proto", "https, http")
|
|
.extension(PeerAddr("127.0.0.1:45000".parse().unwrap()))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
duplicated
|
|
.headers_mut()
|
|
.append("x-forwarded-host", "panel.example.test".parse().unwrap());
|
|
assert_eq!(
|
|
router.clone().oneshot(duplicated).await.unwrap().status(),
|
|
StatusCode::FORBIDDEN
|
|
);
|
|
store.close().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn without_public_origin_ambiguous_forwarded_proto_stays_forbidden() {
|
|
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 result = router
|
|
.oneshot(
|
|
request("/api/auth/session")
|
|
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME))
|
|
.header("x-forwarded-host", "panel.example.test")
|
|
.header("x-forwarded-proto", "https, http")
|
|
.extension(PeerAddr("127.0.0.1:45000".parse().unwrap()))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result.status(), StatusCode::FORBIDDEN);
|
|
store.close().await;
|
|
}
|
|
include!("preferences_tests.rs");
|
|
}
|