Skip to main content

nono_proxy/
reverse.rs

1//! Reverse proxy handler (Mode 2 — Credential Injection).
2//!
3//! Routes requests by path prefix to upstream APIs, injecting credentials
4//! from the keystore. The agent uses `http://localhost:PORT/openai/v1/chat`
5//! and the proxy rewrites to `https://api.openai.com/v1/chat` with the
6//! real credential injected.
7//!
8//! Supports multiple injection modes:
9//! - `header`: Inject into HTTP header (e.g., `Authorization: Bearer ...`)
10//! - `url_path`: Replace pattern in URL path (e.g., Telegram `/bot{}/`)
11//! - `query_param`: Add/replace query parameter (e.g., `?api_key=...`)
12//! - `basic_auth`: HTTP Basic Authentication
13//!
14//! Streaming responses (SSE, MCP Streamable HTTP, A2A JSON-RPC) are
15//! forwarded without buffering.
16
17use crate::audit;
18use crate::config::InjectMode;
19use crate::credential::{CredentialStore, LoadedCredential};
20use crate::error::{ProxyError, Result};
21use crate::filter::ProxyFilter;
22use crate::forward::{self, AuditCtx, UpstreamScheme, UpstreamSpec, UpstreamStrategy};
23use crate::route::RouteStore;
24use crate::token;
25use std::net::SocketAddr;
26use tokio::io::AsyncReadExt;
27use tokio::io::AsyncWriteExt;
28use tokio::net::TcpStream;
29use tokio_rustls::TlsConnector;
30use tracing::{debug, warn};
31use zeroize::Zeroizing;
32
33/// Maximum request body size (16 MiB). Prevents DoS from malicious Content-Length.
34const MAX_REQUEST_BODY: usize = 16 * 1024 * 1024;
35
36fn auth_mechanism_for_inject_mode(mode: &InjectMode) -> nono::undo::NetworkAuditAuthMechanism {
37    match mode {
38        InjectMode::Header | InjectMode::BasicAuth => {
39            nono::undo::NetworkAuditAuthMechanism::PhantomHeader
40        }
41        InjectMode::UrlPath => nono::undo::NetworkAuditAuthMechanism::PhantomPath,
42        InjectMode::QueryParam => nono::undo::NetworkAuditAuthMechanism::PhantomQuery,
43    }
44}
45
46fn audit_injection_mode_for_inject_mode(
47    mode: &InjectMode,
48) -> nono::undo::NetworkAuditInjectionMode {
49    match mode {
50        InjectMode::Header => nono::undo::NetworkAuditInjectionMode::Header,
51        InjectMode::UrlPath => nono::undo::NetworkAuditInjectionMode::UrlPath,
52        InjectMode::QueryParam => nono::undo::NetworkAuditInjectionMode::QueryParam,
53        InjectMode::BasicAuth => nono::undo::NetworkAuditInjectionMode::BasicAuth,
54    }
55}
56
57fn proxy_auth_event_ctx<'a>(route_id: &'a str) -> audit::EventContext<'a> {
58    audit::EventContext {
59        route_id: Some(route_id),
60        auth_mechanism: Some(nono::undo::NetworkAuditAuthMechanism::ProxyAuthorization),
61        ..audit::EventContext::default()
62    }
63}
64
65fn managed_credential_event_ctx<'a>(
66    route_id: &'a str,
67    proxy_mode: &InjectMode,
68    inject_mode: nono::undo::NetworkAuditInjectionMode,
69) -> audit::EventContext<'a> {
70    audit::EventContext {
71        route_id: Some(route_id),
72        auth_mechanism: Some(auth_mechanism_for_inject_mode(proxy_mode)),
73        managed_credential_active: Some(true),
74        injection_mode: Some(inject_mode),
75        ..audit::EventContext::default()
76    }
77}
78
79/// Handle a non-CONNECT HTTP request (reverse proxy mode).
80///
81/// Reads the full HTTP request from the client, matches path prefix to
82/// a configured route, injects credentials, and forwards to the upstream.
83/// Shared context passed from the server to the reverse proxy handler.
84pub struct ReverseProxyCtx<'a> {
85    /// Route store for upstream URL, L7 filtering, and per-route TLS
86    pub route_store: &'a RouteStore,
87    /// Credential store for service lookups (optional injection)
88    pub credential_store: &'a CredentialStore,
89    /// Session token for authentication
90    pub session_token: &'a Zeroizing<String>,
91    /// Host filter for upstream validation
92    pub filter: &'a ProxyFilter,
93    /// Shared TLS connector
94    pub tls_connector: &'a TlsConnector,
95    /// Shared network audit sink for session metadata capture
96    pub audit_log: Option<&'a audit::SharedAuditLog>,
97}
98
99/// Handle a non-CONNECT HTTP request (reverse proxy mode).
100///
101/// `buffered_body` contains any bytes the BufReader read ahead beyond the
102/// headers. These are prepended to the body read from the stream to prevent
103/// data loss.
104///
105/// ## Phantom Token Pattern
106///
107/// The client (SDK) sends the session token as its "API key". The proxy:
108/// 1. Extracts the service from the path (e.g., `/openai/v1/chat` → `openai`)
109/// 2. Looks up which header that service uses (e.g., `Authorization` or `x-api-key`)
110/// 3. Validates the phantom token from that header
111/// 4. Replaces it with the real credential from keyring
112pub async fn handle_reverse_proxy(
113    first_line: &str,
114    stream: &mut TcpStream,
115    remaining_header: &[u8],
116    ctx: &ReverseProxyCtx<'_>,
117    buffered_body: &[u8],
118) -> Result<()> {
119    // Parse method, path, and HTTP version
120    let (method, path, version) = parse_request_line(first_line)?;
121    debug!("Reverse proxy: {} {}", method, path);
122
123    // Extract service prefix from path (e.g., "/openai/v1/chat" -> ("openai", "/v1/chat"))
124    let (service, upstream_path) = parse_service_prefix(&path)?;
125    let route = ctx
126        .route_store
127        .get(&service)
128        .ok_or_else(|| ProxyError::UnknownService {
129            prefix: service.clone(),
130        })?;
131    let static_cred = ctx.credential_store.get(&service);
132    let oauth2_route = ctx.credential_store.get_oauth2(&service);
133    let managed_ctx = static_cred.map(|cred| {
134        managed_credential_event_ctx(
135            &service,
136            &cred.proxy_inject_mode,
137            audit_injection_mode_for_inject_mode(&cred.inject_mode),
138        )
139    });
140    let oauth2_ctx = oauth2_route.map(|_| audit::EventContext {
141        route_id: Some(&service),
142        auth_mechanism: Some(nono::undo::NetworkAuditAuthMechanism::PhantomHeader),
143        managed_credential_active: Some(true),
144        injection_mode: Some(nono::undo::NetworkAuditInjectionMode::OAuth2),
145        ..audit::EventContext::default()
146    });
147    let route_ctx = managed_ctx
148        .clone()
149        .or_else(|| oauth2_ctx.clone())
150        .unwrap_or_else(|| audit::EventContext {
151            route_id: Some(&service),
152            managed_credential_active: Some(false),
153            ..audit::EventContext::default()
154        });
155
156    if route.missing_managed_credential(static_cred.is_some(), oauth2_route.is_some()) {
157        let reason = format!(
158            "managed credential unavailable for service '{}': route is configured for proxy-supplied auth",
159            service
160        );
161        warn!("{}", reason);
162        let deny_ctx = audit::EventContext {
163            route_id: Some(&service),
164            auth_mechanism: route.managed_auth_mechanism.clone(),
165            auth_outcome: Some(nono::undo::NetworkAuditAuthOutcome::Failed),
166            managed_credential_active: Some(false),
167            injection_mode: route.managed_injection_mode.clone(),
168            denial_category: Some(
169                nono::undo::NetworkAuditDenialCategory::ManagedCredentialUnavailable,
170            ),
171        };
172        audit::log_denied(
173            ctx.audit_log,
174            audit::ProxyMode::Reverse,
175            &deny_ctx,
176            &service,
177            0,
178            &reason,
179        );
180        send_error(stream, 503, "Service Unavailable").await?;
181        return Ok(());
182    }
183
184    // L7 endpoint filtering runs for all reverse-proxy routes, whether or not
185    // they inject a credential.
186    if !route.endpoint_rules.is_allowed(&method, &upstream_path) {
187        let reason = format!(
188            "endpoint denied: {} {} on service '{}'",
189            method, upstream_path, service
190        );
191        warn!("{}", reason);
192        let deny_ctx = audit::EventContext {
193            denial_category: Some(nono::undo::NetworkAuditDenialCategory::EndpointPolicy),
194            ..route_ctx.clone()
195        };
196        audit::log_denied(
197            ctx.audit_log,
198            audit::ProxyMode::Reverse,
199            &deny_ctx,
200            &service,
201            0,
202            &reason,
203        );
204        send_error(stream, 403, "Forbidden").await?;
205        return Ok(());
206    }
207
208    if let Some(oauth2_route) = oauth2_route {
209        return handle_oauth2_credential(
210            oauth2_route,
211            route,
212            &service,
213            &upstream_path,
214            &method,
215            &version,
216            stream,
217            remaining_header,
218            buffered_body,
219            ctx,
220        )
221        .await;
222    }
223
224    let cred = static_cred;
225
226    // Authenticate the request. Every reverse proxy request must prove
227    // possession of the session token, regardless of whether a credential
228    // is configured — this is the localhost auth boundary.
229    if let Some(cred) = cred {
230        if let Err(e) = validate_phantom_token_for_mode(
231            &cred.proxy_inject_mode,
232            remaining_header,
233            &upstream_path,
234            &cred.proxy_header_name,
235            cred.proxy_path_pattern.as_deref(),
236            cred.proxy_query_param_name.as_deref(),
237            ctx.session_token,
238        ) {
239            let deny_ctx = audit::EventContext {
240                auth_outcome: Some(nono::undo::NetworkAuditAuthOutcome::Failed),
241                denial_category: Some(nono::undo::NetworkAuditDenialCategory::AuthenticationFailed),
242                ..managed_ctx.clone().unwrap_or_else(|| route_ctx.clone())
243            };
244            audit::log_denied(
245                ctx.audit_log,
246                audit::ProxyMode::Reverse,
247                &deny_ctx,
248                &service,
249                0,
250                &e.to_string(),
251            );
252            send_error(stream, 401, "Unauthorized").await?;
253            return Ok(());
254        }
255    } else if let Err(e) = token::validate_proxy_auth(remaining_header, ctx.session_token) {
256        let deny_ctx = audit::EventContext {
257            auth_outcome: Some(nono::undo::NetworkAuditAuthOutcome::Failed),
258            denial_category: Some(nono::undo::NetworkAuditDenialCategory::AuthenticationFailed),
259            ..proxy_auth_event_ctx(&service)
260        };
261        audit::log_denied(
262            ctx.audit_log,
263            audit::ProxyMode::Reverse,
264            &deny_ctx,
265            &service,
266            0,
267            &e.to_string(),
268        );
269        send_error(stream, 407, "Proxy Authentication Required").await?;
270        return Ok(());
271    }
272
273    let transformed_path = if let Some(cred) = cred {
274        let cleaned_path = strip_proxy_artifacts(
275            &upstream_path,
276            &cred.proxy_inject_mode,
277            &cred.inject_mode,
278            cred.proxy_path_pattern.as_deref(),
279            cred.proxy_query_param_name.as_deref(),
280        );
281        transform_path_for_mode(
282            &cred.inject_mode,
283            &cleaned_path,
284            cred.path_pattern.as_deref(),
285            cred.path_replacement.as_deref(),
286            cred.query_param_name.as_deref(),
287            &cred.raw_credential,
288        )?
289    } else {
290        upstream_path.clone()
291    };
292
293    let upstream_url = format!(
294        "{}{}",
295        route.upstream.trim_end_matches('/'),
296        transformed_path
297    );
298    debug!("Forwarding to upstream: {} {}", method, upstream_url);
299
300    let (upstream_scheme, upstream_host, upstream_port, upstream_path_full) =
301        parse_upstream_url(&upstream_url)?;
302    let check = ctx.filter.check_host(&upstream_host, upstream_port).await?;
303    if !check.result.is_allowed() {
304        let reason = check.result.reason();
305        warn!("Upstream host denied by filter: {}", reason);
306        send_error(stream, 403, "Forbidden").await?;
307        let deny_ctx = audit::EventContext {
308            denial_category: Some(nono::undo::NetworkAuditDenialCategory::HostDenied),
309            ..route_ctx.clone()
310        };
311        audit::log_denied(
312            ctx.audit_log,
313            audit::ProxyMode::Reverse,
314            &deny_ctx,
315            &service,
316            0,
317            &reason,
318        );
319        return Ok(());
320    }
321    if let Err(reason) =
322        validate_http_upstream_target(upstream_scheme, &upstream_host, &check.resolved_addrs)
323    {
324        warn!("{}", reason);
325        send_error(stream, 502, "Bad Gateway").await?;
326        let deny_ctx = audit::EventContext {
327            denial_category: Some(nono::undo::NetworkAuditDenialCategory::UpstreamConnectFailed),
328            ..route_ctx.clone()
329        };
330        audit::log_denied(
331            ctx.audit_log,
332            audit::ProxyMode::Reverse,
333            &deny_ctx,
334            &service,
335            0,
336            &reason,
337        );
338        return Ok(());
339    }
340
341    let success_ctx = if let Some(ctx) = managed_ctx.clone() {
342        audit::EventContext {
343            auth_outcome: Some(nono::undo::NetworkAuditAuthOutcome::Succeeded),
344            ..ctx
345        }
346    } else if oauth2_ctx.is_some() {
347        audit::EventContext {
348            auth_outcome: Some(nono::undo::NetworkAuditAuthOutcome::Succeeded),
349            ..oauth2_ctx.clone().unwrap_or_default()
350        }
351    } else {
352        audit::EventContext {
353            auth_outcome: Some(nono::undo::NetworkAuditAuthOutcome::Succeeded),
354            managed_credential_active: Some(false),
355            ..proxy_auth_event_ctx(&service)
356        }
357    };
358
359    let strip_header = cred.map(|c| c.proxy_header_name.as_str()).unwrap_or("");
360    let filtered_headers = filter_headers(remaining_header, strip_header);
361    let content_length = extract_content_length(remaining_header);
362    let body = match read_request_body(stream, content_length, buffered_body).await? {
363        Some(body) => body,
364        None => return Ok(()),
365    };
366
367    let upstream_authority = format_host_header(upstream_scheme, &upstream_host, upstream_port);
368    let mut request = Zeroizing::new(format!(
369        "{} {} {}\r\nHost: {}\r\n",
370        method, upstream_path_full, version, upstream_authority
371    ));
372
373    if let Some(cred) = cred {
374        inject_credential_for_mode(cred, &mut request);
375    }
376
377    let auth_header_lower = cred.map(|c| c.header_name.to_lowercase());
378    for (name, value) in &filtered_headers {
379        if let (Some(cred), Some(header_lower)) = (cred, auth_header_lower.as_ref()) {
380            if matches!(cred.inject_mode, InjectMode::Header | InjectMode::BasicAuth)
381                && name.to_lowercase() == *header_lower
382            {
383                continue;
384            }
385        }
386        request.push_str(&format!("{}: {}\r\n", name, value));
387    }
388
389    request.push_str("Connection: close\r\n");
390    if !body.is_empty() {
391        request.push_str(&format!("Content-Length: {}\r\n", body.len()));
392    }
393    request.push_str("\r\n");
394
395    let connector = route.tls_connector.as_ref().unwrap_or(ctx.tls_connector);
396    let upstream_spec = UpstreamSpec {
397        scheme: upstream_scheme,
398        host: &upstream_host,
399        port: upstream_port,
400        strategy: UpstreamStrategy::Direct {
401            resolved_addrs: &check.resolved_addrs,
402        },
403        tls_connector: connector,
404    };
405    let audit_ctx = AuditCtx {
406        log: ctx.audit_log,
407        mode: audit::ProxyMode::Reverse,
408        event_ctx: success_ctx.clone(),
409        target: &service,
410        method: &method,
411        path: &upstream_path,
412    };
413    if let Err(e) =
414        forward::forward_request(stream, request.as_bytes(), &body, upstream_spec, audit_ctx).await
415    {
416        warn!("Upstream connection failed: {}", e);
417        send_error(stream, 502, "Bad Gateway").await?;
418        let deny_ctx = audit::EventContext {
419            denial_category: Some(nono::undo::NetworkAuditDenialCategory::UpstreamConnectFailed),
420            ..success_ctx.clone()
421        };
422        audit::log_denied(
423            ctx.audit_log,
424            audit::ProxyMode::Reverse,
425            &deny_ctx,
426            &service,
427            0,
428            &e.to_string(),
429        );
430    }
431    Ok(())
432}
433
434/// Handle a reverse proxy request using an OAuth2 token cache.
435///
436/// Retrieves a (possibly refreshed) access token from the cache and injects
437/// it as `Authorization: Bearer <token>`. The agent authenticates with the
438/// session token via the `Authorization: Bearer <phantom>` header, which is
439/// validated and then replaced with the real OAuth2 access token.
440#[allow(clippy::too_many_arguments)]
441async fn handle_oauth2_credential(
442    oauth2_route: &crate::credential::OAuth2Route,
443    route: &crate::route::LoadedRoute,
444    service: &str,
445    upstream_path: &str,
446    method: &str,
447    version: &str,
448    stream: &mut TcpStream,
449    remaining_header: &[u8],
450    buffered_body: &[u8],
451    ctx: &ReverseProxyCtx<'_>,
452) -> Result<()> {
453    // Get (possibly refreshed) OAuth2 access token
454    let access_token = oauth2_route.cache.get_or_refresh().await;
455
456    // Validate session token from Authorization header (phantom token pattern).
457    // OAuth2 routes still require the agent to authenticate with the session
458    // token — this prevents unauthorized access to the token-exchanged credential.
459    if let Err(e) = validate_phantom_token(remaining_header, "Authorization", ctx.session_token) {
460        let deny_ctx = audit::EventContext {
461            route_id: Some(service),
462            auth_mechanism: Some(nono::undo::NetworkAuditAuthMechanism::PhantomHeader),
463            auth_outcome: Some(nono::undo::NetworkAuditAuthOutcome::Failed),
464            managed_credential_active: Some(true),
465            injection_mode: Some(nono::undo::NetworkAuditInjectionMode::OAuth2),
466            denial_category: Some(nono::undo::NetworkAuditDenialCategory::AuthenticationFailed),
467        };
468        audit::log_denied(
469            ctx.audit_log,
470            audit::ProxyMode::Reverse,
471            &deny_ctx,
472            service,
473            0,
474            &e.to_string(),
475        );
476        send_error(stream, 401, "Unauthorized").await?;
477        return Ok(());
478    }
479
480    let upstream_url = format!(
481        "{}{}",
482        oauth2_route.upstream.trim_end_matches('/'),
483        upstream_path
484    );
485    debug!("OAuth2 forwarding to upstream: {} {}", method, upstream_url);
486
487    let (upstream_scheme, upstream_host, upstream_port, upstream_path_full) =
488        parse_upstream_url(&upstream_url)?;
489    // DNS resolve + host check via the filter
490    let check = ctx.filter.check_host(&upstream_host, upstream_port).await?;
491    if !check.result.is_allowed() {
492        let reason = check.result.reason();
493        warn!("Upstream host denied by filter: {}", reason);
494        send_error(stream, 403, "Forbidden").await?;
495        let route_ctx = audit::EventContext {
496            route_id: Some(service),
497            managed_credential_active: Some(true),
498            injection_mode: Some(nono::undo::NetworkAuditInjectionMode::OAuth2),
499            denial_category: Some(nono::undo::NetworkAuditDenialCategory::HostDenied),
500            ..audit::EventContext::default()
501        };
502        audit::log_denied(
503            ctx.audit_log,
504            audit::ProxyMode::Reverse,
505            &route_ctx,
506            service,
507            0,
508            &reason,
509        );
510        return Ok(());
511    }
512    if let Err(reason) =
513        validate_http_upstream_target(upstream_scheme, &upstream_host, &check.resolved_addrs)
514    {
515        warn!("{}", reason);
516        send_error(stream, 502, "Bad Gateway").await?;
517        let route_ctx = audit::EventContext {
518            route_id: Some(service),
519            managed_credential_active: Some(true),
520            injection_mode: Some(nono::undo::NetworkAuditInjectionMode::OAuth2),
521            denial_category: Some(nono::undo::NetworkAuditDenialCategory::UpstreamConnectFailed),
522            ..audit::EventContext::default()
523        };
524        audit::log_denied(
525            ctx.audit_log,
526            audit::ProxyMode::Reverse,
527            &route_ctx,
528            service,
529            0,
530            &reason,
531        );
532        return Ok(());
533    }
534
535    // Collect remaining request headers, stripping the client-supplied
536    // Authorization header that carries the phantom token.
537    let filtered_headers = filter_headers(remaining_header, "Authorization");
538    let content_length = extract_content_length(remaining_header);
539
540    // Read request body
541    let body = match read_request_body(stream, content_length, buffered_body).await? {
542        Some(body) => body,
543        None => return Ok(()),
544    };
545
546    // Build upstream request with Bearer token injection
547    let upstream_authority = format_host_header(upstream_scheme, &upstream_host, upstream_port);
548    let mut request = Zeroizing::new(format!(
549        "{} {} {}\r\nHost: {}\r\n",
550        method, upstream_path_full, version, upstream_authority
551    ));
552
553    // Inject OAuth2 access token as Authorization: Bearer
554    request.push_str(&format!(
555        "Authorization: Bearer {}\r\n",
556        access_token.as_str()
557    ));
558
559    // Forward filtered headers (auth headers already stripped by filter_headers)
560    for (name, value) in &filtered_headers {
561        request.push_str(&format!("{}: {}\r\n", name, value));
562    }
563
564    if !body.is_empty() {
565        request.push_str(&format!("Content-Length: {}\r\n", body.len()));
566    }
567    request.push_str("\r\n");
568
569    let connector = route.tls_connector.as_ref().unwrap_or(ctx.tls_connector);
570    let upstream_spec = UpstreamSpec {
571        scheme: upstream_scheme,
572        host: &upstream_host,
573        port: upstream_port,
574        strategy: UpstreamStrategy::Direct {
575            resolved_addrs: &check.resolved_addrs,
576        },
577        tls_connector: connector,
578    };
579    let audit_ctx = AuditCtx {
580        log: ctx.audit_log,
581        mode: audit::ProxyMode::Reverse,
582        event_ctx: audit::EventContext {
583            route_id: Some(service),
584            auth_mechanism: Some(nono::undo::NetworkAuditAuthMechanism::PhantomHeader),
585            auth_outcome: Some(nono::undo::NetworkAuditAuthOutcome::Succeeded),
586            managed_credential_active: Some(true),
587            injection_mode: Some(nono::undo::NetworkAuditInjectionMode::OAuth2),
588            denial_category: None,
589        },
590        target: service,
591        method,
592        path: upstream_path,
593    };
594    if let Err(e) =
595        forward::forward_request(stream, request.as_bytes(), &body, upstream_spec, audit_ctx).await
596    {
597        warn!("Upstream connection failed: {}", e);
598        send_error(stream, 502, "Bad Gateway").await?;
599        audit::log_denied(
600            ctx.audit_log,
601            audit::ProxyMode::Reverse,
602            &audit::EventContext {
603                route_id: Some(service),
604                auth_mechanism: Some(nono::undo::NetworkAuditAuthMechanism::PhantomHeader),
605                auth_outcome: Some(nono::undo::NetworkAuditAuthOutcome::Succeeded),
606                managed_credential_active: Some(true),
607                injection_mode: Some(nono::undo::NetworkAuditInjectionMode::OAuth2),
608                denial_category: Some(
609                    nono::undo::NetworkAuditDenialCategory::UpstreamConnectFailed,
610                ),
611            },
612            service,
613            0,
614            &e.to_string(),
615        );
616    }
617    Ok(())
618}
619
620/// Read request body from the client stream with size limit.
621///
622/// `buffered_body` contains bytes the BufReader read ahead beyond headers.
623/// Generic over the inbound stream so the TLS-intercept handler can reuse
624/// it on a `TlsStream<…>` without duplication.
625pub(crate) async fn read_request_body<S>(
626    stream: &mut S,
627    content_length: Option<usize>,
628    buffered_body: &[u8],
629) -> Result<Option<Vec<u8>>>
630where
631    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
632{
633    if let Some(len) = content_length {
634        if len > MAX_REQUEST_BODY {
635            send_error_generic(stream, 413, "Payload Too Large").await?;
636            return Ok(None);
637        }
638        let mut buf = Vec::with_capacity(len);
639        let pre = buffered_body.len().min(len);
640        buf.extend_from_slice(&buffered_body[..pre]);
641        let remaining = len - pre;
642        if remaining > 0 {
643            let mut rest = vec![0u8; remaining];
644            stream.read_exact(&mut rest).await?;
645            buf.extend_from_slice(&rest);
646        }
647        Ok(Some(buf))
648    } else {
649        Ok(Some(Vec::new()))
650    }
651}
652
653/// Generic equivalent of `send_error` used by [`read_request_body`].
654pub(crate) async fn send_error_generic<S>(stream: &mut S, status: u16, reason: &str) -> Result<()>
655where
656    S: tokio::io::AsyncWrite + Unpin,
657{
658    let body = format!("{{\"error\":\"{}\"}}", reason);
659    let response = format!(
660        "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
661        status,
662        reason,
663        body.len(),
664        body
665    );
666    stream.write_all(response.as_bytes()).await?;
667    stream.flush().await?;
668    Ok(())
669}
670
671/// Parse an HTTP request line into (method, path, version).
672fn parse_request_line(line: &str) -> Result<(String, String, String)> {
673    let parts: Vec<&str> = line.split_whitespace().collect();
674    if parts.len() < 3 {
675        return Err(ProxyError::HttpParse(format!(
676            "malformed request line: {}",
677            line
678        )));
679    }
680    Ok((
681        parts[0].to_string(),
682        parts[1].to_string(),
683        parts[2].to_string(),
684    ))
685}
686
687/// Extract service prefix from path.
688///
689/// "/openai/v1/chat/completions" -> ("openai", "/v1/chat/completions")
690/// "/anthropic/v1/messages" -> ("anthropic", "/v1/messages")
691fn parse_service_prefix(path: &str) -> Result<(String, String)> {
692    let trimmed = path.strip_prefix('/').unwrap_or(path);
693    if let Some((prefix, rest)) = trimmed.split_once('/') {
694        Ok((prefix.to_string(), format!("/{}", rest)))
695    } else {
696        // No sub-path, just the prefix
697        Ok((trimmed.to_string(), "/".to_string()))
698    }
699}
700
701/// Validate the phantom token from the service's auth header.
702///
703/// The SDK sends the session token as its "API key" in the standard auth header
704/// for that service (e.g., `Authorization: Bearer <token>` for OpenAI,
705/// `x-api-key: <token>` for Anthropic). We validate the token matches the
706/// session token before swapping in the real credential.
707fn validate_phantom_token(
708    header_bytes: &[u8],
709    header_name: &str,
710    session_token: &Zeroizing<String>,
711) -> Result<()> {
712    let header_str = std::str::from_utf8(header_bytes).map_err(|_| ProxyError::InvalidToken)?;
713    let header_name_lower = header_name.to_lowercase();
714
715    for line in header_str.lines() {
716        let lower = line.to_lowercase();
717        if lower.starts_with(&format!("{}:", header_name_lower)) {
718            let value = line.split_once(':').map(|(_, v)| v.trim()).unwrap_or("");
719
720            // Handle "Bearer <token>" format (strip "Bearer " prefix if present)
721            // Use case-insensitive check, then slice original value by length
722            let value_lower = value.to_lowercase();
723            let token_value = if value_lower.starts_with("bearer ") {
724                // "bearer ".len() == 7
725                value[7..].trim()
726            } else {
727                value
728            };
729
730            if token::constant_time_eq(token_value.as_bytes(), session_token.as_bytes()) {
731                return Ok(());
732            }
733            warn!("Invalid phantom token in {} header", header_name);
734            return Err(ProxyError::InvalidToken);
735        }
736    }
737
738    warn!(
739        "Missing {} header for phantom token validation",
740        header_name
741    );
742    Err(ProxyError::InvalidToken)
743}
744
745/// Filter headers, removing hop-by-hop and proxy-internal headers.
746///
747/// Always strips:
748/// - `Host` (rewritten to upstream)
749/// - `Content-Length` (re-added after body is read)
750/// - `Proxy-Authorization` (hop-by-hop, contains session token)
751///
752/// When `cred_header` is non-empty, also strips that header (it contains
753/// the phantom token that must not be forwarded alongside the real credential).
754/// When `cred_header` is empty (no-credential route), all other headers
755/// including `Authorization` are passed through to the upstream.
756pub(crate) fn filter_headers(header_bytes: &[u8], cred_header: &str) -> Vec<(String, String)> {
757    let header_str = std::str::from_utf8(header_bytes).unwrap_or("");
758    let cred_header_lower = if cred_header.is_empty() {
759        String::new()
760    } else {
761        format!("{}:", cred_header.to_lowercase())
762    };
763    let mut headers = Vec::new();
764
765    for line in header_str.lines() {
766        let lower = line.to_lowercase();
767        if lower.starts_with("host:")
768            || lower.starts_with("content-length:")
769            || lower.starts_with("connection:")
770            || lower.starts_with("proxy-authorization:")
771            || (!cred_header_lower.is_empty() && lower.starts_with(&cred_header_lower))
772            || line.trim().is_empty()
773        {
774            continue;
775        }
776        if let Some((name, value)) = line.split_once(':') {
777            headers.push((name.trim().to_string(), value.trim().to_string()));
778        }
779    }
780
781    headers
782}
783
784/// Extract Content-Length value from raw headers.
785pub(crate) fn extract_content_length(header_bytes: &[u8]) -> Option<usize> {
786    let header_str = std::str::from_utf8(header_bytes).ok()?;
787    for line in header_str.lines() {
788        if line.to_lowercase().starts_with("content-length:") {
789            let value = line.split_once(':')?.1.trim();
790            return value.parse().ok();
791        }
792    }
793    None
794}
795
796fn validate_http_upstream_target(
797    scheme: UpstreamScheme,
798    host: &str,
799    resolved_addrs: &[SocketAddr],
800) -> std::result::Result<(), String> {
801    if matches!(scheme, UpstreamScheme::Https) {
802        return Ok(());
803    }
804
805    if is_local_only_target(host, resolved_addrs) {
806        Ok(())
807    } else {
808        Err(format!(
809            "refusing insecure http upstream for non-local host '{}'; http is only allowed for loopback addresses",
810            host
811        ))
812    }
813}
814
815fn is_local_only_target(host: &str, resolved_addrs: &[SocketAddr]) -> bool {
816    if !resolved_addrs.is_empty() {
817        return resolved_addrs.iter().all(|addr| addr.ip().is_loopback());
818    }
819
820    match host.parse::<std::net::IpAddr>() {
821        Ok(std::net::IpAddr::V4(ip)) => ip.is_loopback(),
822        Ok(std::net::IpAddr::V6(ip)) => ip.is_loopback(),
823        Err(_) => false,
824    }
825}
826
827pub(crate) fn format_host_header(scheme: UpstreamScheme, host: &str, port: u16) -> String {
828    let default_port = match scheme {
829        UpstreamScheme::Http => 80,
830        UpstreamScheme::Https => 443,
831    };
832    let bracketed_host = if host.contains(':') && !host.starts_with('[') {
833        format!("[{}]", host)
834    } else {
835        host.to_string()
836    };
837
838    if port == default_port {
839        bracketed_host
840    } else {
841        format!("{}:{}", bracketed_host, port)
842    }
843}
844
845fn parse_upstream_url(url_str: &str) -> Result<(UpstreamScheme, String, u16, String)> {
846    let parsed = url::Url::parse(url_str)
847        .map_err(|e| ProxyError::HttpParse(format!("invalid upstream URL '{}': {}", url_str, e)))?;
848
849    let scheme = match parsed.scheme() {
850        "https" => UpstreamScheme::Https,
851        "http" => UpstreamScheme::Http,
852        _ => {
853            return Err(ProxyError::HttpParse(format!(
854                "unsupported URL scheme: {}",
855                url_str
856            )));
857        }
858    };
859
860    let host = parsed
861        .host_str()
862        .ok_or_else(|| ProxyError::HttpParse(format!("missing host in URL: {}", url_str)))?
863        .to_string();
864
865    let default_port = if matches!(scheme, UpstreamScheme::Https) {
866        443
867    } else {
868        80
869    };
870    let port = parsed.port().unwrap_or(default_port);
871
872    let path = parsed.path().to_string();
873    let path = if path.is_empty() {
874        "/".to_string()
875    } else {
876        path
877    };
878
879    // Include query string if present
880    let path_with_query = if let Some(query) = parsed.query() {
881        format!("{}?{}", path, query)
882    } else {
883        path
884    };
885
886    Ok((scheme, host, port, path_with_query))
887}
888
889/// Send an HTTP error response.
890async fn send_error(stream: &mut TcpStream, status: u16, reason: &str) -> Result<()> {
891    let body = format!("{{\"error\":\"{}\"}}", reason);
892    let response = format!(
893        "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
894        status,
895        reason,
896        body.len(),
897        body
898    );
899    stream.write_all(response.as_bytes()).await?;
900    stream.flush().await?;
901    Ok(())
902}
903
904// ============================================================================
905// Injection mode helpers
906// ============================================================================
907
908/// Validate phantom token based on injection mode.
909///
910/// Different modes extract the phantom token from different locations:
911/// - `Header`/`BasicAuth`: From the auth header (Authorization, x-api-key, etc.)
912/// - `UrlPath`: From the URL path pattern (e.g., `/bot<token>/getMe`)
913/// - `QueryParam`: From the query parameter (e.g., `?api_key=<token>`)
914pub(crate) fn validate_phantom_token_for_mode(
915    mode: &InjectMode,
916    header_bytes: &[u8],
917    path: &str,
918    header_name: &str,
919    path_pattern: Option<&str>,
920    query_param_name: Option<&str>,
921    session_token: &Zeroizing<String>,
922) -> Result<()> {
923    match mode {
924        InjectMode::Header | InjectMode::BasicAuth => {
925            // Validate from header (existing behavior)
926            validate_phantom_token(header_bytes, header_name, session_token)
927        }
928        InjectMode::UrlPath => {
929            // Validate from URL path
930            let pattern = path_pattern.ok_or_else(|| {
931                ProxyError::HttpParse("url_path mode requires path_pattern".to_string())
932            })?;
933            validate_phantom_token_in_path(path, pattern, session_token)
934        }
935        InjectMode::QueryParam => {
936            // Validate from query parameter
937            let param_name = query_param_name.ok_or_else(|| {
938                ProxyError::HttpParse("query_param mode requires query_param_name".to_string())
939            })?;
940            validate_phantom_token_in_query(path, param_name, session_token)
941        }
942    }
943}
944
945/// Validate phantom token embedded in URL path.
946///
947/// Extracts the token from the path using the pattern (e.g., `/bot{}/` matches
948/// `/bot<token>/getMe` and extracts `<token>`).
949fn validate_phantom_token_in_path(
950    path: &str,
951    pattern: &str,
952    session_token: &Zeroizing<String>,
953) -> Result<()> {
954    // Split pattern on {} to get prefix and suffix
955    let parts: Vec<&str> = pattern.split("{}").collect();
956    if parts.len() != 2 {
957        return Err(ProxyError::HttpParse(format!(
958            "invalid path_pattern '{}': must contain exactly one {{}}",
959            pattern
960        )));
961    }
962    let (prefix, suffix) = (parts[0], parts[1]);
963
964    // Find the token in the path
965    if let Some(start) = path.find(prefix) {
966        let after_prefix = start + prefix.len();
967
968        // Handle empty suffix case (token extends to end of path or next '/' or '?')
969        let end_offset = if suffix.is_empty() {
970            path[after_prefix..]
971                .find(['/', '?'])
972                .unwrap_or(path[after_prefix..].len())
973        } else {
974            match path[after_prefix..].find(suffix) {
975                Some(offset) => offset,
976                None => {
977                    warn!("Missing phantom token in URL path (pattern: {})", pattern);
978                    return Err(ProxyError::InvalidToken);
979                }
980            }
981        };
982
983        let token = &path[after_prefix..after_prefix + end_offset];
984        if token::constant_time_eq(token.as_bytes(), session_token.as_bytes()) {
985            return Ok(());
986        }
987        warn!("Invalid phantom token in URL path");
988        return Err(ProxyError::InvalidToken);
989    }
990
991    warn!("Missing phantom token in URL path (pattern: {})", pattern);
992    Err(ProxyError::InvalidToken)
993}
994
995/// Validate phantom token in query parameter.
996fn validate_phantom_token_in_query(
997    path: &str,
998    param_name: &str,
999    session_token: &Zeroizing<String>,
1000) -> Result<()> {
1001    // Parse query string from path
1002    if let Some(query_start) = path.find('?') {
1003        let query = &path[query_start + 1..];
1004        for pair in query.split('&') {
1005            if let Some((name, value)) = pair.split_once('=') {
1006                if name == param_name {
1007                    // URL-decode the value
1008                    let decoded = urlencoding::decode(value).unwrap_or_else(|_| value.into());
1009                    if token::constant_time_eq(decoded.as_bytes(), session_token.as_bytes()) {
1010                        return Ok(());
1011                    }
1012                    warn!("Invalid phantom token in query parameter '{}'", param_name);
1013                    return Err(ProxyError::InvalidToken);
1014                }
1015            }
1016        }
1017    }
1018
1019    warn!("Missing phantom token in query parameter '{}'", param_name);
1020    Err(ProxyError::InvalidToken)
1021}
1022
1023/// Transform URL path based on injection mode.
1024///
1025/// - `UrlPath`: Replace phantom token with real credential in path
1026/// - `QueryParam`: Add/replace query parameter with real credential
1027/// - `Header`/`BasicAuth`: No path transformation needed
1028pub(crate) fn transform_path_for_mode(
1029    mode: &InjectMode,
1030    path: &str,
1031    path_pattern: Option<&str>,
1032    path_replacement: Option<&str>,
1033    query_param_name: Option<&str>,
1034    credential: &Zeroizing<String>,
1035) -> Result<String> {
1036    match mode {
1037        InjectMode::Header | InjectMode::BasicAuth => {
1038            // No path transformation needed
1039            Ok(path.to_string())
1040        }
1041        InjectMode::UrlPath => {
1042            let pattern = path_pattern.ok_or_else(|| {
1043                ProxyError::HttpParse("url_path mode requires path_pattern".to_string())
1044            })?;
1045            let replacement = path_replacement.unwrap_or(pattern);
1046            transform_url_path(path, pattern, replacement, credential)
1047        }
1048        InjectMode::QueryParam => {
1049            let param_name = query_param_name.ok_or_else(|| {
1050                ProxyError::HttpParse("query_param mode requires query_param_name".to_string())
1051            })?;
1052            transform_query_param(path, param_name, credential)
1053        }
1054    }
1055}
1056
1057/// Transform URL path by replacing phantom token pattern with real credential.
1058///
1059/// Example: `/bot<phantom>/getMe` with pattern `/bot{}/` becomes `/bot<real>/getMe`
1060fn transform_url_path(
1061    path: &str,
1062    pattern: &str,
1063    replacement: &str,
1064    credential: &Zeroizing<String>,
1065) -> Result<String> {
1066    // Split pattern on {} to get prefix and suffix
1067    let parts: Vec<&str> = pattern.split("{}").collect();
1068    if parts.len() != 2 {
1069        return Err(ProxyError::HttpParse(format!(
1070            "invalid path_pattern '{}': must contain exactly one {{}}",
1071            pattern
1072        )));
1073    }
1074    let (pattern_prefix, pattern_suffix) = (parts[0], parts[1]);
1075
1076    // Split replacement on {}
1077    let repl_parts: Vec<&str> = replacement.split("{}").collect();
1078    if repl_parts.len() != 2 {
1079        return Err(ProxyError::HttpParse(format!(
1080            "invalid path_replacement '{}': must contain exactly one {{}}",
1081            replacement
1082        )));
1083    }
1084    let (repl_prefix, repl_suffix) = (repl_parts[0], repl_parts[1]);
1085
1086    // Find and replace the token in the path
1087    if let Some(start) = path.find(pattern_prefix) {
1088        let after_prefix = start + pattern_prefix.len();
1089
1090        // Handle empty suffix case (token extends to end of path or next '/' or '?')
1091        let end_offset = if pattern_suffix.is_empty() {
1092            // Find the next path segment delimiter or end of path
1093            path[after_prefix..]
1094                .find(['/', '?'])
1095                .unwrap_or(path[after_prefix..].len())
1096        } else {
1097            // Find the suffix in the remaining path
1098            match path[after_prefix..].find(pattern_suffix) {
1099                Some(offset) => offset,
1100                None => {
1101                    return Err(ProxyError::HttpParse(format!(
1102                        "path '{}' does not match pattern '{}'",
1103                        path, pattern
1104                    )));
1105                }
1106            }
1107        };
1108
1109        let before = &path[..start];
1110        let after = &path[after_prefix + end_offset + pattern_suffix.len()..];
1111        return Ok(format!(
1112            "{}{}{}{}{}",
1113            before,
1114            repl_prefix,
1115            credential.as_str(),
1116            repl_suffix,
1117            after
1118        ));
1119    }
1120
1121    Err(ProxyError::HttpParse(format!(
1122        "path '{}' does not match pattern '{}'",
1123        path, pattern
1124    )))
1125}
1126
1127/// Transform query string by adding or replacing a parameter with the credential.
1128fn transform_query_param(
1129    path: &str,
1130    param_name: &str,
1131    credential: &Zeroizing<String>,
1132) -> Result<String> {
1133    let encoded_value = urlencoding::encode(credential.as_str());
1134
1135    if let Some(query_start) = path.find('?') {
1136        let base_path = &path[..query_start];
1137        let query = &path[query_start + 1..];
1138
1139        // Check if parameter already exists
1140        let mut found = false;
1141        let new_query: Vec<String> = query
1142            .split('&')
1143            .map(|pair| {
1144                if let Some((name, _)) = pair.split_once('=') {
1145                    if name == param_name {
1146                        found = true;
1147                        return format!("{}={}", param_name, encoded_value);
1148                    }
1149                }
1150                pair.to_string()
1151            })
1152            .collect();
1153
1154        if found {
1155            Ok(format!("{}?{}", base_path, new_query.join("&")))
1156        } else {
1157            // Append the parameter
1158            Ok(format!(
1159                "{}?{}&{}={}",
1160                base_path, query, param_name, encoded_value
1161            ))
1162        }
1163    } else {
1164        // No query string, add one
1165        Ok(format!("{}?{}={}", path, param_name, encoded_value))
1166    }
1167}
1168
1169/// Strip proxy-side artifacts from the path when proxy and upstream modes differ.
1170///
1171/// When the proxy validates the phantom token using a different injection mode
1172/// than the upstream (e.g., proxy uses `url_path` or `query_param` while upstream
1173/// uses `header`), the proxy-side token is embedded in the URL. This function
1174/// removes it before the path is forwarded to the upstream, preventing phantom
1175/// token leakage.
1176///
1177/// When both modes are the same, the upstream transform handles replacement
1178/// (phantom → real credential), so no stripping is needed.
1179pub(crate) fn strip_proxy_artifacts(
1180    path: &str,
1181    proxy_mode: &InjectMode,
1182    upstream_mode: &InjectMode,
1183    proxy_path_pattern: Option<&str>,
1184    proxy_query_param_name: Option<&str>,
1185) -> String {
1186    // Only strip when modes differ — same-mode cases are handled by the
1187    // upstream transform which replaces the phantom token with the real one.
1188    if proxy_mode == upstream_mode {
1189        return path.to_string();
1190    }
1191
1192    match proxy_mode {
1193        InjectMode::UrlPath => {
1194            if let Some(pattern) = proxy_path_pattern {
1195                strip_proxy_path_token(path, pattern)
1196            } else {
1197                path.to_string()
1198            }
1199        }
1200        InjectMode::QueryParam => {
1201            if let Some(param_name) = proxy_query_param_name {
1202                strip_proxy_query_param(path, param_name)
1203            } else {
1204                path.to_string()
1205            }
1206        }
1207        // Header and BasicAuth modes don't embed artifacts in the URL path.
1208        InjectMode::Header | InjectMode::BasicAuth => path.to_string(),
1209    }
1210}
1211
1212/// Remove a phantom token path segment matched by the given pattern.
1213///
1214/// Example: path `/TOKEN123/api/v1/pods` with pattern `/{}/` → `/api/v1/pods`
1215fn strip_proxy_path_token(path: &str, pattern: &str) -> String {
1216    let parts: Vec<&str> = pattern.split("{}").collect();
1217    if parts.len() != 2 {
1218        return path.to_string();
1219    }
1220    let (prefix, suffix) = (parts[0], parts[1]);
1221
1222    // Prefer matching at the start of the path to avoid false hits on
1223    // common prefixes like "/" that would otherwise match at position 0
1224    // even if the intended token is in a later segment.
1225    let start = if path.starts_with(prefix) {
1226        Some(0)
1227    } else {
1228        path.find(prefix)
1229    };
1230
1231    if let Some(start) = start {
1232        let after_prefix = start + prefix.len();
1233        let end_offset = if suffix.is_empty() {
1234            path[after_prefix..]
1235                .find(['/', '?'])
1236                .unwrap_or(path[after_prefix..].len())
1237        } else {
1238            match path[after_prefix..].find(suffix) {
1239                Some(offset) => offset,
1240                None => return path.to_string(),
1241            }
1242        };
1243
1244        let before = &path[..start];
1245        let after = &path[after_prefix + end_offset + suffix.len()..];
1246
1247        // Join before and after with exactly one separator to avoid
1248        // malformed paths: "/prefixapi" (missing slash) or "/api//v1"
1249        // (double slash) when the stripped segment was mid-path.
1250        let joined = match (before.ends_with('/'), after.starts_with('/')) {
1251            (true, true) => format!("{}{}", before, &after[1..]),
1252            (false, false) if !before.is_empty() && !after.is_empty() => {
1253                format!("{}/{}", before, after)
1254            }
1255            _ => format!("{}{}", before, after),
1256        };
1257
1258        if joined.is_empty() || !joined.starts_with('/') {
1259            format!("/{}", joined)
1260        } else {
1261            joined
1262        }
1263    } else {
1264        path.to_string()
1265    }
1266}
1267
1268/// Remove a phantom token query parameter from the URL.
1269///
1270/// Example: path `/api/v1/pods?token=XXX&limit=10` → `/api/v1/pods?limit=10`
1271fn strip_proxy_query_param(path: &str, param_name: &str) -> String {
1272    if let Some(query_start) = path.find('?') {
1273        let base_path = &path[..query_start];
1274        let query = &path[query_start + 1..];
1275
1276        let remaining: Vec<&str> = query
1277            .split('&')
1278            .filter(|pair| {
1279                pair.split_once('=')
1280                    .map(|(name, _)| name != param_name)
1281                    .unwrap_or(true)
1282            })
1283            .collect();
1284
1285        if remaining.is_empty() {
1286            base_path.to_string()
1287        } else {
1288            format!("{}?{}", base_path, remaining.join("&"))
1289        }
1290    } else {
1291        path.to_string()
1292    }
1293}
1294
1295/// Inject credential into request based on mode.
1296///
1297/// For header/basic_auth modes, adds the credential header.
1298/// For url_path/query_param modes, the credential is already in the path.
1299pub(crate) fn inject_credential_for_mode(cred: &LoadedCredential, request: &mut Zeroizing<String>) {
1300    match cred.inject_mode {
1301        InjectMode::Header | InjectMode::BasicAuth => {
1302            // Inject credential header
1303            request.push_str(&format!(
1304                "{}: {}\r\n",
1305                cred.header_name,
1306                cred.header_value.as_str()
1307            ));
1308        }
1309        InjectMode::UrlPath | InjectMode::QueryParam => {
1310            // Credential is already injected into the URL path/query
1311            // No header injection needed
1312        }
1313    }
1314}
1315
1316#[cfg(test)]
1317#[allow(clippy::unwrap_used)]
1318mod tests {
1319    use super::*;
1320
1321    #[test]
1322    fn test_parse_request_line() {
1323        let (method, path, version) = parse_request_line("POST /openai/v1/chat HTTP/1.1").unwrap();
1324        assert_eq!(method, "POST");
1325        assert_eq!(path, "/openai/v1/chat");
1326        assert_eq!(version, "HTTP/1.1");
1327    }
1328
1329    #[test]
1330    fn test_parse_request_line_malformed() {
1331        assert!(parse_request_line("GET").is_err());
1332    }
1333
1334    #[test]
1335    fn test_parse_service_prefix() {
1336        let (service, path) = parse_service_prefix("/openai/v1/chat/completions").unwrap();
1337        assert_eq!(service, "openai");
1338        assert_eq!(path, "/v1/chat/completions");
1339    }
1340
1341    #[test]
1342    fn test_parse_service_prefix_no_subpath() {
1343        let (service, path) = parse_service_prefix("/anthropic").unwrap();
1344        assert_eq!(service, "anthropic");
1345        assert_eq!(path, "/");
1346    }
1347
1348    #[test]
1349    fn test_validate_phantom_token_bearer_valid() {
1350        let token = Zeroizing::new("secret123".to_string());
1351        let header = b"Authorization: Bearer secret123\r\nContent-Type: application/json\r\n\r\n";
1352        assert!(validate_phantom_token(header, "Authorization", &token).is_ok());
1353    }
1354
1355    #[test]
1356    fn test_validate_phantom_token_bearer_invalid() {
1357        let token = Zeroizing::new("secret123".to_string());
1358        let header = b"Authorization: Bearer wrong\r\n\r\n";
1359        assert!(validate_phantom_token(header, "Authorization", &token).is_err());
1360    }
1361
1362    #[test]
1363    fn test_validate_phantom_token_x_api_key_valid() {
1364        let token = Zeroizing::new("secret123".to_string());
1365        let header = b"x-api-key: secret123\r\nContent-Type: application/json\r\n\r\n";
1366        assert!(validate_phantom_token(header, "x-api-key", &token).is_ok());
1367    }
1368
1369    #[test]
1370    fn test_validate_phantom_token_x_goog_api_key_valid() {
1371        let token = Zeroizing::new("secret123".to_string());
1372        let header = b"x-goog-api-key: secret123\r\nContent-Type: application/json\r\n\r\n";
1373        assert!(validate_phantom_token(header, "x-goog-api-key", &token).is_ok());
1374    }
1375
1376    #[test]
1377    fn test_validate_phantom_token_missing() {
1378        let token = Zeroizing::new("secret123".to_string());
1379        let header = b"Content-Type: application/json\r\n\r\n";
1380        assert!(validate_phantom_token(header, "Authorization", &token).is_err());
1381    }
1382
1383    #[test]
1384    fn test_validate_phantom_token_case_insensitive_header() {
1385        let token = Zeroizing::new("secret123".to_string());
1386        let header = b"AUTHORIZATION: Bearer secret123\r\n\r\n";
1387        assert!(validate_phantom_token(header, "Authorization", &token).is_ok());
1388    }
1389
1390    #[test]
1391    fn test_filter_headers_removes_host_auth() {
1392        let header = b"Host: localhost:8080\r\nAuthorization: Bearer old\r\nContent-Type: application/json\r\nAccept: */*\r\n\r\n";
1393        let filtered = filter_headers(header, "Authorization");
1394        assert_eq!(filtered.len(), 2);
1395        assert_eq!(filtered[0].0, "Content-Type");
1396        assert_eq!(filtered[1].0, "Accept");
1397    }
1398
1399    #[test]
1400    fn test_filter_headers_removes_x_api_key() {
1401        let header = b"x-api-key: sk-old\r\nContent-Type: application/json\r\n\r\n";
1402        let filtered = filter_headers(header, "x-api-key");
1403        assert_eq!(filtered.len(), 1);
1404        assert_eq!(filtered[0].0, "Content-Type");
1405    }
1406
1407    #[test]
1408    fn test_filter_headers_removes_custom_header() {
1409        let header = b"PRIVATE-TOKEN: phantom123\r\nContent-Type: application/json\r\n\r\n";
1410        let filtered = filter_headers(header, "PRIVATE-TOKEN");
1411        assert_eq!(filtered.len(), 1);
1412        assert_eq!(filtered[0].0, "Content-Type");
1413    }
1414
1415    #[test]
1416    fn test_extract_content_length() {
1417        let header = b"Content-Type: application/json\r\nContent-Length: 42\r\n\r\n";
1418        assert_eq!(extract_content_length(header), Some(42));
1419    }
1420
1421    #[test]
1422    fn test_extract_content_length_missing() {
1423        let header = b"Content-Type: application/json\r\n\r\n";
1424        assert_eq!(extract_content_length(header), None);
1425    }
1426
1427    #[test]
1428    fn test_parse_upstream_url_https() {
1429        let (scheme, host, port, path) =
1430            parse_upstream_url("https://api.openai.com/v1/chat/completions").unwrap();
1431        assert_eq!(scheme, UpstreamScheme::Https);
1432        assert_eq!(host, "api.openai.com");
1433        assert_eq!(port, 443);
1434        assert_eq!(path, "/v1/chat/completions");
1435    }
1436
1437    #[test]
1438    fn test_parse_upstream_url_http_with_port() {
1439        let (scheme, host, port, path) = parse_upstream_url("http://localhost:8080/api").unwrap();
1440        assert_eq!(scheme, UpstreamScheme::Http);
1441        assert_eq!(host, "localhost");
1442        assert_eq!(port, 8080);
1443        assert_eq!(path, "/api");
1444    }
1445
1446    #[test]
1447    fn test_parse_upstream_url_no_path() {
1448        let (scheme, host, port, path) = parse_upstream_url("https://api.anthropic.com").unwrap();
1449        assert_eq!(scheme, UpstreamScheme::Https);
1450        assert_eq!(host, "api.anthropic.com");
1451        assert_eq!(port, 443);
1452        assert_eq!(path, "/");
1453    }
1454
1455    #[test]
1456    fn test_parse_upstream_url_invalid_scheme() {
1457        assert!(parse_upstream_url("ftp://example.com").is_err());
1458    }
1459
1460    #[test]
1461    fn test_validate_http_upstream_target_rejects_non_local_host() {
1462        let err = validate_http_upstream_target(UpstreamScheme::Http, "api.example.com", &[])
1463            .expect_err("non-local http upstream should be rejected");
1464        assert!(err.contains("refusing insecure http upstream"));
1465    }
1466
1467    #[test]
1468    fn test_validate_http_upstream_target_allows_loopback() {
1469        let loopback = [SocketAddr::from(([127, 0, 0, 1], 8080))];
1470        assert!(validate_http_upstream_target(UpstreamScheme::Http, "127.0.0.1", &[]).is_ok());
1471        assert!(validate_http_upstream_target(UpstreamScheme::Http, "::1", &[]).is_ok());
1472        assert!(
1473            validate_http_upstream_target(UpstreamScheme::Http, "localhost", &loopback).is_ok()
1474        );
1475    }
1476
1477    #[test]
1478    fn test_validate_http_upstream_target_rejects_unspecified_addresses() {
1479        let unspecified = [SocketAddr::from(([0, 0, 0, 0], 8080))];
1480        let err = validate_http_upstream_target(UpstreamScheme::Http, "0.0.0.0", &[])
1481            .expect_err("unspecified http upstream should be rejected");
1482        assert!(err.contains("loopback addresses"));
1483
1484        let err = validate_http_upstream_target(UpstreamScheme::Http, "localhost", &unspecified)
1485            .expect_err("localhost resolving to unspecified should be rejected");
1486        assert!(err.contains("loopback addresses"));
1487    }
1488
1489    #[test]
1490    fn test_validate_http_upstream_target_rejects_localhost_resolving_non_loopback() {
1491        let poisoned = [SocketAddr::from(([203, 0, 113, 10], 8080))];
1492        let err = validate_http_upstream_target(UpstreamScheme::Http, "localhost", &poisoned)
1493            .expect_err("localhost resolving off-host should be rejected");
1494        assert!(err.contains("refusing insecure http upstream"));
1495    }
1496
1497    #[test]
1498    fn test_format_host_header_uses_port_for_non_default_http() {
1499        assert_eq!(
1500            format_host_header(UpstreamScheme::Http, "localhost", 8080),
1501            "localhost:8080"
1502        );
1503    }
1504
1505    #[test]
1506    fn test_format_host_header_omits_default_https_port() {
1507        assert_eq!(
1508            format_host_header(UpstreamScheme::Https, "api.openai.com", 443),
1509            "api.openai.com"
1510        );
1511    }
1512
1513    #[test]
1514    fn test_format_host_header_brackets_ipv6() {
1515        assert_eq!(
1516            format_host_header(UpstreamScheme::Http, "::1", 8080),
1517            "[::1]:8080"
1518        );
1519    }
1520
1521    // Status-line parsing moved to `crate::forward` along with the upstream
1522    // response-streaming pipeline; coverage continues there.
1523
1524    // ============================================================================
1525    // URL Path Injection Mode Tests
1526    // ============================================================================
1527
1528    #[test]
1529    fn test_validate_phantom_token_in_path_valid() {
1530        let token = Zeroizing::new("session123".to_string());
1531        let path = "/bot/session123/getMe";
1532        let pattern = "/bot/{}/";
1533        assert!(validate_phantom_token_in_path(path, pattern, &token).is_ok());
1534    }
1535
1536    #[test]
1537    fn test_validate_phantom_token_in_path_invalid() {
1538        let token = Zeroizing::new("session123".to_string());
1539        let path = "/bot/wrong_token/getMe";
1540        let pattern = "/bot/{}/";
1541        assert!(validate_phantom_token_in_path(path, pattern, &token).is_err());
1542    }
1543
1544    #[test]
1545    fn test_validate_phantom_token_in_path_missing() {
1546        let token = Zeroizing::new("session123".to_string());
1547        let path = "/api/getMe";
1548        let pattern = "/bot/{}/";
1549        assert!(validate_phantom_token_in_path(path, pattern, &token).is_err());
1550    }
1551
1552    #[test]
1553    fn test_transform_url_path_basic() {
1554        let credential = Zeroizing::new("real_token".to_string());
1555        let path = "/bot/phantom_token/getMe";
1556        let pattern = "/bot/{}/";
1557        let replacement = "/bot/{}/";
1558        let result = transform_url_path(path, pattern, replacement, &credential).unwrap();
1559        assert_eq!(result, "/bot/real_token/getMe");
1560    }
1561
1562    #[test]
1563    fn test_transform_url_path_different_replacement() {
1564        let credential = Zeroizing::new("real_token".to_string());
1565        let path = "/api/v1/phantom_token/chat";
1566        let pattern = "/api/v1/{}/";
1567        let replacement = "/v2/bot/{}/";
1568        let result = transform_url_path(path, pattern, replacement, &credential).unwrap();
1569        assert_eq!(result, "/v2/bot/real_token/chat");
1570    }
1571
1572    #[test]
1573    fn test_transform_url_path_no_trailing_slash() {
1574        let credential = Zeroizing::new("real_token".to_string());
1575        let path = "/bot/phantom_token";
1576        let pattern = "/bot/{}";
1577        let replacement = "/bot/{}";
1578        let result = transform_url_path(path, pattern, replacement, &credential).unwrap();
1579        assert_eq!(result, "/bot/real_token");
1580    }
1581
1582    // ============================================================================
1583    // Query Param Injection Mode Tests
1584    // ============================================================================
1585
1586    #[test]
1587    fn test_validate_phantom_token_in_query_valid() {
1588        let token = Zeroizing::new("session123".to_string());
1589        let path = "/api/data?api_key=session123&other=value";
1590        assert!(validate_phantom_token_in_query(path, "api_key", &token).is_ok());
1591    }
1592
1593    #[test]
1594    fn test_validate_phantom_token_in_query_invalid() {
1595        let token = Zeroizing::new("session123".to_string());
1596        let path = "/api/data?api_key=wrong_token";
1597        assert!(validate_phantom_token_in_query(path, "api_key", &token).is_err());
1598    }
1599
1600    #[test]
1601    fn test_validate_phantom_token_in_query_missing_param() {
1602        let token = Zeroizing::new("session123".to_string());
1603        let path = "/api/data?other=value";
1604        assert!(validate_phantom_token_in_query(path, "api_key", &token).is_err());
1605    }
1606
1607    #[test]
1608    fn test_validate_phantom_token_in_query_no_query_string() {
1609        let token = Zeroizing::new("session123".to_string());
1610        let path = "/api/data";
1611        assert!(validate_phantom_token_in_query(path, "api_key", &token).is_err());
1612    }
1613
1614    #[test]
1615    fn test_validate_phantom_token_in_query_url_encoded() {
1616        let token = Zeroizing::new("token with spaces".to_string());
1617        let path = "/api/data?api_key=token%20with%20spaces";
1618        assert!(validate_phantom_token_in_query(path, "api_key", &token).is_ok());
1619    }
1620
1621    #[test]
1622    fn test_transform_query_param_add_to_no_query() {
1623        let credential = Zeroizing::new("real_key".to_string());
1624        let path = "/api/data";
1625        let result = transform_query_param(path, "api_key", &credential).unwrap();
1626        assert_eq!(result, "/api/data?api_key=real_key");
1627    }
1628
1629    #[test]
1630    fn test_transform_query_param_add_to_existing_query() {
1631        let credential = Zeroizing::new("real_key".to_string());
1632        let path = "/api/data?other=value";
1633        let result = transform_query_param(path, "api_key", &credential).unwrap();
1634        assert_eq!(result, "/api/data?other=value&api_key=real_key");
1635    }
1636
1637    #[test]
1638    fn test_transform_query_param_replace_existing() {
1639        let credential = Zeroizing::new("real_key".to_string());
1640        let path = "/api/data?api_key=phantom&other=value";
1641        let result = transform_query_param(path, "api_key", &credential).unwrap();
1642        assert_eq!(result, "/api/data?api_key=real_key&other=value");
1643    }
1644
1645    #[test]
1646    fn test_transform_query_param_url_encodes_special_chars() {
1647        let credential = Zeroizing::new("key with spaces".to_string());
1648        let path = "/api/data";
1649        let result = transform_query_param(path, "api_key", &credential).unwrap();
1650        assert_eq!(result, "/api/data?api_key=key%20with%20spaces");
1651    }
1652
1653    #[test]
1654    fn test_validate_phantom_token_uses_proxy_mode_over_upstream_mode() {
1655        let token = Zeroizing::new("session123".to_string());
1656        let header = b"Authorization: Bearer session123\r\n\r\n";
1657        let path = "/api/data?api_key=wrong";
1658
1659        // Simulate split config where proxy-side mode is header while upstream
1660        // mode might be query_param.
1661        let result = validate_phantom_token_for_mode(
1662            &InjectMode::Header,
1663            header,
1664            path,
1665            "Authorization",
1666            None,
1667            Some("api_key"),
1668            &token,
1669        );
1670
1671        assert!(result.is_ok());
1672    }
1673
1674    #[test]
1675    fn test_transform_path_uses_upstream_mode_independently() {
1676        let credential = Zeroizing::new("real_key".to_string());
1677        let path = "/api/data?api_key=phantom";
1678
1679        // Simulate split config where upstream mode is query_param.
1680        let transformed = transform_path_for_mode(
1681            &InjectMode::QueryParam,
1682            path,
1683            None,
1684            None,
1685            Some("api_key"),
1686            &credential,
1687        )
1688        .expect("query-param transform should succeed");
1689
1690        assert_eq!(transformed, "/api/data?api_key=real_key");
1691    }
1692
1693    // ========================================================================
1694    // Proxy artifact stripping tests
1695    // ========================================================================
1696
1697    #[test]
1698    fn test_strip_proxy_path_token_basic() {
1699        // Pattern: /{}/  — token is the first path segment
1700        let result = strip_proxy_path_token("/PHANTOM123/api/v1/pods", "/{}/");
1701        assert_eq!(result, "/api/v1/pods");
1702    }
1703
1704    #[test]
1705    fn test_strip_proxy_path_token_nested_pattern() {
1706        // Pattern: /auth/{}/  — token is in a nested segment
1707        let result = strip_proxy_path_token("/auth/PHANTOM123/api/v1/pods", "/auth/{}/");
1708        assert_eq!(result, "/api/v1/pods");
1709    }
1710
1711    #[test]
1712    fn test_strip_proxy_path_token_no_trailing_slash() {
1713        // Pattern: /{}  — token at end of path with no trailing content
1714        let result = strip_proxy_path_token("/PHANTOM123", "/{}");
1715        assert_eq!(result, "/");
1716    }
1717
1718    #[test]
1719    fn test_strip_proxy_path_token_preserves_query() {
1720        // Pattern: /{}/  — should preserve query string after stripping
1721        let result = strip_proxy_path_token("/PHANTOM123/api?limit=10", "/{}/");
1722        assert_eq!(result, "/api?limit=10");
1723    }
1724
1725    #[test]
1726    fn test_strip_proxy_path_token_no_match() {
1727        // Pattern doesn't match — return path unchanged
1728        let result = strip_proxy_path_token("/api/v1/pods", "/auth/{}/");
1729        assert_eq!(result, "/api/v1/pods");
1730    }
1731
1732    #[test]
1733    fn test_strip_proxy_path_token_mid_path_slash_join() {
1734        // Token in the middle: before="/api" after="data" must join with "/"
1735        let result = strip_proxy_path_token("/api/k8s/PHANTOM/data", "/k8s/{}/");
1736        assert_eq!(result, "/api/data");
1737    }
1738
1739    #[test]
1740    fn test_strip_proxy_path_token_no_double_slash() {
1741        // Before ends with "/" and after starts with "/" — collapse to one
1742        let result = strip_proxy_path_token("/prefix/PHANTOM//suffix", "/prefix/{}/");
1743        assert_eq!(result, "/suffix");
1744    }
1745
1746    #[test]
1747    fn test_strip_proxy_query_param_only_param() {
1748        let result = strip_proxy_query_param("/api/v1/pods?token=PHANTOM123", "token");
1749        assert_eq!(result, "/api/v1/pods");
1750    }
1751
1752    #[test]
1753    fn test_strip_proxy_query_param_with_other_params() {
1754        let result = strip_proxy_query_param("/api/v1/pods?token=PHANTOM123&limit=10", "token");
1755        assert_eq!(result, "/api/v1/pods?limit=10");
1756    }
1757
1758    #[test]
1759    fn test_strip_proxy_query_param_middle() {
1760        let result =
1761            strip_proxy_query_param("/api/v1/pods?limit=10&token=PHANTOM123&watch=true", "token");
1762        assert_eq!(result, "/api/v1/pods?limit=10&watch=true");
1763    }
1764
1765    #[test]
1766    fn test_strip_proxy_query_param_no_match() {
1767        let result = strip_proxy_query_param("/api/v1/pods?limit=10", "token");
1768        assert_eq!(result, "/api/v1/pods?limit=10");
1769    }
1770
1771    #[test]
1772    fn test_strip_proxy_query_param_no_query_string() {
1773        let result = strip_proxy_query_param("/api/v1/pods", "token");
1774        assert_eq!(result, "/api/v1/pods");
1775    }
1776
1777    #[test]
1778    fn test_strip_proxy_artifacts_same_mode_noop() {
1779        // When proxy and upstream use the same mode, no stripping (upstream transform handles it)
1780        let path = "/PHANTOM123/api/v1/pods";
1781        let result = strip_proxy_artifacts(
1782            path,
1783            &InjectMode::UrlPath,
1784            &InjectMode::UrlPath,
1785            Some("/{}/"),
1786            None,
1787        );
1788        assert_eq!(result, path);
1789    }
1790
1791    #[test]
1792    fn test_strip_proxy_artifacts_url_path_to_header() {
1793        // Proxy uses url_path, upstream uses header — must strip path token
1794        let result = strip_proxy_artifacts(
1795            "/PHANTOM123/api/v1/pods",
1796            &InjectMode::UrlPath,
1797            &InjectMode::Header,
1798            Some("/{}/"),
1799            None,
1800        );
1801        assert_eq!(result, "/api/v1/pods");
1802    }
1803
1804    #[test]
1805    fn test_strip_proxy_artifacts_query_param_to_header() {
1806        // Proxy uses query_param, upstream uses header — must strip query param
1807        let result = strip_proxy_artifacts(
1808            "/api/v1/pods?token=PHANTOM123",
1809            &InjectMode::QueryParam,
1810            &InjectMode::Header,
1811            None,
1812            Some("token"),
1813        );
1814        assert_eq!(result, "/api/v1/pods");
1815    }
1816
1817    #[test]
1818    fn test_strip_proxy_artifacts_header_to_query_param() {
1819        // Proxy uses header, upstream uses query_param — no URL artifacts to strip
1820        let path = "/api/v1/pods";
1821        let result = strip_proxy_artifacts(
1822            path,
1823            &InjectMode::Header,
1824            &InjectMode::QueryParam,
1825            None,
1826            None,
1827        );
1828        assert_eq!(result, path);
1829    }
1830
1831    #[test]
1832    fn test_end_to_end_url_path_proxy_header_upstream() {
1833        // Full flow: proxy validates via url_path, upstream injects via header.
1834        // The path token must be stripped before forwarding.
1835        let token = Zeroizing::new("session456".to_string());
1836        let credential = Zeroizing::new("real_bearer_token".to_string());
1837        let path = "/session456/api/v1/namespaces";
1838
1839        // 1. Proxy-side validation succeeds
1840        assert!(validate_phantom_token_for_mode(
1841            &InjectMode::UrlPath,
1842            b"\r\n\r\n", // no auth header needed for url_path mode
1843            path,
1844            "Authorization",
1845            Some("/{}/"),
1846            None,
1847            &token,
1848        )
1849        .is_ok());
1850
1851        // 2. Strip proxy artifacts
1852        let cleaned = strip_proxy_artifacts(
1853            path,
1854            &InjectMode::UrlPath,
1855            &InjectMode::Header,
1856            Some("/{}/"),
1857            None,
1858        );
1859        assert_eq!(cleaned, "/api/v1/namespaces");
1860
1861        // 3. Upstream transform (header mode = no path change)
1862        let transformed =
1863            transform_path_for_mode(&InjectMode::Header, &cleaned, None, None, None, &credential)
1864                .unwrap();
1865        assert_eq!(transformed, "/api/v1/namespaces");
1866    }
1867
1868    #[test]
1869    fn test_end_to_end_query_param_proxy_header_upstream() {
1870        // Full flow: proxy validates via query_param, upstream injects via header.
1871        let token = Zeroizing::new("session789".to_string());
1872        let credential = Zeroizing::new("real_bearer_token".to_string());
1873        let path = "/api/v1/pods?token=session789&limit=100";
1874
1875        // 1. Proxy-side validation succeeds
1876        assert!(validate_phantom_token_for_mode(
1877            &InjectMode::QueryParam,
1878            b"\r\n\r\n",
1879            path,
1880            "Authorization",
1881            None,
1882            Some("token"),
1883            &token,
1884        )
1885        .is_ok());
1886
1887        // 2. Strip proxy artifacts
1888        let cleaned = strip_proxy_artifacts(
1889            path,
1890            &InjectMode::QueryParam,
1891            &InjectMode::Header,
1892            None,
1893            Some("token"),
1894        );
1895        assert_eq!(cleaned, "/api/v1/pods?limit=100");
1896
1897        // 3. Upstream transform (header mode = no path change)
1898        let transformed =
1899            transform_path_for_mode(&InjectMode::Header, &cleaned, None, None, None, &credential)
1900                .unwrap();
1901        assert_eq!(transformed, "/api/v1/pods?limit=100");
1902    }
1903}