Skip to main content

rws_http/server_config/
log_privacy.rs

1//! What the access log may say about a client (18.33.0, #102): IP
2//! anonymisation, header redaction and query-string redaction.
3//!
4//! All off by default, and then the access log is byte for byte what it was:
5//! [`LogPrivacy::is_off`] is checked first and the old code runs.
6//!
7//! - **`RWS_CONFIG_ACCESS_LOG_ANONYMIZE_IP`** — `off` (default), `truncate`
8//!   (IPv4 to its /24, IPv6 to its /48: `203.0.113.0`, `2001:db8:1::`, as
9//!   nginx's `$remote_addr` maps and Google Analytics do) or `hash` (a
10//!   16-hex-digit HMAC-SHA256 of the address under
11//!   `RWS_CONFIG_ACCESS_LOG_IP_HASH_SECRET`: the same client gets the same
12//!   pseudonym, which can't be turned back into the address without the
13//!   key; with no key `hash` truncates instead).
14//! - **`RWS_CONFIG_LOG_REDACT_HEADERS`** — header names whose values are
15//!   written as `REDACTED` wherever a header value is logged. `Authorization`,
16//!   `Proxy-Authorization`, `Cookie` and `Set-Cookie` always are.
17//! - **`RWS_CONFIG_LOG_REDACT_QUERY`** — query parameter names (`token,api_key`)
18//!   whose values are written as `REDACTED` in logged request targets and
19//!   `Referer`s.
20
21use std::fmt;
22use std::net::IpAddr;
23use std::sync::atomic::{AtomicBool, Ordering};
24use std::sync::Arc;
25
26/// `RWS_CONFIG_ACCESS_LOG_ANONYMIZE_IP`: `off`, `truncate` or `hash`.
27pub const RWS_CONFIG_ACCESS_LOG_ANONYMIZE_IP: &str = "RWS_CONFIG_ACCESS_LOG_ANONYMIZE_IP";
28/// `RWS_CONFIG_ACCESS_LOG_IP_HASH_SECRET`: the HMAC key for `hash`.
29pub const RWS_CONFIG_ACCESS_LOG_IP_HASH_SECRET: &str = "RWS_CONFIG_ACCESS_LOG_IP_HASH_SECRET";
30/// `RWS_CONFIG_LOG_REDACT_HEADERS`: more header names to redact.
31pub const RWS_CONFIG_LOG_REDACT_HEADERS: &str = "RWS_CONFIG_LOG_REDACT_HEADERS";
32/// `RWS_CONFIG_LOG_REDACT_QUERY`: query parameter names to redact.
33pub const RWS_CONFIG_LOG_REDACT_QUERY: &str = "RWS_CONFIG_LOG_REDACT_QUERY";
34
35/// What a redacted value is written as.
36pub const REDACTED: &str = "REDACTED";
37
38/// Headers whose values are never logged, whatever the settings.
39pub const ALWAYS_REDACTED_HEADERS: [&str; 4] = ["authorization", "proxy-authorization", "cookie", "set-cookie"];
40
41/// Set when the last configuration built had anything on: code that would
42/// otherwise not look at the configuration per request (`LogLayer`) checks
43/// this first.
44static ACTIVE: AtomicBool = AtomicBool::new(false);
45
46/// Whether the last [`LogPrivacy::from_env`] turned anything on.
47#[doc(hidden)]
48pub fn maybe_active() -> bool {
49    ACTIVE.load(Ordering::Relaxed)
50}
51
52/// How client addresses are written.
53#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
54pub enum AnonymizeIp {
55    /// As they are.
56    #[default]
57    Off,
58    /// IPv4 /24, IPv6 /48; the rest zeroed.
59    Truncate,
60    /// A keyed hash: 16 hex digits of HMAC-SHA256(key, address).
61    Hash,
62}
63
64impl AnonymizeIp {
65    /// `off`/`false`/`none`/empty, `truncate`, `hash`; `None` for anything else.
66    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/// The access log's privacy settings (see the module docs). The default
77/// is everything off.
78#[derive(Clone, Default, PartialEq)]
79pub struct LogPrivacy {
80    /// How client addresses are written.
81    pub anonymize_ip: AnonymizeIp,
82    /// The HMAC key for [`AnonymizeIp::Hash`] (empty: truncate instead).
83    pub ip_hash_key: Arc<[u8]>,
84    /// Lower-case header names redacted besides [`ALWAYS_REDACTED_HEADERS`].
85    pub redact_headers: Vec<String>,
86    /// Query parameter names whose values are redacted (matched ignoring
87    /// ASCII case).
88    pub redact_query: Vec<String>,
89}
90
91/// The key is never printed.
92impl 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    /// From the `RWS_CONFIG_*` variables above; an unknown
109    /// `ACCESS_LOG_ANONYMIZE_IP` value truncates (fails towards privacy —
110    /// `rws --check` reports it).
111    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    /// Nothing on: logs are written exactly as before.
124    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    /// Write `ip` as configured.
129    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    /// `ip` as configured, as a `String`.
151    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    /// Whether the value of header `name` is redacted.
158    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    /// `target` (a request target or URL) with the values of the
164    /// [`redact_query`](Self::redact_query) parameters replaced by
165    /// [`REDACTED`], passed to `push` piece by piece (so a caller can escape
166    /// as it writes). Without such parameters `target` goes through whole.
167    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    /// [`redact_target`](Self::redact_target) as a `String`.
195    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
202/// IPv4 to its /24, IPv6 to its /48 (an IPv4-mapped IPv6 address as the
203/// IPv4 address it maps).
204pub 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;