diff --git a/actix-web-lab/CHANGELOG.md b/actix-web-lab/CHANGELOG.md index 331086ba..4bd13e34 100644 --- a/actix-web-lab/CHANGELOG.md +++ b/actix-web-lab/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased - 2022-xx-xx +- Add alternative `NormalizePath` middleware with redirect option. ## 0.16.3 - 2022-07-03 diff --git a/actix-web-lab/Cargo.toml b/actix-web-lab/Cargo.toml index 9fa67b26..05e1f9e4 100644 --- a/actix-web-lab/Cargo.toml +++ b/actix-web-lab/Cargo.toml @@ -48,6 +48,7 @@ local-channel = "0.1" mime = "0.3" once_cell = "1.8" pin-project-lite = "0.2.7" +regex = "1.5.5" serde = "1" serde_json = "1" serde_html_form = "0.1" diff --git a/actix-web-lab/README.md b/actix-web-lab/README.md index 34acf50f..d9960359 100644 --- a/actix-web-lab/README.md +++ b/actix-web-lab/README.md @@ -27,6 +27,7 @@ - `RedirectHttps`: middleware to redirect traffic to HTTPS if connection is insecure with optional HSTS [(docs)](https://docs.rs/actix-web-lab/0.16.3/actix_web_lab/middleware/struct.RedirectHttps.html) - `redirect_to_www`: function middleware to redirect traffic to `www.` if not already there [(docs)](https://docs.rs/actix-web-lab/0.16.3/actix_web_lab/middleware/fn.redirect_to_www.html) - `ErrorHandlers`: alternative error handler middleware with simpler interface [(docs)](https://docs.rs/actix-web-lab/0.16.3/actix_web_lab/middleware/struct.ErrorHandlers.html) +- `NormalizePath`: alternative path normalizing middleware with redirect option [(docs)](https://docs.rs/actix-web-lab/0.16.3/actix_web_lab/middleware/struct.NormalizePath.html) ### Extractors diff --git a/actix-web-lab/src/lib.rs b/actix-web-lab/src/lib.rs index 9e67a0ca..a08a72d4 100644 --- a/actix-web-lab/src/lib.rs +++ b/actix-web-lab/src/lib.rs @@ -42,6 +42,7 @@ mod lazy_data; mod local_data; mod middleware_from_fn; mod ndjson; +mod normalize_path; mod path; mod query; mod redirect; diff --git a/actix-web-lab/src/middleware.rs b/actix-web-lab/src/middleware.rs index d72e2a7f..ee03d206 100644 --- a/actix-web-lab/src/middleware.rs +++ b/actix-web-lab/src/middleware.rs @@ -4,5 +4,6 @@ pub use crate::err_handler::ErrorHandlers; pub use crate::middleware_from_fn::{from_fn, MiddlewareFn, Next}; +pub use crate::normalize_path::NormalizePath; pub use crate::redirect_to_https::RedirectHttps; pub use crate::redirect_to_www::redirect_to_www; diff --git a/actix-web-lab/src/normalize_path.rs b/actix-web-lab/src/normalize_path.rs new file mode 100644 index 00000000..76b83cff --- /dev/null +++ b/actix-web-lab/src/normalize_path.rs @@ -0,0 +1,603 @@ +//! For middleware documentation, see [`NormalizePath`]. + +use std::{ + future::Future, + marker::PhantomData, + pin::Pin, + task::{Context, Poll}, +}; + +use actix_service::{Service, Transform}; +use actix_utils::future::{ready, Ready}; +use actix_web::{ + body::EitherBody, + dev::{ServiceRequest, ServiceResponse}, + http::{ + header, + uri::{PathAndQuery, Uri}, + StatusCode, + }, + middleware::TrailingSlash, + Error, HttpResponse, +}; +use bytes::Bytes; +use futures_core::ready; +use pin_project_lite::pin_project; +use regex::Regex; + +/// Middleware for normalizing a request's path so that routes can be matched more flexibly. +/// +/// # Normalization Steps +/// - Merges consecutive slashes into one. (For example, `/path//one` always becomes `/path/one`.) +/// - Appends a trailing slash if one is not present, removes one if present, or keeps trailing +/// slashes as-is, depending on which [`TrailingSlash`] variant is supplied +/// to [`new`](NormalizePath::new()). +/// +/// # Default Behavior +/// The default constructor chooses to strip trailing slashes from the end of paths with them +/// ([`TrailingSlash::Trim`]). The implication is that route definitions should be defined without +/// trailing slashes or else they will be inaccessible (or vice versa when using the +/// `TrailingSlash::Always` behavior), as shown in the example tests below. +/// +/// # Examples +/// ``` +/// use actix_web::{web, middleware, App}; +/// +/// # actix_web::rt::System::new().block_on(async { +/// let app = App::new() +/// .wrap(middleware::NormalizePath::trim()) +/// .route("/test", web::get().to(|| async { "test" })) +/// .route("/unmatchable/", web::get().to(|| async { "unmatchable" })); +/// +/// use actix_web::http::StatusCode; +/// use actix_web::test::{call_service, init_service, TestRequest}; +/// +/// let app = init_service(app).await; +/// +/// let req = TestRequest::with_uri("/test").to_request(); +/// let res = call_service(&app, req).await; +/// assert_eq!(res.status(), StatusCode::OK); +/// +/// let req = TestRequest::with_uri("/test/").to_request(); +/// let res = call_service(&app, req).await; +/// assert_eq!(res.status(), StatusCode::OK); +/// +/// let req = TestRequest::with_uri("/unmatchable").to_request(); +/// let res = call_service(&app, req).await; +/// assert_eq!(res.status(), StatusCode::NOT_FOUND); +/// +/// let req = TestRequest::with_uri("/unmatchable/").to_request(); +/// let res = call_service(&app, req).await; +/// assert_eq!(res.status(), StatusCode::NOT_FOUND); +/// # }) +/// ``` +#[derive(Debug, Clone, Copy)] +pub struct NormalizePath { + /// Controls path normalization behavior. + trailing_slash_behavior: TrailingSlash, + + /// Returns redirects for non-normalized paths if `Some`. + use_redirects: Option, +} + +impl Default for NormalizePath { + fn default() -> Self { + Self { + trailing_slash_behavior: TrailingSlash::Trim, + use_redirects: None, + } + } +} + +impl NormalizePath { + /// Create new `NormalizePath` middleware with the specified trailing slash style. + pub fn new(behavior: TrailingSlash) -> Self { + Self { + trailing_slash_behavior: behavior, + use_redirects: None, + } + } + + /// Constructs a new `NormalizePath` middleware with [trim](TrailingSlash::Trim) semantics. + /// + /// Use this instead of `NormalizePath::default()` to avoid deprecation warning. + pub fn trim() -> Self { + Self::new(TrailingSlash::Trim) + } + + /// Configures middleware to respond to requests with non-normalized paths with a 307 redirect. + /// + /// If configured + /// + /// For example, a request with the path `/api//v1/foo/` would receive a response with a + /// `Location: /api/v1/foo` header (assuming `Trim` trailing slash behavior.) + /// + /// To customize the status code, use [`use_redirects_with`](Self::use_redirects_with). + pub fn use_redirects(mut self) -> Self { + self.use_redirects = Some(StatusCode::TEMPORARY_REDIRECT); + self + } + + /// Configures middleware to respond to requests with non-normalized paths with a redirect. + /// + /// For example, a request with the path `/api//v1/foo/` would receive a 307 response with a + /// `Location: /api/v1/foo` header (assuming `Trim` trailing slash behavior.) + /// + /// # Panics + /// Panics if `status_code` is not a redirect (300-399). + pub fn use_redirects_with(mut self, status_code: StatusCode) -> Self { + assert!(status_code.is_redirection()); + self.use_redirects = Some(status_code); + self + } +} + +impl Transform for NormalizePath +where + S: Service, Error = Error>, + S::Future: 'static, +{ + type Response = ServiceResponse>; + type Error = Error; + type Transform = NormalizePathService; + type InitError = (); + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ready(Ok(NormalizePathService { + service, + merge_slash: Regex::new("//+").unwrap(), + trailing_slash_behavior: self.trailing_slash_behavior, + use_redirects: self.use_redirects, + })) + } +} + +pub struct NormalizePathService { + service: S, + merge_slash: Regex, + trailing_slash_behavior: TrailingSlash, + use_redirects: Option, +} + +impl Service for NormalizePathService +where + S: Service, Error = Error>, + S::Future: 'static, +{ + type Response = ServiceResponse>; + type Error = Error; + type Future = NormalizePathFuture; + + actix_service::forward_ready!(service); + + fn call(&self, mut req: ServiceRequest) -> Self::Future { + let head = req.head_mut(); + + let original_path = head.uri.path(); + + // An empty path here means that the URI has no valid path. We skip normalization in this + // case, because adding a path can make the URI invalid + if !original_path.is_empty() { + // Either adds a string to the end (duplicates will be removed anyways) or trims all + // slashes from the end + let path = match self.trailing_slash_behavior { + TrailingSlash::Always => format!("{}/", original_path), + TrailingSlash::MergeOnly => original_path.to_string(), + TrailingSlash::Trim => original_path.trim_end_matches('/').to_string(), + ts_behavior => panic!("unknown trailing slash behavior: {ts_behavior:?}"), + }; + + // normalize multiple /'s to one / + let path = self.merge_slash.replace_all(&path, "/"); + + // Ensure root paths are still resolvable. If resulting path is blank after previous + // step it means the path was one or more slashes. Reduce to single slash. + let path = if path.is_empty() { "/" } else { path.as_ref() }; + + // Check whether the path has been changed + // + // This check was previously implemented as string length comparison + // + // That approach fails when a trailing slash is added, + // and a duplicate slash is removed, + // since the length of the strings remains the same + // + // For example, the path "/v1//s" will be normalized to "/v1/s/" + // Both of the paths have the same length, + // so the change can not be deduced from the length comparison + if path != original_path { + let mut parts = head.uri.clone().into_parts(); + let query = parts.path_and_query.as_ref().and_then(|pq| pq.query()); + + let path = match query { + Some(query) => Bytes::from(format!("{}?{}", path, query)), + None => Bytes::copy_from_slice(path.as_bytes()), + }; + parts.path_and_query = Some(PathAndQuery::from_maybe_shared(path).unwrap()); + + let uri = Uri::from_parts(parts).unwrap(); + req.match_info_mut().get_mut().update(&uri); + req.head_mut().uri = uri; + } + } + + match self.use_redirects { + Some(code) => { + let mut res = HttpResponse::with_body(code, ()); + res.headers_mut().insert( + header::LOCATION, + req.head_mut().uri.to_string().parse().unwrap(), + ); + NormalizePathFuture::redirect(req.into_response(res)) + } + + None => NormalizePathFuture::service(self.service.call(req)), + } + } +} + +pin_project! { + pub struct NormalizePathFuture, B> { + #[pin] inner: Inner, + } +} + +impl, B> NormalizePathFuture { + fn service(fut: S::Future) -> Self { + Self { + inner: Inner::Service { + fut, + _body: PhantomData, + }, + } + } + + fn redirect(res: ServiceResponse<()>) -> Self { + Self { + inner: Inner::Redirect { res: Some(res) }, + } + } +} + +pin_project! { + #[project = InnerProj] + enum Inner, B> { + Redirect { res: Option>, }, + Service { + #[pin] fut: S::Future, + _body: PhantomData, + }, + } +} + +impl Future for NormalizePathFuture +where + S: Service, Error = Error>, +{ + type Output = Result>, Error>; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.project(); + + match this.inner.project() { + InnerProj::Redirect { res } => { + Poll::Ready(Ok(res.take().unwrap().map_into_right_body())) + } + + InnerProj::Service { fut, .. } => { + let res = ready!(fut.poll(cx))?; + Poll::Ready(Ok(res.map_into_left_body())) + } + } + } +} + +#[cfg(test)] +mod tests { + use actix_service::IntoService; + use actix_web::{ + dev::ServiceRequest, + guard::fn_guard, + test::{self, call_service, init_service, TestRequest}, + web, App, HttpResponse, + }; + + use super::*; + + #[actix_web::test] + async fn test_wrap() { + let app = init_service( + App::new() + .wrap(NormalizePath::default()) + .service(web::resource("/").to(HttpResponse::Ok)) + .service(web::resource("/v1/something").to(HttpResponse::Ok)) + .service( + web::resource("/v2/something") + .guard(fn_guard(|ctx| ctx.head().uri.query() == Some("query=test"))) + .to(HttpResponse::Ok), + ), + ) + .await; + + let test_uris = vec![ + "/", + "/?query=test", + "///", + "/v1//something", + "/v1//something////", + "//v1/something", + "//v1//////something", + "/v2//something?query=test", + "/v2//something////?query=test", + "//v2/something?query=test", + "//v2//////something?query=test", + ]; + + for uri in test_uris { + let req = TestRequest::with_uri(uri).to_request(); + let res = call_service(&app, req).await; + assert!(res.status().is_success(), "Failed uri: {}", uri); + } + } + + #[actix_web::test] + async fn trim_trailing_slashes() { + let app = init_service( + App::new() + .wrap(NormalizePath::new(TrailingSlash::Trim)) + .service(web::resource("/").to(HttpResponse::Ok)) + .service(web::resource("/v1/something").to(HttpResponse::Ok)) + .service( + web::resource("/v2/something") + .guard(fn_guard(|ctx| ctx.head().uri.query() == Some("query=test"))) + .to(HttpResponse::Ok), + ), + ) + .await; + + let test_uris = vec![ + "/", + "///", + "/v1/something", + "/v1/something/", + "/v1/something////", + "//v1//something", + "//v1//something//", + "/v2/something?query=test", + "/v2/something/?query=test", + "/v2/something////?query=test", + "//v2//something?query=test", + "//v2//something//?query=test", + ]; + + for uri in test_uris { + let req = TestRequest::with_uri(uri).to_request(); + let res = call_service(&app, req).await; + assert!(res.status().is_success(), "Failed uri: {}", uri); + } + } + + #[actix_web::test] + async fn trim_root_trailing_slashes_with_query() { + let app = init_service( + App::new() + .wrap(NormalizePath::new(TrailingSlash::Trim)) + .service( + web::resource("/") + .guard(fn_guard(|ctx| ctx.head().uri.query() == Some("query=test"))) + .to(HttpResponse::Ok), + ), + ) + .await; + + let test_uris = vec!["/?query=test", "//?query=test", "///?query=test"]; + + for uri in test_uris { + let req = TestRequest::with_uri(uri).to_request(); + let res = call_service(&app, req).await; + assert!(res.status().is_success(), "Failed uri: {}", uri); + } + } + + #[actix_web::test] + async fn ensure_trailing_slash() { + let app = init_service( + App::new() + .wrap(NormalizePath::new(TrailingSlash::Always)) + .service(web::resource("/").to(HttpResponse::Ok)) + .service(web::resource("/v1/something/").to(HttpResponse::Ok)) + .service( + web::resource("/v2/something/") + .guard(fn_guard(|ctx| ctx.head().uri.query() == Some("query=test"))) + .to(HttpResponse::Ok), + ), + ) + .await; + + let test_uris = vec![ + "/", + "///", + "/v1/something", + "/v1/something/", + "/v1/something////", + "//v1//something", + "//v1//something//", + "/v2/something?query=test", + "/v2/something/?query=test", + "/v2/something////?query=test", + "//v2//something?query=test", + "//v2//something//?query=test", + ]; + + for uri in test_uris { + let req = TestRequest::with_uri(uri).to_request(); + let res = call_service(&app, req).await; + assert!(res.status().is_success(), "Failed uri: {}", uri); + } + } + + #[actix_web::test] + async fn ensure_root_trailing_slash_with_query() { + let app = init_service( + App::new() + .wrap(NormalizePath::new(TrailingSlash::Always)) + .service( + web::resource("/") + .guard(fn_guard(|ctx| ctx.head().uri.query() == Some("query=test"))) + .to(HttpResponse::Ok), + ), + ) + .await; + + let test_uris = vec!["/?query=test", "//?query=test", "///?query=test"]; + + for uri in test_uris { + let req = TestRequest::with_uri(uri).to_request(); + let res = call_service(&app, req).await; + assert!(res.status().is_success(), "Failed uri: {}", uri); + } + } + + #[actix_web::test] + async fn keep_trailing_slash_unchanged() { + let app = init_service( + App::new() + .wrap(NormalizePath::new(TrailingSlash::MergeOnly)) + .service(web::resource("/").to(HttpResponse::Ok)) + .service(web::resource("/v1/something").to(HttpResponse::Ok)) + .service(web::resource("/v1/").to(HttpResponse::Ok)) + .service( + web::resource("/v2/something") + .guard(fn_guard(|ctx| ctx.head().uri.query() == Some("query=test"))) + .to(HttpResponse::Ok), + ), + ) + .await; + + let tests = vec![ + ("/", true), // root paths should still work + ("/?query=test", true), + ("///", true), + ("/v1/something////", false), + ("/v1/something/", false), + ("//v1//something", true), + ("/v1/", true), + ("/v1", false), + ("/v1////", true), + ("//v1//", true), + ("///v1", false), + ("/v2/something?query=test", true), + ("/v2/something/?query=test", false), + ("/v2/something//?query=test", false), + ("//v2//something?query=test", true), + ]; + + for (uri, success) in tests { + let req = TestRequest::with_uri(uri).to_request(); + let res = call_service(&app, req).await; + assert_eq!(res.status().is_success(), success, "Failed uri: {}", uri); + } + } + + #[actix_web::test] + async fn no_path() { + let app = init_service( + App::new() + .wrap(NormalizePath::default()) + .service(web::resource("/").to(HttpResponse::Ok)), + ) + .await; + + // This URI will be interpreted as an authority form, i.e. there is no path nor scheme + // (https://datatracker.ietf.org/doc/html/rfc7230#section-5.3.3) + let req = TestRequest::with_uri("eh").to_request(); + let res = call_service(&app, req).await; + assert_eq!(res.status(), StatusCode::NOT_FOUND); + } + + #[actix_web::test] + async fn test_in_place_normalization() { + let srv = |req: ServiceRequest| { + assert_eq!("/v1/something", req.path()); + ready(Ok(req.into_response(HttpResponse::Ok().finish()))) + }; + + let normalize = NormalizePath::default() + .new_transform(srv.into_service()) + .await + .unwrap(); + + let test_uris = vec![ + "/v1//something////", + "///v1/something", + "//v1///something", + "/v1//something", + ]; + + for uri in test_uris { + let req = TestRequest::with_uri(uri).to_srv_request(); + let res = normalize.call(req).await.unwrap(); + assert!(res.status().is_success(), "Failed uri: {}", uri); + } + } + + #[actix_web::test] + async fn should_normalize_nothing() { + const URI: &str = "/v1/something"; + + let srv = |req: ServiceRequest| { + assert_eq!(URI, req.path()); + ready(Ok(req.into_response(HttpResponse::Ok().finish()))) + }; + + let normalize = NormalizePath::default() + .new_transform(srv.into_service()) + .await + .unwrap(); + + let req = TestRequest::with_uri(URI).to_srv_request(); + let res = normalize.call(req).await.unwrap(); + assert!(res.status().is_success()); + } + + #[actix_web::test] + async fn should_normalize_no_trail() { + let srv = |req: ServiceRequest| { + assert_eq!("/v1/something", req.path()); + ready(Ok(req.into_response(HttpResponse::Ok().finish()))) + }; + + let normalize = NormalizePath::default() + .new_transform(srv.into_service()) + .await + .unwrap(); + + let req = TestRequest::with_uri("/v1/something/").to_srv_request(); + let res = normalize.call(req).await.unwrap(); + assert!(res.status().is_success()); + } + + #[actix_web::test] + async fn should_return_redirects_when_configured() { + let normalize = NormalizePath::trim() + .use_redirects() + .new_transform(test::ok_service()) + .await + .unwrap(); + + let req = TestRequest::with_uri("/v1/something/").to_srv_request(); + let res = normalize.call(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::TEMPORARY_REDIRECT); + + let normalize = NormalizePath::trim() + .use_redirects_with(StatusCode::PERMANENT_REDIRECT) + .new_transform(test::ok_service()) + .await + .unwrap(); + + let req = TestRequest::with_uri("/v1/something/").to_srv_request(); + let res = normalize.call(req).await.unwrap(); + assert_eq!(res.status(), StatusCode::PERMANENT_REDIRECT); + } +}