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:
@@ -331,6 +331,13 @@ fn build_router(state: RouterState, extra_routes: Option<Router>) -> Router {
|
||||
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
|
||||
// the extra routes and every default-surface route registered above
|
||||
// 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]
|
||||
async fn openapi_json_is_cached_and_generic_on_cache_miss() {
|
||||
let adapter = HttpAdapter::new(static_provider(), empty_registry());
|
||||
|
||||
Reference in New Issue
Block a user