Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ jobs:
# Still better to maintain fewer manual version overrides though.
- run: cargo update -p async-compression --precise 0.4.23
- run: cargo update -p flate2 --precise 1.0.35
- uses: dtolnay/rust-toolchain@1.64
- uses: dtolnay/rust-toolchain@1.65
- uses: Swatinem/rust-cache@v2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ The [examples] folder contains various examples of how to use Tower HTTP:

## Minimum supported Rust version

tower-http's MSRV is 1.66.
tower-http's MSRV is 1.65.

## Getting Help

Expand Down
16 changes: 16 additions & 0 deletions tower-http/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
feature entries; the underlying dependencies are still pulled in transitively
by the features that need them (e.g. `compression-gzip`, `fs`, `timeout`).
([#628])
- MSRV bumped from 1.64 to 1.65.
- **breaking:** `follow-redirect`: `FollowRedirect` now forwards request
`Extensions` to redirected requests instead of dropping them. The `Standard`
policy drops extensions on cross-origin redirections (same-origin keeps them).
Opt out with `FollowRedirectLayer::preserve_extensions(false)`; keep specific
types with `FilterCredentials::allow_extension::<T>()` or all of them with
`keep_all_extensions()`. ([#581])
- **breaking:** `follow-redirect`: header and extension filtering is now
cumulative. A value a policy drops on one hop is no longer replayed on later
hops, so `FilterCredentials` no longer re-sends `Cookie`/`Authorization` to a
same-origin target reached after a cross-origin hop. Custom `Policy::on_request`
impls now see the previous hop's filtered request, not the original. ([#581])

[#215]: https://github.com/tower-rs/tower-http/issues/215
[#360]: https://github.com/tower-rs/tower-http/pull/360
[#581]: https://github.com/tower-rs/tower-http/pull/581
[#628]: https://github.com/tower-rs/tower-http/pull/628
[#642]: https://github.com/tower-rs/tower-http/pull/642

## Added

- **validate-request:** Add `ValidateRequestHeaderLayer::has_header_value()` to reject requests when a header does not have an expected value ([#360])
- `body`: `UnsyncBoxBody::new()` constructor and `From<ServeFileSystemResponseBody>` conversion to avoid double-boxing when combining `ServeDir` responses with other body types ([#537])
- `fs`: Add `Backend` trait to make `ServeDir` work with non-filesystem sources. The default `TokioBackend` preserves existing behavior. Use `ServeDir::with_backend()` to plug in custom implementations.

# 0.6.11

Expand Down
2 changes: 1 addition & 1 deletion tower-http/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ repository = "https://github.com/tower-rs/tower-http"
homepage = "https://github.com/tower-rs/tower-http"
categories = ["asynchronous", "network-programming", "web-programming"]
keywords = ["io", "async", "futures", "service", "http"]
rust-version = "1.64"
rust-version = "1.65"

[dependencies]
bitflags = "2.0.2"
Expand Down
226 changes: 214 additions & 12 deletions tower-http/src/follow_redirect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@
//! redirections.
//!
//! The middleware tries to clone the original [`Request`] when making a redirected request.
//! However, since [`Extensions`][http::Extensions] are `!Clone`, any extensions set by outer
//! middleware will be discarded. Also, the request body cannot always be cloned. When the
//! original body is known to be empty by [`Body::size_hint`], the middleware uses `Default`
//! implementation of the body type to create a new request body. If you know that the body can be
//! cloned in some way, you can tell the middleware to clone it by configuring a [`policy`].
//! Request headers and [`Extensions`] are carried over to redirected requests; the [`policy`]
//! decides which survive each hop (the [`Standard`] policy drops credential headers and all
//! extensions cross-origin), and filtering is cumulative, so a dropped value never reappears later
//! in the chain. Extension forwarding can be disabled with
//! [`FollowRedirectLayer::preserve_extensions`].
//!
//! The request body cannot always be cloned. When the original body is known to be empty by
//! [`Body::size_hint`], the middleware uses the `Default` implementation of the body type. If the
//! body can be cloned in some way, you can tell the middleware to clone it by configuring a
//! [`policy`].
//!
//! # Examples
//!
Expand Down Expand Up @@ -98,8 +103,8 @@ use self::policy::{Action, Attempt, Policy, Standard};
use futures_util::future::Either;
use http::{
header::CONTENT_ENCODING, header::CONTENT_LENGTH, header::CONTENT_TYPE, header::LOCATION,
header::TRANSFER_ENCODING, HeaderMap, HeaderValue, Method, Request, Response, StatusCode, Uri,
Version,
header::TRANSFER_ENCODING, Extensions, HeaderMap, HeaderValue, Method, Request, Response,
StatusCode, Uri, Version,
};
use http_body::Body;
use pin_project_lite::pin_project;
Expand All @@ -119,9 +124,10 @@ use url::Url;
/// [`Layer`] for retrying requests with a [`Service`] to follow redirection responses.
///
/// See the [module docs](self) for more details.
#[derive(Clone, Copy, Debug, Default)]
#[derive(Clone, Copy, Debug)]
pub struct FollowRedirectLayer<P = Standard> {
policy: P,
preserve_extensions: bool,
}

impl FollowRedirectLayer {
Expand All @@ -134,7 +140,26 @@ impl FollowRedirectLayer {
impl<P> FollowRedirectLayer<P> {
/// Create a new [`FollowRedirectLayer`] with the given redirection [`Policy`].
pub fn with_policy(policy: P) -> Self {
FollowRedirectLayer { policy }
FollowRedirectLayer {
policy,
preserve_extensions: true,
}
}

/// Whether request [`Extensions`] are carried over to redirected requests. Defaults to `true`.
///
/// Setting this to `false` drops all extensions on redirected requests. When preserved, the
/// [`policy`] still filters them via [`Policy::on_request`]; the [`Standard`] policy drops
/// extensions cross-origin (see [`FilterCredentials`][policy::FilterCredentials]).
pub fn preserve_extensions(mut self, preserve: bool) -> Self {
self.preserve_extensions = preserve;
self
}
}

impl<P: Default> Default for FollowRedirectLayer<P> {
fn default() -> Self {
FollowRedirectLayer::with_policy(P::default())
}
}

Expand All @@ -147,6 +172,7 @@ where

fn layer(&self, inner: S) -> Self::Service {
FollowRedirect::with_policy(inner, self.policy.clone())
.preserve_extensions(self.preserve_extensions)
}
}

Expand All @@ -157,6 +183,7 @@ where
pub struct FollowRedirect<S, P = Standard> {
inner: S,
policy: P,
preserve_extensions: bool,
}

impl<S> FollowRedirect<S> {
Expand All @@ -179,7 +206,11 @@ where
{
/// Create a new [`FollowRedirect`] with the given redirection [`Policy`].
pub fn with_policy(inner: S, policy: P) -> Self {
FollowRedirect { inner, policy }
FollowRedirect {
inner,
policy,
preserve_extensions: true,
}
}

/// Returns a new [`Layer`] that wraps services with a `FollowRedirect` middleware
Expand All @@ -193,6 +224,16 @@ where
define_inner_service_accessors!();
}

impl<S, P> FollowRedirect<S, P> {
/// Whether request [`Extensions`] are carried over to redirected requests. Defaults to `true`.
///
/// See [`FollowRedirectLayer::preserve_extensions`].
pub fn preserve_extensions(mut self, preserve: bool) -> Self {
self.preserve_extensions = preserve;
self
}
}

impl<ReqBody, ResBody, S, P> Service<Request<ReqBody>> for FollowRedirect<S, P>
where
S: Service<Request<ReqBody>, Response = Response<ResBody>> + Clone,
Expand All @@ -214,11 +255,18 @@ where
let mut body = BodyRepr::None;
body.try_clone_from(req.body(), &policy);
policy.on_request(&mut req);
// Snapshot the extensions to replay on redirected requests (empty when not preserving).
let extensions = if self.preserve_extensions {
req.extensions().clone()
} else {
Extensions::new()
};
ResponseFuture {
method: req.method().clone(),
uri: req.uri().clone(),
version: req.version(),
headers: req.headers().clone(),
extensions,
body,
future: Either::Left(service.call(req)),
service,
Expand All @@ -242,6 +290,7 @@ pin_project! {
uri: Uri,
version: Version,
headers: HeaderMap<HeaderValue>,
extensions: Extensions,
body: BodyRepr<B>,
}
}
Expand Down Expand Up @@ -325,7 +374,12 @@ where
*req.method_mut() = this.method.clone();
*req.version_mut() = *this.version;
*req.headers_mut() = this.headers.clone();
*req.extensions_mut() = this.extensions.clone();
this.policy.on_request(&mut req);
// Carry the filtered headers and extensions forward so anything dropped on this
// hop stays dropped on the next one (e.g. credentials after a cross-origin hop).
*this.headers = req.headers().clone();
*this.extensions = req.extensions().clone();
this.future
.set(Either::Right(Oneshot::new(this.service.clone(), req)));

Expand All @@ -337,7 +391,7 @@ where
}
}

/// Response [`Extensions`][http::Extensions] value that represents the effective request URI of
/// Response [`Extensions`] value that represents the effective request URI of
/// a response returned by a [`FollowRedirect`] middleware.
///
/// The value differs from the original request's effective URI if the middleware has followed
Expand Down Expand Up @@ -463,8 +517,153 @@ mod tests {
);
}

#[derive(Clone, Debug, PartialEq)]
struct Marker(u32);

#[tokio::test]
async fn preserves_extensions() {
let svc = ServiceBuilder::new()
.layer(FollowRedirectLayer::new())
.buffer(1)
.service_fn(handle);
let mut req = Request::builder()
.uri("http://example.com/42")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(Marker(7));
let res = svc.oneshot(req).await.unwrap();
// The same-origin redirect chain should carry the extension through to the final request.
assert_eq!(res.extensions().get::<Marker>(), Some(&Marker(7)));
}

#[tokio::test]
async fn preserve_extensions_opt_out() {
let svc = ServiceBuilder::new()
.layer(FollowRedirectLayer::new().preserve_extensions(false))
.buffer(1)
.service_fn(handle);
let mut req = Request::builder()
.uri("http://example.com/42")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(Marker(7));
let res = svc.oneshot(req).await.unwrap();
assert!(res.extensions().get::<Marker>().is_none());
}

#[tokio::test]
async fn drops_extensions_cross_origin() {
let svc = ServiceBuilder::new()
.layer(FollowRedirectLayer::new())
.buffer(1)
.service_fn(cross_origin);
let mut req = Request::builder()
.uri("http://a.example.com/")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(Marker(7));
let res = svc.oneshot(req).await.unwrap();
// The Standard policy treats the cross-origin hop as blocked and drops the extension.
assert!(res.extensions().get::<Marker>().is_none());
assert_eq!(
res.extensions().get::<RequestUri>().unwrap().0,
"http://b.example.com/"
);
}

#[tokio::test]
async fn allowlisted_extension_survives_cross_origin() {
#[derive(Clone, Debug, PartialEq)]
struct Allowed(u32);

let svc = ServiceBuilder::new()
.layer(FollowRedirectLayer::with_policy(
FilterCredentials::new().allow_extension::<Allowed>(),
))
.buffer(1)
.service_fn(cross_origin);
let mut req = Request::builder()
.uri("http://a.example.com/")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(Marker(7));
req.extensions_mut().insert(Allowed(9));
let res = svc.oneshot(req).await.unwrap();
assert!(res.extensions().get::<Marker>().is_none());
assert_eq!(res.extensions().get::<Allowed>(), Some(&Allowed(9)));
}

#[tokio::test]
async fn headers_and_extensions_do_not_resurrect_after_cross_origin() {
let svc = ServiceBuilder::new()
.layer(FollowRedirectLayer::new())
.buffer(1)
.service_fn(resurrection_chain);
let mut req = Request::builder()
.uri("http://a.example.com/")
.header(http::header::COOKIE, "secret")
.body(Body::empty())
.unwrap();
req.extensions_mut().insert(Marker(7));
let res = svc.oneshot(req).await.unwrap();
// The chain is a.example.com -> b.example.com/second (cross-origin, both dropped) ->
// b.example.com/final (same-origin). Neither the cookie nor the extension may reappear on
// the final, same-origin request just because the original snapshot is replayed.
assert_eq!(
res.extensions().get::<RequestUri>().unwrap().0,
"http://b.example.com/final"
);
assert!(res.extensions().get::<Marker>().is_none());
assert!(!res.headers().contains_key("x-saw-cookie"));
}

/// Redirects `a.example.com` to `b.example.com` once, then echoes the final request's
/// extensions back on the response.
async fn cross_origin<B>(req: Request<B>) -> Result<Response<u64>, Infallible> {
let mut res = Response::builder();
if req.uri().host() == Some("a.example.com") {
res = res
.status(StatusCode::MOVED_PERMANENTLY)
.header(LOCATION, "http://b.example.com/");
}
if let Some(extensions) = res.extensions_mut() {
*extensions = req.extensions().clone();
}
Ok::<_, Infallible>(res.body(0).unwrap())
}

/// A three-hop chain: `a.example.com` redirects cross-origin to `b.example.com/second`, which
/// redirects same-origin to `b.example.com/final`. Each response echoes the request's
/// extensions and flags (via the `x-saw-cookie` response header) whether the request still
/// carried a `Cookie`, so a test can detect credentials or extensions reappearing after the
/// cross-origin hop.
async fn resurrection_chain<B>(req: Request<B>) -> Result<Response<u64>, Infallible> {
let location = match (req.uri().host(), req.uri().path()) {
(Some("a.example.com"), _) => Some("http://b.example.com/second"),
(Some("b.example.com"), "/second") => Some("http://b.example.com/final"),
_ => None,
};
let saw_cookie = req.headers().contains_key(http::header::COOKIE);
let mut builder = Response::builder();
if let Some(location) = location {
builder = builder
.status(StatusCode::TEMPORARY_REDIRECT)
.header(LOCATION, location);
}
if let Some(extensions) = builder.extensions_mut() {
*extensions = req.extensions().clone();
}
let mut res = builder.body(0).unwrap();
if saw_cookie {
res.headers_mut()
.insert("x-saw-cookie", HeaderValue::from_static("yes"));
}
Ok::<_, Infallible>(res)
}

/// A server with an endpoint `/{n}` which redirects to `/{n-1}` unless `n` equals zero,
/// returning `n` as the response body.
/// returning `n` as the response body. The request's extensions are echoed back on the
/// response so tests can observe which extensions reached the final request.
async fn handle<B>(req: Request<B>) -> Result<Response<u64>, Infallible> {
let n: u64 = req.uri().path()[1..].parse().unwrap();
let mut res = Response::builder();
Expand All @@ -473,6 +672,9 @@ mod tests {
.status(StatusCode::MOVED_PERMANENTLY)
.header(LOCATION, format!("/{}", n - 1));
}
if let Some(extensions) = res.extensions_mut() {
*extensions = req.extensions().clone();
}
Ok::<_, Infallible>(res.body(n).unwrap())
}

Expand Down
Loading
Loading