1use http::{
2 HeaderMap, Method, StatusCode,
3 header::{AUTHORIZATION, COOKIE, HOST, HeaderValue, LOCATION},
4 request::Parts,
5 uri::{PathAndQuery, Uri},
6};
7use motore::{layer::Layer, service::Service};
8use volo::{client::Apply, context::Context};
9
10use crate::{
11 body::Body,
12 client::{Target, target::RemoteHost},
13 context::ClientContext,
14 error::{
15 ClientError,
16 client::{Result, body_not_replayable, invalid_redirect_location, too_many_redirects},
17 },
18 request::Request,
19 response::Response,
20};
21
22const DEFAULT_MAX_REDIRECTS: usize = 10;
23
24pub trait RedirectPredicate: Clone + Send + Sync + 'static {
31 fn should_follow(
33 &self,
34 target: &Target,
35 method: &Method,
36 uri: &Uri,
37 headers: &HeaderMap,
38 ) -> bool;
39}
40
41#[derive(Clone, Copy, Debug, Default)]
43pub struct AlwaysRedirect;
44
45impl RedirectPredicate for AlwaysRedirect {
46 fn should_follow(&self, _: &Target, _: &Method, _: &Uri, _: &HeaderMap) -> bool {
47 true
48 }
49}
50
51impl<F> RedirectPredicate for F
52where
53 F: Fn(&Target, &Method, &Uri, &HeaderMap) -> bool + Clone + Send + Sync + 'static,
54{
55 fn should_follow(
56 &self,
57 target: &Target,
58 method: &Method,
59 uri: &Uri,
60 headers: &HeaderMap,
61 ) -> bool {
62 self(target, method, uri, headers)
63 }
64}
65
66#[derive(Clone, Copy, Debug)]
83pub struct FollowRedirect<P = AlwaysRedirect> {
84 max_redirects: usize,
85 predicate: P,
86}
87
88impl FollowRedirect<AlwaysRedirect> {
89 pub const fn new(max_redirects: usize) -> Self {
93 Self {
94 max_redirects,
95 predicate: AlwaysRedirect,
96 }
97 }
98}
99
100impl<P> FollowRedirect<P> {
101 pub fn when<Next>(self, predicate: Next) -> FollowRedirect<Next>
108 where
109 Next: RedirectPredicate,
110 {
111 FollowRedirect {
112 max_redirects: self.max_redirects,
113 predicate,
114 }
115 }
116
117 pub const fn max_redirects(&self) -> usize {
119 self.max_redirects
120 }
121}
122
123impl Default for FollowRedirect<AlwaysRedirect> {
124 fn default() -> Self {
125 Self::new(DEFAULT_MAX_REDIRECTS)
126 }
127}
128
129impl<S, P> Layer<S> for FollowRedirect<P>
130where
131 P: RedirectPredicate,
132{
133 type Service = FollowRedirectService<S, P>;
134
135 fn layer(self, inner: S) -> Self::Service {
136 FollowRedirectService {
137 inner,
138 max_redirects: self.max_redirects,
139 predicate: self.predicate,
140 }
141 }
142}
143
144pub struct FollowRedirectService<S, P = AlwaysRedirect> {
148 inner: S,
149 max_redirects: usize,
150 predicate: P,
151}
152
153impl<S, P, B, RespBody> Service<ClientContext, Request<B>> for FollowRedirectService<S, P>
154where
155 P: RedirectPredicate,
156 B: Into<Body> + Send,
157 S: Service<ClientContext, Request<Body>, Response = Response<RespBody>, Error = ClientError>
158 + Send
159 + Sync,
160{
161 type Response = Response<RespBody>;
162 type Error = ClientError;
163
164 async fn call(&self, cx: &mut ClientContext, req: Request<B>) -> Result<Self::Response> {
165 if self.max_redirects == 0
168 || !self
169 .predicate
170 .should_follow(cx.target(), req.method(), req.uri(), req.headers())
171 {
172 let (parts, body) = req.into_parts();
173 return self
174 .inner
175 .call(cx, Request::from_parts(parts, body.into()))
176 .await;
177 }
178
179 let (mut parts, body) = req.into_parts();
180 let mut body: Body = body.into();
181 let mut redirects = 0usize;
182
183 loop {
184 let (this_body, can_replay_body) = match body.try_clone() {
185 Some(retained) => (std::mem::replace(&mut body, retained), true),
186 None => (std::mem::take(&mut body), false),
187 };
188
189 let req = Request::from_parts(parts.clone(), this_body);
190 let resp = self.inner.call(cx, req).await?;
191 let status = resp.status();
192
193 if !status_is_redirect(status) {
194 return Ok(resp);
195 }
196
197 if redirects >= self.max_redirects {
198 return Err(too_many_redirects());
199 }
200
201 let location = match resp.headers().get(LOCATION) {
202 Some(location) => location.clone(),
203 None => return Ok(resp),
204 };
205
206 let old_target = cx.target().clone();
207 let (target, uri) = resolve_redirect(&old_target, &parts.uri, &location)
208 .ok_or_else(invalid_redirect_location)?;
209
210 let changes_to_get = should_change_to_get(status, &parts.method);
214 if changes_to_get {
215 change_to_get(&mut parts);
216 body = Body::empty();
217 }
218 parts.uri = uri;
219 remove_sensitive_headers_on_origin_change(&mut parts, &old_target, &target);
220 update_host(&mut parts, &target);
221 super::utils::update_request_extension(&mut parts.extensions, &target);
222
223 if !self
227 .predicate
228 .should_follow(&target, &parts.method, &parts.uri, &parts.headers)
229 {
230 return Ok(resp);
231 }
232 if !changes_to_get && !can_replay_body {
234 return Err(body_not_replayable());
235 }
236
237 redirects += 1;
238 update_target(cx, target)?;
239 }
240 }
241}
242
243fn update_target(cx: &mut ClientContext, target: Target) -> Result<()> {
244 cx.rpc_info_mut().callee_mut().clear();
245 target.apply(cx)
246}
247
248fn update_host(parts: &mut Parts, target: &Target) {
249 if parts.headers.contains_key(HOST) {
250 parts.headers.remove(HOST);
251 if let Some(host) = super::header::gen_host(target) {
252 parts.headers.insert(HOST, host);
253 }
254 }
255}
256
257fn remove_sensitive_headers_on_origin_change(
258 parts: &mut Parts,
259 old_target: &Target,
260 new_target: &Target,
261) {
262 if origin_changed(old_target, new_target) {
263 parts.headers.remove(AUTHORIZATION);
264 parts.headers.remove(COOKIE);
265 }
266}
267
268fn origin_changed(old_target: &Target, new_target: &Target) -> bool {
269 match (old_target.remote_ref(), new_target.remote_ref()) {
270 (Some(old), Some(new)) => {
271 old.scheme != new.scheme
272 || old.port != new.port
273 || remote_host_changed(&old.host, &new.host)
274 }
275 _ => true,
276 }
277}
278
279fn remote_host_changed(old_host: &RemoteHost, new_host: &RemoteHost) -> bool {
280 match (old_host, new_host) {
281 (RemoteHost::Ip(old), RemoteHost::Ip(new)) => old != new,
282 (RemoteHost::Name(old), RemoteHost::Name(new)) => {
283 !old.as_str().eq_ignore_ascii_case(new.as_str())
284 }
285 _ => true,
286 }
287}
288
289fn change_to_get(parts: &mut Parts) {
290 parts.method = Method::GET;
291 parts.headers.remove(http::header::CONTENT_TYPE);
292 parts.headers.remove(http::header::CONTENT_LENGTH);
293 parts.headers.remove(http::header::CONTENT_ENCODING);
294 parts.headers.remove(http::header::TRANSFER_ENCODING);
295}
296
297fn status_is_redirect(status: StatusCode) -> bool {
298 matches!(
299 status,
300 StatusCode::MOVED_PERMANENTLY
301 | StatusCode::FOUND
302 | StatusCode::SEE_OTHER
303 | StatusCode::TEMPORARY_REDIRECT
304 | StatusCode::PERMANENT_REDIRECT
305 )
306}
307
308fn should_change_to_get(status: StatusCode, method: &Method) -> bool {
309 match status {
310 StatusCode::SEE_OTHER => method != Method::HEAD,
311 StatusCode::MOVED_PERMANENTLY | StatusCode::FOUND => method == Method::POST,
312 _ => false,
313 }
314}
315
316fn resolve_redirect(
317 target: &Target,
318 current_uri: &Uri,
319 location: &HeaderValue,
320) -> Option<(Target, Uri)> {
321 let location = location.to_str().ok()?;
322 let base = url::Url::parse(&format!("{target}{current_uri}")).ok()?;
323 let resolved = base.join(location).ok()?;
324
325 let uri: Uri = resolved.as_str().parse().ok()?;
326 let target = Target::from_uri(&uri).ok()?;
327 let path_and_query = uri
328 .path_and_query()
329 .map(PathAndQuery::to_owned)
330 .unwrap_or_else(|| PathAndQuery::from_static("/"))
331 .into();
332
333 Some((target, path_and_query))
334}
335
336#[cfg(test)]
337mod tests {
338 use std::sync::{Arc, Mutex};
339
340 use bytes::Bytes;
341 use http::{HeaderMap, Method, StatusCode, Uri, header};
342 use http_body::Frame;
343 use motore::service::Service;
344
345 use crate::{
346 ClientBuilder,
347 body::{Body, BodyConversion},
348 client::{Client, Target, layer::header::Host, test_helpers::MockTransport},
349 context::ClientContext,
350 error::ClientError,
351 request::{Request, RequestPartsExt},
352 response::Response,
353 };
354
355 #[derive(Clone, Debug, PartialEq, Eq)]
356 struct Hop {
357 target: String,
358 path: String,
359 host: Option<String>,
360 url_scheme: Option<String>,
361 authorization: Option<String>,
362 proxy_authorization: Option<String>,
363 cookie: Option<String>,
364 method: Method,
365 body: String,
366 }
367
368 #[derive(Clone, Default)]
369 struct RedirectMock {
370 hops: Arc<Mutex<Vec<Hop>>>,
371 }
372
373 impl RedirectMock {
374 fn hops(&self) -> Vec<Hop> {
375 self.hops.lock().unwrap().clone()
376 }
377 }
378
379 fn redirect_to(location: &str, status: StatusCode) -> Response {
380 let mut resp = Response::new(Body::empty());
381 *resp.status_mut() = status;
382 resp.headers_mut()
383 .insert(header::LOCATION, location.parse().unwrap());
384 resp
385 }
386
387 fn ok_with(body: &'static str) -> Response {
388 Response::new(Body::from(body))
389 }
390
391 impl Service<ClientContext, Request> for RedirectMock {
392 type Response = Response;
393 type Error = ClientError;
394
395 async fn call(
396 &self,
397 cx: &mut ClientContext,
398 req: Request,
399 ) -> Result<Self::Response, Self::Error> {
400 let target = cx.target().to_string();
401 let path = req.uri().path().to_owned();
402 let host = req
403 .headers()
404 .get(header::HOST)
405 .map(|value| value.to_str().unwrap().to_owned());
406 let authorization = req
407 .headers()
408 .get(header::AUTHORIZATION)
409 .map(|value| value.to_str().unwrap().to_owned());
410 let proxy_authorization = req
411 .headers()
412 .get(header::PROXY_AUTHORIZATION)
413 .map(|value| value.to_str().unwrap().to_owned());
414 let cookie = req
415 .headers()
416 .get(header::COOKIE)
417 .map(|value| value.to_str().unwrap().to_owned());
418 let method = req.method().clone();
419 let url_scheme = req.url().map(|url| url.scheme().to_owned());
420 let body = req.into_body().into_string().await.unwrap();
421
422 self.hops.lock().unwrap().push(Hop {
423 target: target.clone(),
424 path: path.clone(),
425 host,
426 url_scheme,
427 authorization,
428 proxy_authorization,
429 cookie,
430 method: method.clone(),
431 body: body.clone(),
432 });
433
434 let resp = match (target.as_str(), path.as_str()) {
435 ("http://first.local", "/cross") => {
436 redirect_to("http://second.local/landing", StatusCode::FOUND)
437 }
438 ("http://second.local", "/landing") => ok_with("cross-host"),
439 ("https://secure.local", "/to-http") => {
440 redirect_to("http://plain.local/final", StatusCode::FOUND)
441 }
442 ("http://plain.local", "/to-https") => {
443 redirect_to("https://secure.local/final", StatusCode::FOUND)
444 }
445 ("http://plain.local", "/final") => ok_with("plain"),
446 ("https://secure.local", "/final") => ok_with("secure"),
447 (_, "/see-other") => redirect_to("/method", StatusCode::SEE_OTHER),
448 (_, "/temporary") => redirect_to("/method", StatusCode::TEMPORARY_REDIRECT),
449 (_, "/relative") => redirect_to("final", StatusCode::FOUND),
450 (_, "/final") => ok_with("relative"),
451 (_, "/method") => ok_with(if method == Method::GET && body.is_empty() {
452 "GET:"
453 } else if method == Method::POST && body == "payload" {
454 "POST:payload"
455 } else {
456 "unexpected"
457 }),
458 (_, "/loop") => redirect_to("/loop", StatusCode::FOUND),
459 (_, "/no-location") => {
460 let mut resp = Response::new(Body::empty());
461 *resp.status_mut() = StatusCode::FOUND;
462 resp
463 }
464 (_, "/bad-location") => redirect_to("ftp://example.com/next", StatusCode::FOUND),
465 _ => ok_with("default"),
466 };
467 Ok(resp)
468 }
469 }
470
471 #[tokio::test]
472 async fn follows_relative_location() {
473 let mock = RedirectMock::default();
474 let client = mock_client(&mock, 10);
475
476 let body = client
477 .get("http://host.local/relative")
478 .send()
479 .await
480 .unwrap()
481 .into_string()
482 .await
483 .unwrap();
484
485 assert_eq!(body, "relative");
486 assert_eq!(
487 mock.hops()
488 .into_iter()
489 .map(|hop| hop.path)
490 .collect::<Vec<_>>(),
491 vec!["/relative".to_owned(), "/final".to_owned()]
492 );
493 }
494
495 fn mock_client(mock: &RedirectMock, max_redirects: usize) -> Client {
496 ClientBuilder::new()
497 .follow_redirects(max_redirects)
498 .mock(MockTransport::service(mock.clone()))
499 .unwrap()
500 }
501
502 fn mock_client_with_host(mock: &RedirectMock, max_redirects: usize) -> Client {
503 ClientBuilder::new()
504 .follow_redirects(max_redirects)
505 .layer_outer_front(Host::Auto)
506 .mock(MockTransport::service(mock.clone()))
507 .unwrap()
508 }
509
510 fn predicate_client<P>(mock: &RedirectMock, predicate: P) -> Client
511 where
512 P: Fn(&Target, &Method, &Uri, &HeaderMap) -> bool + Clone + Send + Sync + 'static,
513 {
514 ClientBuilder::new()
515 .follow_redirects_when(10, predicate)
516 .mock(MockTransport::service(mock.clone()))
517 .unwrap()
518 }
519
520 #[tokio::test]
521 async fn predicate_skips_initial_request() {
522 let mock = RedirectMock::default();
523 let client = predicate_client(&mock, |_: &Target, _: &Method, uri: &Uri, _: &HeaderMap| {
524 uri.path() == "/allowed"
525 });
526
527 let resp = client
528 .get("http://host.local/relative")
529 .send()
530 .await
531 .unwrap();
532
533 assert_eq!(resp.status(), StatusCode::FOUND);
534 assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "final");
535 assert_eq!(mock.hops().len(), 1);
536 }
537
538 #[tokio::test]
539 async fn predicate_stops_before_disallowed_next_hop() {
540 let mock = RedirectMock::default();
541 let client = predicate_client(
542 &mock,
543 |target: &Target, _: &Method, _: &Uri, _: &HeaderMap| {
544 target.to_string() == "http://first.local"
545 },
546 );
547
548 let resp = client.get("http://first.local/cross").send().await.unwrap();
549
550 assert_eq!(resp.status(), StatusCode::FOUND);
551 assert_eq!(
552 resp.headers().get(header::LOCATION).unwrap(),
553 "http://second.local/landing"
554 );
555 assert_eq!(mock.hops().len(), 1);
556 }
557
558 #[tokio::test]
559 async fn follows_cross_host_and_updates_host() {
560 let mock = RedirectMock::default();
561 let client = mock_client_with_host(&mock, 10);
562
563 let body = client
564 .get("http://first.local/cross")
565 .header(header::AUTHORIZATION, "Bearer first")
566 .header(header::PROXY_AUTHORIZATION, "Basic proxy")
567 .header(header::COOKIE, "session=first")
568 .send()
569 .await
570 .unwrap()
571 .into_string()
572 .await
573 .unwrap();
574
575 assert_eq!(body, "cross-host");
576 assert_eq!(
577 mock.hops(),
578 vec![
579 Hop {
580 target: "http://first.local".to_owned(),
581 path: "/cross".to_owned(),
582 host: Some("first.local".to_owned()),
583 url_scheme: Some("http".to_owned()),
584 authorization: Some("Bearer first".to_owned()),
585 proxy_authorization: Some("Basic proxy".to_owned()),
586 cookie: Some("session=first".to_owned()),
587 method: Method::GET,
588 body: String::new(),
589 },
590 Hop {
591 target: "http://second.local".to_owned(),
592 path: "/landing".to_owned(),
593 host: Some("second.local".to_owned()),
594 url_scheme: Some("http".to_owned()),
595 authorization: None,
596 proxy_authorization: Some("Basic proxy".to_owned()),
597 cookie: None,
598 method: Method::GET,
599 body: String::new(),
600 },
601 ]
602 );
603 }
604
605 #[tokio::test]
606 async fn see_other_switches_to_get_and_drops_body() {
607 let mock = RedirectMock::default();
608 let client = mock_client(&mock, 10);
609
610 let body = client
611 .post("http://host.local/see-other")
612 .data("payload")
613 .send()
614 .await
615 .unwrap()
616 .into_string()
617 .await
618 .unwrap();
619
620 assert_eq!(body, "GET:");
621 }
622
623 #[tokio::test]
624 async fn temporary_redirect_preserves_method_and_body() {
625 let mock = RedirectMock::default();
626 let client = mock_client(&mock, 10);
627
628 let body = client
629 .post("http://host.local/temporary")
630 .data("payload")
631 .send()
632 .await
633 .unwrap()
634 .into_string()
635 .await
636 .unwrap();
637
638 assert_eq!(body, "POST:payload");
639 }
640
641 #[tokio::test]
642 async fn streaming_body_redirect_errors() {
643 let mock = RedirectMock::default();
644 let client = mock_client(&mock, 10);
645 let stream = futures_util::stream::once(async {
646 Ok::<_, crate::error::BoxError>(Frame::data(Bytes::from_static(b"payload")))
647 });
648
649 let err = client
650 .post("http://host.local/temporary")
651 .body(Body::from_stream(stream))
652 .send()
653 .await
654 .unwrap_err();
655
656 assert!(
657 err.to_string()
658 .contains("request body is not replayable for redirect"),
659 "got: {err}"
660 );
661 assert_eq!(mock.hops().len(), 1);
662 }
663
664 #[tokio::test]
665 async fn zero_redirects_returns_redirect_response() {
666 let mock = RedirectMock::default();
667 let client = mock_client(&mock, 0);
668
669 let resp = client
670 .get("http://host.local/relative")
671 .send()
672 .await
673 .unwrap();
674
675 assert_eq!(resp.status(), StatusCode::FOUND);
676 assert_eq!(resp.headers().get(header::LOCATION).unwrap(), "final");
677 assert_eq!(mock.hops().len(), 1);
678 }
679
680 #[tokio::test]
681 async fn too_many_redirects_errors() {
682 let mock = RedirectMock::default();
683 let client = mock_client(&mock, 2);
684
685 let err = client
686 .get("http://host.local/loop")
687 .send()
688 .await
689 .unwrap_err();
690
691 assert!(err.to_string().contains("too many redirects"), "got: {err}");
692 assert_eq!(mock.hops().len(), 3);
693 }
694
695 #[tokio::test]
696 async fn missing_location_returns_redirect_response() {
697 let mock = RedirectMock::default();
698 let client = mock_client(&mock, 10);
699
700 let resp = client
701 .get("http://host.local/no-location")
702 .send()
703 .await
704 .unwrap();
705
706 assert_eq!(resp.status(), StatusCode::FOUND);
707 assert!(resp.headers().get(header::LOCATION).is_none());
708 assert_eq!(mock.hops().len(), 1);
709 }
710
711 #[tokio::test]
712 async fn invalid_location_errors() {
713 let mock = RedirectMock::default();
714 let client = mock_client(&mock, 10);
715
716 let err = client
717 .get("http://host.local/bad-location")
718 .send()
719 .await
720 .unwrap_err();
721
722 assert!(
723 err.to_string()
724 .contains("invalid Location header in redirect response"),
725 "got: {err}"
726 );
727 assert_eq!(mock.hops().len(), 1);
728 }
729
730 #[cfg(feature = "__tls")]
731 #[tokio::test]
732 async fn redirect_updates_request_scheme_from_https_to_http() {
733 let mock = RedirectMock::default();
734 let client = mock_client_with_host(&mock, 10);
735
736 let body = client
737 .get("https://secure.local/to-http")
738 .send()
739 .await
740 .unwrap()
741 .into_string()
742 .await
743 .unwrap();
744
745 assert_eq!(body, "plain");
746 assert_eq!(
747 mock.hops()
748 .into_iter()
749 .map(|hop| hop.url_scheme)
750 .collect::<Vec<_>>(),
751 vec![Some("https".to_owned()), Some("http".to_owned())]
752 );
753 }
754
755 #[cfg(feature = "__tls")]
756 #[tokio::test]
757 async fn redirect_updates_request_scheme_from_http_to_https() {
758 let mock = RedirectMock::default();
759 let client = mock_client_with_host(&mock, 10);
760
761 let body = client
762 .get("http://plain.local/to-https")
763 .send()
764 .await
765 .unwrap()
766 .into_string()
767 .await
768 .unwrap();
769
770 assert_eq!(body, "secure");
771 assert_eq!(
772 mock.hops()
773 .into_iter()
774 .map(|hop| hop.url_scheme)
775 .collect::<Vec<_>>(),
776 vec![Some("http".to_owned()), Some("https".to_owned())]
777 );
778 }
779
780 #[cfg(feature = "http1")]
781 mod http_proxy_redirect_tests {
782 use super::*;
783 use crate::client::layer::http_proxy::HttpProxy;
784
785 #[derive(Clone, Debug, PartialEq, Eq)]
786 struct ProxyHop {
787 dial_target: String,
788 uri: String,
789 host: Option<String>,
790 authorization: Option<String>,
791 proxy_authorization: Option<String>,
792 cookie: Option<String>,
793 }
794
795 #[derive(Clone, Default)]
796 struct ProxyRedirectMock {
797 hops: Arc<Mutex<Vec<ProxyHop>>>,
798 }
799
800 impl ProxyRedirectMock {
801 fn hops(&self) -> Vec<ProxyHop> {
802 self.hops.lock().unwrap().clone()
803 }
804 }
805
806 impl Service<ClientContext, Request> for ProxyRedirectMock {
807 type Response = Response;
808 type Error = ClientError;
809
810 async fn call(
811 &self,
812 cx: &mut ClientContext,
813 req: Request,
814 ) -> Result<Self::Response, Self::Error> {
815 let dial_target = cx.target().to_string();
816 let uri = req.uri().to_string();
817 let upstream_host = req.uri().host().map(str::to_owned);
818 let path = req.uri().path().to_owned();
819 let host = req
820 .headers()
821 .get(header::HOST)
822 .map(|value| value.to_str().unwrap().to_owned());
823 let authorization = req
824 .headers()
825 .get(header::AUTHORIZATION)
826 .map(|value| value.to_str().unwrap().to_owned());
827 let proxy_authorization = req
828 .headers()
829 .get(header::PROXY_AUTHORIZATION)
830 .map(|value| value.to_str().unwrap().to_owned());
831 let cookie = req
832 .headers()
833 .get(header::COOKIE)
834 .map(|value| value.to_str().unwrap().to_owned());
835
836 self.hops.lock().unwrap().push(ProxyHop {
837 dial_target,
838 uri,
839 host,
840 authorization,
841 proxy_authorization,
842 cookie,
843 });
844
845 Ok(match (upstream_host.as_deref(), path.as_str()) {
846 (Some("first.local"), "/relative") => redirect_to("final", StatusCode::FOUND),
847 (Some("first.local"), "/final") => ok_with("relative-via-proxy"),
848 (Some("first.local"), "/cross") => {
849 redirect_to("http://second.local/landing", StatusCode::FOUND)
850 }
851 (Some("second.local"), "/landing") => ok_with("cross-via-proxy"),
852 (Some("first.local"), "/to-https") => {
853 redirect_to("https://secure.local/final", StatusCode::FOUND)
854 }
855 _ => ok_with("unexpected"),
856 })
857 }
858 }
859
860 fn proxy_then_redirect_client(mock: &ProxyRedirectMock) -> Client {
861 ClientBuilder::new()
862 .layer_outer(HttpProxy::new("http://proxy.local:8080"))
863 .follow_redirects(10)
864 .layer_outer_front(Host::Auto)
865 .mock(MockTransport::service(mock.clone()))
866 .unwrap()
867 }
868
869 fn redirect_then_proxy_client(mock: &ProxyRedirectMock) -> Client {
870 ClientBuilder::new()
871 .follow_redirects(10)
872 .layer_outer(HttpProxy::new("http://proxy.local:8080"))
873 .layer_outer_front(Host::Auto)
874 .mock(MockTransport::service(mock.clone()))
875 .unwrap()
876 }
877
878 async fn assert_relative_redirect_via_proxy(client: Client, mock: &ProxyRedirectMock) {
879 let body = client
880 .get("http://first.local/relative")
881 .header(header::PROXY_AUTHORIZATION, "Basic proxy")
882 .send()
883 .await
884 .unwrap()
885 .into_string()
886 .await
887 .unwrap();
888
889 assert_eq!(body, "relative-via-proxy");
890 assert_eq!(
891 mock.hops(),
892 vec![
893 ProxyHop {
894 dial_target: "http://proxy.local:8080".to_owned(),
895 uri: "http://first.local/relative".to_owned(),
896 host: Some("first.local".to_owned()),
897 authorization: None,
898 proxy_authorization: Some("Basic proxy".to_owned()),
899 cookie: None,
900 },
901 ProxyHop {
902 dial_target: "http://proxy.local:8080".to_owned(),
903 uri: "http://first.local/final".to_owned(),
904 host: Some("first.local".to_owned()),
905 authorization: None,
906 proxy_authorization: Some("Basic proxy".to_owned()),
907 cookie: None,
908 },
909 ]
910 );
911 }
912
913 #[tokio::test]
914 async fn relative_redirect_uses_proxy_when_proxy_is_added_first() {
915 let mock = ProxyRedirectMock::default();
916 assert_relative_redirect_via_proxy(proxy_then_redirect_client(&mock), &mock).await;
917 }
918
919 #[tokio::test]
920 async fn relative_redirect_uses_proxy_when_redirect_is_added_first() {
921 let mock = ProxyRedirectMock::default();
922 assert_relative_redirect_via_proxy(redirect_then_proxy_client(&mock), &mock).await;
923 }
924
925 #[tokio::test]
926 async fn cross_origin_redirect_via_proxy_strips_origin_credentials() {
927 let mock = ProxyRedirectMock::default();
928 let client = proxy_then_redirect_client(&mock);
929
930 let body = client
931 .get("http://first.local/cross")
932 .header(header::AUTHORIZATION, "Bearer first")
933 .header(header::PROXY_AUTHORIZATION, "Basic proxy")
934 .header(header::COOKIE, "session=first")
935 .send()
936 .await
937 .unwrap()
938 .into_string()
939 .await
940 .unwrap();
941
942 assert_eq!(body, "cross-via-proxy");
943 let hops = mock.hops();
944 assert_eq!(hops.len(), 2);
945
946 assert_eq!(hops[0].dial_target, "http://proxy.local:8080");
947 assert_eq!(hops[0].uri, "http://first.local/cross");
948 assert_eq!(hops[0].authorization.as_deref(), Some("Bearer first"));
949 assert_eq!(hops[0].cookie.as_deref(), Some("session=first"));
950
951 assert_eq!(hops[1].dial_target, "http://proxy.local:8080");
952 assert_eq!(hops[1].uri, "http://second.local/landing");
953 assert_eq!(hops[1].host.as_deref(), Some("second.local"));
954 assert_eq!(hops[1].authorization, None);
955 assert_eq!(hops[1].cookie, None);
956
957 assert_eq!(hops[1].proxy_authorization.as_deref(), Some("Basic proxy"));
959 }
960
961 #[cfg(feature = "__tls")]
962 #[tokio::test]
963 async fn redirect_to_https_preserves_direct_fallback() {
964 let mock = ProxyRedirectMock::default();
965 let client = proxy_then_redirect_client(&mock);
966
967 client
968 .get("http://first.local/to-https")
969 .send()
970 .await
971 .unwrap();
972
973 let hops = mock.hops();
974 assert_eq!(hops.len(), 2);
975
976 assert_eq!(hops[0].dial_target, "http://proxy.local:8080");
977 assert_eq!(hops[0].uri, "http://first.local/to-https");
978
979 assert_eq!(hops[1].dial_target, "https://secure.local");
980 assert_eq!(hops[1].uri, "/final");
981 assert_eq!(hops[1].host.as_deref(), Some("secure.local"));
982 }
983 }
984}