diff --git a/SECURITY_AUDIT_1_TASKS.md b/SECURITY_AUDIT_1_TASKS.md index 70b22485..8f38b73c 100644 --- a/SECURITY_AUDIT_1_TASKS.md +++ b/SECURITY_AUDIT_1_TASKS.md @@ -71,6 +71,8 @@ Done when: ### 3. Add an auth surface regression gate for Node Manager +Status: Done. Added the missing HTML route build-contract check, websocket same-origin guard, template-page block, and CORS regression test; existing cookie and read-only dashboard tests were rerun on 2026-05-15. + Why: The branch fixed important auth behavior: no anonymous WebUI mode, CORS removed, session cookies hardened, durable session token no longer copied into websocket auth, and read-only dashboard writes denied. A-grade security needs a regression gate so these do not slip back. diff --git a/src/rust/lqosd/src/node_manager/js_build/test-build-contract.sh b/src/rust/lqosd/src/node_manager/js_build/test-build-contract.sh index 0740be56..0e80bd29 100755 --- a/src/rust/lqosd/src/node_manager/js_build/test-build-contract.sh +++ b/src/rust/lqosd/src/node_manager/js_build/test-build-contract.sh @@ -51,6 +51,21 @@ for page in "${served_pages[@]}"; do [[ -f "${STATIC_DIR}/${page}" ]] || fail "Page is served by static_pages.rs but missing from static2: ${page}" done +declare -A directly_handled_html=( + ["first-run.html"]=1 + ["login.html"]=1 + ["template.html"]=1 +) + +for html_path in "${STATIC_DIR}"/*.html; do + [[ -e "${html_path}" ]] || continue + page="$(basename "${html_path}")" + [[ -n "${directly_handled_html[${page}]:-}" ]] && continue + if ! printf '%s\n' "${served_pages[@]}" | grep -qx -- "${page}"; then + fail "HTML page is present in static2 but not in the authenticated static_pages.rs route list: ${page}" + fi +done + required_template_assets=( "node_manager.css" "vendor/bootstrap.min.css" diff --git a/src/rust/lqosd/src/node_manager/run.rs b/src/rust/lqosd/src/node_manager/run.rs index af46e0db..9f90aab1 100644 --- a/src/rust/lqosd/src/node_manager/run.rs +++ b/src/rust/lqosd/src/node_manager/run.rs @@ -10,7 +10,7 @@ use crate::system_stats::SystemStats; use anyhow::{Result, bail}; use axum::Router; use axum::http::StatusCode; -use axum::response::Redirect; +use axum::response::{IntoResponse, Redirect, Response}; use axum::routing::{get, post}; use lqos_bus::BusRequest; use lqos_config::load_config; @@ -58,6 +58,7 @@ pub async fn spawn_webserver( .route("/doLogin", post(auth::try_login)) .route("/firstLogin", post(auth::first_user)) .route("/health", get(health_check)) + .route("/template.html", get(not_found)) .route("/configuration.html", get(redirect_configuration_page)) // Backwards compatible aliases for historical misspellings. .route_service( @@ -106,3 +107,23 @@ async fn redirect_configuration_page() -> Redirect { async fn health_check() -> (StatusCode, &'static str) { (StatusCode::OK, "OK") } + +async fn not_found() -> Response { + StatusCode::NOT_FOUND.into_response() +} + +#[cfg(test)] +mod tests { + #[test] + fn node_manager_router_sources_do_not_enable_cors_layer() { + let sources = [ + include_str!("run.rs"), + include_str!("local_api.rs"), + include_str!("ws.rs"), + ]; + for source in sources { + assert!(!source.contains(concat!("Cors", "Layer"))); + assert!(!source.contains(concat!("tower_http::", "cors"))); + } + } +} diff --git a/src/rust/lqosd/src/node_manager/ws.rs b/src/rust/lqosd/src/node_manager/ws.rs index 7e114924..da6c7ab1 100644 --- a/src/rust/lqosd/src/node_manager/ws.rs +++ b/src/rust/lqosd/src/node_manager/ws.rs @@ -38,8 +38,8 @@ use axum::{ WebSocketUpgrade, ws::{Message, WebSocket}, }, - http::{HeaderMap, StatusCode, header}, - response::IntoResponse, + http::{HeaderMap, StatusCode, Uri, header}, + response::{IntoResponse, Response}, routing::get, }; use futures_util::{SinkExt, StreamExt}; @@ -203,7 +203,12 @@ async fn ws_handler( Extension(probe_client): Extension, Extension(shaper_query): Extension>, headers: HeaderMap, -) -> impl IntoResponse { +) -> Response { + if !websocket_origin_allowed(&headers) { + warn!("Rejected websocket upgrade with cross-origin Origin header"); + return StatusCode::FORBIDDEN.into_response(); + } + let has_cookie = headers.contains_key(header::COOKIE); let user_agent = headers .get(header::USER_AGENT) @@ -236,6 +241,39 @@ async fn ws_handler( ) .await; }) + .into_response() +} + +fn websocket_origin_allowed(headers: &HeaderMap) -> bool { + let Some(origin) = headers + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + else { + return true; + }; + + let Some(host) = headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + else { + return false; + }; + + let Ok(uri) = origin.parse::() else { + return false; + }; + let Some(scheme) = uri.scheme_str() else { + return false; + }; + if scheme != "http" && scheme != "https" { + return false; + } + + uri.authority() + .map(|authority| authority.as_str().eq_ignore_ascii_case(host)) + .unwrap_or(false) } async fn handle_socket(socket: WebSocket, context: WsUpgradeContext) { @@ -2713,12 +2751,13 @@ mod tests { use super::{ can_write_dashboard_themes, decode_ws_request, maybe_schedule_lts_signup_shutdown, run_topology_manager_blocking_with_timeout, run_topology_manager_mutation_blocking, + websocket_origin_allowed, }; use crate::node_manager::auth::LoginResult; use crate::node_manager::local_api::urgent::{UrgentList, UrgentStatus}; use crate::node_manager::ws::messages::WsRequest; use crate::node_manager::ws::messages::{WsResponse, encode_ws_message}; - use axum::http::StatusCode; + use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; use serde_cbor::Value as CborValue; use std::collections::BTreeMap; use std::sync::{ @@ -2977,6 +3016,15 @@ mod tests { entries.contains_key(&text("request_id")) } + fn origin_headers(host: &str, origin: Option<&str>) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(header::HOST, HeaderValue::from_str(host).unwrap()); + if let Some(origin) = origin { + headers.insert(header::ORIGIN, HeaderValue::from_str(origin).unwrap()); + } + headers + } + #[test] fn read_only_websocket_users_cannot_write_dashboard_themes() { assert!(can_write_dashboard_themes(LoginResult::Admin)); @@ -2984,6 +3032,37 @@ mod tests { assert!(!can_write_dashboard_themes(LoginResult::Denied)); } + #[test] + fn websocket_origin_allows_same_origin_and_non_browser_clients() { + assert!(websocket_origin_allowed(&origin_headers( + "node.example.test:9123", + Some("https://node.example.test:9123") + ))); + assert!(websocket_origin_allowed(&origin_headers( + "node.example.test:9123", + None + ))); + } + + #[test] + fn websocket_origin_rejects_cross_origin_and_invalid_origin() { + assert!(!websocket_origin_allowed(&origin_headers( + "node.example.test:9123", + Some("https://evil.example.test") + ))); + assert!(!websocket_origin_allowed(&origin_headers( + "node.example.test:9123", + Some("null") + ))); + + let mut headers = HeaderMap::new(); + headers.insert( + header::ORIGIN, + HeaderValue::from_static("https://node.example.test:9123"), + ); + assert!(!websocket_origin_allowed(&headers)); + } + #[test] fn decodes_urgent_status_with_request_id() { let payload = serde_cbor::to_vec(&map(vec![(