Skip to content

Commit 78310ff

Browse files
committed
mason: fix serve wildcard host policy
1 parent 6f999bc commit 78310ff

1 file changed

Lines changed: 104 additions & 12 deletions

File tree

  • packages/dashboard/src-tauri/src/serve

packages/dashboard/src-tauri/src/serve/mod.rs

Lines changed: 104 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ pub struct ServeState {
3939
token: Arc<str>,
4040
allowed_hosts: Arc<HashSet<String>>,
4141
allowed_origins: Arc<HashSet<String>>,
42+
wildcard_bind: bool,
4243
subprocess_limit: Arc<Semaphore>,
4344
}
4445

@@ -140,6 +141,7 @@ pub fn build_router(app_state: Arc<AppState>, options: &ServeOptions, token: Str
140141
token: Arc::from(token),
141142
allowed_hosts: Arc::new(allowed_hosts),
142143
allowed_origins: Arc::new(allowed_origins),
144+
wildcard_bind: options.host.is_unspecified(),
143145
subprocess_limit: Arc::new(Semaphore::new(SUBPROCESS_CONCURRENCY_LIMIT)),
144146
};
145147

@@ -386,27 +388,44 @@ fn allowed_origins(hosts: &HashSet<String>) -> HashSet<String> {
386388
}
387389

388390
fn host_allowed(headers: &HeaderMap, state: &ServeState) -> bool {
389-
let Some(host) = headers
390-
.get(header::HOST)
391-
.and_then(|value| value.to_str().ok())
392-
else {
391+
let Some(host) = request_host(headers) else {
393392
return false;
394393
};
395-
state
396-
.allowed_hosts
397-
.contains(&host.trim().to_ascii_lowercase())
394+
if state.wildcard_bind {
395+
// A wildcard bind can be reached through any local interface address, so a fixed
396+
// Host list would reject the operator's actual LAN URL.
397+
return true;
398+
}
399+
state.allowed_hosts.contains(&host)
398400
}
399401

400402
fn origin_allowed(headers: &HeaderMap, state: &ServeState) -> bool {
401403
let Some(origin) = headers
402404
.get(header::ORIGIN)
403405
.and_then(|value| value.to_str().ok())
406+
.map(normalize_header_value)
404407
else {
405408
return true;
406409
};
407-
state
408-
.allowed_origins
409-
.contains(&origin.trim().to_ascii_lowercase())
410+
if state.wildcard_bind {
411+
let Some(host) = request_host(headers) else {
412+
return false;
413+
};
414+
return origin == format!("http://{host}");
415+
}
416+
state.allowed_origins.contains(&origin)
417+
}
418+
419+
fn request_host(headers: &HeaderMap) -> Option<String> {
420+
headers
421+
.get(header::HOST)
422+
.and_then(|value| value.to_str().ok())
423+
.map(normalize_header_value)
424+
.filter(|value| !value.is_empty())
425+
}
426+
427+
fn normalize_header_value(value: &str) -> String {
428+
value.trim().to_ascii_lowercase()
410429
}
411430

412431
pub(crate) fn authorization_valid(headers: &HeaderMap, expected_token: &str) -> bool {
@@ -563,14 +582,22 @@ mod tests {
563582
}
564583

565584
async fn spawn_test_server() -> TestServer {
585+
spawn_test_server_with_options(DEFAULT_HOST, false).await
586+
}
587+
588+
async fn spawn_remote_test_server() -> TestServer {
589+
spawn_test_server_with_options(IpAddr::V4(Ipv4Addr::UNSPECIFIED), true).await
590+
}
591+
592+
async fn spawn_test_server_with_options(host: IpAddr, allow_remote: bool) -> TestServer {
566593
let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
567594
.await
568595
.expect("test listener");
569596
let port = listener.local_addr().expect("local addr").port();
570597
let options = ServeOptions {
571-
host: DEFAULT_HOST,
598+
host,
572599
port,
573-
allow_remote: false,
600+
allow_remote,
574601
};
575602
let token = "a".repeat(64);
576603
let app = build_router(Arc::new(state_without_db()), &options, token.clone());
@@ -844,6 +871,71 @@ mod tests {
844871
}
845872
}
846873

874+
#[tokio::test]
875+
async fn route_accepts_loopback_host_header() {
876+
let server = spawn_test_server().await;
877+
let response = reqwest::Client::new()
878+
.post(format!("{}/api/invoke", server.base_url))
879+
.bearer_auth(&server.token)
880+
.header(reqwest::header::HOST, format!("127.0.0.1:{}", server.port))
881+
.json(&json!({ "cmd": "get_db_health", "args": {} }))
882+
.send()
883+
.await
884+
.expect("response");
885+
assert_eq!(response.status(), StatusCode::OK);
886+
}
887+
888+
#[tokio::test]
889+
async fn remote_route_accepts_wildcard_host_without_origin() {
890+
let server = spawn_remote_test_server().await;
891+
let response = reqwest::Client::new()
892+
.post(format!("{}/api/invoke", server.base_url))
893+
.bearer_auth(&server.token)
894+
.header(
895+
reqwest::header::HOST,
896+
format!("192.168.1.5:{}", server.port),
897+
)
898+
.json(&json!({ "cmd": "get_db_health", "args": {} }))
899+
.send()
900+
.await
901+
.expect("response");
902+
assert_eq!(response.status(), StatusCode::OK);
903+
}
904+
905+
#[tokio::test]
906+
async fn remote_route_rejects_cross_origin_for_wildcard_host() {
907+
let server = spawn_remote_test_server().await;
908+
let response = reqwest::Client::new()
909+
.post(format!("{}/api/invoke", server.base_url))
910+
.bearer_auth(&server.token)
911+
.header(
912+
reqwest::header::HOST,
913+
format!("192.168.1.5:{}", server.port),
914+
)
915+
.header(reqwest::header::ORIGIN, "http://evil.test")
916+
.json(&json!({ "cmd": "get_db_health", "args": {} }))
917+
.send()
918+
.await
919+
.expect("response");
920+
assert_eq!(response.status(), StatusCode::FORBIDDEN);
921+
}
922+
923+
#[tokio::test]
924+
async fn remote_route_accepts_same_origin_for_wildcard_host() {
925+
let server = spawn_remote_test_server().await;
926+
let host = format!("192.168.1.5:{}", server.port);
927+
let response = reqwest::Client::new()
928+
.post(format!("{}/api/invoke", server.base_url))
929+
.bearer_auth(&server.token)
930+
.header(reqwest::header::HOST, &host)
931+
.header(reqwest::header::ORIGIN, format!("http://{host}"))
932+
.json(&json!({ "cmd": "get_db_health", "args": {} }))
933+
.send()
934+
.await
935+
.expect("response");
936+
assert_eq!(response.status(), StatusCode::OK);
937+
}
938+
847939
#[tokio::test]
848940
async fn route_rejects_wrong_host_header() {
849941
let server = spawn_test_server().await;

0 commit comments

Comments
 (0)