Skip to main content

aurum_core/remote/
client.rs

1//! Policy-enforcing HTTP client for remote STT and cleanup (JOE-1587).
2
3use super::status::redact_secret;
4use crate::error::{ProviderError, Result, UserError};
5use crate::postprocess::truncate_chars;
6use reqwest::{Client, Method, RequestBuilder, StatusCode};
7use std::time::Duration;
8use url::Url;
9
10/// Official OpenRouter HTTPS origin (credentialed default).
11pub const DEFAULT_OPENROUTER_ORIGIN: &str = "https://openrouter.ai";
12
13/// Validated remote endpoint with trust classification.
14#[derive(Debug, Clone)]
15pub struct RemoteEndpoint {
16    pub base_url: String,
17    /// True when this is the official OpenRouter origin.
18    pub is_official: bool,
19    /// True when credentials may be sent (official or explicit custom trust).
20    pub credentials_allowed: bool,
21}
22
23/// Policy knobs for building the shared client.
24#[derive(Debug, Clone)]
25pub struct RemotePolicy {
26    /// Connect timeout.
27    pub connect_timeout: Duration,
28    /// Total request timeout.
29    pub total_timeout: Duration,
30    /// When false (default), system proxy is not used.
31    pub use_system_proxy: bool,
32    /// When true, allow custom non-OpenRouter HTTPS endpoints with credentials
33    /// (requires separate config opt-in).
34    pub allow_custom_credentialed_endpoint: bool,
35    /// When true, allow HTTP only for loopback hosts (tests).
36    pub allow_loopback_http: bool,
37}
38
39impl Default for RemotePolicy {
40    fn default() -> Self {
41        Self {
42            connect_timeout: Duration::from_secs(30),
43            total_timeout: Duration::from_secs(600),
44            use_system_proxy: false,
45            allow_custom_credentialed_endpoint: false,
46            allow_loopback_http: false,
47        }
48    }
49}
50
51/// Parse and validate a base URL under the remote policy.
52pub fn validate_endpoint(raw: &str, policy: &RemotePolicy) -> Result<RemoteEndpoint> {
53    let trimmed = raw.trim().trim_end_matches('/');
54    if trimmed.is_empty() {
55        return Err(UserError::InvalidConfig {
56            reason: "remote base URL is empty".into(),
57        }
58        .into());
59    }
60    let url = Url::parse(trimmed).map_err(|e| UserError::InvalidConfig {
61        reason: format!("invalid remote base URL: {e}"),
62    })?;
63
64    if url.username() != "" || url.password().is_some() {
65        return Err(UserError::InvalidConfig {
66            reason: "remote base URL must not embed userinfo/credentials".into(),
67        }
68        .into());
69    }
70
71    let scheme = url.scheme();
72    let host = url.host_str().unwrap_or("").to_ascii_lowercase();
73    let is_loopback = matches!(host.as_str(), "127.0.0.1" | "localhost" | "::1");
74    let is_official = host == "openrouter.ai" && scheme == "https";
75
76    match scheme {
77        "https" => {}
78        "http" if policy.allow_loopback_http && is_loopback => {}
79        "http" => {
80            return Err(UserError::InvalidConfig {
81                reason: format!(
82                    "HTTP remote endpoints are only allowed for loopback test mode (got {trimmed})"
83                ),
84            }
85            .into());
86        }
87        other => {
88            return Err(UserError::InvalidConfig {
89                reason: format!("unsupported URL scheme '{other}' (use https)"),
90            }
91            .into());
92        }
93    }
94
95    let credentials_allowed = is_official
96        || (is_loopback && policy.allow_loopback_http)
97        || (policy.allow_custom_credentialed_endpoint && scheme == "https");
98    if !credentials_allowed {
99        return Err(UserError::InvalidConfig {
100            reason: format!(
101                "credentialed remote endpoint '{trimmed}' is not the official OpenRouter origin.\n  \
102                 Hint: set openrouter.allow_custom_endpoint = true only for trusted compatible APIs, \
103                 or use {DEFAULT_OPENROUTER_ORIGIN}."
104            ),
105        }
106        .into());
107    }
108
109    Ok(RemoteEndpoint {
110        base_url: trimmed.to_string(),
111        is_official,
112        credentials_allowed,
113    })
114}
115
116/// Hardened reqwest client shared by STT and cleanup.
117#[derive(Clone)]
118pub struct HardenedHttpClient {
119    http: Client,
120    endpoint: RemoteEndpoint,
121    policy: RemotePolicy,
122}
123
124impl std::fmt::Debug for HardenedHttpClient {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        f.debug_struct("HardenedHttpClient")
127            .field("base_url", &self.endpoint.base_url)
128            .field("is_official", &self.endpoint.is_official)
129            .finish()
130    }
131}
132
133impl HardenedHttpClient {
134    pub fn build(base_url: Option<&str>, policy: RemotePolicy) -> Result<Self> {
135        let raw = base_url
136            .map(|s| s.trim())
137            .filter(|s| !s.is_empty())
138            .unwrap_or("https://openrouter.ai/api/v1");
139        let endpoint = validate_endpoint(raw, &policy)?;
140
141        let mut builder = Client::builder()
142            .user_agent(concat!("aurum-core/", env!("CARGO_PKG_VERSION")))
143            .connect_timeout(policy.connect_timeout)
144            .timeout(policy.total_timeout)
145            // Never follow redirects with credentials (JOE-1587).
146            .redirect(reqwest::redirect::Policy::none());
147
148        if !policy.use_system_proxy {
149            builder = builder.no_proxy();
150        }
151
152        let http = builder.build().map_err(|e| ProviderError::Network {
153            provider: "remote".into(),
154            reason: e.to_string(),
155        })?;
156
157        Ok(Self {
158            http,
159            endpoint,
160            policy,
161        })
162    }
163
164    pub fn endpoint(&self) -> &RemoteEndpoint {
165        &self.endpoint
166    }
167
168    pub fn base_url(&self) -> &str {
169        &self.endpoint.base_url
170    }
171
172    pub fn policy(&self) -> &RemotePolicy {
173        &self.policy
174    }
175
176    /// Build a request with auth + standard OpenRouter headers.
177    pub fn request(&self, method: Method, path: &str, api_key: &str) -> Result<RequestBuilder> {
178        if !self.endpoint.credentials_allowed {
179            return Err(UserError::InvalidConfig {
180                reason: "credentials are not allowed for this endpoint under current policy".into(),
181            }
182            .into());
183        }
184        let path = path.trim_start_matches('/');
185        let url = format!("{}/{}", self.endpoint.base_url, path);
186        // Reject if path somehow rewrites host (defense in depth).
187        if let Ok(u) = Url::parse(&url) {
188            let base = Url::parse(&self.endpoint.base_url).ok();
189            if let Some(b) = base {
190                if u.origin() != b.origin() {
191                    return Err(UserError::InvalidConfig {
192                        reason: "request URL origin diverged from validated endpoint".into(),
193                    }
194                    .into());
195                }
196            }
197        }
198        Ok(self
199            .http
200            .request(method, url)
201            .header("Authorization", format!("Bearer {api_key}"))
202            .header("HTTP-Referer", "https://github.com/joe-broadhead/aurum")
203            .header("X-Title", "Aurum")
204            .header(
205                "X-Request-Id",
206                format!(
207                    "aurum-{}",
208                    std::time::SystemTime::now()
209                        .duration_since(std::time::UNIX_EPOCH)
210                        .map(|d| d.as_millis())
211                        .unwrap_or(0)
212                ),
213            ))
214    }
215
216    pub fn get_raw(&self) -> &Client {
217        &self.http
218    }
219}
220
221/// Map HTTP status codes to typed provider errors (no payload echo of secrets).
222pub fn map_http_status(provider: &str, status: StatusCode, body: &str) -> Result<()> {
223    let safe = redact_secret(&truncate_chars(body, 300));
224    match status.as_u16() {
225        200..=299 => Ok(()),
226        401 | 403 => Err(ProviderError::Auth {
227            provider: provider.into(),
228            reason: safe,
229        }
230        .into()),
231        429 => Err(ProviderError::RateLimited {
232            provider: provider.into(),
233        }
234        .into()),
235        402 => Err(ProviderError::QuotaExceeded {
236            provider: provider.into(),
237            reason: safe,
238        }
239        .into()),
240        _ => Err(ProviderError::Remote {
241            provider: provider.into(),
242            reason: format!("HTTP {status}: {safe}"),
243        }
244        .into()),
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn official_endpoint_ok() {
254        let ep =
255            validate_endpoint("https://openrouter.ai/api/v1", &RemotePolicy::default()).unwrap();
256        assert!(ep.is_official);
257        assert!(ep.credentials_allowed);
258    }
259
260    #[test]
261    fn foreign_host_rejected_by_default() {
262        let err =
263            validate_endpoint("https://evil.example/api", &RemotePolicy::default()).unwrap_err();
264        assert_eq!(err.exit_code(), 2);
265        assert!(
266            err.to_string().contains("allow_custom_endpoint")
267                || err.to_string().contains("official")
268        );
269    }
270
271    #[test]
272    fn custom_allowed_with_opt_in() {
273        let policy = RemotePolicy {
274            allow_custom_credentialed_endpoint: true,
275            ..Default::default()
276        };
277        let ep = validate_endpoint("https://compatible.example/v1", &policy).unwrap();
278        assert!(!ep.is_official);
279        assert!(ep.credentials_allowed);
280    }
281
282    #[test]
283    fn rejects_userinfo() {
284        let err = validate_endpoint(
285            "https://user:pass@openrouter.ai/api/v1",
286            &RemotePolicy::default(),
287        )
288        .unwrap_err();
289        assert!(err.to_string().contains("userinfo") || err.to_string().contains("credential"));
290    }
291
292    #[test]
293    fn loopback_http_for_tests() {
294        let policy = RemotePolicy {
295            allow_loopback_http: true,
296            ..Default::default()
297        };
298        let ep = validate_endpoint("http://127.0.0.1:9", &policy).unwrap();
299        assert!(ep.credentials_allowed);
300    }
301
302    #[test]
303    fn http_non_loopback_rejected() {
304        assert!(validate_endpoint("http://evil.example", &RemotePolicy::default()).is_err());
305    }
306}