use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use std::sync::OnceLock;
use http::HeaderMap;
use http::HeaderValue;
use schemars::JsonSchema;
use serde::Deserialize;
use crate::Context;
use crate::configuration::header_masking_config::HeaderMaskingConfig;
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum RedactMode {
Allow,
Mask,
}
pub(crate) const MASKED_VALUE: &str = "***MASKED***";
#[derive(Clone, Debug, Default)]
pub(crate) struct HeaderMaskingRules {
sensitive_headers: HashSet<String>,
}
impl HeaderMaskingRules {
pub(crate) fn from_config(config: &HeaderMaskingConfig) -> Self {
if !config.enabled {
return Self::default();
}
let sensitive_headers: HashSet<String> = config
.effective_sensitive_headers()
.into_iter()
.map(|h| h.to_lowercase())
.collect();
if sensitive_headers.is_empty() {
tracing::warn!(
"Header masking is enabled but the effective sensitive-headers list is empty \
(replace_defaults: true with no sensitive_headers). No headers will be masked \
in logs or telemetry, including authorization and cookie. Add entries to \
sensitive_headers or remove replace_defaults: true to restore the built-in \
fail-secure defaults."
);
}
Self { sensitive_headers }
}
pub(crate) fn should_mask(&self, header_name: &str) -> bool {
if header_name.bytes().all(|b| !b.is_ascii_uppercase()) {
self.sensitive_headers.contains(header_name)
} else {
self.sensitive_headers
.contains(&header_name.to_ascii_lowercase())
}
}
pub(crate) fn mask_externalized_headers(
&self,
input: &HashMap<String, Vec<String>>,
) -> HashMap<String, Vec<String>> {
input
.iter()
.map(|(k, v)| {
if self.should_mask(k) {
(k.clone(), vec![MASKED_VALUE.to_string()])
} else {
(k.clone(), v.clone())
}
})
.collect()
}
pub(crate) fn mask_headers_debug(&self, input: &HeaderMap<HeaderValue>) -> String {
let mut parts = Vec::with_capacity(input.len());
for (k, v) in input {
let k_str = k.as_str();
let value_str = if self.should_mask(k_str) {
MASKED_VALUE
} else {
v.to_str().unwrap_or("<non-utf8>")
};
parts.push(format!("{k_str:?}: {value_str:?}"));
}
parts.sort();
format!("{{{}}}", parts.join(", "))
}
}
#[derive(Debug, Default)]
pub(crate) struct DirectionRules {
global: Arc<HeaderMaskingRules>,
per_subgraph: HashMap<String, Arc<HeaderMaskingRules>>,
}
impl DirectionRules {
pub(crate) fn new(
global: Arc<HeaderMaskingRules>,
per_subgraph: HashMap<String, Arc<HeaderMaskingRules>>,
) -> Self {
Self {
global,
per_subgraph,
}
}
fn get(&self, subgraph_name: Option<&str>) -> &Arc<HeaderMaskingRules> {
subgraph_name
.and_then(|n| self.per_subgraph.get(n))
.unwrap_or(&self.global)
}
}
#[derive(Debug)]
pub(crate) struct MaskingRulesMap {
request: DirectionRules,
response: DirectionRules,
}
impl MaskingRulesMap {
pub(crate) fn new(request: DirectionRules, response: DirectionRules) -> Self {
Self { request, response }
}
#[cfg(test)]
pub(crate) fn new_test(
global: Arc<HeaderMaskingRules>,
per_subgraph: HashMap<String, Arc<HeaderMaskingRules>>,
) -> Self {
Self::new(
DirectionRules::new(global.clone(), per_subgraph.clone()),
DirectionRules::new(global, per_subgraph),
)
}
pub(crate) fn get_request(&self, subgraph_name: Option<&str>) -> &Arc<HeaderMaskingRules> {
self.request.get(subgraph_name)
}
pub(crate) fn get_response(&self, subgraph_name: Option<&str>) -> &Arc<HeaderMaskingRules> {
self.response.get(subgraph_name)
}
fn rules_for(&self, direction: Direction, subgraph: Option<&str>) -> &Arc<HeaderMaskingRules> {
match direction {
Direction::Request => self.get_request(subgraph),
Direction::Response => self.get_response(subgraph),
}
}
}
#[derive(Clone, Copy)]
pub(crate) enum Direction {
Request,
Response,
}
pub(crate) fn default_masking_rules() -> &'static HeaderMaskingRules {
static DEFAULT: OnceLock<HeaderMaskingRules> = OnceLock::new();
DEFAULT.get_or_init(|| HeaderMaskingRules::from_config(&HeaderMaskingConfig::default()))
}
fn should_mask_header(
context: &Context,
direction: Direction,
subgraph: Option<&str>,
header_name: &str,
) -> bool {
context
.extensions()
.with_lock(|lock| match lock.get::<Arc<MaskingRulesMap>>() {
Some(m) => m.rules_for(direction, subgraph).should_mask(header_name),
None => default_masking_rules().should_mask(header_name),
})
}
pub(crate) fn is_sensitive_request_header(context: &Context, header_name: &str) -> bool {
should_mask_header(context, Direction::Request, None, header_name)
}
pub(crate) fn redact_header_value(
context: &Context,
direction: Direction,
subgraph: Option<&str>,
header_name: &str,
value: Option<String>,
redact: Option<&RedactMode>,
) -> Option<String> {
match (redact, &value) {
(Some(RedactMode::Allow), _) => value,
(Some(RedactMode::Mask), Some(_)) => Some(MASKED_VALUE.to_string()),
(None, Some(_)) if should_mask_header(context, direction, subgraph, header_name) => {
Some(MASKED_VALUE.to_string())
}
_ => value,
}
}
pub(crate) fn masked_headers_for_log(
context: &Context,
direction: Direction,
subgraph: Option<&str>,
headers: &HeaderMap<HeaderValue>,
) -> String {
context
.extensions()
.with_lock(|lock| match lock.get::<Arc<MaskingRulesMap>>() {
Some(m) => m.rules_for(direction, subgraph).mask_headers_debug(headers),
None => default_masking_rules().mask_headers_debug(headers),
})
}
#[cfg(test)]
mod tests {
use http::header::HeaderName;
use super::*;
fn create_test_rules() -> HeaderMaskingRules {
let config = HeaderMaskingConfig {
enabled: true,
sensitive_headers: vec![
"authorization".to_string(),
"cookie".to_string(),
"x-api-key".to_string(),
],
replace_defaults: false,
};
HeaderMaskingRules::from_config(&config)
}
#[test]
fn test_should_mask_case_insensitive() {
let rules = create_test_rules();
assert!(rules.should_mask("authorization"));
assert!(rules.should_mask("cookie"));
assert!(rules.should_mask("x-api-key"));
assert!(rules.should_mask("Authorization"));
assert!(rules.should_mask("AUTHORIZATION"));
assert!(rules.should_mask("Cookie"));
assert!(rules.should_mask("X-API-KEY"));
assert!(rules.should_mask("X-Api-Key"));
assert!(!rules.should_mask("content-type"));
assert!(!rules.should_mask("accept"));
assert!(!rules.should_mask("x-custom-header"));
}
#[test]
fn test_mask_headers_debug() {
let rules = create_test_rules();
let mut headers = HeaderMap::new();
headers.insert(
HeaderName::from_static("authorization"),
HeaderValue::from_static("Bearer secret-token"), );
headers.insert(
HeaderName::from_static("content-type"),
HeaderValue::from_static("application/json"),
);
let result = rules.mask_headers_debug(&headers);
assert!(result.contains("authorization"));
assert!(result.contains(MASKED_VALUE));
assert!(!result.contains("secret-token"));
assert!(result.contains("content-type"));
assert!(result.contains("application/json"));
}
#[test]
fn test_mask_headers_debug_escapes_special_characters() {
let rules = create_test_rules();
let mut headers = HeaderMap::new();
headers.insert(
HeaderName::from_static("etag"),
HeaderValue::from_static(r#""abc\123""#),
);
let result = rules.mask_headers_debug(&headers);
assert!(
result.contains(r#""etag": "\"abc\\123\"""#),
"expected escaped value, got: {result}"
);
}
#[test]
fn test_empty_config_with_replace_defaults_masks_nothing() {
let config = HeaderMaskingConfig {
enabled: true,
sensitive_headers: vec![],
replace_defaults: true,
};
let rules = HeaderMaskingRules::from_config(&config);
assert!(!rules.should_mask("authorization"));
assert!(!rules.should_mask("cookie"));
}
#[test]
fn empty_user_list_with_default_replace_defaults_still_masks_built_in_headers() {
let config = HeaderMaskingConfig::default();
let rules = HeaderMaskingRules::from_config(&config);
assert!(rules.should_mask("authorization"));
assert!(rules.should_mask("cookie"));
}
#[test]
fn test_masking_rules_map_separates_request_and_response() {
let request_rules = Arc::new(HeaderMaskingRules::from_config(&HeaderMaskingConfig {
enabled: true,
sensitive_headers: vec!["authorization".to_string()],
replace_defaults: true,
}));
let response_rules = Arc::new(HeaderMaskingRules::from_config(&HeaderMaskingConfig {
enabled: true,
sensitive_headers: vec!["set-cookie".to_string()],
replace_defaults: true,
}));
let per_subgraph_response: HashMap<String, Arc<HeaderMaskingRules>> = [(
"products".to_string(),
Arc::new(HeaderMaskingRules::from_config(&HeaderMaskingConfig {
enabled: true,
sensitive_headers: vec!["x-products-secret".to_string()],
replace_defaults: true,
})),
)]
.into_iter()
.collect();
let map = MaskingRulesMap::new(
DirectionRules::new(request_rules, HashMap::new()),
DirectionRules::new(response_rules, per_subgraph_response),
);
assert!(map.get_request(None).should_mask("authorization"));
assert!(!map.get_request(None).should_mask("set-cookie"));
assert!(map.get_response(None).should_mask("set-cookie"));
assert!(!map.get_response(None).should_mask("authorization"));
assert!(
map.get_response(Some("products"))
.should_mask("x-products-secret")
);
assert!(map.get_response(Some("nobody")).should_mask("set-cookie"));
}
#[test]
fn masked_headers_for_log_masks_via_default_rules_without_map() {
let ctx = Context::new();
let mut headers = HeaderMap::new();
headers.insert(
HeaderName::from_static("authorization"),
HeaderValue::from_static("Bearer secret"), );
headers.insert(
HeaderName::from_static("content-type"),
HeaderValue::from_static("application/json"),
);
let out = masked_headers_for_log(&ctx, Direction::Request, None, &headers);
assert!(
out.contains(MASKED_VALUE),
"authorization should be masked by fail-secure defaults: {out}"
);
assert!(!out.contains("secret"));
assert!(out.contains("application/json"));
}
#[test]
fn redact_header_value_precedence() {
let ctx = Context::new();
assert_eq!(
redact_header_value(
&ctx,
Direction::Request,
None,
"authorization",
Some("Bearer x".to_string()), Some(&RedactMode::Allow),
),
Some("Bearer x".to_string()) );
assert_eq!(
redact_header_value(
&ctx,
Direction::Request,
None,
"x-custom",
Some("v".to_string()),
Some(&RedactMode::Mask),
),
Some(MASKED_VALUE.to_string())
);
assert_eq!(
redact_header_value(
&ctx,
Direction::Request,
None,
"authorization",
Some("Bearer x".to_string()), None,
),
Some(MASKED_VALUE.to_string())
);
assert_eq!(
redact_header_value(
&ctx,
Direction::Request,
None,
"x-not-sensitive",
Some("v".to_string()),
None,
),
Some("v".to_string())
);
}
}