[Security][Rust Frontend] Normalize HTTP method labels in metrics (#56058)

Co-authored-by: Bugen Zhao <i@bugenzhao.com> Co-authored-by: OpenAI Codex <noreply@openai.com> Signed-off-by: Juan Pérez de Algaba <jperezde@redhat.com> Signed-off-by: Bugen Zhao <i@bugenzhao.com>

Juan Pérez de Algaba committed Sep 10, 2026 at 04:54 UTC 3735c2d5f5248259482b9045c34fb7a8a3892352
2 files changed +109 -1
rust/src/server/src/middleware/metrics.rs
+51 -1
@@ -4,6 +4,7 @@
4 use std::time::Instant;
5
6 use axum::extract::{MatchedPath, Request};
7 +use axum::http::Method;
8 use axum::middleware::Next;
9 use axum::response::Response;
10 use vllm_metrics::{HttpHandlerLabels, HttpRequestLabels, METRICS};
@@ -29,10 +30,29 @@ const EXCLUDED_HANDLERS: &[&str] = &[
30 "/is_sleeping",
31 ];
32
33 +/// Map the request method to a bounded Prometheus label.
34 +///
35 +/// HTTP parsers accept arbitrary method tokens. Recording the raw token as a
36 +/// label would let unique values create unbounded time series.
37 +pub(crate) fn http_metrics_method(method: &Method) -> &str {
38 + match *method {
39 + Method::GET
40 + | Method::POST
41 + | Method::PUT
42 + | Method::PATCH
43 + | Method::DELETE
44 + | Method::HEAD
45 + | Method::OPTIONS
46 + | Method::CONNECT
47 + | Method::TRACE => method.as_str(),
48 + _ => "other",
49 + }
50 +}
51 +
52 /// Record API-server HTTP metrics with Python-compatible
53 /// (`PrometheusFastApiInstrumentator` style) family names and labels.
54 pub async fn track_http_metrics(req: Request, next: Next) -> Response {
35 - let method = req.method().as_str().to_string();
55 + let method = http_metrics_method(req.method()).to_string();
56 let handler = req
57 .extensions()
58 .get::<MatchedPath>()
@@ -80,3 +100,33 @@ fn status_group(status: u16) -> &'static str {
100 _ => "unknown",
101 }
102 }
103 +
104 +#[cfg(test)]
105 +mod tests {
106 + use super::*;
107 +
108 + #[test]
109 + fn known_methods_keep_their_names() {
110 + for (method, expected) in [
111 + (Method::GET, "GET"),
112 + (Method::POST, "POST"),
113 + (Method::PUT, "PUT"),
114 + (Method::PATCH, "PATCH"),
115 + (Method::DELETE, "DELETE"),
116 + (Method::HEAD, "HEAD"),
117 + (Method::OPTIONS, "OPTIONS"),
118 + (Method::CONNECT, "CONNECT"),
119 + (Method::TRACE, "TRACE"),
120 + ] {
121 + assert_eq!(http_metrics_method(&method), expected);
122 + }
123 + }
124 +
125 + #[test]
126 + fn unknown_methods_collapse_to_other() {
127 + let method = Method::from_bytes(b"XVULNCARD000001").expect("valid token");
128 + assert_eq!(http_metrics_method(&method), "other");
129 + let other = Method::from_bytes(b"XVULNCARD000002").expect("valid token");
130 + assert_eq!(http_metrics_method(&other), "other");
131 + }
132 +}
rust/src/server/src/routes/tests.rs
+58
@@ -3115,6 +3115,64 @@ async fn http_metrics_group_error_statuses() {
3115 );
3116 }
3117
3118 +#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3119 +#[serial]
3120 +async fn http_metrics_collapse_unknown_methods() {
3121 + let mut app = test_app().await;
3122 + let before = METRICS.render().unwrap();
3123 +
3124 + for (method, path) in [
3125 + ("XVULNCARD000001", "/tokenize"),
3126 + ("XVULNCARD000002", "/tokenize"),
3127 + ("XVULNCARD000003", "/detokenize"),
3128 + ] {
3129 + let response = app
3130 + .call(
3131 + Request::builder()
3132 + .method(method)
3133 + .uri(path)
3134 + .body(Body::empty())
3135 + .expect("build request"),
3136 + )
3137 + .await
3138 + .expect("call app");
3139 + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
3140 + }
3141 +
3142 + let after = METRICS.render().unwrap();
3143 + assert!(
3144 + !after.contains("XVULNCARD"),
3145 + "raw method tokens must not appear in metrics: {after}"
3146 + );
3147 + assert_eq!(
3148 + metric_delta(
3149 + &before,
3150 + &after,
3151 + "http_requests_total",
3152 + Some("method=\"other\",status=\"4xx\",handler=\"/tokenize\""),
3153 + ),
3154 + 2.0
3155 + );
3156 + assert_eq!(
3157 + metric_delta(
3158 + &before,
3159 + &after,
3160 + "http_requests_total",
3161 + Some("method=\"other\",status=\"4xx\",handler=\"/detokenize\""),
3162 + ),
3163 + 1.0
3164 + );
3165 + assert_eq!(
3166 + metric_delta(
3167 + &before,
3168 + &after,
3169 + "http_request_duration_seconds_count",
3170 + Some("method=\"other\",handler=\"/tokenize\""),
3171 + ),
3172 + 2.0
3173 + );
3174 +}
3175 +
3176 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3177 #[serial]
3178 async fn load_endpoint_tracks_chat_stream_lifecycle() {