rws_http/server_config/
log_privacy.rs1use std::fmt;
22use std::net::IpAddr;
23use std::sync::atomic::{AtomicBool, Ordering};
24use std::sync::Arc;
25
26pub const RWS_CONFIG_ACCESS_LOG_ANONYMIZE_IP: &str = "RWS_CONFIG_ACCESS_LOG_ANONYMIZE_IP";
28pub const RWS_CONFIG_ACCESS_LOG_IP_HASH_SECRET: &str = "RWS_CONFIG_ACCESS_LOG_IP_HASH_SECRET";
30pub const RWS_CONFIG_LOG_REDACT_HEADERS: &str = "RWS_CONFIG_LOG_REDACT_HEADERS";
32pub const RWS_CONFIG_LOG_REDACT_QUERY: &str = "RWS_CONFIG_LOG_REDACT_QUERY";
34
35pub const REDACTED: &str = "REDACTED";
37
38pub const ALWAYS_REDACTED_HEADERS: [&str; 4] = ["authorization", "proxy-authorization", "cookie", "set-cookie"];
40
41static ACTIVE: AtomicBool = AtomicBool::new(false);
45
46#[doc(hidden)]
48pub fn maybe_active() -> bool {
49 ACTIVE.load(Ordering::Relaxed)
50}
51
52#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
54pub enum AnonymizeIp {
55 #[default]
57 Off,
58 Truncate,
60 Hash,
62}
63
64impl AnonymizeIp {
65 pub fn parse(value: &str) -> Option<AnonymizeIp> {
67 match value.trim().to_ascii_lowercase().as_str() {
68 "" | "off" | "false" | "none" => Some(AnonymizeIp::Off),
69 "truncate" | "true" | "on" => Some(AnonymizeIp::Truncate),
70 "hash" => Some(AnonymizeIp::Hash),
71 _ => None,
72 }
73 }
74}
75
76#[derive(Clone, Default, PartialEq)]
79pub struct LogPrivacy {
80 pub anonymize_ip: AnonymizeIp,
82 pub ip_hash_key: Arc<[u8]>,
84 pub redact_headers: Vec<String>,
86 pub redact_query: Vec<String>,
89}
90
91impl fmt::Debug for LogPrivacy {
93 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94 f.debug_struct("LogPrivacy")
95 .field("anonymize_ip", &self.anonymize_ip)
96 .field("ip_hash_key", &if self.ip_hash_key.is_empty() { "(none)" } else { "(set)" })
97 .field("redact_headers", &self.redact_headers)
98 .field("redact_query", &self.redact_query)
99 .finish()
100 }
101}
102
103fn names(value: &str) -> Vec<String> {
104 value.split(',').map(|n| n.trim().to_ascii_lowercase()).filter(|n| !n.is_empty()).collect()
105}
106
107impl LogPrivacy {
108 pub fn from_env() -> LogPrivacy {
112 let read = |key: &str| std::env::var(key).unwrap_or_default();
113 let privacy = LogPrivacy {
114 anonymize_ip: AnonymizeIp::parse(&read(RWS_CONFIG_ACCESS_LOG_ANONYMIZE_IP)).unwrap_or(AnonymizeIp::Truncate),
115 ip_hash_key: read(RWS_CONFIG_ACCESS_LOG_IP_HASH_SECRET).trim().as_bytes().into(),
116 redact_headers: names(&read(RWS_CONFIG_LOG_REDACT_HEADERS)),
117 redact_query: names(&read(RWS_CONFIG_LOG_REDACT_QUERY)),
118 };
119 ACTIVE.store(!privacy.is_off(), Ordering::Relaxed);
120 privacy
121 }
122
123 pub fn is_off(&self) -> bool {
125 self.anonymize_ip == AnonymizeIp::Off && self.redact_headers.is_empty() && self.redact_query.is_empty()
126 }
127
128 pub fn write_ip(&self, out: &mut Vec<u8>, ip: IpAddr) {
130 use std::io::Write;
131 match self.anonymize_ip {
132 AnonymizeIp::Off => {
133 let _ = write!(out, "{ip}");
134 }
135 AnonymizeIp::Hash if !self.ip_hash_key.is_empty() => {
136 let text = ip.to_canonical().to_string();
137 let mac = crate::core::sha256::hmac_sha256(&self.ip_hash_key, text.as_bytes());
138 const HEX: &[u8; 16] = b"0123456789abcdef";
139 for b in &mac[..8] {
140 out.push(HEX[(b >> 4) as usize]);
141 out.push(HEX[(b & 15) as usize]);
142 }
143 }
144 AnonymizeIp::Truncate | AnonymizeIp::Hash => {
145 let _ = write!(out, "{}", truncate_ip(ip));
146 }
147 }
148 }
149
150 pub fn ip_string(&self, ip: IpAddr) -> String {
152 let mut out = Vec::with_capacity(40);
153 self.write_ip(&mut out, ip);
154 String::from_utf8(out).unwrap_or_default()
155 }
156
157 pub fn redacts_header(&self, name: &str) -> bool {
159 ALWAYS_REDACTED_HEADERS.iter().any(|h| h.eq_ignore_ascii_case(name))
160 || self.redact_headers.iter().any(|h| h.eq_ignore_ascii_case(name))
161 }
162
163 pub fn redact_target<'a>(&self, target: &'a str, mut push: impl FnMut(&'a str)) {
168 let Some(q) = target.find('?').filter(|_| !self.redact_query.is_empty()) else {
169 push(target);
170 return;
171 };
172 let (head, rest) = target.split_at(q + 1);
173 let (query, fragment) = match rest.find('#') {
174 Some(h) => rest.split_at(h),
175 None => (rest, ""),
176 };
177 push(head);
178 for (i, pair) in query.split('&').enumerate() {
179 if i > 0 {
180 push("&");
181 }
182 match pair.split_once('=') {
183 Some((name, _)) if self.redact_query.iter().any(|n| n.eq_ignore_ascii_case(name)) => {
184 push(name);
185 push("=");
186 push(REDACTED);
187 }
188 _ => push(pair),
189 }
190 }
191 push(fragment);
192 }
193
194 pub fn redacted_target(&self, target: &str) -> String {
196 let mut out = String::with_capacity(target.len());
197 self.redact_target(target, |piece| out.push_str(piece));
198 out
199 }
200}
201
202pub fn truncate_ip(ip: IpAddr) -> IpAddr {
205 match ip.to_canonical() {
206 IpAddr::V4(v4) => {
207 let [a, b, c, _] = v4.octets();
208 IpAddr::from([a, b, c, 0])
209 }
210 IpAddr::V6(v6) => {
211 let s = v6.segments();
212 IpAddr::from([s[0], s[1], s[2], 0, 0, 0, 0, 0])
213 }
214 }
215}
216
217#[cfg(test)]
218mod tests;