474 lines
19 KiB
Rust
474 lines
19 KiB
Rust
#[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["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["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",
|
|
"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();
|
|
assert_eq!(json_body(persisted).await, selected);
|
|
// 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;
|
|
}
|
|
|
|
|
|
|
|
|