Skip to content

Commit 9855533

Browse files
fix(gateway): reject schemeless worker URLs at the API boundary (#1977)
1 parent 9d7a762 commit 9855533

3 files changed

Lines changed: 168 additions & 45 deletions

File tree

model_gateway/src/config/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ pub(crate) mod validation;
44

55
pub use builder::*;
66
pub use types::*;
7-
pub use validation::validate_mesh_server_name;
7+
pub use validation::{validate_mesh_server_name, validate_worker_url};
88

99
#[derive(Debug, thiserror::Error)]
1010
pub enum ConfigError {

model_gateway/src/config/validation.rs

Lines changed: 106 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,63 @@ pub fn validate_mesh_server_name(name: &str) -> ConfigResult<()> {
1818
Ok(())
1919
}
2020

21+
/// Validate a single worker URL: non-empty, an allowed scheme, and a
22+
/// parseable host. Shared by [`ConfigValidator::validate_urls`] (startup
23+
/// config) and the worker-management API so both reject schemeless or
24+
/// unparsable URLs identically. Rejecting at the API boundary prevents
25+
/// the orphaned `url_to_id` reservation from #1533: the AddWorker
26+
/// workflow rewrites schemeless input via `normalize_url`, so a
27+
/// reservation keyed on the raw URL would never match the registered
28+
/// worker.
29+
pub fn validate_worker_url(url: &str) -> ConfigResult<()> {
30+
if url.is_empty() {
31+
return Err(ConfigError::InvalidValue {
32+
field: "worker_url".to_string(),
33+
value: url.to_string(),
34+
reason: "URL cannot be empty".to_string(),
35+
});
36+
}
37+
38+
// Exact (lowercase) scheme allow-list. Case-insensitive matching is
39+
// tempting but wrong here: the AddWorker workflow's normalize_url
40+
// matches schemes case-sensitively, so a mixed-case scheme would be
41+
// rewritten downstream and diverge from the reservation key — the
42+
// same orphan failure as a schemeless URL.
43+
const ALLOWED_SCHEMES: &[&str] = &["http", "https", "grpc", "grpcs"];
44+
let scheme = url.split_once("://").map_or("", |(s, _)| s);
45+
if !ALLOWED_SCHEMES.contains(&scheme) {
46+
return Err(ConfigError::InvalidValue {
47+
field: "worker_url".to_string(),
48+
value: url.to_string(),
49+
reason:
50+
"URL must start with a lowercase http://, https://, grpc://, or grpcs:// scheme"
51+
.to_string(),
52+
});
53+
}
54+
55+
match ::url::Url::parse(url) {
56+
Ok(parsed) => {
57+
if parsed.host_str().is_none() {
58+
return Err(ConfigError::InvalidValue {
59+
field: "worker_url".to_string(),
60+
value: url.to_string(),
61+
reason: "URL must have a valid host".to_string(),
62+
});
63+
}
64+
}
65+
Err(e) => {
66+
return Err(ConfigError::InvalidValue {
67+
field: "worker_url".to_string(),
68+
value: url.to_string(),
69+
reason: format!("Invalid URL format: {e}"),
70+
});
71+
}
72+
}
73+
Ok(())
74+
}
75+
2176
/// Configuration validator
2277
pub(crate) struct ConfigValidator;
23-
2478
impl ConfigValidator {
2579
pub(crate) fn validate(config: &RouterConfig) -> ConfigResult<()> {
2680
Self::validate_mode(&config.mode)?;
@@ -958,48 +1012,7 @@ impl ConfigValidator {
9581012

9591013
fn validate_urls(urls: &[String]) -> ConfigResult<()> {
9601014
for url in urls {
961-
if url.is_empty() {
962-
return Err(ConfigError::InvalidValue {
963-
field: "worker_url".to_string(),
964-
value: url.clone(),
965-
reason: "URL cannot be empty".to_string(),
966-
});
967-
}
968-
969-
// Case-insensitive scheme allow-list. Compare just the scheme
970-
// segment so we don't allocate a lowercased copy of the full URL.
971-
const ALLOWED_SCHEMES: &[&str] = &["http", "https", "grpc", "grpcs"];
972-
let scheme = url.split_once("://").map_or("", |(s, _)| s);
973-
if !ALLOWED_SCHEMES
974-
.iter()
975-
.any(|allowed| scheme.eq_ignore_ascii_case(allowed))
976-
{
977-
return Err(ConfigError::InvalidValue {
978-
field: "worker_url".to_string(),
979-
value: url.clone(),
980-
reason: "URL must start with http://, https://, grpc://, or grpcs://"
981-
.to_string(),
982-
});
983-
}
984-
985-
match ::url::Url::parse(url) {
986-
Ok(parsed) => {
987-
if parsed.host_str().is_none() {
988-
return Err(ConfigError::InvalidValue {
989-
field: "worker_url".to_string(),
990-
value: url.clone(),
991-
reason: "URL must have a valid host".to_string(),
992-
});
993-
}
994-
}
995-
Err(e) => {
996-
return Err(ConfigError::InvalidValue {
997-
field: "worker_url".to_string(),
998-
value: url.clone(),
999-
reason: format!("Invalid URL format: {e}"),
1000-
});
1001-
}
1002-
}
1015+
validate_worker_url(url)?;
10031016
}
10041017
Ok(())
10051018
}
@@ -1031,6 +1044,56 @@ mod tests {
10311044
assert!(validate_mesh_server_name("node-a").is_ok());
10321045
}
10331046

1047+
#[test]
1048+
fn worker_url_accepts_allowed_schemes() {
1049+
for url in [
1050+
"http://10.0.0.5:8000",
1051+
"https://worker.example.com",
1052+
"grpc://10.0.0.5:50051",
1053+
"grpcs://worker.example.com:443",
1054+
] {
1055+
assert!(validate_worker_url(url).is_ok(), "expected {url} to pass");
1056+
}
1057+
}
1058+
1059+
#[test]
1060+
fn worker_url_rejects_non_lowercase_scheme() {
1061+
// normalize_url in the AddWorker workflow matches schemes
1062+
// case-sensitively, so `HTTP://…` would be mangled into
1063+
// `http://HTTP://…` and orphan the reservation just like a
1064+
// schemeless URL would.
1065+
assert!(validate_worker_url("HTTP://10.0.0.5:8000").is_err());
1066+
assert!(validate_worker_url("Grpc://10.0.0.5:50051").is_err());
1067+
}
1068+
1069+
#[test]
1070+
fn worker_url_rejects_empty() {
1071+
assert!(matches!(
1072+
validate_worker_url(""),
1073+
Err(ConfigError::InvalidValue { ref field, .. }) if field == "worker_url"
1074+
));
1075+
}
1076+
1077+
#[test]
1078+
fn worker_url_rejects_schemeless_host_port() {
1079+
// The #1533 case: bare host:port input would be rewritten by the
1080+
// AddWorker workflow, orphaning the API-layer ID reservation.
1081+
assert!(matches!(
1082+
validate_worker_url("10.0.0.5:8000"),
1083+
Err(ConfigError::InvalidValue { ref field, .. }) if field == "worker_url"
1084+
));
1085+
}
1086+
1087+
#[test]
1088+
fn worker_url_rejects_disallowed_scheme() {
1089+
assert!(validate_worker_url("ftp://example.com:21").is_err());
1090+
}
1091+
1092+
#[test]
1093+
fn worker_url_rejects_unparsable() {
1094+
assert!(validate_worker_url("http://").is_err());
1095+
}
1096+
10341097
#[test]
10351098
fn test_validate_regular_mode() {
10361099
let config = RouterConfig::new(

model_gateway/src/worker/service.rs

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,21 @@ use serde_json::json;
1616
use tracing::warn;
1717

1818
use crate::{
19-
config::RouterConfig,
19+
config::{validate_worker_url, RouterConfig},
2020
worker::{registry::WorkerId, worker::worker_to_info, WorkerRegistry, WorkerType},
2121
workflow::{Job, JobQueue, WorkerRegistrationMode},
2222
};
2323

24+
/// Validate the URL of an incoming worker-management request, translating a
25+
/// config-layer rejection into `400 Bad Request`. Must run before
26+
/// `WorkerRegistry::reserve_id_for_url` so rejected input can never leave an
27+
/// orphaned reservation (#1533).
28+
fn validate_worker_url_request(url: &str) -> Result<(), WorkerServiceError> {
29+
validate_worker_url(url).map_err(|e| WorkerServiceError::BadRequest {
30+
message: e.to_string(),
31+
})
32+
}
33+
2434
/// Error types for worker service operations
2535
#[derive(Debug)]
2636
pub enum WorkerServiceError {
@@ -256,6 +266,8 @@ impl WorkerService {
256266
&self,
257267
config: WorkerSpec,
258268
) -> Result<CreateWorkerResult, WorkerServiceError> {
269+
validate_worker_url_request(&config.url)?;
270+
259271
if self.router_config.api_key.is_some() && config.api_key.is_none() {
260272
warn!(
261273
"Adding worker {} without API key while router has API key configured. \
@@ -564,4 +576,52 @@ mod tests {
564576
assert_eq!(result.workers.len(), 1);
565577
assert_eq!(result.total, 1);
566578
}
579+
580+
fn worker_spec(url: &str) -> WorkerSpec {
581+
serde_json::from_value(json!({ "url": url })).expect("worker spec")
582+
}
583+
584+
#[tokio::test]
585+
async fn create_worker_rejects_schemeless_url_before_reserving() {
586+
let registry = Arc::new(WorkerRegistry::new());
587+
let service = make_service(registry);
588+
589+
let err = service
590+
.create_worker(worker_spec("10.0.0.5:8000"))
591+
.await
592+
.expect_err("schemeless URL must be rejected at the boundary");
593+
594+
assert!(matches!(err, WorkerServiceError::BadRequest { .. }));
595+
assert_eq!(err.error_code(), "BAD_REQUEST");
596+
assert_eq!(err.status_code(), StatusCode::BAD_REQUEST);
597+
}
598+
599+
#[tokio::test]
600+
async fn create_worker_rejects_mixed_case_scheme() {
601+
let registry = Arc::new(WorkerRegistry::new());
602+
let service = make_service(registry);
603+
604+
let err = service
605+
.create_worker(worker_spec("HTTP://10.0.0.5:8000"))
606+
.await
607+
.expect_err("mixed-case scheme must be rejected at the boundary");
608+
609+
assert!(matches!(err, WorkerServiceError::BadRequest { .. }));
610+
}
611+
612+
#[tokio::test]
613+
async fn create_worker_accepts_schemed_url() {
614+
let registry = Arc::new(WorkerRegistry::new());
615+
let service = make_service(registry);
616+
617+
// The harness leaves the job queue uninitialized, so a valid URL
618+
// passes boundary validation and stops at queue submission —
619+
// proving validation did not reject it.
620+
let err = service
621+
.create_worker(worker_spec("grpc://10.0.0.5:8000"))
622+
.await
623+
.expect_err("queue is uninitialized in the test harness");
624+
625+
assert!(matches!(err, WorkerServiceError::QueueNotInitialized));
626+
}
567627
}

0 commit comments

Comments
 (0)