actix_web/middleware/
normalize.rs

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