Add Node Manager auth regression gates

(cherry picked from commit 2950a66857)
This commit is contained in:
Herbert Wolverson committed 2026-06-14 15:14:14 -05:00
1 parent c0a5ad906d
commit e3022c866e
4 files changed
+122 -5

No files matched your search

+2
View File
@@ -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.
@@ -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"
+22 -1
View File
@@ -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")));
}
}
}
+83 -4
View File
@@ -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<ProbeClient>,
Extension(shaper_query): Extension<Sender<ShaperQueryCommand>>,
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::<Uri>() 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![(