Skip to main content

browser_commander/traces/
redaction.rs

1//! Privacy rules for recorded traces (issue #87).
2//!
3//! The same rules as `js/src/traces/redaction.js`: defaults are additive, a
4//! listed query parameter is replaced wherever a URL carries it (the fragment
5//! included, because implicit OAuth flows put tokens there), and the marker is
6//! written back in readable form.
7
8use std::fmt;
9use std::sync::Arc;
10
11use regex::{NoExpand, Regex};
12use url::{form_urlencoded, Url};
13
14use super::jsonfmt::{Json, JsonObject};
15
16/// What a redacted value is replaced with.
17pub const REDACTED: &str = "[redacted]";
18
19/// Elements whose values the in-page capture never reads.
20pub const DEFAULT_REDACT_SELECTORS: [&str; 3] =
21    ["input[type=password]", "[data-private]", "[data-bc-redact]"];
22
23/// Field and header names whose values are always replaced.
24pub const DEFAULT_REDACT_ATTRIBUTES: [&str; 7] = [
25    "authorization",
26    "proxy-authorization",
27    "cookie",
28    "set-cookie",
29    "x-api-key",
30    "x-auth-token",
31    "x-csrf-token",
32];
33
34/// Query and fragment parameters whose values are always replaced.
35pub const DEFAULT_REDACT_QUERY_PARAMS: [&str; 12] = [
36    "access_token",
37    "api_key",
38    "apikey",
39    "auth",
40    "code",
41    "id_token",
42    "password",
43    "refresh_token",
44    "secret",
45    "session",
46    "signature",
47    "token",
48];
49
50/// What a custom redaction callback is shown.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct RedactContext<'a> {
53    /// `field`, `url` or `header`.
54    pub kind: &'a str,
55    /// The field or header name, when there is one.
56    pub name: Option<&'a str>,
57    /// The text after the built-in rules ran.
58    pub value: &'a str,
59}
60
61/// A caller's last word on a piece of text: `Some` replaces it.
62pub type RedactCallback = Arc<dyn Fn(&RedactContext<'_>) -> Option<String> + Send + Sync>;
63
64/// Privacy options as a caller writes them.
65#[derive(Clone)]
66pub struct TracePrivacyOptions {
67    /// Extra selectors whose elements are captured without their values.
68    pub redact_selectors: Vec<String>,
69    /// Extra field and header names whose values are replaced.
70    pub redact_attributes: Vec<String>,
71    /// Extra query and fragment parameters whose values are replaced.
72    pub redact_query_params: Vec<String>,
73    /// Regular expressions replaced in every recorded string.
74    pub redact_patterns: Vec<String>,
75    /// Called for every recorded string after the built-in rules.
76    pub redact: Option<RedactCallback>,
77    /// Keep the default lists; extras are added to them. Defaults to `true`.
78    pub use_defaults: bool,
79}
80
81impl Default for TracePrivacyOptions {
82    fn default() -> Self {
83        Self {
84            redact_selectors: Vec::new(),
85            redact_attributes: Vec::new(),
86            redact_query_params: Vec::new(),
87            redact_patterns: Vec::new(),
88            redact: None,
89            use_defaults: true,
90        }
91    }
92}
93
94impl fmt::Debug for TracePrivacyOptions {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        f.debug_struct("TracePrivacyOptions")
97            .field("redact_selectors", &self.redact_selectors)
98            .field("redact_attributes", &self.redact_attributes)
99            .field("redact_query_params", &self.redact_query_params)
100            .field("redact_patterns", &self.redact_patterns)
101            .field("redact", &self.redact.as_ref().map(|_| "<callback>"))
102            .field("use_defaults", &self.use_defaults)
103            .finish()
104    }
105}
106
107/// Privacy options with the defaults merged in and the patterns compiled.
108#[derive(Clone)]
109pub struct NormalizedPrivacy {
110    /// Lower-cased, de-duplicated selectors.
111    pub redact_selectors: Vec<String>,
112    /// Lower-cased, de-duplicated field and header names.
113    pub redact_attributes: Vec<String>,
114    /// Lower-cased, de-duplicated parameter names.
115    pub redact_query_params: Vec<String>,
116    /// Compiled patterns.
117    pub redact_patterns: Vec<Regex>,
118    /// The caller's callback.
119    pub redact: Option<RedactCallback>,
120}
121
122impl fmt::Debug for NormalizedPrivacy {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        f.debug_struct("NormalizedPrivacy")
125            .field("redact_selectors", &self.redact_selectors)
126            .field("redact_attributes", &self.redact_attributes)
127            .field("redact_query_params", &self.redact_query_params)
128            .field("redact_patterns", &self.redact_patterns)
129            .field("redact", &self.redact.as_ref().map(|_| "<callback>"))
130            .finish()
131    }
132}
133
134impl Default for NormalizedPrivacy {
135    fn default() -> Self {
136        normalize_privacy_options(&TracePrivacyOptions::default())
137            .expect("the default privacy options have no patterns to fail")
138    }
139}
140
141fn lower_set(defaults: &[&str], use_defaults: bool, extra: &[String]) -> Vec<String> {
142    let mut seen = Vec::<String>::new();
143    let defaults = defaults.iter().copied().filter(|_| use_defaults);
144    for value in defaults.chain(extra.iter().map(String::as_str)) {
145        let lowered = value.to_lowercase();
146        if !seen.contains(&lowered) {
147            seen.push(lowered);
148        }
149    }
150    seen
151}
152
153/// Merge the defaults in and compile the patterns.
154///
155/// # Errors
156///
157/// Returns the pattern's error when one is not a valid regular expression.
158pub fn normalize_privacy_options(
159    privacy: &TracePrivacyOptions,
160) -> Result<NormalizedPrivacy, regex::Error> {
161    let use_defaults = privacy.use_defaults;
162    Ok(NormalizedPrivacy {
163        redact_selectors: lower_set(
164            &DEFAULT_REDACT_SELECTORS,
165            use_defaults,
166            &privacy.redact_selectors,
167        ),
168        redact_attributes: lower_set(
169            &DEFAULT_REDACT_ATTRIBUTES,
170            use_defaults,
171            &privacy.redact_attributes,
172        ),
173        redact_query_params: lower_set(
174            &DEFAULT_REDACT_QUERY_PARAMS,
175            use_defaults,
176            &privacy.redact_query_params,
177        ),
178        redact_patterns: privacy
179            .redact_patterns
180            .iter()
181            .map(|pattern| Regex::new(pattern))
182            .collect::<Result<_, _>>()?,
183        redact: privacy.redact.clone(),
184    })
185}
186
187/// Replace every pattern match, then let the callback have its say.
188pub fn redact_text(
189    value: &str,
190    privacy: &NormalizedPrivacy,
191    kind: &str,
192    name: Option<&str>,
193) -> String {
194    if value.is_empty() {
195        return String::new();
196    }
197    let mut text = value.to_string();
198    for pattern in &privacy.redact_patterns {
199        text = pattern.replace_all(&text, NoExpand(REDACTED)).into_owned();
200    }
201    if let Some(callback) = &privacy.redact {
202        let context = RedactContext {
203            kind,
204            name,
205            value: &text,
206        };
207        if let Some(replaced) = callback(&context) {
208            text = replaced;
209        }
210    }
211    text
212}
213
214/// `URLSearchParams.set(key, REDACTED)` for every listed key, serialized again
215/// only when one was listed.
216fn redact_params(body: &str, privacy: &NormalizedPrivacy) -> Option<String> {
217    let mut pairs: Vec<(String, String)> = form_urlencoded::parse(body.as_bytes())
218        .map(|(key, value)| (key.into_owned(), value.into_owned()))
219        .collect();
220    let keys: Vec<String> = pairs.iter().map(|(key, _)| key.clone()).collect();
221    let mut changed = false;
222    for key in keys {
223        if !privacy.redact_query_params.contains(&key.to_lowercase()) {
224            continue;
225        }
226        changed = true;
227        let mut found = false;
228        pairs.retain_mut(|(name, value)| {
229            if *name != key {
230                return true;
231            }
232            if found {
233                return false;
234            }
235            found = true;
236            *value = REDACTED.to_string();
237            true
238        });
239        if !found {
240            pairs.push((key, REDACTED.to_string()));
241        }
242    }
243    if !changed {
244        return None;
245    }
246    Some(
247        form_urlencoded::Serializer::new(String::new())
248            .extend_pairs(pairs.iter())
249            .finish(),
250    )
251}
252
253/// Redact a URL's credentials and listed parameters, then its text.
254///
255/// A relative or malformed URL is kept unparsed and only its text is redacted.
256pub fn redact_url(url: &str, privacy: &NormalizedPrivacy) -> String {
257    if url.is_empty() {
258        return String::new();
259    }
260    let mut text = url.to_string();
261    if let Ok(mut parsed) = Url::parse(url) {
262        let has_user = !parsed.username().is_empty();
263        let has_password = parsed
264            .password()
265            .is_some_and(|password| !password.is_empty());
266        if has_user || has_password {
267            let _ = parsed.set_username(if has_user { REDACTED } else { "" });
268            let _ = parsed.set_password(if has_password { Some(REDACTED) } else { None });
269        }
270        if let Some(query) = parsed.query().map(str::to_string) {
271            if let Some(redacted) = redact_params(&query, privacy) {
272                parsed.set_query(Some(&redacted));
273            }
274        }
275        if let Some(fragment) = parsed.fragment().map(str::to_string) {
276            if !fragment.is_empty() {
277                let body = fragment.strip_prefix('?').unwrap_or(&fragment);
278                if let Some(redacted) = redact_params(body, privacy) {
279                    parsed.set_fragment(Some(&redacted));
280                }
281            }
282        }
283        text = parsed.to_string().replace("%5Bredacted%5D", REDACTED);
284    }
285    redact_text(&text, privacy, "url", None)
286}
287
288/// Whether a field name ends in `url`, which is how JavaScript decides a value
289/// is a URL (`/url$/i`).
290fn names_a_url(key: &str) -> bool {
291    key.len() >= 3
292        && key
293            .get(key.len() - 3..)
294            .is_some_and(|tail| tail.eq_ignore_ascii_case("url"))
295}
296
297/// Redact every string inside a value, by the name of the field holding it.
298pub fn redact_value(value: &Json, privacy: &NormalizedPrivacy, key: &str) -> Json {
299    match value {
300        Json::String(text) => {
301            if privacy.redact_attributes.contains(&key.to_lowercase()) {
302                Json::from(REDACTED)
303            } else if names_a_url(key) {
304                Json::String(redact_url(text, privacy))
305            } else {
306                Json::String(redact_text(text, privacy, "field", Some(key)))
307            }
308        }
309        Json::Array(items) => Json::Array(
310            items
311                .iter()
312                .map(|item| redact_value(item, privacy, key))
313                .collect(),
314        ),
315        Json::Object(object) => Json::Object(redact_object(object, privacy)),
316        other => other.clone(),
317    }
318}
319
320/// [`redact_value`] for an object's fields.
321pub fn redact_object(object: &JsonObject, privacy: &NormalizedPrivacy) -> JsonObject {
322    object
323        .iter()
324        .map(|(name, entry)| (name.clone(), redact_value(entry, privacy, name)))
325        .collect()
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    fn privacy() -> NormalizedPrivacy {
333        normalize_privacy_options(&TracePrivacyOptions {
334            redact_patterns: vec!["sk-[a-z0-9]+".to_string()],
335            redact_query_params: vec!["Ticket".to_string()],
336            ..TracePrivacyOptions::default()
337        })
338        .unwrap()
339    }
340
341    #[test]
342    fn defaults_are_additive_and_lower_cased() {
343        let privacy = privacy();
344        assert_eq!(privacy.redact_query_params.last().unwrap(), "ticket");
345        assert_eq!(privacy.redact_query_params.len(), 13);
346    }
347
348    #[test]
349    fn urls_lose_credentials_and_listed_parameters() {
350        let privacy = privacy();
351        assert_eq!(
352            redact_url(
353                "https://user:pw@example.com/start?ticket=t-1&lang=en#access_token=abc&view=1",
354                &privacy
355            ),
356            "https://[redacted]:[redacted]@example.com/start?ticket=[redacted]&lang=en#access_token=[redacted]&view=1"
357        );
358        assert_eq!(
359            redact_url("not a url sk-abc", &privacy),
360            "not a url [redacted]"
361        );
362    }
363
364    #[test]
365    fn values_are_redacted_by_field_name() {
366        let privacy = privacy();
367        let value = Json::parse(
368            r#"{"authorization":"Bearer x","pageUrl":"https://a.example/?token=1","list":["sk-a1"]}"#,
369        )
370        .unwrap();
371        assert_eq!(
372            redact_value(&value, &privacy, "").to_compact(),
373            r#"{"authorization":"[redacted]","pageUrl":"https://a.example/?token=[redacted]","list":["[redacted]"]}"#
374        );
375    }
376}