Skip to main content

securitydept_utils/
base_url.rs

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/// Parsed representation of the `oidc_redirect_url_base` config value.
11#[derive(Debug, Clone, Default)]
12pub enum ExternalBaseUrl {
13    /// Infer from request headers at runtime.
14    #[default]
15    Auto,
16    /// Use this fixed URL.
17    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    /// Resolve the external base URL from config + HTTP request headers.
62    ///
63    /// When config is `Auto`, the priority is:
64    ///   1. `Forwarded` header (RFC 7239) — extract `host` and `proto`
65    ///   2. `X-Forwarded-Host` / `X-Forwarded-Proto` (common non-standard)
66    ///   3. `Host` / `:authority` (standard HTTP header)
67    ///   4. Fallback to `http://{bind_host}:{bind_port}`
68    ///
69    /// When config is `Fixed(url)`, just return that URL.
70    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
87/// HTTP/2 `:authority` pseudo-header name. Only present when the `http` crate
88/// accepts it.
89static 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
111/// Infer external base URL from request headers.
112///
113/// Each source yields (host, protocol) independently; we take the first
114/// non-None host and first non-None protocol by priority, then infer protocol
115/// from host if still missing, then fallback to bind address.
116fn 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
145/// Forwarded (RFC 7239): (host, protocol). Uses first node; strips quotes per
146/// §4.
147fn 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
165/// X-Forwarded-Host / X-Forwarded-Proto: (host, protocol). Proto is None if
166/// header missing.
167fn 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
181/// Host / :authority: (host, None). Host is HTTP/1.1; :authority is the HTTP/2
182/// pseudo-header. Protocol cannot be inferred from these alone.
183fn 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
193/// When protocol is missing (e.g. only Host header), infer from host: loopback
194/// → http, else https.
195fn 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    // Strip port if present
217    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        // Default to https when proto is missing
281        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, // http crate does not accept :authority
428        };
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}