use crate::audit;
use crate::config::InjectMode;
use crate::credential::{CredentialStore, LoadedCredential};
use crate::error::{ProxyError, Result};
use crate::filter::ProxyFilter;
use crate::route::RouteStore;
use crate::token;
use std::net::SocketAddr;
use std::time::Duration;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio_rustls::TlsConnector;
use tracing::{debug, warn};
use zeroize::Zeroizing;
const MAX_REQUEST_BODY: usize = 16 * 1024 * 1024;
const UPSTREAM_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
pub struct ReverseProxyCtx<'a> {
pub route_store: &'a RouteStore,
pub credential_store: &'a CredentialStore,
pub session_token: &'a Zeroizing<String>,
pub filter: &'a ProxyFilter,
pub tls_connector: &'a TlsConnector,
pub audit_log: Option<&'a audit::SharedAuditLog>,
}
pub async fn handle_reverse_proxy(
first_line: &str,
stream: &mut TcpStream,
remaining_header: &[u8],
ctx: &ReverseProxyCtx<'_>,
buffered_body: &[u8],
) -> Result<()> {
let (method, path, version) = parse_request_line(first_line)?;
debug!("Reverse proxy: {} {}", method, path);
let (service, upstream_path) = parse_service_prefix(&path)?;
let route = ctx
.route_store
.get(&service)
.ok_or_else(|| ProxyError::UnknownService {
prefix: service.clone(),
})?;
let static_cred = ctx.credential_store.get(&service);
let oauth2_route = ctx.credential_store.get_oauth2(&service);
if !route.endpoint_rules.is_allowed(&method, &upstream_path) {
let reason = format!(
"endpoint denied: {} {} on service '{}'",
method, upstream_path, service
);
warn!("{}", reason);
audit::log_denied(
ctx.audit_log,
audit::ProxyMode::Reverse,
&service,
0,
&reason,
);
send_error(stream, 403, "Forbidden").await?;
return Ok(());
}
if let Some(oauth2_route) = oauth2_route {
return handle_oauth2_credential(
oauth2_route,
route,
&service,
&upstream_path,
&method,
&version,
stream,
remaining_header,
buffered_body,
ctx,
)
.await;
}
let cred = static_cred;
if let Some(cred) = cred {
if let Err(e) = validate_phantom_token_for_mode(
&cred.proxy_inject_mode,
remaining_header,
&upstream_path,
&cred.proxy_header_name,
cred.proxy_path_pattern.as_deref(),
cred.proxy_query_param_name.as_deref(),
ctx.session_token,
) {
audit::log_denied(
ctx.audit_log,
audit::ProxyMode::Reverse,
&service,
0,
&e.to_string(),
);
send_error(stream, 401, "Unauthorized").await?;
return Ok(());
}
} else if let Err(e) = token::validate_proxy_auth(remaining_header, ctx.session_token) {
audit::log_denied(
ctx.audit_log,
audit::ProxyMode::Reverse,
&service,
0,
&e.to_string(),
);
send_error(stream, 407, "Proxy Authentication Required").await?;
return Ok(());
}
let transformed_path = if let Some(cred) = cred {
let cleaned_path = strip_proxy_artifacts(
&upstream_path,
&cred.proxy_inject_mode,
&cred.inject_mode,
cred.proxy_path_pattern.as_deref(),
cred.proxy_query_param_name.as_deref(),
);
transform_path_for_mode(
&cred.inject_mode,
&cleaned_path,
cred.path_pattern.as_deref(),
cred.path_replacement.as_deref(),
cred.query_param_name.as_deref(),
&cred.raw_credential,
)?
} else {
upstream_path.clone()
};
let upstream_url = format!(
"{}{}",
route.upstream.trim_end_matches('/'),
transformed_path
);
debug!("Forwarding to upstream: {} {}", method, upstream_url);
let (upstream_scheme, upstream_host, upstream_port, upstream_path_full) =
parse_upstream_url(&upstream_url)?;
let check = ctx.filter.check_host(&upstream_host, upstream_port).await?;
if !check.result.is_allowed() {
let reason = check.result.reason();
warn!("Upstream host denied by filter: {}", reason);
send_error(stream, 403, "Forbidden").await?;
audit::log_denied(
ctx.audit_log,
audit::ProxyMode::Reverse,
&service,
0,
&reason,
);
return Ok(());
}
if let Err(reason) =
validate_http_upstream_target(upstream_scheme, &upstream_host, &check.resolved_addrs)
{
warn!("{}", reason);
send_error(stream, 502, "Bad Gateway").await?;
audit::log_denied(
ctx.audit_log,
audit::ProxyMode::Reverse,
&service,
0,
&reason,
);
return Ok(());
}
let strip_header = cred.map(|c| c.proxy_header_name.as_str()).unwrap_or("");
let filtered_headers = filter_headers(remaining_header, strip_header);
let content_length = extract_content_length(remaining_header);
let body = match read_request_body(stream, content_length, buffered_body).await? {
Some(body) => body,
None => return Ok(()),
};
let upstream_authority = format_host_header(upstream_scheme, &upstream_host, upstream_port);
let mut request = Zeroizing::new(format!(
"{} {} {}\r\nHost: {}\r\n",
method, upstream_path_full, version, upstream_authority
));
if let Some(cred) = cred {
inject_credential_for_mode(cred, &mut request);
}
let auth_header_lower = cred.map(|c| c.header_name.to_lowercase());
for (name, value) in &filtered_headers {
if let (Some(cred), Some(header_lower)) = (cred, auth_header_lower.as_ref()) {
if matches!(cred.inject_mode, InjectMode::Header | InjectMode::BasicAuth)
&& name.to_lowercase() == *header_lower
{
continue;
}
}
request.push_str(&format!("{}: {}\r\n", name, value));
}
request.push_str("Connection: close\r\n");
if !body.is_empty() {
request.push_str(&format!("Content-Length: {}\r\n", body.len()));
}
request.push_str("\r\n");
let status_code = match upstream_scheme {
UpstreamScheme::Https => {
let connector = route.tls_connector.as_ref().unwrap_or(ctx.tls_connector);
let mut tls_stream = match connect_upstream_tls(
&upstream_host,
upstream_port,
&check.resolved_addrs,
connector,
)
.await
{
Ok(s) => s,
Err(e) => {
warn!("Upstream connection failed: {}", e);
send_error(stream, 502, "Bad Gateway").await?;
audit::log_denied(
ctx.audit_log,
audit::ProxyMode::Reverse,
&service,
0,
&e.to_string(),
);
return Ok(());
}
};
write_upstream_request(&mut tls_stream, &request, &body).await?;
stream_response(&mut tls_stream, stream).await?
}
UpstreamScheme::Http => {
let mut upstream_stream =
match connect_upstream_tcp(&upstream_host, upstream_port, &check.resolved_addrs)
.await
{
Ok(s) => s,
Err(e) => {
warn!("Upstream connection failed: {}", e);
send_error(stream, 502, "Bad Gateway").await?;
audit::log_denied(
ctx.audit_log,
audit::ProxyMode::Reverse,
&service,
0,
&e.to_string(),
);
return Ok(());
}
};
write_upstream_request(&mut upstream_stream, &request, &body).await?;
stream_response(&mut upstream_stream, stream).await?
}
};
audit::log_reverse_proxy(
ctx.audit_log,
&service,
&method,
&upstream_path,
status_code,
);
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn handle_oauth2_credential(
oauth2_route: &crate::credential::OAuth2Route,
route: &crate::route::LoadedRoute,
service: &str,
upstream_path: &str,
method: &str,
version: &str,
stream: &mut TcpStream,
remaining_header: &[u8],
buffered_body: &[u8],
ctx: &ReverseProxyCtx<'_>,
) -> Result<()> {
let access_token = oauth2_route.cache.get_or_refresh().await;
if let Err(e) = validate_phantom_token(remaining_header, "Authorization", ctx.session_token) {
audit::log_denied(
ctx.audit_log,
audit::ProxyMode::Reverse,
service,
0,
&e.to_string(),
);
send_error(stream, 401, "Unauthorized").await?;
return Ok(());
}
let upstream_url = format!(
"{}{}",
oauth2_route.upstream.trim_end_matches('/'),
upstream_path
);
debug!("OAuth2 forwarding to upstream: {} {}", method, upstream_url);
let (upstream_scheme, upstream_host, upstream_port, upstream_path_full) =
parse_upstream_url(&upstream_url)?;
let check = ctx.filter.check_host(&upstream_host, upstream_port).await?;
if !check.result.is_allowed() {
let reason = check.result.reason();
warn!("Upstream host denied by filter: {}", reason);
send_error(stream, 403, "Forbidden").await?;
audit::log_denied(
ctx.audit_log,
audit::ProxyMode::Reverse,
service,
0,
&reason,
);
return Ok(());
}
if let Err(reason) =
validate_http_upstream_target(upstream_scheme, &upstream_host, &check.resolved_addrs)
{
warn!("{}", reason);
send_error(stream, 502, "Bad Gateway").await?;
audit::log_denied(
ctx.audit_log,
audit::ProxyMode::Reverse,
service,
0,
&reason,
);
return Ok(());
}
let filtered_headers = filter_headers(remaining_header, "Authorization");
let content_length = extract_content_length(remaining_header);
let body = match read_request_body(stream, content_length, buffered_body).await? {
Some(body) => body,
None => return Ok(()),
};
let upstream_authority = format_host_header(upstream_scheme, &upstream_host, upstream_port);
let mut request = Zeroizing::new(format!(
"{} {} {}\r\nHost: {}\r\n",
method, upstream_path_full, version, upstream_authority
));
request.push_str(&format!(
"Authorization: Bearer {}\r\n",
access_token.as_str()
));
for (name, value) in &filtered_headers {
request.push_str(&format!("{}: {}\r\n", name, value));
}
if !body.is_empty() {
request.push_str(&format!("Content-Length: {}\r\n", body.len()));
}
request.push_str("\r\n");
let status_code = match upstream_scheme {
UpstreamScheme::Https => {
let connector = route.tls_connector.as_ref().unwrap_or(ctx.tls_connector);
let mut tls_stream = match connect_upstream_tls(
&upstream_host,
upstream_port,
&check.resolved_addrs,
connector,
)
.await
{
Ok(s) => s,
Err(e) => {
warn!("Upstream connection failed: {}", e);
send_error(stream, 502, "Bad Gateway").await?;
audit::log_denied(
ctx.audit_log,
audit::ProxyMode::Reverse,
service,
0,
&e.to_string(),
);
return Ok(());
}
};
write_upstream_request(&mut tls_stream, &request, &body).await?;
stream_response(&mut tls_stream, stream).await?
}
UpstreamScheme::Http => {
let mut upstream_stream =
match connect_upstream_tcp(&upstream_host, upstream_port, &check.resolved_addrs)
.await
{
Ok(s) => s,
Err(e) => {
warn!("Upstream connection failed: {}", e);
send_error(stream, 502, "Bad Gateway").await?;
audit::log_denied(
ctx.audit_log,
audit::ProxyMode::Reverse,
service,
0,
&e.to_string(),
);
return Ok(());
}
};
write_upstream_request(&mut upstream_stream, &request, &body).await?;
stream_response(&mut upstream_stream, stream).await?
}
};
audit::log_reverse_proxy(ctx.audit_log, service, method, upstream_path, status_code);
Ok(())
}
async fn write_upstream_request<S>(stream: &mut S, request: &str, body: &[u8]) -> Result<()>
where
S: AsyncWrite + Unpin,
{
stream.write_all(request.as_bytes()).await?;
if !body.is_empty() {
stream.write_all(body).await?;
}
stream.flush().await?;
Ok(())
}
async fn read_request_body(
stream: &mut TcpStream,
content_length: Option<usize>,
buffered_body: &[u8],
) -> Result<Option<Vec<u8>>> {
if let Some(len) = content_length {
if len > MAX_REQUEST_BODY {
send_error(stream, 413, "Payload Too Large").await?;
return Ok(None);
}
let mut buf = Vec::with_capacity(len);
let pre = buffered_body.len().min(len);
buf.extend_from_slice(&buffered_body[..pre]);
let remaining = len - pre;
if remaining > 0 {
let mut rest = vec![0u8; remaining];
stream.read_exact(&mut rest).await?;
buf.extend_from_slice(&rest);
}
Ok(Some(buf))
} else {
Ok(Some(Vec::new()))
}
}
async fn stream_response<S>(tls_stream: &mut S, stream: &mut TcpStream) -> Result<u16>
where
S: AsyncRead + AsyncWrite + Unpin,
{
let mut response_buf = [0u8; 8192];
let mut status_code: u16 = 502;
let mut first_chunk = true;
loop {
let n = match tls_stream.read(&mut response_buf).await {
Ok(0) => break,
Ok(n) => n,
Err(e) => {
debug!("Upstream read error: {}", e);
break;
}
};
if first_chunk {
status_code = parse_response_status(&response_buf[..n]);
first_chunk = false;
}
stream.write_all(&response_buf[..n]).await?;
stream.flush().await?;
}
Ok(status_code)
}
fn parse_request_line(line: &str) -> Result<(String, String, String)> {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 3 {
return Err(ProxyError::HttpParse(format!(
"malformed request line: {}",
line
)));
}
Ok((
parts[0].to_string(),
parts[1].to_string(),
parts[2].to_string(),
))
}
fn parse_service_prefix(path: &str) -> Result<(String, String)> {
let trimmed = path.strip_prefix('/').unwrap_or(path);
if let Some((prefix, rest)) = trimmed.split_once('/') {
Ok((prefix.to_string(), format!("/{}", rest)))
} else {
Ok((trimmed.to_string(), "/".to_string()))
}
}
fn validate_phantom_token(
header_bytes: &[u8],
header_name: &str,
session_token: &Zeroizing<String>,
) -> Result<()> {
let header_str = std::str::from_utf8(header_bytes).map_err(|_| ProxyError::InvalidToken)?;
let header_name_lower = header_name.to_lowercase();
for line in header_str.lines() {
let lower = line.to_lowercase();
if lower.starts_with(&format!("{}:", header_name_lower)) {
let value = line.split_once(':').map(|(_, v)| v.trim()).unwrap_or("");
let value_lower = value.to_lowercase();
let token_value = if value_lower.starts_with("bearer ") {
value[7..].trim()
} else {
value
};
if token::constant_time_eq(token_value.as_bytes(), session_token.as_bytes()) {
return Ok(());
}
warn!("Invalid phantom token in {} header", header_name);
return Err(ProxyError::InvalidToken);
}
}
warn!(
"Missing {} header for phantom token validation",
header_name
);
Err(ProxyError::InvalidToken)
}
fn filter_headers(header_bytes: &[u8], cred_header: &str) -> Vec<(String, String)> {
let header_str = std::str::from_utf8(header_bytes).unwrap_or("");
let cred_header_lower = if cred_header.is_empty() {
String::new()
} else {
format!("{}:", cred_header.to_lowercase())
};
let mut headers = Vec::new();
for line in header_str.lines() {
let lower = line.to_lowercase();
if lower.starts_with("host:")
|| lower.starts_with("content-length:")
|| lower.starts_with("connection:")
|| lower.starts_with("proxy-authorization:")
|| (!cred_header_lower.is_empty() && lower.starts_with(&cred_header_lower))
|| line.trim().is_empty()
{
continue;
}
if let Some((name, value)) = line.split_once(':') {
headers.push((name.trim().to_string(), value.trim().to_string()));
}
}
headers
}
fn extract_content_length(header_bytes: &[u8]) -> Option<usize> {
let header_str = std::str::from_utf8(header_bytes).ok()?;
for line in header_str.lines() {
if line.to_lowercase().starts_with("content-length:") {
let value = line.split_once(':')?.1.trim();
return value.parse().ok();
}
}
None
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum UpstreamScheme {
Http,
Https,
}
fn validate_http_upstream_target(
scheme: UpstreamScheme,
host: &str,
resolved_addrs: &[SocketAddr],
) -> std::result::Result<(), String> {
if matches!(scheme, UpstreamScheme::Https) {
return Ok(());
}
if is_local_only_target(host, resolved_addrs) {
Ok(())
} else {
Err(format!(
"refusing insecure http upstream for non-local host '{}'; http is only allowed for loopback addresses",
host
))
}
}
fn is_local_only_target(host: &str, resolved_addrs: &[SocketAddr]) -> bool {
if !resolved_addrs.is_empty() {
return resolved_addrs.iter().all(|addr| addr.ip().is_loopback());
}
match host.parse::<std::net::IpAddr>() {
Ok(std::net::IpAddr::V4(ip)) => ip.is_loopback(),
Ok(std::net::IpAddr::V6(ip)) => ip.is_loopback(),
Err(_) => false,
}
}
fn format_host_header(scheme: UpstreamScheme, host: &str, port: u16) -> String {
let default_port = match scheme {
UpstreamScheme::Http => 80,
UpstreamScheme::Https => 443,
};
let bracketed_host = if host.contains(':') && !host.starts_with('[') {
format!("[{}]", host)
} else {
host.to_string()
};
if port == default_port {
bracketed_host
} else {
format!("{}:{}", bracketed_host, port)
}
}
fn parse_upstream_url(url_str: &str) -> Result<(UpstreamScheme, String, u16, String)> {
let parsed = url::Url::parse(url_str)
.map_err(|e| ProxyError::HttpParse(format!("invalid upstream URL '{}': {}", url_str, e)))?;
let scheme = match parsed.scheme() {
"https" => UpstreamScheme::Https,
"http" => UpstreamScheme::Http,
_ => {
return Err(ProxyError::HttpParse(format!(
"unsupported URL scheme: {}",
url_str
)));
}
};
let host = parsed
.host_str()
.ok_or_else(|| ProxyError::HttpParse(format!("missing host in URL: {}", url_str)))?
.to_string();
let default_port = if matches!(scheme, UpstreamScheme::Https) {
443
} else {
80
};
let port = parsed.port().unwrap_or(default_port);
let path = parsed.path().to_string();
let path = if path.is_empty() {
"/".to_string()
} else {
path
};
let path_with_query = if let Some(query) = parsed.query() {
format!("{}?{}", path, query)
} else {
path
};
Ok((scheme, host, port, path_with_query))
}
async fn connect_upstream_tls(
host: &str,
port: u16,
resolved_addrs: &[SocketAddr],
connector: &TlsConnector,
) -> Result<tokio_rustls::client::TlsStream<TcpStream>> {
let tcp = if resolved_addrs.is_empty() {
let addr = format!("{}:{}", host, port);
match tokio::time::timeout(UPSTREAM_CONNECT_TIMEOUT, TcpStream::connect(&addr)).await {
Ok(Ok(s)) => s,
Ok(Err(e)) => {
return Err(ProxyError::UpstreamConnect {
host: host.to_string(),
reason: e.to_string(),
});
}
Err(_) => {
return Err(ProxyError::UpstreamConnect {
host: host.to_string(),
reason: "connection timed out".to_string(),
});
}
}
} else {
connect_to_resolved(resolved_addrs, host).await?
};
let server_name = rustls::pki_types::ServerName::try_from(host.to_string()).map_err(|_| {
ProxyError::UpstreamConnect {
host: host.to_string(),
reason: "invalid server name for TLS".to_string(),
}
})?;
let tls_stream =
connector
.connect(server_name, tcp)
.await
.map_err(|e| ProxyError::UpstreamConnect {
host: host.to_string(),
reason: format!("TLS handshake failed: {}", e),
})?;
Ok(tls_stream)
}
async fn connect_upstream_tcp(
host: &str,
port: u16,
resolved_addrs: &[SocketAddr],
) -> Result<TcpStream> {
if resolved_addrs.is_empty() {
let addr = format!("{}:{}", host, port);
match tokio::time::timeout(UPSTREAM_CONNECT_TIMEOUT, TcpStream::connect(&addr)).await {
Ok(Ok(s)) => Ok(s),
Ok(Err(e)) => Err(ProxyError::UpstreamConnect {
host: host.to_string(),
reason: e.to_string(),
}),
Err(_) => Err(ProxyError::UpstreamConnect {
host: host.to_string(),
reason: "connection timed out".to_string(),
}),
}
} else {
connect_to_resolved(resolved_addrs, host).await
}
}
async fn connect_to_resolved(addrs: &[SocketAddr], host: &str) -> Result<TcpStream> {
let mut last_err = None;
for addr in addrs {
match tokio::time::timeout(UPSTREAM_CONNECT_TIMEOUT, TcpStream::connect(addr)).await {
Ok(Ok(stream)) => return Ok(stream),
Ok(Err(e)) => {
debug!("Connect to {} failed: {}", addr, e);
last_err = Some(e.to_string());
}
Err(_) => {
debug!("Connect to {} timed out", addr);
last_err = Some("connection timed out".to_string());
}
}
}
Err(ProxyError::UpstreamConnect {
host: host.to_string(),
reason: last_err.unwrap_or_else(|| "no addresses to connect to".to_string()),
})
}
fn parse_response_status(data: &[u8]) -> u16 {
let line_end = data
.iter()
.position(|&b| b == b'\r' || b == b'\n')
.unwrap_or(data.len());
let first_line = &data[..line_end.min(64)];
if let Ok(line) = std::str::from_utf8(first_line) {
let mut parts = line.split_whitespace();
if let Some(version) = parts.next() {
if version.starts_with("HTTP/") {
if let Some(code_str) = parts.next() {
if code_str.len() == 3 {
return code_str.parse().unwrap_or(502);
}
}
}
}
}
502
}
async fn send_error(stream: &mut TcpStream, status: u16, reason: &str) -> Result<()> {
let body = format!("{{\"error\":\"{}\"}}", reason);
let response = format!(
"HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
status,
reason,
body.len(),
body
);
stream.write_all(response.as_bytes()).await?;
stream.flush().await?;
Ok(())
}
fn validate_phantom_token_for_mode(
mode: &InjectMode,
header_bytes: &[u8],
path: &str,
header_name: &str,
path_pattern: Option<&str>,
query_param_name: Option<&str>,
session_token: &Zeroizing<String>,
) -> Result<()> {
match mode {
InjectMode::Header | InjectMode::BasicAuth => {
validate_phantom_token(header_bytes, header_name, session_token)
}
InjectMode::UrlPath => {
let pattern = path_pattern.ok_or_else(|| {
ProxyError::HttpParse("url_path mode requires path_pattern".to_string())
})?;
validate_phantom_token_in_path(path, pattern, session_token)
}
InjectMode::QueryParam => {
let param_name = query_param_name.ok_or_else(|| {
ProxyError::HttpParse("query_param mode requires query_param_name".to_string())
})?;
validate_phantom_token_in_query(path, param_name, session_token)
}
}
}
fn validate_phantom_token_in_path(
path: &str,
pattern: &str,
session_token: &Zeroizing<String>,
) -> Result<()> {
let parts: Vec<&str> = pattern.split("{}").collect();
if parts.len() != 2 {
return Err(ProxyError::HttpParse(format!(
"invalid path_pattern '{}': must contain exactly one {{}}",
pattern
)));
}
let (prefix, suffix) = (parts[0], parts[1]);
if let Some(start) = path.find(prefix) {
let after_prefix = start + prefix.len();
let end_offset = if suffix.is_empty() {
path[after_prefix..]
.find(['/', '?'])
.unwrap_or(path[after_prefix..].len())
} else {
match path[after_prefix..].find(suffix) {
Some(offset) => offset,
None => {
warn!("Missing phantom token in URL path (pattern: {})", pattern);
return Err(ProxyError::InvalidToken);
}
}
};
let token = &path[after_prefix..after_prefix + end_offset];
if token::constant_time_eq(token.as_bytes(), session_token.as_bytes()) {
return Ok(());
}
warn!("Invalid phantom token in URL path");
return Err(ProxyError::InvalidToken);
}
warn!("Missing phantom token in URL path (pattern: {})", pattern);
Err(ProxyError::InvalidToken)
}
fn validate_phantom_token_in_query(
path: &str,
param_name: &str,
session_token: &Zeroizing<String>,
) -> Result<()> {
if let Some(query_start) = path.find('?') {
let query = &path[query_start + 1..];
for pair in query.split('&') {
if let Some((name, value)) = pair.split_once('=') {
if name == param_name {
let decoded = urlencoding::decode(value).unwrap_or_else(|_| value.into());
if token::constant_time_eq(decoded.as_bytes(), session_token.as_bytes()) {
return Ok(());
}
warn!("Invalid phantom token in query parameter '{}'", param_name);
return Err(ProxyError::InvalidToken);
}
}
}
}
warn!("Missing phantom token in query parameter '{}'", param_name);
Err(ProxyError::InvalidToken)
}
fn transform_path_for_mode(
mode: &InjectMode,
path: &str,
path_pattern: Option<&str>,
path_replacement: Option<&str>,
query_param_name: Option<&str>,
credential: &Zeroizing<String>,
) -> Result<String> {
match mode {
InjectMode::Header | InjectMode::BasicAuth => {
Ok(path.to_string())
}
InjectMode::UrlPath => {
let pattern = path_pattern.ok_or_else(|| {
ProxyError::HttpParse("url_path mode requires path_pattern".to_string())
})?;
let replacement = path_replacement.unwrap_or(pattern);
transform_url_path(path, pattern, replacement, credential)
}
InjectMode::QueryParam => {
let param_name = query_param_name.ok_or_else(|| {
ProxyError::HttpParse("query_param mode requires query_param_name".to_string())
})?;
transform_query_param(path, param_name, credential)
}
}
}
fn transform_url_path(
path: &str,
pattern: &str,
replacement: &str,
credential: &Zeroizing<String>,
) -> Result<String> {
let parts: Vec<&str> = pattern.split("{}").collect();
if parts.len() != 2 {
return Err(ProxyError::HttpParse(format!(
"invalid path_pattern '{}': must contain exactly one {{}}",
pattern
)));
}
let (pattern_prefix, pattern_suffix) = (parts[0], parts[1]);
let repl_parts: Vec<&str> = replacement.split("{}").collect();
if repl_parts.len() != 2 {
return Err(ProxyError::HttpParse(format!(
"invalid path_replacement '{}': must contain exactly one {{}}",
replacement
)));
}
let (repl_prefix, repl_suffix) = (repl_parts[0], repl_parts[1]);
if let Some(start) = path.find(pattern_prefix) {
let after_prefix = start + pattern_prefix.len();
let end_offset = if pattern_suffix.is_empty() {
path[after_prefix..]
.find(['/', '?'])
.unwrap_or(path[after_prefix..].len())
} else {
match path[after_prefix..].find(pattern_suffix) {
Some(offset) => offset,
None => {
return Err(ProxyError::HttpParse(format!(
"path '{}' does not match pattern '{}'",
path, pattern
)));
}
}
};
let before = &path[..start];
let after = &path[after_prefix + end_offset + pattern_suffix.len()..];
return Ok(format!(
"{}{}{}{}{}",
before,
repl_prefix,
credential.as_str(),
repl_suffix,
after
));
}
Err(ProxyError::HttpParse(format!(
"path '{}' does not match pattern '{}'",
path, pattern
)))
}
fn transform_query_param(
path: &str,
param_name: &str,
credential: &Zeroizing<String>,
) -> Result<String> {
let encoded_value = urlencoding::encode(credential.as_str());
if let Some(query_start) = path.find('?') {
let base_path = &path[..query_start];
let query = &path[query_start + 1..];
let mut found = false;
let new_query: Vec<String> = query
.split('&')
.map(|pair| {
if let Some((name, _)) = pair.split_once('=') {
if name == param_name {
found = true;
return format!("{}={}", param_name, encoded_value);
}
}
pair.to_string()
})
.collect();
if found {
Ok(format!("{}?{}", base_path, new_query.join("&")))
} else {
Ok(format!(
"{}?{}&{}={}",
base_path, query, param_name, encoded_value
))
}
} else {
Ok(format!("{}?{}={}", path, param_name, encoded_value))
}
}
fn strip_proxy_artifacts(
path: &str,
proxy_mode: &InjectMode,
upstream_mode: &InjectMode,
proxy_path_pattern: Option<&str>,
proxy_query_param_name: Option<&str>,
) -> String {
if proxy_mode == upstream_mode {
return path.to_string();
}
match proxy_mode {
InjectMode::UrlPath => {
if let Some(pattern) = proxy_path_pattern {
strip_proxy_path_token(path, pattern)
} else {
path.to_string()
}
}
InjectMode::QueryParam => {
if let Some(param_name) = proxy_query_param_name {
strip_proxy_query_param(path, param_name)
} else {
path.to_string()
}
}
InjectMode::Header | InjectMode::BasicAuth => path.to_string(),
}
}
fn strip_proxy_path_token(path: &str, pattern: &str) -> String {
let parts: Vec<&str> = pattern.split("{}").collect();
if parts.len() != 2 {
return path.to_string();
}
let (prefix, suffix) = (parts[0], parts[1]);
let start = if path.starts_with(prefix) {
Some(0)
} else {
path.find(prefix)
};
if let Some(start) = start {
let after_prefix = start + prefix.len();
let end_offset = if suffix.is_empty() {
path[after_prefix..]
.find(['/', '?'])
.unwrap_or(path[after_prefix..].len())
} else {
match path[after_prefix..].find(suffix) {
Some(offset) => offset,
None => return path.to_string(),
}
};
let before = &path[..start];
let after = &path[after_prefix + end_offset + suffix.len()..];
let joined = match (before.ends_with('/'), after.starts_with('/')) {
(true, true) => format!("{}{}", before, &after[1..]),
(false, false) if !before.is_empty() && !after.is_empty() => {
format!("{}/{}", before, after)
}
_ => format!("{}{}", before, after),
};
if joined.is_empty() || !joined.starts_with('/') {
format!("/{}", joined)
} else {
joined
}
} else {
path.to_string()
}
}
fn strip_proxy_query_param(path: &str, param_name: &str) -> String {
if let Some(query_start) = path.find('?') {
let base_path = &path[..query_start];
let query = &path[query_start + 1..];
let remaining: Vec<&str> = query
.split('&')
.filter(|pair| {
pair.split_once('=')
.map(|(name, _)| name != param_name)
.unwrap_or(true)
})
.collect();
if remaining.is_empty() {
base_path.to_string()
} else {
format!("{}?{}", base_path, remaining.join("&"))
}
} else {
path.to_string()
}
}
fn inject_credential_for_mode(cred: &LoadedCredential, request: &mut Zeroizing<String>) {
match cred.inject_mode {
InjectMode::Header | InjectMode::BasicAuth => {
request.push_str(&format!(
"{}: {}\r\n",
cred.header_name,
cred.header_value.as_str()
));
}
InjectMode::UrlPath | InjectMode::QueryParam => {
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_parse_request_line() {
let (method, path, version) = parse_request_line("POST /openai/v1/chat HTTP/1.1").unwrap();
assert_eq!(method, "POST");
assert_eq!(path, "/openai/v1/chat");
assert_eq!(version, "HTTP/1.1");
}
#[test]
fn test_parse_request_line_malformed() {
assert!(parse_request_line("GET").is_err());
}
#[test]
fn test_parse_service_prefix() {
let (service, path) = parse_service_prefix("/openai/v1/chat/completions").unwrap();
assert_eq!(service, "openai");
assert_eq!(path, "/v1/chat/completions");
}
#[test]
fn test_parse_service_prefix_no_subpath() {
let (service, path) = parse_service_prefix("/anthropic").unwrap();
assert_eq!(service, "anthropic");
assert_eq!(path, "/");
}
#[test]
fn test_validate_phantom_token_bearer_valid() {
let token = Zeroizing::new("secret123".to_string());
let header = b"Authorization: Bearer secret123\r\nContent-Type: application/json\r\n\r\n";
assert!(validate_phantom_token(header, "Authorization", &token).is_ok());
}
#[test]
fn test_validate_phantom_token_bearer_invalid() {
let token = Zeroizing::new("secret123".to_string());
let header = b"Authorization: Bearer wrong\r\n\r\n";
assert!(validate_phantom_token(header, "Authorization", &token).is_err());
}
#[test]
fn test_validate_phantom_token_x_api_key_valid() {
let token = Zeroizing::new("secret123".to_string());
let header = b"x-api-key: secret123\r\nContent-Type: application/json\r\n\r\n";
assert!(validate_phantom_token(header, "x-api-key", &token).is_ok());
}
#[test]
fn test_validate_phantom_token_x_goog_api_key_valid() {
let token = Zeroizing::new("secret123".to_string());
let header = b"x-goog-api-key: secret123\r\nContent-Type: application/json\r\n\r\n";
assert!(validate_phantom_token(header, "x-goog-api-key", &token).is_ok());
}
#[test]
fn test_validate_phantom_token_missing() {
let token = Zeroizing::new("secret123".to_string());
let header = b"Content-Type: application/json\r\n\r\n";
assert!(validate_phantom_token(header, "Authorization", &token).is_err());
}
#[test]
fn test_validate_phantom_token_case_insensitive_header() {
let token = Zeroizing::new("secret123".to_string());
let header = b"AUTHORIZATION: Bearer secret123\r\n\r\n";
assert!(validate_phantom_token(header, "Authorization", &token).is_ok());
}
#[test]
fn test_filter_headers_removes_host_auth() {
let header = b"Host: localhost:8080\r\nAuthorization: Bearer old\r\nContent-Type: application/json\r\nAccept: */*\r\n\r\n";
let filtered = filter_headers(header, "Authorization");
assert_eq!(filtered.len(), 2);
assert_eq!(filtered[0].0, "Content-Type");
assert_eq!(filtered[1].0, "Accept");
}
#[test]
fn test_filter_headers_removes_x_api_key() {
let header = b"x-api-key: sk-old\r\nContent-Type: application/json\r\n\r\n";
let filtered = filter_headers(header, "x-api-key");
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].0, "Content-Type");
}
#[test]
fn test_filter_headers_removes_custom_header() {
let header = b"PRIVATE-TOKEN: phantom123\r\nContent-Type: application/json\r\n\r\n";
let filtered = filter_headers(header, "PRIVATE-TOKEN");
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].0, "Content-Type");
}
#[test]
fn test_extract_content_length() {
let header = b"Content-Type: application/json\r\nContent-Length: 42\r\n\r\n";
assert_eq!(extract_content_length(header), Some(42));
}
#[test]
fn test_extract_content_length_missing() {
let header = b"Content-Type: application/json\r\n\r\n";
assert_eq!(extract_content_length(header), None);
}
#[test]
fn test_parse_upstream_url_https() {
let (scheme, host, port, path) =
parse_upstream_url("https://api.openai.com/v1/chat/completions").unwrap();
assert_eq!(scheme, UpstreamScheme::Https);
assert_eq!(host, "api.openai.com");
assert_eq!(port, 443);
assert_eq!(path, "/v1/chat/completions");
}
#[test]
fn test_parse_upstream_url_http_with_port() {
let (scheme, host, port, path) = parse_upstream_url("http://localhost:8080/api").unwrap();
assert_eq!(scheme, UpstreamScheme::Http);
assert_eq!(host, "localhost");
assert_eq!(port, 8080);
assert_eq!(path, "/api");
}
#[test]
fn test_parse_upstream_url_no_path() {
let (scheme, host, port, path) = parse_upstream_url("https://api.anthropic.com").unwrap();
assert_eq!(scheme, UpstreamScheme::Https);
assert_eq!(host, "api.anthropic.com");
assert_eq!(port, 443);
assert_eq!(path, "/");
}
#[test]
fn test_parse_upstream_url_invalid_scheme() {
assert!(parse_upstream_url("ftp://example.com").is_err());
}
#[test]
fn test_validate_http_upstream_target_rejects_non_local_host() {
let err = validate_http_upstream_target(UpstreamScheme::Http, "api.example.com", &[])
.expect_err("non-local http upstream should be rejected");
assert!(err.contains("refusing insecure http upstream"));
}
#[test]
fn test_validate_http_upstream_target_allows_loopback() {
let loopback = [SocketAddr::from(([127, 0, 0, 1], 8080))];
assert!(validate_http_upstream_target(UpstreamScheme::Http, "127.0.0.1", &[]).is_ok());
assert!(validate_http_upstream_target(UpstreamScheme::Http, "::1", &[]).is_ok());
assert!(
validate_http_upstream_target(UpstreamScheme::Http, "localhost", &loopback).is_ok()
);
}
#[test]
fn test_validate_http_upstream_target_rejects_unspecified_addresses() {
let unspecified = [SocketAddr::from(([0, 0, 0, 0], 8080))];
let err = validate_http_upstream_target(UpstreamScheme::Http, "0.0.0.0", &[])
.expect_err("unspecified http upstream should be rejected");
assert!(err.contains("loopback addresses"));
let err = validate_http_upstream_target(UpstreamScheme::Http, "localhost", &unspecified)
.expect_err("localhost resolving to unspecified should be rejected");
assert!(err.contains("loopback addresses"));
}
#[test]
fn test_validate_http_upstream_target_rejects_localhost_resolving_non_loopback() {
let poisoned = [SocketAddr::from(([203, 0, 113, 10], 8080))];
let err = validate_http_upstream_target(UpstreamScheme::Http, "localhost", &poisoned)
.expect_err("localhost resolving off-host should be rejected");
assert!(err.contains("refusing insecure http upstream"));
}
#[test]
fn test_format_host_header_uses_port_for_non_default_http() {
assert_eq!(
format_host_header(UpstreamScheme::Http, "localhost", 8080),
"localhost:8080"
);
}
#[test]
fn test_format_host_header_omits_default_https_port() {
assert_eq!(
format_host_header(UpstreamScheme::Https, "api.openai.com", 443),
"api.openai.com"
);
}
#[test]
fn test_format_host_header_brackets_ipv6() {
assert_eq!(
format_host_header(UpstreamScheme::Http, "::1", 8080),
"[::1]:8080"
);
}
#[test]
fn test_parse_response_status_200() {
let data = b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n";
assert_eq!(parse_response_status(data), 200);
}
#[test]
fn test_parse_response_status_404() {
let data = b"HTTP/1.1 404 Not Found\r\n\r\n";
assert_eq!(parse_response_status(data), 404);
}
#[test]
fn test_parse_response_status_garbage() {
let data = b"not an http response";
assert_eq!(parse_response_status(data), 502);
}
#[test]
fn test_parse_response_status_empty() {
assert_eq!(parse_response_status(b""), 502);
}
#[test]
fn test_parse_response_status_partial() {
let data = b"HTTP/1.1 ";
assert_eq!(parse_response_status(data), 502);
}
#[test]
fn test_validate_phantom_token_in_path_valid() {
let token = Zeroizing::new("session123".to_string());
let path = "/bot/session123/getMe";
let pattern = "/bot/{}/";
assert!(validate_phantom_token_in_path(path, pattern, &token).is_ok());
}
#[test]
fn test_validate_phantom_token_in_path_invalid() {
let token = Zeroizing::new("session123".to_string());
let path = "/bot/wrong_token/getMe";
let pattern = "/bot/{}/";
assert!(validate_phantom_token_in_path(path, pattern, &token).is_err());
}
#[test]
fn test_validate_phantom_token_in_path_missing() {
let token = Zeroizing::new("session123".to_string());
let path = "/api/getMe";
let pattern = "/bot/{}/";
assert!(validate_phantom_token_in_path(path, pattern, &token).is_err());
}
#[test]
fn test_transform_url_path_basic() {
let credential = Zeroizing::new("real_token".to_string());
let path = "/bot/phantom_token/getMe";
let pattern = "/bot/{}/";
let replacement = "/bot/{}/";
let result = transform_url_path(path, pattern, replacement, &credential).unwrap();
assert_eq!(result, "/bot/real_token/getMe");
}
#[test]
fn test_transform_url_path_different_replacement() {
let credential = Zeroizing::new("real_token".to_string());
let path = "/api/v1/phantom_token/chat";
let pattern = "/api/v1/{}/";
let replacement = "/v2/bot/{}/";
let result = transform_url_path(path, pattern, replacement, &credential).unwrap();
assert_eq!(result, "/v2/bot/real_token/chat");
}
#[test]
fn test_transform_url_path_no_trailing_slash() {
let credential = Zeroizing::new("real_token".to_string());
let path = "/bot/phantom_token";
let pattern = "/bot/{}";
let replacement = "/bot/{}";
let result = transform_url_path(path, pattern, replacement, &credential).unwrap();
assert_eq!(result, "/bot/real_token");
}
#[test]
fn test_validate_phantom_token_in_query_valid() {
let token = Zeroizing::new("session123".to_string());
let path = "/api/data?api_key=session123&other=value";
assert!(validate_phantom_token_in_query(path, "api_key", &token).is_ok());
}
#[test]
fn test_validate_phantom_token_in_query_invalid() {
let token = Zeroizing::new("session123".to_string());
let path = "/api/data?api_key=wrong_token";
assert!(validate_phantom_token_in_query(path, "api_key", &token).is_err());
}
#[test]
fn test_validate_phantom_token_in_query_missing_param() {
let token = Zeroizing::new("session123".to_string());
let path = "/api/data?other=value";
assert!(validate_phantom_token_in_query(path, "api_key", &token).is_err());
}
#[test]
fn test_validate_phantom_token_in_query_no_query_string() {
let token = Zeroizing::new("session123".to_string());
let path = "/api/data";
assert!(validate_phantom_token_in_query(path, "api_key", &token).is_err());
}
#[test]
fn test_validate_phantom_token_in_query_url_encoded() {
let token = Zeroizing::new("token with spaces".to_string());
let path = "/api/data?api_key=token%20with%20spaces";
assert!(validate_phantom_token_in_query(path, "api_key", &token).is_ok());
}
#[test]
fn test_transform_query_param_add_to_no_query() {
let credential = Zeroizing::new("real_key".to_string());
let path = "/api/data";
let result = transform_query_param(path, "api_key", &credential).unwrap();
assert_eq!(result, "/api/data?api_key=real_key");
}
#[test]
fn test_transform_query_param_add_to_existing_query() {
let credential = Zeroizing::new("real_key".to_string());
let path = "/api/data?other=value";
let result = transform_query_param(path, "api_key", &credential).unwrap();
assert_eq!(result, "/api/data?other=value&api_key=real_key");
}
#[test]
fn test_transform_query_param_replace_existing() {
let credential = Zeroizing::new("real_key".to_string());
let path = "/api/data?api_key=phantom&other=value";
let result = transform_query_param(path, "api_key", &credential).unwrap();
assert_eq!(result, "/api/data?api_key=real_key&other=value");
}
#[test]
fn test_transform_query_param_url_encodes_special_chars() {
let credential = Zeroizing::new("key with spaces".to_string());
let path = "/api/data";
let result = transform_query_param(path, "api_key", &credential).unwrap();
assert_eq!(result, "/api/data?api_key=key%20with%20spaces");
}
#[test]
fn test_validate_phantom_token_uses_proxy_mode_over_upstream_mode() {
let token = Zeroizing::new("session123".to_string());
let header = b"Authorization: Bearer session123\r\n\r\n";
let path = "/api/data?api_key=wrong";
let result = validate_phantom_token_for_mode(
&InjectMode::Header,
header,
path,
"Authorization",
None,
Some("api_key"),
&token,
);
assert!(result.is_ok());
}
#[test]
fn test_transform_path_uses_upstream_mode_independently() {
let credential = Zeroizing::new("real_key".to_string());
let path = "/api/data?api_key=phantom";
let transformed = transform_path_for_mode(
&InjectMode::QueryParam,
path,
None,
None,
Some("api_key"),
&credential,
)
.expect("query-param transform should succeed");
assert_eq!(transformed, "/api/data?api_key=real_key");
}
#[test]
fn test_strip_proxy_path_token_basic() {
let result = strip_proxy_path_token("/PHANTOM123/api/v1/pods", "/{}/");
assert_eq!(result, "/api/v1/pods");
}
#[test]
fn test_strip_proxy_path_token_nested_pattern() {
let result = strip_proxy_path_token("/auth/PHANTOM123/api/v1/pods", "/auth/{}/");
assert_eq!(result, "/api/v1/pods");
}
#[test]
fn test_strip_proxy_path_token_no_trailing_slash() {
let result = strip_proxy_path_token("/PHANTOM123", "/{}");
assert_eq!(result, "/");
}
#[test]
fn test_strip_proxy_path_token_preserves_query() {
let result = strip_proxy_path_token("/PHANTOM123/api?limit=10", "/{}/");
assert_eq!(result, "/api?limit=10");
}
#[test]
fn test_strip_proxy_path_token_no_match() {
let result = strip_proxy_path_token("/api/v1/pods", "/auth/{}/");
assert_eq!(result, "/api/v1/pods");
}
#[test]
fn test_strip_proxy_path_token_mid_path_slash_join() {
let result = strip_proxy_path_token("/api/k8s/PHANTOM/data", "/k8s/{}/");
assert_eq!(result, "/api/data");
}
#[test]
fn test_strip_proxy_path_token_no_double_slash() {
let result = strip_proxy_path_token("/prefix/PHANTOM//suffix", "/prefix/{}/");
assert_eq!(result, "/suffix");
}
#[test]
fn test_strip_proxy_query_param_only_param() {
let result = strip_proxy_query_param("/api/v1/pods?token=PHANTOM123", "token");
assert_eq!(result, "/api/v1/pods");
}
#[test]
fn test_strip_proxy_query_param_with_other_params() {
let result = strip_proxy_query_param("/api/v1/pods?token=PHANTOM123&limit=10", "token");
assert_eq!(result, "/api/v1/pods?limit=10");
}
#[test]
fn test_strip_proxy_query_param_middle() {
let result =
strip_proxy_query_param("/api/v1/pods?limit=10&token=PHANTOM123&watch=true", "token");
assert_eq!(result, "/api/v1/pods?limit=10&watch=true");
}
#[test]
fn test_strip_proxy_query_param_no_match() {
let result = strip_proxy_query_param("/api/v1/pods?limit=10", "token");
assert_eq!(result, "/api/v1/pods?limit=10");
}
#[test]
fn test_strip_proxy_query_param_no_query_string() {
let result = strip_proxy_query_param("/api/v1/pods", "token");
assert_eq!(result, "/api/v1/pods");
}
#[test]
fn test_strip_proxy_artifacts_same_mode_noop() {
let path = "/PHANTOM123/api/v1/pods";
let result = strip_proxy_artifacts(
path,
&InjectMode::UrlPath,
&InjectMode::UrlPath,
Some("/{}/"),
None,
);
assert_eq!(result, path);
}
#[test]
fn test_strip_proxy_artifacts_url_path_to_header() {
let result = strip_proxy_artifacts(
"/PHANTOM123/api/v1/pods",
&InjectMode::UrlPath,
&InjectMode::Header,
Some("/{}/"),
None,
);
assert_eq!(result, "/api/v1/pods");
}
#[test]
fn test_strip_proxy_artifacts_query_param_to_header() {
let result = strip_proxy_artifacts(
"/api/v1/pods?token=PHANTOM123",
&InjectMode::QueryParam,
&InjectMode::Header,
None,
Some("token"),
);
assert_eq!(result, "/api/v1/pods");
}
#[test]
fn test_strip_proxy_artifacts_header_to_query_param() {
let path = "/api/v1/pods";
let result = strip_proxy_artifacts(
path,
&InjectMode::Header,
&InjectMode::QueryParam,
None,
None,
);
assert_eq!(result, path);
}
#[test]
fn test_end_to_end_url_path_proxy_header_upstream() {
let token = Zeroizing::new("session456".to_string());
let credential = Zeroizing::new("real_bearer_token".to_string());
let path = "/session456/api/v1/namespaces";
assert!(validate_phantom_token_for_mode(
&InjectMode::UrlPath,
b"\r\n\r\n", path,
"Authorization",
Some("/{}/"),
None,
&token,
)
.is_ok());
let cleaned = strip_proxy_artifacts(
path,
&InjectMode::UrlPath,
&InjectMode::Header,
Some("/{}/"),
None,
);
assert_eq!(cleaned, "/api/v1/namespaces");
let transformed =
transform_path_for_mode(&InjectMode::Header, &cleaned, None, None, None, &credential)
.unwrap();
assert_eq!(transformed, "/api/v1/namespaces");
}
#[test]
fn test_end_to_end_query_param_proxy_header_upstream() {
let token = Zeroizing::new("session789".to_string());
let credential = Zeroizing::new("real_bearer_token".to_string());
let path = "/api/v1/pods?token=session789&limit=100";
assert!(validate_phantom_token_for_mode(
&InjectMode::QueryParam,
b"\r\n\r\n",
path,
"Authorization",
None,
Some("token"),
&token,
)
.is_ok());
let cleaned = strip_proxy_artifacts(
path,
&InjectMode::QueryParam,
&InjectMode::Header,
None,
Some("token"),
);
assert_eq!(cleaned, "/api/v1/pods?limit=100");
let transformed =
transform_path_for_mode(&InjectMode::Header, &cleaned, None, None, None, &credential)
.unwrap();
assert_eq!(transformed, "/api/v1/pods?limit=100");
}
}