Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/common/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
// This will fail 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");

Expand Down
53 changes: 52 additions & 1 deletion crates/common/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> {
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,
Expand Down Expand Up @@ -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);
}
}
182 changes: 169 additions & 13 deletions crates/common/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,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)
}

Expand All @@ -384,6 +396,27 @@ 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.
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 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
Expand Down Expand Up @@ -756,42 +789,71 @@ 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_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");
temp_env::with_var(path_key_0, Some("^/env-handler"), || {
temp_env::with_var(username_key_0, Some("env-user"), || {
temp_env::with_var(password_key_0, Some("env-pass"), || {
temp_env::with_var(path_key_1, Some("^/admin"), || {
temp_env::with_var(username_key_1, Some("admin"), || {
temp_env::with_var(password_key_1, Some("admin-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");
});
});
});
});
});
});
Expand Down Expand Up @@ -1169,4 +1231,98 @@ 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() {
let settings = Settings::from_toml(&settings_str_without_admin_handler())
.expect("should parse valid 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"
"#;
let settings = Settings::from_toml(&toml_str).expect("should parse valid 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}"
);
},
);
}
}
5 changes: 5 additions & 0 deletions crates/common/src/test_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions trusted-server.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down