diff --git a/src/server/adapter.rs b/src/server/adapter.rs index 57b1480..35710e3 100644 --- a/src/server/adapter.rs +++ b/src/server/adapter.rs @@ -331,6 +331,13 @@ fn build_router(state: RouterState, extra_routes: Option) -> 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());