Guard admin endpoints behind auth by default#443
Guard admin endpoints behind auth by default#443ChristianPavilonis wants to merge 4 commits intomainfrom
Conversation
There was a problem hiding this comment.
Summary
This PR correctly identifies and fixes the security gap where admin endpoints could be accessed without authentication when no handler is configured. The fail-closed approach is sound. However, the implementation adds complexity that may not be necessary given the existing handler-based auth system.
♻️ refactor
- Simplify: use config validation instead of runtime guard: The handler-based auth already enforces credentials correctly. Instead of adding
is_admin_path()and special-case logic inenforce_basic_auth, makeSettings::validate()return an error when no handler covers admin endpoints. This keepsenforce_basic_authunchanged, eliminates the dual-mechanism (is_admin_pathprefix check vsADMIN_ENDPOINTSlist), and catches misconfiguration at build time as an error rather than silently returning 401s at runtime. The existinguncovered_admin_endpoints()method is the right building block — call it fromvalidate()instead ofbuild.rs.
|
To clarify the refactor suggestion above — the required config is just a standard [[handlers]]
path = "^/admin"
username = "admin"
password = "a-strong-password"With the validation approach, |
|
Addressed the review feedback — refactored from runtime guard to build-time config validation:
All 472 tests pass, clippy clean, fmt clean. |
Admin paths (/admin/*) were only auth-gated when a configured handler's path regex happened to match them. The default config (^/secure) left /admin/keys/rotate and /admin/keys/deactivate publicly accessible. Now enforce_basic_auth always denies admin paths that no handler covers, and a startup warning alerts operators when no handler matches /admin/. Closes #400
P2: Check all admin endpoints (rotate + deactivate) in coverage
warning instead of only checking rotate.
P2: Move admin coverage warning from per-request main() to build
time via cargo:warning in build.rs, avoiding log noise in
Fastly Compute where every request is a fresh WASM instance.
P3: Guard exact /admin path in addition to /admin/ prefix so
future endpoints at that path are also protected.
Move admin endpoint protection from a runtime check in enforce_basic_auth to a build-time validation error in Settings::from_toml_and_env. This eliminates the dual-mechanism (is_admin_path prefix check vs handler-based auth) and catches misconfiguration at build time rather than silently returning 401s at runtime.
e3dd80c to
59a036e
Compare
aram356
left a comment
There was a problem hiding this comment.
Summary
Build-time config validation for admin endpoints is the right approach — misconfiguration becomes a compile error. The implementation is clean and well-tested. Two items need addressing before merge.
Blocking
🔧 wrench
from_toml()bypasses admin validation:from_toml()ispuband skips theuncovered_admin_endpoints()check entirely. Defense-in-depth: add the same check tofrom_toml()so no code path can construct aSettingswithout admin coverage. Cost is one trivialVecallocation. See inline comment onsettings.rs:377.ADMIN_ENDPOINTScan drift from router: The hardcoded list has no compile-time or test-time link to the actual route table inmain.rs:97-98. A new admin route added without updating this constant ships unprotected. See inline comment onsettings.rs:404.
Non-blocking
🌱 seedling
- Placeholder
changemepassword: No validation rejects weak handler passwords, unlike the synthetic secret key check. (trusted-server.toml:9)
♻️ refactor
- Nested
temp_env::with_var: 7-level nesting can be flattened withtemp_env::with_vars. (crates/common/src/settings.rs:841)
🤔 thinking
uncovered_admin_endpointsvisibility:pubis broader than needed —pub(crate)would suffice. (crates/common/src/settings.rs:412)
⛏ nitpick
- Comment says "fail" but means "panic":
.expect()panics, not returns an error. (crates/common/build.rs:31)
👍 praise
- Build-time validation approach: Shifting auth enforcement to config validation at compile time is architecturally sound. The
uncovered_admin_endpointsmethod and its test coverage (full/partial/no coverage) are thorough.
CI Status
- cargo fmt: PASS
- cargo test: PASS
- vitest: PASS
- format-typescript: PASS
- format-docs: PASS
- CodeQL: PASS
| }) | ||
| })?; | ||
|
|
||
| let uncovered = settings.uncovered_admin_endpoints(); |
There was a problem hiding this comment.
🔧 wrench — This check only runs in from_toml_and_env(), but from_toml() (line 331) is pub and skips it entirely.
The safety argument is that build.rs already validated, so the embedded TOML is guaranteed safe. But from_toml() is a public API — nothing prevents a future caller from constructing Settings with TOML that lacks an admin handler.
Adding the same uncovered_admin_endpoints() check inside from_toml() costs one trivial Vec allocation per server startup and provides defense-in-depth:
pub fn from_toml(toml_str: &str) -> Result<Self, Report<TrustedServerError>> {
let settings: Self =
toml::from_str(toml_str).change_context(TrustedServerError::Configuration {
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): {}",
uncovered.join(", ")
),
}));
}
Ok(settings)
}
crates/common/src/settings.rs
Outdated
| /// [`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"]; |
There was a problem hiding this comment.
🔧 wrench — ADMIN_ENDPOINTS can drift from the actual route table.
This hardcoded list must be manually kept in sync with the route match arms in crates/fastly/src/main.rs:97-98. No compiler error or test catches drift — if someone adds POST /admin/keys/list to main.rs but forgets to update this constant, the new endpoint ships unprotected.
Options (pick one):
- Extract admin routes into a shared constant in
crates/commonthat both the router and this validation reference. - Add a test in
crates/fastlythat asserts the routes andADMIN_ENDPOINTSstay in sync. - At minimum, add a comment at the route definitions in
main.rs:97-98pointing back to this constant.
|
|
||
| [[handlers]] | ||
| path = "^/admin" | ||
| username = "admin" |
There was a problem hiding this comment.
🌱 seedling — Placeholder changeme password ships in config.
The synthetic secret key has a runtime rejection for placeholder values (settings_data.rs:37), but handler passwords have no equivalent check. A deployment with password = "changeme" passes all validation.
Consider a validate_password custom validator for admin handlers in a follow-up, or at minimum document that operators must override via env vars in production.
crates/common/src/settings.rs
Outdated
| 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"), || { |
There was a problem hiding this comment.
♻️ refactor — 7-level temp_env::with_var nesting.
Use temp_env::with_vars (plural) to flatten to a single level:
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")),
],
|| {
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");
},
);
crates/common/src/settings.rs
Outdated
| /// 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> { |
There was a problem hiding this comment.
🤔 thinking — uncovered_admin_endpoints is pub but only called internally by from_toml_and_env() and tests. pub(crate) would keep the API surface tighter and signal this is an internal mechanism, not a stable public API.
crates/common/build.rs
Outdated
|
|
||
| // 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. |
There was a problem hiding this comment.
⛏ nitpick — "This will fail if..." — since .expect() is called on line 33, the build panics. Consider: "Panics if admin endpoints are not covered by a handler."
| Add a [[handlers]] entry with a path regex matching /admin/ \ | ||
| to protect admin access.", | ||
| uncovered.join(", ") | ||
| ), |
There was a problem hiding this comment.
👍 praise — Shifting from runtime guards to build-time config validation is the right architectural call. Misconfiguration becomes a compile error rather than a silent runtime gap. The uncovered_admin_endpoints method is clean, testable, and well-documented, and the test coverage of full/partial/no coverage is thorough.
Add admin endpoint validation to from_toml() so no code path can construct Settings without admin coverage. Add a compile-time sync test that verifies ADMIN_ENDPOINTS matches the Fastly router source. Also flatten nested temp_env::with_var calls, narrow visibility of uncovered_admin_endpoints to pub(crate), and fix build.rs comment.
Review feedback addressedAll comments from the second review have been addressed in 0ffad1b: Blocking
Non-blocking
All 474 tests pass, clippy clean, fmt clean. |
Summary
/admin/keys/rotateand/admin/keys/deactivatewere publicly accessible when no handler regex covered/admin/*pathsSettings::from_toml_and_env()now returns a hard error when no handler covers admin endpoints, catching misconfiguration at build timeenforce_basic_authremains a single, simple mechanism — admin protection is enforced by requiring handler coverage in the configurationChanges
crates/common/src/auth.rsenforce_basic_auth— removeis_admin_path()and runtime admin guard; admin paths use standard handler-based authcrates/common/src/settings.rsfrom_toml_and_env()errors whenuncovered_admin_endpoints()finds unprotected admin paths; update doc comments and testscrates/common/build.rscargo:warningloop (redundant —from_toml_and_envnow errors on uncovered admin endpoints)crates/common/src/test_support.rs^/adminhandler to base test settingstrusted-server.toml^/adminhandler to dev configCloses
Closes #400
Test plan
cargo test --workspace(472 tests pass)cargo clippy --all-targets --all-features -- -D warningscargo fmt --all -- --checkChecklist
unwrap()in production code — useexpect("should ...")tracingmacros (notprintln!)