use std::borrow::Cow;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use percent_encoding::percent_decode;
use super::config::{SecretsConfig, ViolationAction};
pub struct SecretsHandler {
eligible: Vec<EligibleSecret>,
all_placeholders: Vec<String>,
on_violation: ViolationAction,
has_ineligible: bool,
tls_intercepted: bool,
max_placeholder_len: usize,
prev_tail: Vec<u8>,
}
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 EligibleSecret {
fn wants_header_injection(&self) -> bool {
self.inject_headers || self.inject_basic_auth || self.inject_query_params
}
fn substitute_in_headers(&self, headers: &str) -> 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");
}
match self.substitute_in_header_line(line, i == 0) {
Some(s) => result.push_str(&s),
None => result.push_str(line),
}
}
result
}
fn substitute_in_header_line(&self, line: &str, is_request_line: bool) -> Option<String> {
if self.inject_basic_auth
&& is_authorization_header(line)
&& let Some(replaced) = self.substitute_basic_auth_header(line)
{
return Some(replaced);
}
if self.inject_headers {
return Some(line.replace(&self.placeholder, &self.value));
}
if is_request_line && self.inject_query_params {
return Some(line.replace(&self.placeholder, &self.value));
}
None
}
fn substitute_basic_auth_header(&self, line: &str) -> Option<String> {
let decoded = decode_basic_credentials(line)?;
if !decoded.contains(&self.placeholder) {
return None;
}
let (name, _) = line.split_once(':')?;
let replaced = decoded.replace(&self.placeholder, &self.value);
Some(format!(
"{name}: Basic {}",
BASE64.encode(replaced.as_bytes())
))
}
}
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();
let max_placeholder_len = all_placeholders.iter().map(String::len).max().unwrap_or(0);
Self {
eligible,
all_placeholders,
on_violation: config.on_violation.clone(),
has_ineligible,
tls_intercepted,
max_placeholder_len,
prev_tail: Vec::new(),
}
}
pub fn substitute<'a>(&mut self, data: &'a [u8]) -> Option<Cow<'a, [u8]>> {
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()
};
if self.has_ineligible && self.has_violation(data, &header_str) {
self.update_tail(data);
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;
}
}
}
self.update_tail(data);
if self.eligible.is_empty() {
return Some(Cow::Borrowed(data));
}
for secret in &self.eligible {
if secret.require_tls_identity && !self.tls_intercepted {
continue;
}
if secret.wants_header_injection() {
header_str = secret.substitute_in_headers(&header_str);
}
if boundary.is_some() && secret.inject_body && body_str.contains(&secret.placeholder) {
body_str = body_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)
}
fn has_violation(&self, data: &[u8], headers: &str) -> bool {
if self.eligible.len() == self.all_placeholders.len() {
return false;
}
let scan_buf: Cow<[u8]> = if self.prev_tail.is_empty() {
Cow::Borrowed(data)
} else {
let mut stitched = Vec::with_capacity(self.prev_tail.len() + data.len());
stitched.extend_from_slice(&self.prev_tail);
stitched.extend_from_slice(data);
Cow::Owned(stitched)
};
let scan = scan_buf.as_ref();
for placeholder in &self.all_placeholders {
if self.eligible.iter().any(|s| s.placeholder == *placeholder) {
continue;
}
let needle = placeholder.as_bytes();
if contains_bytes(scan, needle)
|| url_decoded_contains(scan, needle)
|| json_escaped_contains(scan, needle)
|| basic_auth_decoded_contains(headers, placeholder)
{
return true;
}
}
false
}
fn update_tail(&mut self, data: &[u8]) {
let tail_size = self.max_placeholder_len.saturating_sub(1);
if tail_size == 0 {
return;
}
if data.len() >= tail_size {
self.prev_tail.clear();
self.prev_tail
.extend_from_slice(&data[data.len() - tail_size..]);
return;
}
self.prev_tail.extend_from_slice(data);
let overflow = self.prev_tail.len().saturating_sub(tail_size);
if overflow > 0 {
self.prev_tail.drain(..overflow);
}
}
}
fn is_authorization_header(line: &str) -> bool {
line.as_bytes()
.get(..14)
.is_some_and(|b| b.eq_ignore_ascii_case(b"authorization:"))
}
fn decode_basic_credentials(line: &str) -> Option<String> {
let (_, raw_value) = line.split_once(':')?;
let (scheme, encoded) = split_auth_scheme(raw_value.trim_start())?;
if !scheme.eq_ignore_ascii_case("basic") {
return None;
}
let bytes = BASE64.decode(encoded.trim()).ok()?;
String::from_utf8(bytes).ok()
}
fn split_auth_scheme(header_value: &str) -> Option<(&str, &str)> {
let split_at = header_value.find(char::is_whitespace)?;
let (scheme, rest) = header_value.split_at(split_at);
Some((scheme, rest.trim_start()))
}
fn basic_auth_decoded_contains(headers: &str, placeholder: &str) -> bool {
headers
.split("\r\n")
.filter(|line| is_authorization_header(line))
.filter_map(decode_basic_credentials)
.any(|decoded| decoded.contains(placeholder))
}
fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() || haystack.len() < needle.len() {
return false;
}
haystack.windows(needle.len()).any(|w| w == needle)
}
fn url_decoded_contains(haystack: &[u8], needle: &[u8]) -> bool {
let decoded: Vec<u8> = percent_decode(haystack).collect();
contains_bytes(&decoded, needle)
}
fn json_escaped_contains(haystack: &[u8], needle: &[u8]) -> bool {
let mut decoded = Vec::with_capacity(haystack.len());
let mut i = 0;
while i < haystack.len() {
if haystack[i] == b'\\'
&& i + 5 < haystack.len()
&& haystack[i + 1] == b'u'
&& let (Some(a), Some(b), Some(c), Some(d)) = (
hex_digit(haystack[i + 2]),
hex_digit(haystack[i + 3]),
hex_digit(haystack[i + 4]),
hex_digit(haystack[i + 5]),
)
{
let cp = ((a as u32) << 12) | ((b as u32) << 8) | ((c as u32) << 4) | (d as u32);
if let Some(ch) = char::from_u32(cp) {
let mut buf = [0u8; 4];
decoded.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
}
i += 6;
continue;
}
decoded.push(haystack[i]);
i += 1;
}
contains_bytes(&decoded, needle)
}
fn hex_digit(b: u8) -> Option<u8> {
(b as char).to_digit(16).map(|d| d as u8)
}
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,
}
}
fn basic_auth_only() -> SecretInjection {
SecretInjection {
headers: false,
basic_auth: true,
query_params: false,
body: false,
}
}
#[test]
fn substitute_in_headers() {
let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
let mut 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 mut 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 mut 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 mut 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 mut 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 mut 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 mut 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 mut 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 mut 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_does_not_substitute_other_schemes() {
let mut secret = make_secret("$KEY", "real-secret", "api.openai.com");
secret.injection = basic_auth_only();
let config = make_config(vec![secret]);
let mut 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 $KEY"));
assert!(result.contains("X-Custom: $KEY"));
}
#[test]
fn basic_auth_decodes_substitutes_and_reencodes_credentials() {
let mut user = make_secret("$MSB_USER", "alice", "api.openai.com");
user.env_var = "USER".into();
user.injection = basic_auth_only();
let mut password = make_secret("$MSB_PASSWORD", "s3cr3t", "api.openai.com");
password.env_var = "PASSWORD".into();
password.injection = basic_auth_only();
let config = make_config(vec![user, password]);
let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
let encoded = BASE64.encode(b"$MSB_USER:$MSB_PASSWORD");
let input = format!("GET / HTTP/1.1\r\nAuthorization: Basic {encoded}\r\n\r\n");
let output = handler.substitute(input.as_bytes()).unwrap();
let result = String::from_utf8(output.into_owned()).unwrap();
assert!(result.contains(&format!(
"Authorization: Basic {}",
BASE64.encode(b"alice:s3cr3t")
)));
assert!(!result.contains("$MSB_USER"));
assert!(!result.contains("$MSB_PASSWORD"));
}
#[test]
fn basic_auth_encoded_placeholder_is_blocked_for_wrong_host() {
let mut secret = make_secret("$MSB_PASSWORD", "s3cr3t", "api.openai.com");
secret.injection = basic_auth_only();
let config = make_config(vec![secret]);
let mut handler = SecretsHandler::new(&config, "evil.com", true);
let encoded = BASE64.encode(b"user:$MSB_PASSWORD");
let input = format!("GET / HTTP/1.1\r\nAuthorization: Basic {encoded}\r\n\r\n");
assert!(handler.substitute(input.as_bytes()).is_none());
}
#[test]
fn basic_auth_encoded_placeholder_is_not_replaced_when_scope_disabled() {
let mut secret = make_secret("$MSB_PASSWORD", "s3cr3t", "api.openai.com");
secret.injection = SecretInjection {
headers: false,
basic_auth: false,
query_params: false,
body: false,
};
let config = make_config(vec![secret]);
let mut handler = SecretsHandler::new(&config, "api.openai.com", true);
let encoded = BASE64.encode(b"user:$MSB_PASSWORD");
let input = format!("GET / HTTP/1.1\r\nAuthorization: Basic {encoded}\r\n\r\n");
let output = handler.substitute(input.as_bytes()).unwrap();
assert_eq!(String::from_utf8(output.into_owned()).unwrap(), input);
}
#[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 mut 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"));
}
#[test]
fn url_encoded_placeholder_in_query_blocks_for_wrong_host() {
let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
let mut handler = SecretsHandler::new(&config, "evil.com", true);
let input = b"GET /api?token=%24KEY HTTP/1.1\r\nHost: evil.com\r\n\r\n";
assert!(handler.substitute(input).is_none());
}
#[test]
fn url_encoded_placeholder_in_body_blocks_for_wrong_host() {
let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
let mut handler = SecretsHandler::new(&config, "evil.com", true);
let input = b"POST / HTTP/1.1\r\nContent-Length: 13\r\n\r\nkey=%24KEY&x=1";
assert!(handler.substitute(input).is_none());
}
#[test]
fn json_escaped_placeholder_in_body_blocks_for_wrong_host() {
let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
let mut handler = SecretsHandler::new(&config, "evil.com", true);
let input =
b"POST / HTTP/1.1\r\nContent-Type: application/json\r\n\r\n{\"k\":\"\\u0024KEY\"}";
assert!(handler.substitute(input).is_none());
}
#[test]
fn placeholder_split_across_writes_blocks_for_wrong_host() {
let config = make_config(vec![make_secret("$KEY", "real-secret", "api.openai.com")]);
let mut handler = SecretsHandler::new(&config, "evil.com", true);
let first = b"GET / HTTP/1.1\r\nX-Token: $K";
let second = b"EY\r\nHost: evil.com\r\n\r\n";
assert!(handler.substitute(first).is_some());
assert!(handler.substitute(second).is_none());
}
#[test]
fn url_decoded_contains_basic() {
assert!(url_decoded_contains(b"foo%24KEYbar", b"$KEY"));
assert!(!url_decoded_contains(b"fooKEYbar", b"$KEY"));
assert!(url_decoded_contains(b"%2", b"%2"));
}
#[test]
fn json_escaped_contains_basic() {
assert!(json_escaped_contains(b"\"\\u0024KEY\"", b"$KEY"));
assert!(json_escaped_contains(
b"\\u0024\\u004B\\u0045\\u0059",
b"$KEY"
));
assert!(!json_escaped_contains(b"KEY", b"$KEY"));
}
}