trillium_client/
into_url.rs1use crate::{Error, Method, Result};
2use std::{
3 borrow::Cow,
4 net::{IpAddr, SocketAddr},
5 str::FromStr,
6};
7use trillium_server_common::url::{ParseError, Url};
8
9pub trait IntoUrl {
11 fn into_url(self, base: Option<&Url>) -> Result<Url>;
13
14 fn request_target(&self, _method: Method) -> Option<Cow<'static, str>> {
17 None
18 }
19}
20
21impl IntoUrl for Url {
22 fn into_url(self, base: Option<&Url>) -> Result<Url> {
23 if self.cannot_be_a_base() {
24 return Err(Error::UnexpectedUriFormat);
25 }
26
27 if base.is_some_and(|base| !self.as_str().starts_with(base.as_str())) {
28 Err(Error::UnexpectedUriFormat)
29 } else {
30 Ok(self)
31 }
32 }
33}
34
35impl IntoUrl for &str {
36 fn into_url(self, base: Option<&Url>) -> Result<Url> {
37 match (Url::from_str(self), base) {
38 (Ok(url), base) => url.into_url(base),
39 (Err(ParseError::RelativeUrlWithoutBase), Some(base)) => base
44 .join(&format!("./{}", self.trim_start_matches('/')))
45 .map_err(|_| Error::UnexpectedUriFormat),
46 _ => Err(Error::UnexpectedUriFormat),
47 }
48 }
49
50 fn request_target(&self, method: Method) -> Option<Cow<'static, str>> {
51 match method {
52 Method::Connect if !self.contains('/') => Url::from_str(&format!("http://{self}"))
53 .ok()
54 .map(|_| Cow::Owned(self.to_string())),
55
56 Method::Options if *self == "*" => Some(Cow::Borrowed("*")),
57 _ => None,
58 }
59 }
60}
61
62impl IntoUrl for String {
63 #[inline(always)]
64 fn into_url(self, base: Option<&Url>) -> Result<Url> {
65 self.as_str().into_url(base)
66 }
67
68 fn request_target(&self, method: Method) -> Option<Cow<'static, str>> {
69 self.as_str().request_target(method)
70 }
71}
72
73impl<S: AsRef<str>> IntoUrl for &[S] {
74 fn into_url(self, base: Option<&Url>) -> Result<Url> {
75 let Some(mut url) = base.cloned() else {
76 return Err(Error::UnexpectedUriFormat);
77 };
78 url.path_segments_mut()
79 .map_err(|_| Error::UnexpectedUriFormat)?
80 .pop_if_empty()
81 .extend(self);
82 Ok(url)
83 }
84}
85
86impl<S: AsRef<str>, const N: usize> IntoUrl for [S; N] {
87 fn into_url(self, base: Option<&Url>) -> Result<Url> {
88 self.as_slice().into_url(base)
89 }
90}
91
92impl<S: AsRef<str>> IntoUrl for Vec<S> {
93 fn into_url(self, base: Option<&Url>) -> Result<Url> {
94 self.as_slice().into_url(base)
95 }
96}
97
98impl IntoUrl for SocketAddr {
99 fn into_url(self, base: Option<&Url>) -> Result<Url> {
100 let scheme = if self.port() == 443 { "https" } else { "http" };
101 format!("{scheme}://{self}").into_url(base)
102 }
103
104 fn request_target(&self, method: Method) -> Option<Cow<'static, str>> {
105 (method == Method::Connect).then(|| self.to_string().into())
106 }
107}
108
109impl IntoUrl for IpAddr {
110 fn into_url(self, base: Option<&Url>) -> Result<Url> {
112 match self {
113 IpAddr::V4(v4) => format!("http://{v4}"),
114 IpAddr::V6(v6) => format!("http://[{v6}]"),
115 }
116 .into_url(base)
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::IntoUrl;
123 use trillium_server_common::url::Url;
124
125 fn base() -> Url {
126 Url::parse("https://something.real/").unwrap()
127 }
128
129 #[test]
136 fn relative_paths_never_escape_the_base_origin() {
137 let base = base();
138 let adversarial = [
139 "/https://sneaky.com",
140 "/https://sneaky.com/path",
141 "//sneaky.com",
142 "//sneaky.com/path",
143 "/\\/sneaky.com",
144 "/\\\\sneaky.com",
145 "/..//sneaky.com",
146 "/trillium::Handler",
147 "/path:with:colons",
148 "/a/b?x=https://sneaky.com",
149 "/@sneaky.com",
150 "/user:pass@sneaky.com",
151 "/foo/../../../etc",
152 "/./..//sneaky.com",
153 "/%2e%2e/sneaky.com",
154 "/normal/path?q=1",
155 "/",
156 ];
157
158 for input in adversarial {
159 if let Ok(url) = input.into_url(Some(&base)) {
160 assert_eq!(
161 url.host_str(),
162 Some("something.real"),
163 "input {input:?} escaped the base origin -> {url}"
164 );
165 assert_eq!(url.scheme(), "https", "input {input:?} -> {url}");
166 assert_eq!(
167 url.port_or_known_default(),
168 base.port_or_known_default(),
169 "input {input:?} -> {url}"
170 );
171 }
172 }
173 }
174
175 #[test]
176 fn colon_in_first_segment_is_preserved_as_a_path() {
177 assert_eq!(
178 "/trillium::Handler"
179 .into_url(Some(&base()))
180 .unwrap()
181 .as_str(),
182 "https://something.real/trillium::Handler"
183 );
184 }
185
186 #[test]
187 fn embedded_absolute_url_becomes_a_path_segment() {
188 assert_eq!(
189 "/https://sneaky.com"
190 .into_url(Some(&base()))
191 .unwrap()
192 .as_str(),
193 "https://something.real/https://sneaky.com"
194 );
195 }
196
197 #[test]
198 fn same_origin_absolute_str_is_allowed() {
199 assert_eq!(
200 "https://something.real/allowed"
201 .into_url(Some(&base()))
202 .unwrap()
203 .as_str(),
204 "https://something.real/allowed"
205 );
206 }
207
208 #[test]
209 fn cross_origin_absolute_str_is_rejected() {
210 assert!("https://sneaky.com/".into_url(Some(&base())).is_err());
211 }
212}