1use 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
33const 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
79pub struct ReverseProxyCtx<'a> {
85 pub route_store: &'a RouteStore,
87 pub credential_store: &'a CredentialStore,
89 pub session_token: &'a Zeroizing<String>,
91 pub filter: &'a ProxyFilter,
93 pub tls_connector: &'a TlsConnector,
95 pub audit_log: Option<&'a audit::SharedAuditLog>,
97}
98
99pub 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 let (method, path, version) = parse_request_line(first_line)?;
121 debug!("Reverse proxy: {} {}", method, path);
122
123 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 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 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#[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 let access_token = oauth2_route.cache.get_or_refresh().await;
455
456 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 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 let filtered_headers = filter_headers(remaining_header, "Authorization");
538 let content_length = extract_content_length(remaining_header);
539
540 let body = match read_request_body(stream, content_length, buffered_body).await? {
542 Some(body) => body,
543 None => return Ok(()),
544 };
545
546 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 request.push_str(&format!(
555 "Authorization: Bearer {}\r\n",
556 access_token.as_str()
557 ));
558
559 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
620pub(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
653pub(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
671fn 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
687fn 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 Ok((trimmed.to_string(), "/".to_string()))
698 }
699}
700
701fn 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 let value_lower = value.to_lowercase();
723 let token_value = if value_lower.starts_with("bearer ") {
724 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
745pub(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
784pub(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 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
889async 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
904pub(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_phantom_token(header_bytes, header_name, session_token)
927 }
928 InjectMode::UrlPath => {
929 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 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
945fn validate_phantom_token_in_path(
950 path: &str,
951 pattern: &str,
952 session_token: &Zeroizing<String>,
953) -> Result<()> {
954 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 if let Some(start) = path.find(prefix) {
966 let after_prefix = start + prefix.len();
967
968 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
995fn validate_phantom_token_in_query(
997 path: &str,
998 param_name: &str,
999 session_token: &Zeroizing<String>,
1000) -> Result<()> {
1001 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 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
1023pub(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 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
1057fn transform_url_path(
1061 path: &str,
1062 pattern: &str,
1063 replacement: &str,
1064 credential: &Zeroizing<String>,
1065) -> Result<String> {
1066 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 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 if let Some(start) = path.find(pattern_prefix) {
1088 let after_prefix = start + pattern_prefix.len();
1089
1090 let end_offset = if pattern_suffix.is_empty() {
1092 path[after_prefix..]
1094 .find(['/', '?'])
1095 .unwrap_or(path[after_prefix..].len())
1096 } else {
1097 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
1127fn 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 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 Ok(format!(
1159 "{}?{}&{}={}",
1160 base_path, query, param_name, encoded_value
1161 ))
1162 }
1163 } else {
1164 Ok(format!("{}?{}={}", path, param_name, encoded_value))
1166 }
1167}
1168
1169pub(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 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 InjectMode::Header | InjectMode::BasicAuth => path.to_string(),
1209 }
1210}
1211
1212fn 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 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 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
1268fn 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
1295pub(crate) fn inject_credential_for_mode(cred: &LoadedCredential, request: &mut Zeroizing<String>) {
1300 match cred.inject_mode {
1301 InjectMode::Header | InjectMode::BasicAuth => {
1302 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 }
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 #[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 #[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 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 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 #[test]
1698 fn test_strip_proxy_path_token_basic() {
1699 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 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 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 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 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 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 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 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 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 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 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 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 assert!(validate_phantom_token_for_mode(
1841 &InjectMode::UrlPath,
1842 b"\r\n\r\n", path,
1844 "Authorization",
1845 Some("/{}/"),
1846 None,
1847 &token,
1848 )
1849 .is_ok());
1850
1851 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 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 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 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 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 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}