Skip to main content

subx_cli/services/ai/
security.rs

1//! Shared security helpers for AI provider clients.
2//!
3//! These helpers centralize precautionary checks that every AI client should
4//! perform before sending credentials over the wire.
5
6use url::Url;
7
8/// Emit a warning if the parsed `url` uses plaintext HTTP against a non-local
9/// host while an API key is configured, because the API key would be sent
10/// unencrypted.
11///
12/// Loopback hosts (`127.0.0.1`, `::1`, `localhost`) are exempt because they
13/// never leave the machine.
14pub fn warn_on_insecure_http(url: &Url, api_key: &str) {
15    if url.scheme() != "http" {
16        return;
17    }
18    if api_key.trim().is_empty() {
19        return;
20    }
21    let host = url.host_str().unwrap_or("");
22    let is_loopback = matches!(host, "127.0.0.1" | "::1" | "localhost");
23    if is_loopback {
24        return;
25    }
26    log::warn!(
27        "AI endpoint uses plaintext HTTP ({}). API key will be transmitted unencrypted; consider using HTTPS.",
28        host
29    );
30}
31
32/// Convenience wrapper that parses `url_str` and forwards to
33/// [`warn_on_insecure_http`]. Parse errors are silently ignored because the
34/// caller's existing URL validation will have already rejected malformed
35/// URLs.
36pub fn warn_on_insecure_http_str(url_str: &str, api_key: &str) {
37    if let Ok(url) = Url::parse(url_str) {
38        warn_on_insecure_http(&url, api_key);
39    }
40}
41
42/// Canonical advisory string surfaced when a hosted-provider failure pattern
43/// (HTTPS validation rejection, connection refused / DNS failure to a private
44/// host, or HTTP 200 with a non-OpenAI-canonical body) suggests the user
45/// intended to call an OpenAI-compatible local or LAN endpoint.
46///
47/// The wording is reused verbatim by:
48/// - [`crate::config::validator::validate_ai_config`] when rejecting a
49///   non-`https` `ai.base_url` for a hosted provider.
50/// - The hosted-provider AI clients (`openai`, `openrouter`, `azure-openai`)
51///   when wrapping connection / parse errors that match the local-endpoint
52///   misconfiguration pattern.
53///
54/// Keeping the string in a single helper guarantees the advisory text stays
55/// in lockstep across every emission site.
56pub fn local_provider_hint() -> &'static str {
57    "If you intended to call an OpenAI-compatible local or LAN endpoint, \
58     set `ai.provider = \"local\"` (or `ollama`) and configure `ai.base_url` \
59     to your endpoint."
60}
61
62#[cfg(test)]
63mod local_provider_hint_tests {
64    use super::local_provider_hint;
65
66    #[test]
67    fn hint_is_non_empty_and_mentions_local_and_ollama() {
68        let hint = local_provider_hint();
69        assert!(!hint.is_empty(), "hint must not be empty");
70        assert!(hint.contains("local"), "hint must mention `local`: {hint}");
71        assert!(
72            hint.contains("ollama"),
73            "hint must mention `ollama`: {hint}"
74        );
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn https_never_warns() {
84        let url = Url::parse("https://api.example.com/v1").unwrap();
85        warn_on_insecure_http(&url, "sk-secret");
86    }
87
88    #[test]
89    fn http_loopback_does_not_warn() {
90        for host in [
91            "http://127.0.0.1:8080",
92            "http://localhost/v1",
93            "http://[::1]/",
94        ] {
95            let url = Url::parse(host).unwrap();
96            warn_on_insecure_http(&url, "sk-secret");
97        }
98    }
99
100    #[test]
101    fn http_public_with_empty_key_does_not_warn() {
102        let url = Url::parse("http://api.example.com/v1").unwrap();
103        warn_on_insecure_http(&url, "");
104        warn_on_insecure_http(&url, "   ");
105    }
106
107    #[test]
108    fn http_public_with_key_runs_without_panic() {
109        // Log output cannot be asserted without a test logger crate; we just
110        // make sure the branch is exercised.
111        let url = Url::parse("http://api.example.com/v1").unwrap();
112        warn_on_insecure_http(&url, "sk-secret");
113    }
114}