v0.25.1
This commit is contained in:
1 parent
87e571c170
commit
fa7abc9e53
308 files changed
+49746
-50237
No files matched your search
@@ -0,0 +1,366 @@
|
||||
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"
|
||||
));
|
||||
}
|
||||
|
||||
#[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;
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
#[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",
|
||||
"/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("/assets/login.js"));
|
||||
|
||||
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("/assets/app.js"));
|
||||
}
|
||||
store.close().await;
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
#[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/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",
|
||||
"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}).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}).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["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 = |key_id: &'static str, value: String| {
|
||||
request("/api/certificates/reload")
|
||||
.method("POST")
|
||||
.header("authorization", format!("Bearer {value}"))
|
||||
.header("x-rustproxy-certificate-key", key_id)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.unwrap()
|
||||
};
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(external("certbot-main", token.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(external("deploy-hook", token_two.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
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("certbot-main", token.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(external("deploy-hook", token_two.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
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("deploy-hook", token_two))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
store.close().await;
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
#[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["theme"], "dark");
|
||||
|
||||
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["theme"], "dark");
|
||||
|
||||
let selected = json!({
|
||||
"live_log_columns":["time","request","status","upstream_error"],
|
||||
"theme":"light"
|
||||
});
|
||||
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);
|
||||
|
||||
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
|
||||
.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);
|
||||
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.key_pair.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.key_pair.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/config",
|
||||
"/api/backends",
|
||||
"/api/docker",
|
||||
"/api/traffic",
|
||||
"/api/cache?limit=invalid",
|
||||
"/api/logs?limit=invalid",
|
||||
"/api/live-logs",
|
||||
"/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/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/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/acme/issue", "POST"),
|
||||
("/api/acme/renew", "POST"),
|
||||
("/api/docker/refresh", "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 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("/assets/login.js"));
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
#[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/init.js",
|
||||
"/docs/style.css",
|
||||
"/docs/swagger-ui-bundle.js",
|
||||
"/docs/swagger-ui.css",
|
||||
"/docs/licenses.txt",
|
||||
"/docs/content.json",
|
||||
"/assets/app.js",
|
||||
] {
|
||||
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());
|
||||
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/V0.25.0_AUDIT.md"));
|
||||
assert!(documents.iter().any(|document| document["path"] == "docs/V0.25.0_SECURITY_PREPROD_AUDIT.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;
|
||||
}
|
||||
Reference in new issue
Block a user