mirror of
https://github.com/LibreQoE/LibreQoS.git
synced 2026-10-25 14:20:05 -05:00
Add Node Manager auth regression gates
(cherry picked from commit 2950a66857)
This commit is contained in:
1 parent
c0a5ad906d
commit
e3022c866e
4 files changed
+122
-5
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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![(
|
||||
|
||||
Reference in new issue
Block a user