Skip to main content

nono_proxy/
external.rs

1//! External proxy passthrough handler (Mode 3 — Enterprise).
2//!
3//! Chains CONNECT requests to an upstream enterprise proxy (Squid, Cisco WSA,
4//! Zscaler, etc.). Cloud metadata endpoints are still denied before forwarding.
5//! The enterprise proxy makes the final allow/deny decision.
6//!
7//! The CONNECT-handshake-against-the-enterprise-proxy logic is extracted into
8//! [`connect_via_proxy`] so the TLS-intercept upstream leg can reuse it.
9
10use crate::audit;
11use crate::config::ExternalProxyConfig;
12use crate::error::{ProxyError, Result};
13use crate::filter::ProxyFilter;
14use crate::token;
15use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
16use tokio::net::TcpStream;
17use tracing::debug;
18use zeroize::Zeroizing;
19
20/// TCP-connect to an enterprise proxy and CONNECT through it to `target_host:target_port`.
21///
22/// Returns the resulting TCP stream after a successful `200 Connection Established`.
23/// Used by:
24///
25/// * [`handle_external_proxy`] for the transparent passthrough mode (the
26///   stream is then byte-relayed to the agent).
27/// * The TLS-intercept upstream leg ([`crate::tls_intercept`]) which wraps
28///   the returned stream in a TLS handshake to the real upstream.
29///
30/// `proxy_auth_header` is the literal value to send in `Proxy-Authorization`
31/// when authenticating to the enterprise proxy (e.g. `"Basic dXNlcjpwYXNz"`).
32/// Pass `None` for unauthenticated proxies.
33pub async fn connect_via_proxy(
34    proxy_addr: &str,
35    target_host: &str,
36    target_port: u16,
37    proxy_auth_header: Option<&str>,
38) -> Result<TcpStream> {
39    let mut proxy_stream = TcpStream::connect(proxy_addr).await.map_err(|e| {
40        ProxyError::ExternalProxy(format!(
41            "cannot connect to external proxy {}: {}",
42            proxy_addr, e
43        ))
44    })?;
45
46    let mut connect_req = format!(
47        "CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\n",
48        target_host, target_port, target_host, target_port
49    );
50    if let Some(auth) = proxy_auth_header {
51        connect_req.push_str(&format!("Proxy-Authorization: {}\r\n", auth));
52    }
53    connect_req.push_str("\r\n");
54
55    proxy_stream
56        .write_all(connect_req.as_bytes())
57        .await
58        .map_err(|e| {
59            ProxyError::ExternalProxy(format!("failed to send CONNECT to external proxy: {}", e))
60        })?;
61
62    let mut buf_reader = BufReader::new(&mut proxy_stream);
63    let mut response_line = String::new();
64    buf_reader
65        .read_line(&mut response_line)
66        .await
67        .map_err(|e| {
68            ProxyError::ExternalProxy(format!(
69                "failed to read response from external proxy: {}",
70                e
71            ))
72        })?;
73
74    let status = parse_status_code(&response_line)?;
75    if status != 200 {
76        return Err(ProxyError::ExternalProxy(format!(
77            "enterprise proxy rejected CONNECT to {}:{} with status {}",
78            target_host, target_port, status
79        )));
80    }
81
82    // Drain headers up to the empty line.
83    loop {
84        let mut line = String::new();
85        buf_reader.read_line(&mut line).await.map_err(|e| {
86            ProxyError::ExternalProxy(format!("failed to drain proxy response headers: {}", e))
87        })?;
88        if line.trim().is_empty() {
89            break;
90        }
91    }
92    drop(buf_reader);
93    Ok(proxy_stream)
94}
95
96/// Matcher for hosts that should bypass the external proxy.
97///
98/// Supports exact hostname match and `*.` wildcard suffix match,
99/// both case-insensitive. Uses the same `*`-prefix parsing pattern
100/// as `HostFilter::new()`.
101#[derive(Debug, Clone)]
102pub struct BypassMatcher {
103    /// Exact hostnames (lowercased)
104    exact: Vec<String>,
105    /// Wildcard suffixes (e.g., ".internal.corp", lowercased)
106    suffixes: Vec<String>,
107}
108
109impl BypassMatcher {
110    /// Create a new bypass matcher from a list of host patterns.
111    ///
112    /// Entries starting with `*.` are wildcard patterns matching any subdomain.
113    /// All other entries are exact matches. Matching is case-insensitive.
114    ///
115    /// Only the `*.domain` form is accepted for wildcards. Bare `*` and
116    /// patterns like `*corp` (without the dot) are treated as exact hostnames
117    /// to prevent accidental over-broad matching.
118    #[must_use]
119    pub fn new(hosts: &[String]) -> Self {
120        let mut exact = Vec::new();
121        let mut suffixes = Vec::new();
122
123        for host in hosts {
124            let lower = host.to_lowercase();
125            if let Some(suffix) = lower.strip_prefix("*.") {
126                // *.example.com -> .example.com
127                if !suffix.is_empty() {
128                    suffixes.push(format!(".{suffix}"));
129                }
130                // Bare "*." with nothing after is silently ignored (no valid domain)
131            } else {
132                exact.push(lower);
133            }
134        }
135
136        Self { exact, suffixes }
137    }
138
139    /// Check whether a host should bypass the external proxy.
140    #[must_use]
141    pub fn matches(&self, host: &str) -> bool {
142        let lower = host.to_lowercase();
143
144        // Exact match
145        if self.exact.contains(&lower) {
146            return true;
147        }
148
149        // Wildcard suffix match
150        for suffix in &self.suffixes {
151            if lower.ends_with(suffix.as_str()) && lower.len() > suffix.len() {
152                return true;
153            }
154        }
155
156        false
157    }
158
159    /// Whether any bypass hosts are configured.
160    #[must_use]
161    pub fn is_empty(&self) -> bool {
162        self.exact.is_empty() && self.suffixes.is_empty()
163    }
164}
165
166/// Handle a CONNECT request by chaining it to an external proxy.
167///
168/// 1. Validate session token
169/// 2. Check host against cloud metadata deny list
170/// 3. Connect to enterprise proxy
171/// 4. Send CONNECT to enterprise proxy (with optional Proxy-Authorization)
172/// 5. Wait for enterprise proxy 200
173/// 6. Bidirectional tunnel: agent <-> enterprise proxy <-> upstream
174pub async fn handle_external_proxy(
175    first_line: &str,
176    stream: &mut TcpStream,
177    remaining_header: &[u8],
178    filter: &ProxyFilter,
179    session_token: &Zeroizing<String>,
180    external_config: &ExternalProxyConfig,
181    audit_log: Option<&audit::SharedAuditLog>,
182) -> Result<()> {
183    // Parse CONNECT target
184    let (host, port) = parse_connect_target(first_line)?;
185    debug!("External proxy CONNECT to {}:{}", host, port);
186
187    // Validate session token
188    validate_proxy_auth(remaining_header, session_token)?;
189
190    // Check cloud metadata deny list.
191    // Cloud metadata endpoints are always blocked even through enterprise proxies.
192    let check = filter.check_host(&host, port).await?;
193    if !check.result.is_allowed() {
194        let reason = check.result.reason();
195        audit::log_denied(
196            audit_log,
197            audit::ProxyMode::External,
198            &audit::EventContext {
199                auth_mechanism: Some(nono::undo::NetworkAuditAuthMechanism::ProxyAuthorization),
200                auth_outcome: Some(nono::undo::NetworkAuditAuthOutcome::Succeeded),
201                denial_category: Some(nono::undo::NetworkAuditDenialCategory::HostDenied),
202                ..audit::EventContext::default()
203            },
204            &host,
205            port,
206            &reason,
207        );
208        send_response(stream, 403, &format!("Forbidden: {}", reason)).await?;
209        return Err(ProxyError::HostDenied { host, reason });
210    }
211
212    // External proxy authentication is not yet implemented. If auth is
213    // configured, fail loudly rather than silently sending unauthenticated
214    // requests that the enterprise proxy will reject.
215    if external_config.auth.is_some() {
216        return Err(ProxyError::ExternalProxy(
217            "external proxy authentication is configured but not yet implemented; \
218             remove the auth section from the external proxy config or wait for \
219             a future release"
220                .to_string(),
221        ));
222    }
223
224    // Connect to enterprise proxy and CONNECT through it to the upstream.
225    // Auth is gated above; pass None until configurable proxy auth lands.
226    let mut proxy_stream = match connect_via_proxy(&external_config.address, &host, port, None)
227        .await
228    {
229        Ok(s) => s,
230        Err(ProxyError::ExternalProxy(msg)) if msg.contains("rejected CONNECT") => {
231            // Enterprise proxy returned non-200. Surface the same status
232            // back to the agent so it can react sensibly (e.g. blocked
233            // by corporate policy).
234            audit::log_denied(
235                audit_log,
236                audit::ProxyMode::External,
237                &audit::EventContext {
238                    auth_mechanism: Some(nono::undo::NetworkAuditAuthMechanism::ProxyAuthorization),
239                    auth_outcome: Some(nono::undo::NetworkAuditAuthOutcome::Succeeded),
240                    denial_category: Some(
241                        nono::undo::NetworkAuditDenialCategory::ExternalProxyRejected,
242                    ),
243                    ..audit::EventContext::default()
244                },
245                &host,
246                port,
247                &msg,
248            );
249            send_response(stream, 502, "Bad Gateway").await?;
250            return Err(ProxyError::ExternalProxy(msg));
251        }
252        Err(e) => {
253            audit::log_denied(
254                audit_log,
255                audit::ProxyMode::External,
256                &audit::EventContext {
257                    auth_mechanism: Some(nono::undo::NetworkAuditAuthMechanism::ProxyAuthorization),
258                    auth_outcome: Some(nono::undo::NetworkAuditAuthOutcome::Succeeded),
259                    denial_category: Some(
260                        nono::undo::NetworkAuditDenialCategory::UpstreamConnectFailed,
261                    ),
262                    ..audit::EventContext::default()
263                },
264                &host,
265                port,
266                &e.to_string(),
267            );
268            send_response(stream, 502, "Bad Gateway").await?;
269            return Err(e);
270        }
271    };
272
273    // Send 200 to agent
274    send_response(stream, 200, "Connection Established").await?;
275    audit::log_allowed(
276        audit_log,
277        audit::ProxyMode::External,
278        &audit::EventContext {
279            auth_mechanism: Some(nono::undo::NetworkAuditAuthMechanism::ProxyAuthorization),
280            auth_outcome: Some(nono::undo::NetworkAuditAuthOutcome::Succeeded),
281            ..audit::EventContext::default()
282        },
283        &host,
284        port,
285        "CONNECT",
286    );
287
288    // Bidirectional tunnel: agent <-> enterprise proxy <-> upstream
289    let result = tokio::io::copy_bidirectional(stream, &mut proxy_stream).await;
290    debug!(
291        "External proxy tunnel closed for {}:{}: {:?}",
292        host, port, result
293    );
294
295    Ok(())
296}
297
298/// Parse CONNECT target (reused from connect.rs pattern).
299fn parse_connect_target(line: &str) -> Result<(String, u16)> {
300    let parts: Vec<&str> = line.split_whitespace().collect();
301    if parts.len() < 2 {
302        return Err(ProxyError::HttpParse(format!(
303            "malformed CONNECT line: {}",
304            line
305        )));
306    }
307
308    let authority = parts[1];
309    if let Some((host, port_str)) = authority.rsplit_once(':') {
310        let port = port_str.parse::<u16>().map_err(|_| {
311            ProxyError::HttpParse(format!("invalid port in CONNECT: {}", authority))
312        })?;
313        Ok((host.to_string(), port))
314    } else {
315        Ok((authority.to_string(), 443))
316    }
317}
318
319/// Validate Proxy-Authorization header.
320///
321/// Delegates to `token::validate_proxy_auth` which accepts both Bearer
322/// and Basic auth formats.
323fn validate_proxy_auth(header_bytes: &[u8], session_token: &Zeroizing<String>) -> Result<()> {
324    token::validate_proxy_auth(header_bytes, session_token)
325}
326
327/// Parse HTTP status code from a response line.
328fn parse_status_code(line: &str) -> Result<u16> {
329    let parts: Vec<&str> = line.split_whitespace().collect();
330    if parts.len() < 2 {
331        return Err(ProxyError::HttpParse(format!(
332            "malformed HTTP response: {}",
333            line
334        )));
335    }
336    parts[1]
337        .parse::<u16>()
338        .map_err(|_| ProxyError::HttpParse(format!("invalid status code in response: {}", line)))
339}
340
341/// Send an HTTP response line.
342async fn send_response(stream: &mut TcpStream, status: u16, reason: &str) -> Result<()> {
343    let response = format!("HTTP/1.1 {} {}\r\n\r\n", status, reason);
344    stream.write_all(response.as_bytes()).await?;
345    stream.flush().await?;
346    Ok(())
347}
348
349#[cfg(test)]
350#[allow(clippy::unwrap_used)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn test_parse_connect_target() {
356        let (host, port) = parse_connect_target("CONNECT api.openai.com:443 HTTP/1.1").unwrap();
357        assert_eq!(host, "api.openai.com");
358        assert_eq!(port, 443);
359    }
360
361    #[test]
362    fn test_parse_status_code_200() {
363        assert_eq!(
364            parse_status_code("HTTP/1.1 200 Connection Established\r\n").unwrap(),
365            200
366        );
367    }
368
369    #[test]
370    fn test_parse_status_code_403() {
371        assert_eq!(
372            parse_status_code("HTTP/1.1 403 Forbidden\r\n").unwrap(),
373            403
374        );
375    }
376
377    #[test]
378    fn test_parse_status_code_malformed() {
379        assert!(parse_status_code("garbage").is_err());
380    }
381
382    #[test]
383    fn test_bypass_matcher_exact() {
384        let matcher = BypassMatcher::new(&["internal.corp".to_string()]);
385        assert!(matcher.matches("internal.corp"));
386        assert!(!matcher.matches("other.corp"));
387    }
388
389    #[test]
390    fn test_bypass_matcher_case_insensitive() {
391        let matcher = BypassMatcher::new(&["Internal.Corp".to_string()]);
392        assert!(matcher.matches("internal.corp"));
393        assert!(matcher.matches("INTERNAL.CORP"));
394    }
395
396    #[test]
397    fn test_bypass_matcher_wildcard() {
398        let matcher = BypassMatcher::new(&["*.internal.corp".to_string()]);
399        assert!(matcher.matches("app.internal.corp"));
400        assert!(matcher.matches("deep.sub.internal.corp"));
401        // Bare domain should NOT match wildcard
402        assert!(!matcher.matches("internal.corp"));
403    }
404
405    #[test]
406    fn test_bypass_matcher_wildcard_case_insensitive() {
407        let matcher = BypassMatcher::new(&["*.Internal.Corp".to_string()]);
408        assert!(matcher.matches("APP.INTERNAL.CORP"));
409    }
410
411    #[test]
412    fn test_bypass_matcher_no_match() {
413        let matcher =
414            BypassMatcher::new(&["internal.corp".to_string(), "*.private.net".to_string()]);
415        assert!(!matcher.matches("api.openai.com"));
416        assert!(!matcher.matches("evil.com"));
417    }
418
419    #[test]
420    fn test_bypass_matcher_empty() {
421        let matcher = BypassMatcher::new(&[]);
422        assert!(matcher.is_empty());
423        assert!(!matcher.matches("anything.com"));
424    }
425
426    #[test]
427    fn test_bypass_matcher_mixed() {
428        let matcher =
429            BypassMatcher::new(&["exact.host.com".to_string(), "*.wildcard.com".to_string()]);
430        assert!(matcher.matches("exact.host.com"));
431        assert!(matcher.matches("sub.wildcard.com"));
432        assert!(!matcher.matches("wildcard.com"));
433        assert!(!matcher.matches("other.com"));
434    }
435
436    #[test]
437    fn test_bypass_matcher_bare_star_is_not_wildcard() {
438        // Bare "*" must NOT bypass everything — it should be treated as
439        // a literal (non-matching) hostname, not a universal wildcard.
440        let matcher = BypassMatcher::new(&["*".to_string()]);
441        assert!(!matcher.matches("anything.com"));
442        assert!(!matcher.matches("internal.corp"));
443    }
444
445    #[test]
446    fn test_bypass_matcher_star_without_dot_is_literal() {
447        // "*corp" (no dot) must NOT be treated as a wildcard suffix.
448        // Only "*.corp" is a valid wildcard pattern.
449        let matcher = BypassMatcher::new(&["*corp".to_string()]);
450        assert!(!matcher.matches("internal.corp"));
451        assert!(!matcher.matches("subcorp"));
452        // It's treated as the literal hostname "*corp"
453        assert!(matcher.matches("*corp"));
454    }
455
456    #[test]
457    fn test_bypass_matcher_star_dot_only_is_ignored() {
458        // "*." with nothing after is not a valid domain pattern.
459        let matcher = BypassMatcher::new(&["*.".to_string()]);
460        assert!(matcher.is_empty());
461        assert!(!matcher.matches("anything.com"));
462    }
463}