use std::borrow::Cow;
use super::config::{SecretsConfig, ViolationAction};
pub struct SecretsHandler {
eligible: Vec<EligibleSecret>,
all_placeholders: Vec<String>,
on_violation: ViolationAction,
has_ineligible: bool,
tls_intercepted: bool,
}
struct EligibleSecret {
placeholder: String,
value: String,
inject_headers: bool,
inject_basic_auth: bool,
inject_query_params: bool,
inject_body: bool,
require_tls_identity: bool,
}
impl SecretsHandler {
pub fn new(config: &SecretsConfig, sni: &str, tls_intercepted: bool) -> Self {
let mut eligible = Vec::new();
let mut all_placeholders = Vec::new();
for secret in &config.secrets {
all_placeholders.push(secret.placeholder.clone());
let host_allowed = secret.allowed_hosts.is_empty()
|| secret.allowed_hosts.iter().any(|p| p.matches(sni));
if host_allowed {
eligible.push(EligibleSecret {
placeholder: secret.placeholder.clone(),
value: secret.value.clone(),
inject_headers: secret.injection.headers,
inject_basic_auth: secret.injection.basic_auth,
inject_query_params: secret.injection.query_params,
inject_body: secret.injection.body,
require_tls_identity: secret.require_tls_identity,
});
}
}
let has_ineligible = eligible.len() < all_placeholders.len();
Self {
eligible,
all_placeholders,
on_violation: config.on_violation.clone(),
has_ineligible,
tls_intercepted,
}
}
pub fn substitute<'a>(&self, data: &'a [u8]) -> Option<Cow<'a, [u8]>> {
if self.has_ineligible {
let text = String::from_utf8_lossy(data);
if self.has_violation(&text) {
match self.on_violation {
ViolationAction::Block => return None,
ViolationAction::BlockAndLog => {
tracing::warn!(
"secret violation: placeholder detected for disallowed host"
);
return None;
}
ViolationAction::BlockAndTerminate => {
tracing::error!(
"secret violation: placeholder detected for disallowed host — terminating"
);
return None;
}
}
}
}
if self.eligible.is_empty() {
return Some(Cow::Borrowed(data));
}
let boundary = find_header_boundary(data);
let (header_bytes, body_bytes) = match boundary {
Some(pos) => (&data[..pos], &data[pos..]),
None => (data, &[] as &[u8]),
};
let mut header_str = String::from_utf8_lossy(header_bytes).into_owned();
let mut body_str = if boundary.is_some() {
String::from_utf8_lossy(body_bytes).into_owned()
} else {
String::new()
};
for secret in &self.eligible {
if secret.require_tls_identity && !self.tls_intercepted {
continue;
}
if boundary.is_some() {
if secret.inject_headers || secret.inject_basic_auth || secret.inject_query_params {
if header_str.contains(&secret.placeholder) {
header_str = substitute_in_headers(
&header_str,
&secret.placeholder,
&secret.value,
secret.inject_headers,
secret.inject_basic_auth,
secret.inject_query_params,
);
}
}
if secret.inject_body && body_str.contains(&secret.placeholder) {
body_str = body_str.replace(&secret.placeholder, &secret.value);
}
} else {
if secret.inject_headers && header_str.contains(&secret.placeholder) {
header_str = header_str.replace(&secret.placeholder, &secret.value);
}
}
}
if boundary.is_some() && body_str.len() != body_bytes.len() {
header_str = update_content_length(&header_str, body_str.len());
}
let mut output = header_str;
output.push_str(&body_str);
Some(Cow::Owned(output.into_bytes()))
}
pub fn is_empty(&self) -> bool {
self.all_placeholders.is_empty()
}
pub fn terminates_on_violation(&self) -> bool {
matches!(self.on_violation, ViolationAction::BlockAndTerminate)
}
}
impl SecretsHandler {
fn has_violation(&self, text: &str) -> bool {
if self.eligible.len() == self.all_placeholders.len() {
return false;
}
for placeholder in &self.all_placeholders {
if text.contains(placeholder.as_str())
&& !self.eligible.iter().any(|s| s.placeholder == *placeholder)
{
return true;
}
}
false
}
}
fn substitute_in_headers(
headers: &str,
placeholder: &str,
value: &str,
inject_all_headers: bool,
inject_basic_auth: bool,
inject_query_params: bool,
) -> String {
if inject_all_headers {
return headers.replace(placeholder, value);
}
let mut result = String::with_capacity(headers.len());
for (i, line) in headers.split("\r\n").enumerate() {
if i > 0 {
result.push_str("\r\n");
}
if i == 0 && inject_query_params {
result.push_str(&line.replace(placeholder, value));
} else if inject_basic_auth
&& line
.as_bytes()
.get(..14)
.is_some_and(|b| b.eq_ignore_ascii_case(b"authorization:"))
{
result.push_str(&line.replace(placeholder, value));
} else {
result.push_str(line);
}
}
result
}
fn update_content_length(headers: &str, new_len: usize) -> String {
let mut result = String::with_capacity(headers.len());
for (i, line) in headers.split("\r\n").enumerate() {
if i > 0 {
result.push_str("\r\n");
}
if line
.as_bytes()
.get(..15)
.is_some_and(|b| b.eq_ignore_ascii_case(b"content-length:"))
{
result.push_str(&format!("Content-Length: {new_len}"));
} else {
result.push_str(line);
}
}
result
}
fn find_header_boundary(data: &[u8]) -> Option<usize> {
data.windows(4)
.position(|w| w == b"\r\n\r\n")
.map(|pos| pos + 4)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::secrets::config::*;
fn make_config(secrets: Vec<SecretEntry>) -> SecretsConfig {
SecretsConfig {
secrets,
on_violation: ViolationAction::Block,
}
}
fn make_secret(placeholder: &str, value: &str, host: &str) -> SecretEntry {
SecretEntry {
env_var: "TEST_KEY".into(),
value: value.into(),
placeholder: placeholder.into(),
allowed_hosts: vec![HostPattern::Exact(host.into())],
injection: SecretInjection::default(),
require_tls_identity: true,
}
}
#[test]
fn substitute_in_headers() {
let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
let handler = SecretsHandler::new(&config, "api.openai.com", true);
let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n";
let output = handler.substitute(input).unwrap();
assert_eq!(
String::from_utf8(output.into_owned()).unwrap(),
"GET / HTTP/1.1\r\nAuthorization: Bearer real-secret\r\n\r\n"
);
}
#[test]
fn no_substitute_for_wrong_host() {
let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
let handler = SecretsHandler::new(&config, "evil.com", true);
let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n";
assert!(handler.substitute(input).is_none());
}
#[test]
fn body_injection_disabled_by_default() {
let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
let handler = SecretsHandler::new(&config, "api.openai.com", true);
let input = b"POST / HTTP/1.1\r\n\r\n{\"key\": \"$KEY\"}";
let output = handler.substitute(input).unwrap();
assert!(
String::from_utf8(output.into_owned())
.unwrap()
.contains("$KEY")
);
}
#[test]
fn body_injection_when_enabled() {
let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
secret.injection.body = true;
let config = make_config(vec![secret]);
let handler = SecretsHandler::new(&config, "api.openai.com", true);
let input = b"POST / HTTP/1.1\r\n\r\n{\"key\": \"$KEY\"}";
let output = handler.substitute(input).unwrap();
assert_eq!(
String::from_utf8(output.into_owned()).unwrap(),
"POST / HTTP/1.1\r\n\r\n{\"key\": \"real-secret\"}"
);
}
#[test]
fn body_injection_updates_content_length() {
let mut secret = make_secret("$KEY", "a]longer]secret]value", "api.openai.com");
secret.injection.body = true;
let config = make_config(vec![secret]);
let handler = SecretsHandler::new(&config, "api.openai.com", true);
let body = "{\"key\": \"$KEY\"}";
let input = format!(
"POST / HTTP/1.1\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
let output = handler.substitute(input.as_bytes()).unwrap();
let result = String::from_utf8(output.into_owned()).unwrap();
let expected_body = "{\"key\": \"a]longer]secret]value\"}";
assert!(result.contains(expected_body));
assert!(result.contains(&format!("Content-Length: {}", expected_body.len())));
}
#[test]
fn body_injection_no_content_length_header() {
let mut secret = make_secret("$KEY", "longer-secret", "api.openai.com");
secret.injection.body = true;
let config = make_config(vec![secret]);
let handler = SecretsHandler::new(&config, "api.openai.com", true);
let input = b"POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n{\"key\": \"$KEY\"}";
let output = handler.substitute(input).unwrap();
let result = String::from_utf8(output.into_owned()).unwrap();
assert!(result.contains("longer-secret"));
assert!(!result.contains("Content-Length"));
}
#[test]
fn header_only_substitution_preserves_content_length() {
let config = make_config(vec![make_secret("$KEY", "longer-value", "api.openai.com")]);
let handler = SecretsHandler::new(&config, "api.openai.com", true);
let input =
b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\nContent-Length: 5\r\n\r\nhello";
let output = handler.substitute(input).unwrap();
let result = String::from_utf8(output.into_owned()).unwrap();
assert!(result.contains("Content-Length: 5"));
assert!(result.ends_with("hello"));
}
#[test]
fn no_secrets_passthrough() {
let config = make_config(vec![]);
let handler = SecretsHandler::new(&config, "anything.com", true);
let input = b"GET / HTTP/1.1\r\n\r\n";
let output = handler.substitute(input).unwrap();
assert_eq!(&*output, input);
}
#[test]
fn require_tls_identity_blocks_on_non_intercepted() {
let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
let handler = SecretsHandler::new(&config, "api.openai.com", false);
let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\n\r\n";
let output = handler.substitute(input).unwrap();
assert!(
String::from_utf8(output.into_owned())
.unwrap()
.contains("$KEY")
);
}
#[test]
fn basic_auth_only_substitution() {
let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
secret.injection = SecretInjection {
headers: false,
basic_auth: true,
query_params: false,
body: false,
};
let config = make_config(vec![secret]);
let handler = SecretsHandler::new(&config, "api.openai.com", true);
let input = b"GET / HTTP/1.1\r\nAuthorization: Bearer $KEY\r\nX-Custom: $KEY\r\n\r\n";
let output = handler.substitute(input).unwrap();
let result = String::from_utf8(output.into_owned()).unwrap();
assert!(result.contains("Authorization: Bearer real-secret"));
assert!(result.contains("X-Custom: $KEY"));
}
#[test]
fn query_params_substitution() {
let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
secret.injection = SecretInjection {
headers: false,
basic_auth: false,
query_params: true,
body: false,
};
let config = make_config(vec![secret]);
let handler = SecretsHandler::new(&config, "api.openai.com", true);
let input = b"GET /api?key=$KEY HTTP/1.1\r\nHost: api.openai.com\r\n\r\n";
let output = handler.substitute(input).unwrap();
let result = String::from_utf8(output.into_owned()).unwrap();
assert!(result.contains("GET /api?key=real-secret HTTP/1.1"));
}
}