From 6693d13b272442a4327ab62eb47058fc8f9a6739 Mon Sep 17 00:00:00 2001 From: Denis Cornehl Date: Thu, 25 Dec 2025 15:34:58 +0100 Subject: [PATCH] add security middleware to block malicious paths --- src/web/mod.rs | 2 ++ src/web/security.rs | 73 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 src/web/security.rs diff --git a/src/web/mod.rs b/src/web/mod.rs index d64399d4e..08e7d3882 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -17,6 +17,7 @@ pub mod page; mod releases; mod routes; pub(crate) mod rustdoc; +mod security; mod sitemap; mod source; mod statics; @@ -367,6 +368,7 @@ async fn apply_middleware( set_sentry_transaction_name_from_axum_route, )) .layer(CatchPanicLayer::new()) + .layer(middleware::from_fn(security::security_middleware)) .layer(option_layer( context .config diff --git a/src/web/security.rs b/src/web/security.rs new file mode 100644 index 000000000..cd5be85f1 --- /dev/null +++ b/src/web/security.rs @@ -0,0 +1,73 @@ +use axum::{ + extract::Request as AxumHttpRequest, + middleware::Next, + response::{IntoResponse as _, Response as AxumResponse}, +}; +use docs_rs_uri::url_decode; +use http::{StatusCode, Uri}; +use tracing::warn; + +pub(crate) async fn security_middleware( + uri: Uri, + req: AxumHttpRequest, + next: Next, +) -> AxumResponse { + let path = uri.path(); + + if let Err(err) = url_decode(path) { + warn!(%uri, ?err, "invalid UTF-8 in request path"); + return StatusCode::NOT_ACCEPTABLE.into_response(); + } + + if path.contains("/../") || path.ends_with("/..") { + warn!(%uri, "detected path traversal attempt"); + return StatusCode::NOT_ACCEPTABLE.into_response(); + } + + next.run(req).await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + test::{AxumResponseTestExt as _, AxumRouterTestExt as _}, + web::extractors::Path, + }; + use anyhow::Result; + use axum::{Router, middleware, routing::get}; + use test_case::test_case; + use tower::ServiceBuilder; + + #[tokio::test] + #[test_case("/%80"; "invalid UTF8, continuation byte without a leading byte")] + #[test_case("/../"; "relative path with slash")] + #[test_case("/.."; "relative path")] + #[test_case("/asdf/../"; "relative path 2")] + async fn test_invalid_path(path: &str) -> Result<()> { + let app = Router::new() + .route("/{*inner}", get(|| async { StatusCode::OK })) + .layer(ServiceBuilder::new().layer(middleware::from_fn(security_middleware))); + + let response = app.get(path).await?; + assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); + assert!(response.text().await?.is_empty()); + + Ok(()) + } + + #[tokio::test] + async fn test_pass() -> Result<()> { + let app = Router::new() + .route( + "/{*inner}", + get(|Path(inner): Path| async { inner }), + ) + .layer(ServiceBuilder::new().layer(middleware::from_fn(security_middleware))); + + let response = app.assert_success("/some/path").await?; + assert_eq!(response.text().await?, "some/path"); + + Ok(()) + } +}