Skip to main content

systemprompt_api/services/
request_base_url.rs

1//! Request-derived base URL for OAuth discovery responses.
2//!
3//! RFC 9728 implementations identify themselves coherently from the host the
4//! client actually dialled. A single gateway reachable via both `127.0.0.1`
5//! and `localhost` must echo whichever the client used in every URL it
6//! returns (`issuer`, `authorization_endpoint`, `token_endpoint`, `resource`…),
7//! otherwise the client's RFC 8707 `resource` indicator won't round-trip
8//! against the configured `api_external_url` origin.
9//!
10//! [`RequestBaseUrl`] is an axum extractor that resolves
11//! `scheme://host[:port]` from the incoming request, validating the host
12//! against a small allowlist seeded from `api_external_url`. On allowlist
13//! miss or missing/invalid header it falls back to `api_external_url` — the
14//! gateway never advertises a hostname an attacker fabricated via Host
15//! header injection.
16//!
17//! Copyright (c) systemprompt.io — Business Source License 1.1.
18//! See <https://systemprompt.io> for licensing details.
19
20use axum::extract::FromRequestParts;
21use http::request::Parts;
22use http::{StatusCode, header};
23use systemprompt_models::Config;
24
25#[derive(Debug, Clone)]
26pub struct RequestBaseUrl {
27    base: String,
28    origin: url::Origin,
29}
30
31impl RequestBaseUrl {
32    #[must_use]
33    pub fn as_str(&self) -> &str {
34        &self.base
35    }
36
37    #[must_use]
38    pub const fn origin(&self) -> &url::Origin {
39        &self.origin
40    }
41
42    #[must_use]
43    pub fn into_string(self) -> String {
44        self.base
45    }
46}
47
48fn is_loopback_host(host: &str) -> bool {
49    let bare = host.split(':').next().unwrap_or(host).to_ascii_lowercase();
50    bare == "localhost" || bare == "127.0.0.1" || bare == "[::1]" || bare == "::1"
51}
52
53fn host_in_allowlist(candidate_host: &str, configured: &url::Url) -> bool {
54    let candidate_bare = candidate_host
55        .rsplit_once(':')
56        .map_or(candidate_host, |(h, _)| h)
57        .to_ascii_lowercase();
58    let configured_host = configured.host_str().unwrap_or("").to_ascii_lowercase();
59
60    if candidate_bare == configured_host {
61        return true;
62    }
63    if is_loopback_host(&configured_host) && is_loopback_host(&candidate_bare) {
64        return true;
65    }
66    false
67}
68
69fn fallback_from_url(configured: &url::Url) -> RequestBaseUrl {
70    let trimmed = configured.as_str().trim_end_matches('/').to_owned();
71    RequestBaseUrl {
72        base: trimmed,
73        origin: configured.origin(),
74    }
75}
76
77#[must_use]
78pub fn resolve(raw_host: Option<&str>, configured: &url::Url) -> RequestBaseUrl {
79    if let Some(host) = raw_host.map(str::trim).filter(|s| !s.is_empty())
80        && let Ok(resolved) = build_from_host(host, configured)
81    {
82        return resolved;
83    }
84    fallback_from_url(configured)
85}
86
87fn build_from_host(raw_host: &str, configured: &url::Url) -> Result<RequestBaseUrl, &'static str> {
88    if raw_host.is_empty() || raw_host.contains('/') || raw_host.contains(' ') {
89        return Err("invalid host header");
90    }
91    if !host_in_allowlist(raw_host, configured) {
92        return Err("host not in allowlist");
93    }
94    let host_bare = raw_host
95        .rsplit_once(':')
96        .map_or(raw_host, |(h, _)| h)
97        .to_ascii_lowercase();
98    let scheme = if is_loopback_host(&host_bare) {
99        "http"
100    } else {
101        configured.scheme()
102    };
103    let base = format!("{scheme}://{raw_host}");
104    let parsed = url::Url::parse(&base).map_err(|_e| "host header did not parse as URL")?;
105    Ok(RequestBaseUrl {
106        base: base.trim_end_matches('/').to_owned(),
107        origin: parsed.origin(),
108    })
109}
110
111impl<S: Send + Sync> FromRequestParts<S> for RequestBaseUrl {
112    type Rejection = (StatusCode, String);
113
114    #[expect(
115        clippy::unused_async_trait_impl,
116        reason = "async signature required by the FromRequestParts trait; this \
117                  extractor resolves the base URL synchronously"
118    )]
119    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
120        let cfg = Config::get().map_err(|e| {
121            tracing::error!(error = %e, "Failed to load config for RequestBaseUrl");
122            (
123                StatusCode::INTERNAL_SERVER_ERROR,
124                "Configuration unavailable".to_owned(),
125            )
126        })?;
127        let configured = url::Url::parse(&cfg.api_external_url).map_err(|e| {
128            tracing::error!(
129                error = %e,
130                api_external_url = %cfg.api_external_url,
131                "api_external_url is not a valid URL — bootstrap validation should have caught this"
132            );
133            (
134                StatusCode::INTERNAL_SERVER_ERROR,
135                "Configuration invalid".to_owned(),
136            )
137        })?;
138
139        let raw_host = parts
140            .headers
141            .get(header::HOST)
142            .and_then(|v| v.to_str().ok());
143        Ok(resolve(raw_host, &configured))
144    }
145}