diff --git a/crates/common/build.rs b/crates/common/build.rs index cb1e60ae..cce3beab 100644 --- a/crates/common/build.rs +++ b/crates/common/build.rs @@ -27,7 +27,8 @@ fn main() { let toml_content = fs::read_to_string(init_config_path) .unwrap_or_else(|_| panic!("Failed to read {init_config_path:?}")); - // Merge base TOML with environment variable overrides and write output + // Merge base TOML with environment variable overrides and write output. + // Panics if admin endpoints are not covered by a handler. let settings = settings::Settings::from_toml_and_env(&toml_content) .expect("Failed to parse settings at build time"); diff --git a/crates/common/src/auth.rs b/crates/common/src/auth.rs index ba669e6a..020f648c 100644 --- a/crates/common/src/auth.rs +++ b/crates/common/src/auth.rs @@ -6,8 +6,24 @@ use crate::settings::Settings; const BASIC_AUTH_REALM: &str = r#"Basic realm="Trusted Server""#; +/// Enforces Basic-auth for incoming requests. +/// +/// Authentication is required when a configured handler's `path` regex matches +/// the request path. Paths not covered by any handler pass through without +/// authentication. +/// +/// Admin endpoints are protected by requiring a handler at build time — see +/// [`Settings::from_toml_and_env`]. +/// +/// # Returns +/// +/// * `Some(Response)` — a `401 Unauthorized` response that should be sent back +/// to the client (credentials missing or incorrect). +/// * `None` — the request is allowed to proceed. pub fn enforce_basic_auth(settings: &Settings, req: &Request) -> Option { - let handler = settings.handler_for_path(req.get_path())?; + let path = req.get_path(); + + let handler = settings.handler_for_path(path)?; let (username, password) = match extract_credentials(req) { Some(credentials) => credentials, @@ -118,4 +134,39 @@ mod tests { let response = enforce_basic_auth(&settings, &req).expect("should challenge"); assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED); } + + #[test] + fn allow_admin_path_with_valid_credentials() { + let settings = settings_with_handlers(); + let mut req = Request::new(Method::POST, "https://example.com/admin/keys/rotate"); + let token = STANDARD.encode("admin:admin-pass"); + req.set_header(header::AUTHORIZATION, format!("Basic {token}")); + + assert!( + enforce_basic_auth(&settings, &req).is_none(), + "should allow admin path with correct credentials" + ); + } + + #[test] + fn challenge_admin_path_with_wrong_credentials() { + let settings = settings_with_handlers(); + let mut req = Request::new(Method::POST, "https://example.com/admin/keys/rotate"); + let token = STANDARD.encode("admin:wrong"); + req.set_header(header::AUTHORIZATION, format!("Basic {token}")); + + let response = enforce_basic_auth(&settings, &req) + .expect("should challenge admin path with wrong credentials"); + assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED); + } + + #[test] + fn challenge_admin_path_with_missing_credentials() { + let settings = settings_with_handlers(); + let req = Request::new(Method::POST, "https://example.com/admin/keys/rotate"); + + let response = enforce_basic_auth(&settings, &req) + .expect("should challenge admin path with missing credentials"); + assert_eq!(response.get_status(), StatusCode::UNAUTHORIZED); + } } diff --git a/crates/common/src/integrations/google_tag_manager.rs b/crates/common/src/integrations/google_tag_manager.rs index 7bee4d49..0f3b5f29 100644 --- a/crates/common/src/integrations/google_tag_manager.rs +++ b/crates/common/src/integrations/google_tag_manager.rs @@ -1297,6 +1297,11 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= #[test] fn test_config_parsing() { let toml_str = r#" +[[handlers]] +path = "^/admin" +username = "admin" +password = "admin-pass" + [publisher] domain = "test-publisher.com" cookie_domain = ".test-publisher.com" @@ -1328,6 +1333,11 @@ upstream_url = "https://custom.gtm.example" #[test] fn test_config_defaults() { let toml_str = r#" +[[handlers]] +path = "^/admin" +username = "admin" +password = "admin-pass" + [publisher] domain = "test-publisher.com" cookie_domain = ".test-publisher.com" diff --git a/crates/common/src/integrations/prebid.rs b/crates/common/src/integrations/prebid.rs index 89a8826c..66fc9299 100644 --- a/crates/common/src/integrations/prebid.rs +++ b/crates/common/src/integrations/prebid.rs @@ -1082,6 +1082,11 @@ mod tests { /// Shared TOML prefix for config-parsing tests (publisher + synthetic sections). const TOML_BASE: &str = r#" +[[handlers]] +path = "^/admin" +username = "admin" +password = "admin-pass" + [publisher] domain = "test-publisher.com" cookie_domain = ".test-publisher.com" diff --git a/crates/common/src/settings.rs b/crates/common/src/settings.rs index eff23004..289b893b 100644 --- a/crates/common/src/settings.rs +++ b/crates/common/src/settings.rs @@ -334,6 +334,18 @@ impl Settings { message: "Failed to deserialize TOML configuration".to_string(), })?; + let uncovered = settings.uncovered_admin_endpoints(); + if !uncovered.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "No handler covers admin endpoint(s): {}. \ + Add a [[handlers]] entry with a path regex matching /admin/ \ + to protect admin access.", + uncovered.join(", ") + ), + })); + } + Ok(settings) } @@ -374,6 +386,18 @@ impl Settings { }) })?; + let uncovered = settings.uncovered_admin_endpoints(); + if !uncovered.is_empty() { + return Err(Report::new(TrustedServerError::Configuration { + message: format!( + "No handler covers admin endpoint(s): {}. \ + Add a [[handlers]] entry with a path regex matching /admin/ \ + to protect admin access.", + uncovered.join(", ") + ), + })); + } + Ok(settings) } @@ -384,6 +408,29 @@ impl Settings { .find(|handler| handler.matches_path(path)) } + /// Known admin endpoint paths that must be covered by a handler. + /// + /// [`from_toml_and_env`](Self::from_toml_and_env) rejects configurations + /// where any of these paths lack a matching handler, ensuring admin + /// endpoints are always protected by authentication. + /// Update [`ADMIN_ENDPOINTS`](Self::ADMIN_ENDPOINTS) when adding new + /// admin routes to `crates/fastly/src/main.rs`. + pub(crate) const ADMIN_ENDPOINTS: &[&str] = &["/admin/keys/rotate", "/admin/keys/deactivate"]; + + /// Returns admin endpoint paths that no configured handler covers. + /// + /// Called by [`from_toml_and_env`](Self::from_toml_and_env) at build time + /// to enforce that every admin endpoint has a handler. An empty return + /// value means all admin endpoints are properly covered. + #[must_use] + pub(crate) fn uncovered_admin_endpoints(&self) -> Vec<&'static str> { + Self::ADMIN_ENDPOINTS + .iter() + .copied() + .filter(|path| !self.handlers.iter().any(|h| h.matches_path(path))) + .collect() + } + /// Retrieves the integration configuration of a specific type. /// /// # Errors @@ -756,45 +803,69 @@ mod tests { ENVIRONMENT_VARIABLE_SEPARATOR, ENVIRONMENT_VARIABLE_SEPARATOR ); - let path_key = format!( + // Override handler 0 via env vars + let path_key_0 = format!( "{}{}HANDLERS{}0{}PATH", ENVIRONMENT_VARIABLE_PREFIX, ENVIRONMENT_VARIABLE_SEPARATOR, ENVIRONMENT_VARIABLE_SEPARATOR, ENVIRONMENT_VARIABLE_SEPARATOR ); - let username_key = format!( + let username_key_0 = format!( "{}{}HANDLERS{}0{}USERNAME", ENVIRONMENT_VARIABLE_PREFIX, ENVIRONMENT_VARIABLE_SEPARATOR, ENVIRONMENT_VARIABLE_SEPARATOR, ENVIRONMENT_VARIABLE_SEPARATOR ); - let password_key = format!( + let password_key_0 = format!( "{}{}HANDLERS{}0{}PASSWORD", ENVIRONMENT_VARIABLE_PREFIX, ENVIRONMENT_VARIABLE_SEPARATOR, ENVIRONMENT_VARIABLE_SEPARATOR, ENVIRONMENT_VARIABLE_SEPARATOR ); + // Admin handler at index 1 (required for admin endpoint coverage) + let path_key_1 = format!( + "{}{}HANDLERS{}1{}PATH", + ENVIRONMENT_VARIABLE_PREFIX, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR + ); + let username_key_1 = format!( + "{}{}HANDLERS{}1{}USERNAME", + ENVIRONMENT_VARIABLE_PREFIX, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR + ); + let password_key_1 = format!( + "{}{}HANDLERS{}1{}PASSWORD", + ENVIRONMENT_VARIABLE_PREFIX, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR + ); - temp_env::with_var( - origin_key, - Some("https://origin.test-publisher.com"), + temp_env::with_vars( + [ + (origin_key, Some("https://origin.test-publisher.com")), + (path_key_0, Some("^/env-handler")), + (username_key_0, Some("env-user")), + (password_key_0, Some("env-pass")), + (path_key_1, Some("^/admin")), + (username_key_1, Some("admin")), + (password_key_1, Some("admin-pass")), + ], || { - temp_env::with_var(path_key, Some("^/env-handler"), || { - temp_env::with_var(username_key, Some("env-user"), || { - temp_env::with_var(password_key, Some("env-pass"), || { - let settings = Settings::from_toml_and_env(&toml_str) - .expect("Settings should load from env"); - assert_eq!(settings.handlers.len(), 1); - let handler = &settings.handlers[0]; - assert_eq!(handler.path, "^/env-handler"); - assert_eq!(handler.username, "env-user"); - assert_eq!(handler.password, "env-pass"); - }); - }); - }); + let settings = + Settings::from_toml_and_env(&toml_str).expect("Settings should load from env"); + assert_eq!(settings.handlers.len(), 2); + let handler = &settings.handlers[0]; + assert_eq!(handler.path, "^/env-handler"); + assert_eq!(handler.username, "env-user"); + assert_eq!(handler.password, "env-pass"); }, ); } @@ -1169,4 +1240,161 @@ mod tests { "Empty allowed_context_keys should be respected (blocks all keys)" ); } + + /// Helper that returns a settings TOML string WITHOUT any admin handler, + /// for tests that need to verify uncovered-admin-endpoint behaviour. + fn settings_str_without_admin_handler() -> String { + r#" + [[handlers]] + path = "^/secure" + username = "user" + password = "pass" + + [publisher] + domain = "test-publisher.com" + cookie_domain = ".test-publisher.com" + origin_url = "https://origin.test-publisher.com" + proxy_secret = "unit-test-proxy-secret" + + [synthetic] + counter_store = "test-counter-store" + opid_store = "test-opid-store" + secret_key = "test-secret-key" + template = "{{client_ip}}" + + [request_signing] + config_store_id = "test-config-store-id" + secret_store_id = "test-secret-store-id" + "# + .to_string() + } + + #[test] + fn uncovered_admin_endpoints_returns_all_when_no_handler_covers_admin() { + // Deserialize directly to bypass from_toml's admin validation, + // since this test exercises uncovered_admin_endpoints itself. + let settings: Settings = + toml::from_str(&settings_str_without_admin_handler()).expect("should deserialize TOML"); + let uncovered = settings.uncovered_admin_endpoints(); + assert_eq!( + uncovered, + vec!["/admin/keys/rotate", "/admin/keys/deactivate"], + "should report both admin endpoints as uncovered" + ); + } + + #[test] + fn uncovered_admin_endpoints_returns_empty_when_handler_covers_admin() { + let settings = create_test_settings(); + let uncovered = settings.uncovered_admin_endpoints(); + assert!( + uncovered.is_empty(), + "should report no uncovered admin endpoints when handler covers /admin" + ); + } + + #[test] + fn uncovered_admin_endpoints_detects_partial_coverage() { + let toml_str = settings_str_without_admin_handler() + + r#" + [[handlers]] + path = "^/admin/keys/rotate$" + username = "admin" + password = "secret" + "#; + // Deserialize directly to bypass from_toml's admin validation, + // since this test exercises uncovered_admin_endpoints itself. + let settings: Settings = toml::from_str(&toml_str).expect("should deserialize TOML"); + let uncovered = settings.uncovered_admin_endpoints(); + assert_eq!( + uncovered, + vec!["/admin/keys/deactivate"], + "should detect that only deactivate is uncovered" + ); + } + + #[test] + fn from_toml_and_env_rejects_config_without_admin_handler() { + let origin_key = format!( + "{}{}PUBLISHER{}ORIGIN_URL", + ENVIRONMENT_VARIABLE_PREFIX, + ENVIRONMENT_VARIABLE_SEPARATOR, + ENVIRONMENT_VARIABLE_SEPARATOR + ); + temp_env::with_var( + origin_key, + Some("https://origin.test-publisher.com"), + || { + let result = Settings::from_toml_and_env(&settings_str_without_admin_handler()); + assert!( + result.is_err(), + "should reject configuration when admin endpoints are not covered" + ); + let err = format!("{:?}", result.unwrap_err()); + assert!( + err.contains("No handler covers admin endpoint"), + "error should mention uncovered admin endpoints, got: {err}" + ); + }, + ); + } + + #[test] + fn from_toml_rejects_config_without_admin_handler() { + let result = Settings::from_toml(&settings_str_without_admin_handler()); + assert!( + result.is_err(), + "should reject configuration when admin endpoints are not covered" + ); + let err = format!("{:?}", result.expect_err("should be an error")); + assert!( + err.contains("No handler covers admin endpoint"), + "error should mention uncovered admin endpoints, got: {err}" + ); + } + + /// Verifies that [`Settings::ADMIN_ENDPOINTS`] stays in sync with the + /// admin route table in `crates/fastly/src/main.rs`. + /// + /// If this test fails, a route was added or removed in the Fastly + /// router without updating `ADMIN_ENDPOINTS` (or vice versa). + #[test] + fn admin_endpoints_match_fastly_router() { + let router_source = include_str!("../../fastly/src/main.rs"); + + for endpoint in Settings::ADMIN_ENDPOINTS { + assert!( + router_source.contains(endpoint), + "ADMIN_ENDPOINTS lists \"{endpoint}\" but it was not found in \ + crates/fastly/src/main.rs — remove it from ADMIN_ENDPOINTS or \ + add the route back to the router" + ); + } + + // Also verify we haven't missed any admin routes in the router. + // Scan for path literals under "/admin/" in match arms. + let admin_routes_in_router: Vec<&str> = router_source + .lines() + .filter_map(|line| { + let trimmed = line.trim(); + // Match arms look like: (Method::POST, "/admin/...") => ... + if trimmed.starts_with('(') && trimmed.contains("\"/admin/") { + let start = trimmed.find("\"/admin/")?; + let rest = &trimmed[start + 1..]; + let end = rest.find('"')?; + Some(&rest[..end]) + } else { + None + } + }) + .collect(); + + for route in &admin_routes_in_router { + assert!( + Settings::ADMIN_ENDPOINTS.contains(route), + "Router has admin route \"{route}\" that is missing from \ + Settings::ADMIN_ENDPOINTS — add it to ensure auth coverage" + ); + } + } } diff --git a/crates/common/src/test_support.rs b/crates/common/src/test_support.rs index 5e0d8f97..1cdae640 100644 --- a/crates/common/src/test_support.rs +++ b/crates/common/src/test_support.rs @@ -10,6 +10,11 @@ pub mod tests { username = "user" password = "pass" + [[handlers]] + path = "^/admin" + username = "admin" + password = "admin-pass" + [publisher] domain = "test-publisher.com" cookie_domain = ".test-publisher.com" diff --git a/crates/fastly/src/main.rs b/crates/fastly/src/main.rs index 77b37649..8cd5ba09 100644 --- a/crates/fastly/src/main.rs +++ b/crates/fastly/src/main.rs @@ -94,6 +94,7 @@ async fn route_request( (Method::POST, "/verify-signature") => handle_verify_signature(settings, req), // Key rotation admin endpoints + // Keep in sync with Settings::ADMIN_ENDPOINTS in crates/common/src/settings.rs (Method::POST, "/admin/keys/rotate") => handle_rotate_key(settings, req), (Method::POST, "/admin/keys/deactivate") => handle_deactivate_key(settings, req), diff --git a/trusted-server.toml b/trusted-server.toml index 0c0a6f7e..90d5a3ec 100644 --- a/trusted-server.toml +++ b/trusted-server.toml @@ -3,6 +3,11 @@ path = "^/secure" username = "user" password = "pass" +[[handlers]] +path = "^/admin" +username = "admin" +password = "changeme" + [publisher] domain = "test-publisher.com" cookie_domain = ".test-publisher.com"