1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
//! A redirect policy that keeps a request on the origin it was sent to.
//!
//! reqwest's default follows up to 10 redirects to any destination, and a
//! 307 or 308 re-sends the method and the body. It strips `Authorization` on
//! a cross-host hop but keeps every other header. So a request whose
//! destination was approved by a person, or which carries a credential in a
//! custom header, must not leave the origin it was sent to. One policy, so
//! `http_request`, the remote MCP connectors and the ChatGPT-subscription
//! client all judge an origin the same way.
use reqwest::Url;
/// What [`same_origin`] does with a hop to another origin.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CrossOrigin {
/// Hand the 3xx back to the caller as the response, so the caller can
/// report where it pointed.
Stop,
/// Fail the request with an error naming both origins.
Fail,
/// Hand back the 3xx of a hop that only upgrades the first request from
/// `http` to `https` on the same host (see [`is_https_upgrade`]), and
/// fail on any other origin as [`Fail`](Self::Fail) does. The upgrade
/// reaches the server the caller named over a channel at least as
/// private, so the caller may re-send the request there itself; reqwest
/// cannot follow it for the caller, because it strips `Authorization`
/// on a port change, and the usual upgrade is 80 to 443. `https` to
/// `http` is not an upgrade and fails.
StopAtHttpsUpgrade,
}
/// Follow redirects that stay on the origin (scheme, host and port) of the
/// first request, up to 10 hops, and stop or fail at the first hop that
/// leaves it, as `cross_origin` says. The origin is the first request's,
/// never the previous hop's, so a chain of same-origin hops cannot walk off
/// it.
pub fn same_origin(cross_origin: CrossOrigin) -> reqwest::redirect::Policy {
reqwest::redirect::Policy::custom(move |attempt| {
let first = attempt.previous().first().unwrap_or(attempt.url());
let origin = first.origin();
if attempt.url().origin() != origin {
match cross_origin {
CrossOrigin::Stop => attempt.stop(),
CrossOrigin::StopAtHttpsUpgrade if is_https_upgrade(first, attempt.url()) => {
attempt.stop()
}
CrossOrigin::Fail | CrossOrigin::StopAtHttpsUpgrade => {
let message = format!(
"refusing a redirect from {} to another origin {}",
origin.ascii_serialization(),
attempt.url().origin().ascii_serialization()
);
attempt.error(message)
}
}
} else if attempt.previous().len() > 10 {
attempt.error("too many redirects")
} else {
attempt.follow()
}
})
}
/// Whether `to` is `from` moved from `http` to `https` on the same host, on
/// the same port or from the default 80 to the default 443.
pub fn is_https_upgrade(from: &Url, to: &Url) -> bool {
from.scheme() == "http"
&& to.scheme() == "https"
&& from.host() == to.host()
&& match (from.port_or_known_default(), to.port_or_known_default()) {
(Some(80), Some(443)) => true,
(Some(from_port), Some(to_port)) => from_port == to_port,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn url(text: &str) -> Url {
Url::parse(text).unwrap()
}
/// The ports a local server cannot bind in a test: the default 80 maps
/// to 443, and any other port change is not an upgrade.
#[test]
fn an_https_upgrade_keeps_the_host_and_the_port_or_maps_80_to_443() {
for (from, to) in [
("http://example.com/mcp", "https://example.com/mcp"),
("http://example.com:80/a", "https://example.com:443/b"),
("http://example.com:8080/", "https://example.com:8080/"),
] {
assert!(is_https_upgrade(&url(from), &url(to)), "{from} -> {to}");
}
for (from, to) in [
("https://example.com/", "http://example.com/"),
("http://example.com/", "https://other.example.com/"),
("http://example.com/", "https://example.com:8443/"),
("http://example.com:8080/", "https://example.com/"),
("http://example.com/", "http://example.com:8080/"),
] {
assert!(!is_https_upgrade(&url(from), &url(to)), "{from} -> {to}");
}
}
}