1use std::{
2 fmt::{self, Display},
3 str::FromStr,
4 sync::OnceLock,
5};
6
7use rfc7239::parse as parse_forwarded;
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9
10#[derive(Debug, Clone, Default)]
12pub enum ExternalBaseUrl {
13 #[default]
15 Auto,
16 Fixed(String),
18}
19
20impl FromStr for ExternalBaseUrl {
21 type Err = std::io::Error;
22
23 fn from_str(value: &str) -> Result<Self, Self::Err> {
24 if value.trim().eq_ignore_ascii_case("auto") {
25 Ok(Self::Auto)
26 } else {
27 Ok(Self::Fixed(value.trim_end_matches('/').to_string()))
28 }
29 }
30}
31
32impl Display for ExternalBaseUrl {
33 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34 match self {
35 ExternalBaseUrl::Auto => write!(f, "auto"),
36 ExternalBaseUrl::Fixed(url) => write!(f, "{}", url),
37 }
38 }
39}
40
41impl<'de> Deserialize<'de> for ExternalBaseUrl {
42 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
43 where
44 D: Deserializer<'de>,
45 {
46 let s = String::deserialize(deserializer)?;
47 s.parse().map_err(serde::de::Error::custom)
48 }
49}
50
51impl Serialize for ExternalBaseUrl {
52 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
53 where
54 S: Serializer,
55 {
56 serializer.serialize_str(&self.to_string())
57 }
58}
59
60impl ExternalBaseUrl {
61 pub fn resolve_url(
71 &self,
72 headers: &http::HeaderMap,
73 fallback_host: &str,
74 fallback_port: u16,
75 ) -> Result<url::Url, url::ParseError> {
76 match self {
77 ExternalBaseUrl::Fixed(url) => url::Url::parse(url),
78 ExternalBaseUrl::Auto => url::Url::parse(&infer_external_base_url_from_headers(
79 headers,
80 fallback_host,
81 fallback_port,
82 )),
83 }
84 }
85}
86
87static AUTHORITY_HEADER_NAME: OnceLock<Option<http::HeaderName>> = OnceLock::new();
90
91fn authority_header_name() -> Option<&'static http::HeaderName> {
92 AUTHORITY_HEADER_NAME
93 .get_or_init(|| http::HeaderName::from_bytes(b":authority").ok())
94 .as_ref()
95}
96
97pub fn resolve_external_base_url(
98 config: &ExternalBaseUrl,
99 headers: &http::HeaderMap,
100 fallback_host: &str,
101 fallback_port: u16,
102) -> String {
103 match config {
104 ExternalBaseUrl::Fixed(url) => url.clone(),
105 ExternalBaseUrl::Auto => {
106 infer_external_base_url_from_headers(headers, fallback_host, fallback_port)
107 }
108 }
109}
110
111fn infer_external_base_url_from_headers(
117 headers: &http::HeaderMap,
118 fallback_host: &str,
119 fallback_port: u16,
120) -> String {
121 let sources: [(Option<String>, Option<String>); 3] = [
122 try_forwarded(headers),
123 try_x_forwarded(headers),
124 try_host_header(headers),
125 ];
126
127 let host_from_headers = sources.iter().find_map(|(h, _)| h.clone());
128 let host = host_from_headers
129 .clone()
130 .unwrap_or_else(|| format_fallback_host(fallback_host, fallback_port));
131
132 let protocol = sources
133 .iter()
134 .find_map(|(_, p)| p.clone())
135 .or_else(|| {
136 host_from_headers
137 .as_ref()
138 .map(|h| infer_protocol_from_host(h).to_string())
139 })
140 .unwrap_or_else(|| "http".to_string());
141
142 format!("{}://{}", protocol, host)
143}
144
145fn try_forwarded(headers: &http::HeaderMap) -> (Option<String>, Option<String>) {
148 let value = match headers
149 .get(http::header::FORWARDED)
150 .and_then(|v| v.to_str().ok())
151 {
152 Some(v) => v,
153 None => return (None, None),
154 };
155 let mut nodes = parse_forwarded(value);
156 let node = match nodes.next().and_then(|r| r.ok()) {
157 Some(n) => n,
158 None => return (None, None),
159 };
160 let host = node.host.map(|s| s.trim_matches('"').to_string());
161 let protocol = node.protocol.map(|s| s.trim_matches('"').to_string());
162 (host, protocol)
163}
164
165fn try_x_forwarded(headers: &http::HeaderMap) -> (Option<String>, Option<String>) {
168 let host = headers
169 .get("x-forwarded-host")
170 .and_then(|v| v.to_str().ok())
171 .map(|s| s.trim().to_string())
172 .filter(|s| !s.is_empty());
173 let protocol = headers
174 .get("x-forwarded-proto")
175 .and_then(|v| v.to_str().ok())
176 .map(|s| s.trim().to_string())
177 .filter(|s| !s.is_empty());
178 (host, protocol)
179}
180
181fn try_host_header(headers: &http::HeaderMap) -> (Option<String>, Option<String>) {
184 let host = headers
185 .get(http::header::HOST)
186 .or_else(|| authority_header_name().and_then(|name| headers.get(name)))
187 .and_then(|v| v.to_str().ok())
188 .map(|s| s.trim().to_string())
189 .filter(|s| !s.is_empty());
190 (host, None)
191}
192
193fn infer_protocol_from_host(host: &str) -> &'static str {
196 if is_loopback_host(host) {
197 "http"
198 } else {
199 "https"
200 }
201}
202
203fn format_fallback_host(host: &str, port: u16) -> String {
204 if is_default_port("http", port) {
205 host.to_string()
206 } else {
207 format!("{}:{}", host, port)
208 }
209}
210
211fn is_default_port(proto: &str, port: u16) -> bool {
212 matches!((proto, port), ("http", 80) | ("https", 443))
213}
214
215fn is_loopback_host(host: &str) -> bool {
216 let hostname = host.split(':').next().unwrap_or(host);
218 matches!(hostname, "localhost" | "127.0.0.1" | "::1" | "[::1]")
219}
220
221#[cfg(test)]
222mod tests {
223 use http::HeaderMap;
224
225 use super::*;
226
227 fn make_fallback() -> (&'static str, u16) {
228 ("0.0.0.0", 7021)
229 }
230
231 #[test]
232 fn fixed_config_ignores_headers() {
233 let config = ExternalBaseUrl::Fixed("https://fixed.example.com".to_string());
234 let headers = HeaderMap::new();
235 let (host, port) = make_fallback();
236 assert_eq!(
237 resolve_external_base_url(&config, &headers, host, port),
238 "https://fixed.example.com"
239 );
240 }
241
242 #[test]
243 fn auto_with_forwarded_header() {
244 let config = ExternalBaseUrl::Auto;
245 let mut headers = HeaderMap::new();
246 headers.insert(
247 "forwarded",
248 "for=192.0.2.60;proto=https;host=example.com"
249 .parse()
250 .unwrap(),
251 );
252 let (host, port) = make_fallback();
253 assert_eq!(
254 resolve_external_base_url(&config, &headers, host, port),
255 "https://example.com"
256 );
257 }
258
259 #[test]
260 fn auto_with_forwarded_header_custom_port() {
261 let config = ExternalBaseUrl::Auto;
262 let mut headers = HeaderMap::new();
263 headers.insert(
264 "forwarded",
265 "proto=https;host=example.com:8443".parse().unwrap(),
266 );
267 let (host, port) = make_fallback();
268 assert_eq!(
269 resolve_external_base_url(&config, &headers, host, port),
270 "https://example.com:8443"
271 );
272 }
273
274 #[test]
275 fn auto_with_forwarded_header_no_proto() {
276 let config = ExternalBaseUrl::Auto;
277 let mut headers = HeaderMap::new();
278 headers.insert("forwarded", "host=example.com".parse().unwrap());
279 let (host, port) = make_fallback();
280 assert_eq!(
282 resolve_external_base_url(&config, &headers, host, port),
283 "https://example.com"
284 );
285 }
286
287 #[test]
288 fn auto_with_x_forwarded_headers() {
289 let config = ExternalBaseUrl::Auto;
290 let mut headers = HeaderMap::new();
291 headers.insert("x-forwarded-host", "proxy.example.com".parse().unwrap());
292 headers.insert("x-forwarded-proto", "https".parse().unwrap());
293 let (host, port) = make_fallback();
294 assert_eq!(
295 resolve_external_base_url(&config, &headers, host, port),
296 "https://proxy.example.com"
297 );
298 }
299
300 #[test]
301 fn auto_with_x_forwarded_host_only() {
302 let config = ExternalBaseUrl::Auto;
303 let mut headers = HeaderMap::new();
304 headers.insert("x-forwarded-host", "proxy.example.com".parse().unwrap());
305 let (host, port) = make_fallback();
306 assert_eq!(
307 resolve_external_base_url(&config, &headers, host, port),
308 "https://proxy.example.com"
309 );
310 }
311
312 #[test]
313 fn auto_with_host_header() {
314 let config = ExternalBaseUrl::Auto;
315 let mut headers = HeaderMap::new();
316 headers.insert(http::header::HOST, "myhost.example.com".parse().unwrap());
317 let (host, port) = make_fallback();
318 assert_eq!(
319 resolve_external_base_url(&config, &headers, host, port),
320 "https://myhost.example.com"
321 );
322 }
323
324 #[test]
325 fn auto_with_localhost_host_header() {
326 let config = ExternalBaseUrl::Auto;
327 let mut headers = HeaderMap::new();
328 headers.insert(http::header::HOST, "localhost:3000".parse().unwrap());
329 let (host, port) = make_fallback();
330 assert_eq!(
331 resolve_external_base_url(&config, &headers, host, port),
332 "http://localhost:3000"
333 );
334 }
335
336 #[test]
337 fn auto_fallback_to_bind_address() {
338 let config = ExternalBaseUrl::Auto;
339 let headers = HeaderMap::new();
340 assert_eq!(
341 resolve_external_base_url(&config, &headers, "0.0.0.0", 7021),
342 "http://0.0.0.0:7021"
343 );
344 }
345
346 #[test]
347 fn auto_fallback_default_port() {
348 let config = ExternalBaseUrl::Auto;
349 let headers = HeaderMap::new();
350 assert_eq!(
351 resolve_external_base_url(&config, &headers, "0.0.0.0", 80),
352 "http://0.0.0.0"
353 );
354 }
355
356 #[test]
357 fn forwarded_takes_priority_over_x_forwarded() {
358 let config = ExternalBaseUrl::Auto;
359 let mut headers = HeaderMap::new();
360 headers.insert(
361 "forwarded",
362 "proto=https;host=rfc.example.com".parse().unwrap(),
363 );
364 headers.insert(
365 "x-forwarded-host",
366 "nonstandard.example.com".parse().unwrap(),
367 );
368 let (host, port) = make_fallback();
369 assert_eq!(
370 resolve_external_base_url(&config, &headers, host, port),
371 "https://rfc.example.com"
372 );
373 }
374
375 #[test]
376 fn x_forwarded_takes_priority_over_host() {
377 let config = ExternalBaseUrl::Auto;
378 let mut headers = HeaderMap::new();
379 headers.insert("x-forwarded-host", "proxy.example.com".parse().unwrap());
380 headers.insert("x-forwarded-proto", "https".parse().unwrap());
381 headers.insert(http::header::HOST, "internal.example.com".parse().unwrap());
382 let (host, port) = make_fallback();
383 assert_eq!(
384 resolve_external_base_url(&config, &headers, host, port),
385 "https://proxy.example.com"
386 );
387 }
388
389 #[test]
390 fn forwarded_with_quoted_values() {
391 let config = ExternalBaseUrl::Auto;
392 let mut headers = HeaderMap::new();
393 headers.insert(
394 "forwarded",
395 "for=\"192.0.2.60\";proto=https;host=\"quoted.example.com\""
396 .parse()
397 .unwrap(),
398 );
399 let (host, port) = make_fallback();
400 assert_eq!(
401 resolve_external_base_url(&config, &headers, host, port),
402 "https://quoted.example.com"
403 );
404 }
405
406 #[test]
407 fn forwarded_chain_uses_first_entry() {
408 let config = ExternalBaseUrl::Auto;
409 let mut headers = HeaderMap::new();
410 headers.insert(
411 "forwarded",
412 "proto=https;host=first.example.com, proto=http;host=second.example.com"
413 .parse()
414 .unwrap(),
415 );
416 let (host, port) = make_fallback();
417 assert_eq!(
418 resolve_external_base_url(&config, &headers, host, port),
419 "https://first.example.com"
420 );
421 }
422
423 #[test]
424 fn authority_used_when_host_absent_if_supported() {
425 let name = match authority_header_name() {
426 Some(n) => n.clone(),
427 None => return, };
429 let config = ExternalBaseUrl::Auto;
430 let mut headers = HeaderMap::new();
431 headers.insert(name, "h2.example.com".parse().unwrap());
432 let (host, port) = make_fallback();
433 assert_eq!(
434 resolve_external_base_url(&config, &headers, host, port),
435 "https://h2.example.com"
436 );
437 }
438
439 #[test]
440 fn host_takes_priority_over_authority() {
441 let config = ExternalBaseUrl::Auto;
442 let mut headers = HeaderMap::new();
443 headers.insert(http::header::HOST, "host.example.com".parse().unwrap());
444 if let Some(name) = authority_header_name() {
445 headers.insert(name.clone(), "authority.example.com".parse().unwrap());
446 }
447 let (host, port) = make_fallback();
448 assert_eq!(
449 resolve_external_base_url(&config, &headers, host, port),
450 "https://host.example.com"
451 );
452 }
453}