Skip to main content

actix_web/middleware/
normalize.rs

1//! For middleware documentation, see [`NormalizePath`].
2
3use actix_http::uri::{PathAndQuery, Uri};
4use actix_router::Url;
5use actix_service::{Service, Transform};
6#[expect(deprecated, reason = "preserve the public ready future type")]
7use actix_utils::future::{ready, Ready};
8use bytes::Bytes;
9#[cfg(feature = "unicode")]
10use regex::Regex;
11#[cfg(not(feature = "unicode"))]
12use regex_lite::Regex;
13
14use crate::{
15    service::{ServiceRequest, ServiceResponse},
16    Error,
17};
18
19fn build_byte_index_map(old_path: &str, new_path: &str) -> Vec<u16> {
20    let old_path = old_path.as_bytes();
21    let new_path = new_path.as_bytes();
22
23    let mut map = Vec::with_capacity(old_path.len() + 1);
24    map.push(0);
25
26    let mut old_idx = 0usize;
27    let mut new_idx = 0usize;
28
29    while old_idx < old_path.len() {
30        if new_idx < new_path.len() && old_path[old_idx] == new_path[new_idx] {
31            new_idx += 1;
32        }
33
34        old_idx += 1;
35        map.push(new_idx.min(u16::MAX as usize) as u16);
36    }
37
38    map
39}
40
41/// Determines the behavior of the [`NormalizePath`] middleware.
42///
43/// The default is `TrailingSlash::Trim`.
44#[non_exhaustive]
45#[derive(Debug, Clone, Copy, Default)]
46pub enum TrailingSlash {
47    /// Trim trailing slashes from the end of the path.
48    ///
49    /// Using this will require all routes to omit trailing slashes for them to be accessible.
50    #[default]
51    Trim,
52
53    /// Only merge any present multiple trailing slashes.
54    ///
55    /// This option provides the best compatibility with behavior in actix-web v2.0.
56    MergeOnly,
57
58    /// Always add a trailing slash to the end of the path.
59    ///
60    /// Using this will require all routes have a trailing slash for them to be accessible.
61    Always,
62}
63
64/// Middleware for normalizing a request's path so that routes can be matched more flexibly.
65///
66/// # Normalization Steps
67/// - Merges consecutive slashes into one. (For example, `/path//one` always becomes `/path/one`.)
68/// - Appends a trailing slash if one is not present, removes one if present, or keeps trailing
69///   slashes as-is, depending on which [`TrailingSlash`] variant is supplied
70///   to [`new`](NormalizePath::new()).
71///
72/// # Default Behavior
73/// The default constructor chooses to strip trailing slashes from the end of paths with them
74/// ([`TrailingSlash::Trim`]). The implication is that route definitions should be defined without
75/// trailing slashes or else they will be inaccessible (or vice versa when using the
76/// `TrailingSlash::Always` behavior), as shown in the example tests below.
77///
78/// # Examples
79/// ```
80/// use actix_web::{web, middleware, App};
81///
82/// # actix_web::rt::System::new().block_on(async {
83/// let app = App::new()
84///     .wrap(middleware::NormalizePath::trim())
85///     .route("/test", web::get().to(|| async { "test" }))
86///     .route("/unmatchable/", web::get().to(|| async { "unmatchable" }));
87///
88/// use actix_web::http::StatusCode;
89/// use actix_web::test::{call_service, init_service, TestRequest};
90///
91/// let app = init_service(app).await;
92///
93/// let req = TestRequest::with_uri("/test").to_request();
94/// let res = call_service(&app, req).await;
95/// assert_eq!(res.status(), StatusCode::OK);
96///
97/// let req = TestRequest::with_uri("/test/").to_request();
98/// let res = call_service(&app, req).await;
99/// assert_eq!(res.status(), StatusCode::OK);
100///
101/// let req = TestRequest::with_uri("/unmatchable").to_request();
102/// let res = call_service(&app, req).await;
103/// assert_eq!(res.status(), StatusCode::NOT_FOUND);
104///
105/// let req = TestRequest::with_uri("/unmatchable/").to_request();
106/// let res = call_service(&app, req).await;
107/// assert_eq!(res.status(), StatusCode::NOT_FOUND);
108/// # })
109/// ```
110#[derive(Debug, Clone, Copy)]
111pub struct NormalizePath(TrailingSlash);
112
113impl Default for NormalizePath {
114    fn default() -> Self {
115        log::warn!(
116            "`NormalizePath::default()` is deprecated. The default trailing slash behavior changed \
117            in v4 from `Always` to `Trim`. Update your call to `NormalizePath::new(...)`."
118        );
119
120        Self(TrailingSlash::Trim)
121    }
122}
123
124impl NormalizePath {
125    /// Create new `NormalizePath` middleware with the specified trailing slash style.
126    pub fn new(trailing_slash_style: TrailingSlash) -> Self {
127        Self(trailing_slash_style)
128    }
129
130    /// Constructs a new `NormalizePath` middleware with [trim](TrailingSlash::Trim) semantics.
131    ///
132    /// Use this instead of `NormalizePath::default()` to avoid deprecation warning.
133    pub fn trim() -> Self {
134        Self::new(TrailingSlash::Trim)
135    }
136}
137
138#[expect(deprecated, reason = "preserve the public ready future type")]
139impl<S, B> Transform<S, ServiceRequest> for NormalizePath
140where
141    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
142    S::Future: 'static,
143{
144    type Response = ServiceResponse<B>;
145    type Error = Error;
146    type Transform = NormalizePathNormalization<S>;
147    type InitError = ();
148    type Future = Ready<Result<Self::Transform, Self::InitError>>;
149
150    fn new_transform(&self, service: S) -> Self::Future {
151        ready(Ok(NormalizePathNormalization {
152            service,
153            merge_slash: Regex::new("//+").unwrap(),
154            trailing_slash_behavior: self.0,
155        }))
156    }
157}
158
159pub struct NormalizePathNormalization<S> {
160    service: S,
161    merge_slash: Regex,
162    trailing_slash_behavior: TrailingSlash,
163}
164
165impl<S, B> Service<ServiceRequest> for NormalizePathNormalization<S>
166where
167    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
168    S::Future: 'static,
169{
170    type Response = ServiceResponse<B>;
171    type Error = Error;
172    type Future = S::Future;
173
174    actix_service::forward_ready!(service);
175
176    fn call(&self, mut req: ServiceRequest) -> Self::Future {
177        let head = req.head_mut();
178
179        let original_path = head.uri.path();
180
181        // An empty path here means that the URI has no valid path. We skip normalization in this
182        // case, because adding a path can make the URI invalid
183        if !original_path.is_empty() {
184            // Either adds a string to the end (duplicates will be removed anyways) or trims all
185            // slashes from the end
186            let path = match self.trailing_slash_behavior {
187                TrailingSlash::Always => format!("{}/", original_path),
188                TrailingSlash::MergeOnly => original_path.to_string(),
189                TrailingSlash::Trim => original_path.trim_end_matches('/').to_string(),
190            };
191
192            // normalize multiple /'s to one /
193            let path = self.merge_slash.replace_all(&path, "/");
194
195            // Ensure root paths are still resolvable. If resulting path is blank after previous
196            // step it means the path was one or more slashes. Reduce to single slash.
197            let path = if path.is_empty() { "/" } else { path.as_ref() };
198
199            // Check whether the path has been changed
200            //
201            // This check was previously implemented as string length comparison
202            //
203            // That approach fails when a trailing slash is added,
204            // and a duplicate slash is removed,
205            // since the length of the strings remains the same
206            //
207            // For example, the path "/v1//s" will be normalized to "/v1/s/"
208            // Both of the paths have the same length,
209            // so the change can not be deduced from the length comparison
210            if path != original_path {
211                let reindex = build_byte_index_map(original_path, path);
212                let mut parts = head.uri.clone().into_parts();
213                let query = parts.path_and_query.as_ref().and_then(|pq| pq.query());
214
215                let path = match query {
216                    Some(q) => Bytes::from(format!("{}?{}", path, q)),
217                    None => Bytes::copy_from_slice(path.as_bytes()),
218                };
219                parts.path_and_query = Some(PathAndQuery::from_maybe_shared(path).unwrap());
220
221                let uri = Uri::from_parts(parts).unwrap();
222                req.match_info_mut()
223                    .update_with_reindex(Url::new(uri.clone()), |idx| {
224                        let idx = usize::from(idx).min(reindex.len() - 1);
225                        reindex[idx]
226                    });
227                req.head_mut().uri = uri;
228            }
229        }
230        self.service.call(req)
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use std::future::ready;
237
238    use actix_http::StatusCode;
239    use actix_service::IntoService;
240
241    use super::*;
242    use crate::{
243        guard::fn_guard,
244        test::{call_service, init_service, read_body, TestRequest},
245        web, App, HttpResponse,
246    };
247
248    #[actix_rt::test]
249    async fn test_wrap() {
250        let app = init_service(
251            App::new()
252                .wrap(NormalizePath::default())
253                .service(web::resource("/").to(HttpResponse::Ok))
254                .service(web::resource("/v1/something").to(HttpResponse::Ok))
255                .service(
256                    web::resource("/v2/something")
257                        .guard(fn_guard(|ctx| ctx.head().uri.query() == Some("query=test")))
258                        .to(HttpResponse::Ok),
259                ),
260        )
261        .await;
262
263        let test_uris = vec![
264            "/",
265            "/?query=test",
266            "///",
267            "/v1//something",
268            "/v1//something////",
269            "//v1/something",
270            "//v1//////something",
271            "/v2//something?query=test",
272            "/v2//something////?query=test",
273            "//v2/something?query=test",
274            "//v2//////something?query=test",
275        ];
276
277        for uri in test_uris {
278            let req = TestRequest::with_uri(uri).to_request();
279            let res = call_service(&app, req).await;
280            assert!(res.status().is_success(), "Failed uri: {}", uri);
281        }
282    }
283
284    #[actix_rt::test]
285    async fn trim_trailing_slashes() {
286        let app = init_service(
287            App::new()
288                .wrap(NormalizePath(TrailingSlash::Trim))
289                .service(web::resource("/").to(HttpResponse::Ok))
290                .service(web::resource("/v1/something").to(HttpResponse::Ok))
291                .service(
292                    web::resource("/v2/something")
293                        .guard(fn_guard(|ctx| ctx.head().uri.query() == Some("query=test")))
294                        .to(HttpResponse::Ok),
295                ),
296        )
297        .await;
298
299        let test_uris = vec![
300            "/",
301            "///",
302            "/v1/something",
303            "/v1/something/",
304            "/v1/something////",
305            "//v1//something",
306            "//v1//something//",
307            "/v2/something?query=test",
308            "/v2/something/?query=test",
309            "/v2/something////?query=test",
310            "//v2//something?query=test",
311            "//v2//something//?query=test",
312        ];
313
314        for uri in test_uris {
315            let req = TestRequest::with_uri(uri).to_request();
316            let res = call_service(&app, req).await;
317            assert!(res.status().is_success(), "Failed uri: {}", uri);
318        }
319    }
320
321    #[actix_rt::test]
322    async fn trim_root_trailing_slashes_with_query() {
323        let app = init_service(
324            App::new().wrap(NormalizePath(TrailingSlash::Trim)).service(
325                web::resource("/")
326                    .guard(fn_guard(|ctx| ctx.head().uri.query() == Some("query=test")))
327                    .to(HttpResponse::Ok),
328            ),
329        )
330        .await;
331
332        let test_uris = vec!["/?query=test", "//?query=test", "///?query=test"];
333
334        for uri in test_uris {
335            let req = TestRequest::with_uri(uri).to_request();
336            let res = call_service(&app, req).await;
337            assert!(res.status().is_success(), "Failed uri: {}", uri);
338        }
339    }
340
341    #[actix_rt::test]
342    async fn ensure_trailing_slash() {
343        let app = init_service(
344            App::new()
345                .wrap(NormalizePath(TrailingSlash::Always))
346                .service(web::resource("/").to(HttpResponse::Ok))
347                .service(web::resource("/v1/something/").to(HttpResponse::Ok))
348                .service(
349                    web::resource("/v2/something/")
350                        .guard(fn_guard(|ctx| ctx.head().uri.query() == Some("query=test")))
351                        .to(HttpResponse::Ok),
352                ),
353        )
354        .await;
355
356        let test_uris = vec![
357            "/",
358            "///",
359            "/v1/something",
360            "/v1/something/",
361            "/v1/something////",
362            "//v1//something",
363            "//v1//something//",
364            "/v2/something?query=test",
365            "/v2/something/?query=test",
366            "/v2/something////?query=test",
367            "//v2//something?query=test",
368            "//v2//something//?query=test",
369        ];
370
371        for uri in test_uris {
372            let req = TestRequest::with_uri(uri).to_request();
373            let res = call_service(&app, req).await;
374            assert!(res.status().is_success(), "Failed uri: {}", uri);
375        }
376    }
377
378    #[actix_rt::test]
379    async fn ensure_root_trailing_slash_with_query() {
380        let app = init_service(
381            App::new()
382                .wrap(NormalizePath(TrailingSlash::Always))
383                .service(
384                    web::resource("/")
385                        .guard(fn_guard(|ctx| ctx.head().uri.query() == Some("query=test")))
386                        .to(HttpResponse::Ok),
387                ),
388        )
389        .await;
390
391        let test_uris = vec!["/?query=test", "//?query=test", "///?query=test"];
392
393        for uri in test_uris {
394            let req = TestRequest::with_uri(uri).to_request();
395            let res = call_service(&app, req).await;
396            assert!(res.status().is_success(), "Failed uri: {}", uri);
397        }
398    }
399
400    #[actix_rt::test]
401    async fn keep_trailing_slash_unchanged() {
402        let app = init_service(
403            App::new()
404                .wrap(NormalizePath(TrailingSlash::MergeOnly))
405                .service(web::resource("/").to(HttpResponse::Ok))
406                .service(web::resource("/v1/something").to(HttpResponse::Ok))
407                .service(web::resource("/v1/").to(HttpResponse::Ok))
408                .service(
409                    web::resource("/v2/something")
410                        .guard(fn_guard(|ctx| ctx.head().uri.query() == Some("query=test")))
411                        .to(HttpResponse::Ok),
412                ),
413        )
414        .await;
415
416        let tests = vec![
417            ("/", true), // root paths should still work
418            ("/?query=test", true),
419            ("///", true),
420            ("/v1/something////", false),
421            ("/v1/something/", false),
422            ("//v1//something", true),
423            ("/v1/", true),
424            ("/v1", false),
425            ("/v1////", true),
426            ("//v1//", true),
427            ("///v1", false),
428            ("/v2/something?query=test", true),
429            ("/v2/something/?query=test", false),
430            ("/v2/something//?query=test", false),
431            ("//v2//something?query=test", true),
432        ];
433
434        for (uri, success) in tests {
435            let req = TestRequest::with_uri(uri).to_request();
436            let res = call_service(&app, req).await;
437            assert_eq!(res.status().is_success(), success, "Failed uri: {}", uri);
438        }
439    }
440
441    #[actix_rt::test]
442    async fn scope_dynamic_tail_path_is_reindexed() {
443        async fn handler(path: web::Path<String>) -> HttpResponse {
444            HttpResponse::Ok().body(path.into_inner())
445        }
446
447        let app = init_service(
448            App::new().service(
449                web::scope("{tail:.*}")
450                    .wrap(NormalizePath::trim())
451                    .default_service(web::to(handler)),
452            ),
453        )
454        .await;
455
456        let req = TestRequest::with_uri("/uaie//iuaei").to_request();
457        let res = call_service(&app, req).await;
458
459        assert_eq!(res.status(), StatusCode::OK);
460        assert_eq!(read_body(res).await, Bytes::from_static(b"uaie/iuaei"));
461    }
462
463    #[actix_rt::test]
464    async fn scope_static_prefix_skip_is_reindexed() {
465        let app = init_service(
466            App::new().service(
467                web::scope("/api")
468                    .wrap(NormalizePath::trim())
469                    .service(web::resource("/v1").to(HttpResponse::Ok)),
470            ),
471        )
472        .await;
473
474        let req = TestRequest::with_uri("/api//v1").to_request();
475        let res = call_service(&app, req).await;
476
477        assert_eq!(res.status(), StatusCode::OK);
478    }
479
480    #[actix_rt::test]
481    async fn no_path() {
482        let app = init_service(
483            App::new()
484                .wrap(NormalizePath::default())
485                .service(web::resource("/").to(HttpResponse::Ok)),
486        )
487        .await;
488
489        // This URI will be interpreted as an authority form, i.e. there is no path nor scheme
490        // (https://datatracker.ietf.org/doc/html/rfc7230#section-5.3.3)
491        let req = TestRequest::with_uri("eh").to_request();
492        let res = call_service(&app, req).await;
493        assert_eq!(res.status(), StatusCode::NOT_FOUND);
494    }
495
496    #[actix_rt::test]
497    async fn test_in_place_normalization() {
498        let srv = |req: ServiceRequest| {
499            assert_eq!("/v1/something", req.path());
500            ready(Ok(req.into_response(HttpResponse::Ok().finish())))
501        };
502
503        let normalize = NormalizePath::default()
504            .new_transform(srv.into_service())
505            .await
506            .unwrap();
507
508        let test_uris = vec![
509            "/v1//something////",
510            "///v1/something",
511            "//v1///something",
512            "/v1//something",
513        ];
514
515        for uri in test_uris {
516            let req = TestRequest::with_uri(uri).to_srv_request();
517            let res = normalize.call(req).await.unwrap();
518            assert!(res.status().is_success(), "Failed uri: {}", uri);
519        }
520    }
521
522    #[actix_rt::test]
523    async fn should_normalize_nothing() {
524        const URI: &str = "/v1/something";
525
526        let srv = |req: ServiceRequest| {
527            assert_eq!(URI, req.path());
528            ready(Ok(req.into_response(HttpResponse::Ok().finish())))
529        };
530
531        let normalize = NormalizePath::default()
532            .new_transform(srv.into_service())
533            .await
534            .unwrap();
535
536        let req = TestRequest::with_uri(URI).to_srv_request();
537        let res = normalize.call(req).await.unwrap();
538        assert!(res.status().is_success());
539    }
540
541    #[actix_rt::test]
542    async fn should_normalize_no_trail() {
543        let srv = |req: ServiceRequest| {
544            assert_eq!("/v1/something", req.path());
545            ready(Ok(req.into_response(HttpResponse::Ok().finish())))
546        };
547
548        let normalize = NormalizePath::default()
549            .new_transform(srv.into_service())
550            .await
551            .unwrap();
552
553        let req = TestRequest::with_uri("/v1/something/").to_srv_request();
554        let res = normalize.call(req).await.unwrap();
555        assert!(res.status().is_success());
556    }
557}