fix(server): decoy 405 covers merged extra routes (SRV-12)

method_not_allowed_fallback(decoy_method_not_allowed) was registered on
the default router before the extras merge; axum applies the 405
fallback only to MethodRouters present at call time, so wrong-method
probes on extra routes returned axum's bare 405 (no body, no
Server: nginx) — the exact stealth probe SRV-07 neutralized for the
default surface.

Re-apply the fallback after the extras merge (idempotent for routers
the earlier call covered — axum 0.8.9 replaces only Fallback::Default).
Tests pin both shapes: decoy 405 on an extra route, and no regression
of the default-surface 405 after the merge.

Verification: cargo test, cargo clippy --all-targets -- -D warnings,
cargo fmt --check
This commit is contained in:
2026-08-30 11:53:47 +00:00
parent e2c255d40c
commit 6cd0fd3f86
+76
View File
@@ -331,6 +331,13 @@ fn build_router(state: RouterState, extra_routes: Option<Router>) -> Router {
None => default, None => default,
}; };
// Re-applied after the extras merge (SRV-12): the call covers only
// the MethodRouters registered before it, so without this the extra
// routes keep axum's bare 405 (no decoy body, no `Server: nginx`) —
// the exact stealth probe SRV-07 neutralized for the default
// surface. Idempotent for the routers the earlier call covered.
let with_extras = with_extras.method_not_allowed_fallback(decoy_method_not_allowed);
// Applied after the merges (ADR-046 §4): the bearer-auth layer wraps // Applied after the merges (ADR-046 §4): the bearer-auth layer wraps
// the extra routes and every default-surface route registered above // the extra routes and every default-surface route registered above
// except the WS upgrade route (registered earlier with its own // except the WS upgrade route (registered earlier with its own
@@ -1214,6 +1221,75 @@ mod tests {
); );
} }
#[tokio::test]
async fn method_mismatch_on_extra_route_serves_decoy_405() {
let extra = Router::new().route("/v1/ping", get(|| async { "pong" }));
let adapter =
HttpAdapter::new(static_provider(), empty_registry()).with_extra_routes(extra);
let request = axum::http::Request::builder()
.method(axum::http::Method::DELETE)
.uri("/v1/ping")
.body(axum::body::Body::empty())
.unwrap();
let response = get_with_bearer_with_method(adapter.router().clone(), request).await;
assert_eq!(
response.status(),
axum::http::StatusCode::METHOD_NOT_ALLOWED,
"wrong-method probe on an extra route"
);
let server = response
.headers()
.get(axum::http::header::SERVER)
.map(|v| v.to_str().unwrap().to_string());
assert_eq!(
server.as_deref(),
Some("nginx"),
"extra-route 405 must carry the decoy Server header, not axum's bare 405 (SRV-12)"
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8_lossy(&body);
assert!(body.contains("405 Not Allowed"), "got: {body}");
assert!(
!body.contains("axum") && !body.contains("alk"),
"got: {body}"
);
}
#[tokio::test]
async fn method_mismatch_on_default_surface_still_serves_decoy_405_after_extras_merge() {
let extra = Router::new().route("/v1/ping", get(|| async { "pong" }));
let adapter =
HttpAdapter::new(static_provider(), empty_registry()).with_extra_routes(extra);
let request = axum::http::Request::builder()
.method(axum::http::Method::OPTIONS)
.uri("/search")
.body(axum::body::Body::empty())
.unwrap();
let response = get_with_bearer_with_method(adapter.router().clone(), request).await;
assert_eq!(
response.status(),
axum::http::StatusCode::METHOD_NOT_ALLOWED
);
let server = response
.headers()
.get(axum::http::header::SERVER)
.map(|v| v.to_str().unwrap().to_string());
assert_eq!(
server.as_deref(),
Some("nginx"),
"the re-applied 405 fallback must not regress the default surface (SRV-07)"
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8_lossy(&body);
assert!(body.contains("405 Not Allowed"), "got: {body}");
}
#[tokio::test] #[tokio::test]
async fn openapi_json_is_cached_and_generic_on_cache_miss() { async fn openapi_json_is_cached_and_generic_on_cache_miss() {
let adapter = HttpAdapter::new(static_provider(), empty_registry()); let adapter = HttpAdapter::new(static_provider(), empty_registry());