From 005c397f7f99850a1d2d119c18e9089f22eae40f Mon Sep 17 00:00:00 2001 From: Denis Cornehl Date: Sun, 8 Mar 2026 07:41:01 +0100 Subject: [PATCH] security middleware - also block double/triple-encoded path traversals --- .../docs_rs_web/src/middleware/security.rs | 51 ++++++++++++++----- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/crates/bin/docs_rs_web/src/middleware/security.rs b/crates/bin/docs_rs_web/src/middleware/security.rs index 70e183c0a..13d1284aa 100644 --- a/crates/bin/docs_rs_web/src/middleware/security.rs +++ b/crates/bin/docs_rs_web/src/middleware/security.rs @@ -1,3 +1,4 @@ +use anyhow::{Result, bail}; use axum::{ extract::Request as AxumHttpRequest, middleware::Next, @@ -5,33 +6,51 @@ use axum::{ }; use docs_rs_uri::url_decode; use http::{StatusCode, Uri}; +use std::borrow::Cow; use tracing::warn; +const MAX_DECODE_PASSES: usize = 3; + pub(crate) async fn security_middleware( uri: Uri, req: AxumHttpRequest, next: Next, ) -> AxumResponse { - let path = match url_decode(uri.path()) { - Ok(path) => path, - Err(err) => { - warn!(%uri, ?err, "invalid UTF-8 in request path"); - return StatusCode::NOT_ACCEPTABLE.into_response(); + if let Err(err) = validate_path(uri.path()) { + warn!(%uri, ?err, "detected blocked request path"); + return StatusCode::NOT_ACCEPTABLE.into_response(); + } + + next.run(req).await +} + +fn validate_path(initial_path: &str) -> Result<()> { + let mut path = Cow::Borrowed(initial_path); + for _ in 0..MAX_DECODE_PASSES { + validate_decoded_path(path.as_ref())?; + + match url_decode(path.as_ref())? { + Cow::Borrowed(_) => break, + Cow::Owned(decoded) => path = Cow::Owned(decoded), } - }; + } + + validate_decoded_path(path.as_ref())?; + + Ok(()) +} +fn validate_decoded_path(path: &str) -> Result<()> { if path.contains("/../") || path.ends_with("/..") { - warn!(%uri, "detected path traversal attempt"); - return StatusCode::NOT_ACCEPTABLE.into_response(); + bail!("path traversal attempt"); } - // `#` is never allowed in any rustdoc URLs, even encoded + // `#` is never allowed in any rustdoc URLs, even encoded. if path.contains('#') { - warn!(%uri, "detected `#` in server-side request path"); - return StatusCode::NOT_ACCEPTABLE.into_response(); + bail!("detected `#` in request path"); } - next.run(req).await + Ok(()) } #[cfg(test)] @@ -41,7 +60,6 @@ mod tests { extractors::Path, testing::{AxumResponseTestExt as _, AxumRouterTestExt as _}, }; - use anyhow::Result; use axum::{Router, middleware, routing::get}; use test_case::test_case; use tower::ServiceBuilder; @@ -52,7 +70,14 @@ mod tests { #[test_case("/.."; "relative path")] #[test_case("/asdf/../"; "relative path 2")] #[test_case("/tiny_http/latest/tiny_http%2f%2e%2e"; "encoded")] + #[test_case("/tiny_http/latest/tiny_http%252f%252e%252e"; "double encoded traversal")] + #[test_case("/tiny_http/latest/tiny_http%25252f%25252e%25252e"; "triple encoded traversal")] #[test_case("/minidumper/latest/%23%3c%2f%73%63%72%69%70%74%3e%3c%74%65%73%74%65%3e"; "encoded XSS probe")] + #[test_case("/minidumper/latest/%2523script"; "double encoded hash")] + #[test_case("/minidumper/latest/%252523script"; "triple encoded hash")] + #[test_case( + "/crate/mika-cli/latest/source/..%25c1%259c..%25c1%259c..%25c1%259c..%25c1%259c..%25c1%259c..%25c1%259c..%25c1%259c..%25c1%259c/etc/passwd" + )] async fn test_invalid_path(path: &str) -> Result<()> { let app = Router::new() .route("/{*inner}", get(|| async { StatusCode::OK }))