Skip to main content

agent_first_http/shared/
redact.rs

1//! Header-name redaction list applied to `network.json` by default (and to
2//! any tool-originated log line). Per `design.md §"Secrets are redacted"`,
3//! credential-bearing headers are replaced with `"[redacted]"` unless the
4//! caller passes `--no-network-redact`.
5//!
6//! Server response **bodies** pass through unmodified — redaction is for
7//! tool-captured metadata only.
8
9/// Header names that are always redacted (case-insensitive match).
10pub const ALWAYS_REDACTED: &[&str] = &[
11    "cookie",
12    "set-cookie",
13    "authorization",
14    "proxy-authorization",
15];
16
17/// Header-name *suffixes* that trigger redaction (case-insensitive match
18/// against the full header). Catches `x-api-token`, `x-csrf-token`,
19/// `x-shared-secret`, etc.
20pub const REDACTED_SUFFIXES: &[&str] = &["-token", "-secret"];
21
22/// Legacy/common URL query parameter names that carry credentials but cannot
23/// be renamed by afhttp because they belong to third-party sites.
24///
25/// AFDATA already redacts `_secret` query names. This compatibility list is
26/// deliberately exact and is applied only while parsing a single URL.
27pub const URL_SECRET_NAMES: &[&str] = &[
28    "access_token",
29    "api_key",
30    "auth",
31    "authorization",
32    "code",
33    "handoff",
34    "handoff_secret",
35    "id_token",
36    "key",
37    "password",
38    "passwd",
39    "session",
40    "sessionid",
41    "sig",
42    "signature",
43    "token",
44];
45
46/// Returns true if `header_name` should be redacted under the default
47/// policy. Case-insensitive.
48#[must_use]
49pub fn should_redact(header_name: &str) -> bool {
50    let lower = header_name.to_ascii_lowercase();
51    if ALWAYS_REDACTED.iter().any(|h| *h == lower) {
52        return true;
53    }
54    REDACTED_SUFFIXES
55        .iter()
56        .any(|suffix| lower.ends_with(suffix))
57}
58
59/// Sentinel string written in place of redacted values.
60pub const REDACTED_VALUE: &str = "[redacted]";
61
62/// AFDATA redactor configured for third-party HTTP URL compatibility names.
63///
64/// Keep this URL-only: AFDATA `secret_names` also match ordinary structured
65/// field names, where names such as the protocol's `code` field are public.
66#[must_use]
67fn url_redactor() -> agent_first_data::Redactor {
68    agent_first_data::Redactor::new().secret_names(URL_SECRET_NAMES.iter().copied())
69}
70
71/// Redact credentials from one URL, including userinfo and common third-party
72/// query parameter names.
73#[must_use]
74pub fn redact_url(url: &str) -> String {
75    url_redactor().url(url)
76}
77
78/// Redact third-party credential parameters only inside `_url`/`_URL` fields.
79///
80/// AFDATA deliberately uses one exact-name list for both structured fields and
81/// URL query parameters. Since afhttp does not own third-party parameter names,
82/// it applies that compatibility list to URL values first, then lets the
83/// default AFDATA redactor handle `_secret` fields at serialization time.
84#[must_use]
85pub fn redact_url_fields(value: &serde_json::Value) -> serde_json::Value {
86    let mut value = value.clone();
87    redact_url_fields_in_place(&mut value);
88    value
89}
90
91/// Apply afhttp's URL compatibility policy followed by standard AFDATA value
92/// redaction.
93#[must_use]
94pub fn redact_value(value: &serde_json::Value) -> serde_json::Value {
95    agent_first_data::Redactor::new().value(&redact_url_fields(value))
96}
97
98fn redact_url_fields_in_place(value: &mut serde_json::Value) {
99    match value {
100        serde_json::Value::Object(fields) => {
101            for (name, value) in fields {
102                if (name.ends_with("_url") || name.ends_with("_URL"))
103                    && let serde_json::Value::String(url) = value
104                {
105                    *url = redact_url(url);
106                } else {
107                    redact_url_fields_in_place(value);
108                }
109            }
110        }
111        serde_json::Value::Array(values) => {
112            for value in values {
113                redact_url_fields_in_place(value);
114            }
115        }
116        _ => {}
117    }
118}
119
120/// Mask the password component of every `scheme://user:pass@host` userinfo
121/// found in arbitrary text, leaving the rest byte-for-byte intact.
122///
123/// Used on browser stderr lines surfaced by `/diagnostics`, which can echo
124/// afhttp's own `--proxy-server=http://user:pass@host` launch argument. This
125/// is afhttp's own injected credential — never page-captured data — so masking
126/// it does not violate faithful capture. afdata's `redact_url_secrets` only
127/// operates on a string that is itself a single URL, so it cannot be used on a
128/// prose line; this is the narrow, userinfo-password-only equivalent.
129#[must_use]
130pub fn redact_userinfo_passwords(line: &str) -> String {
131    let mut out = String::with_capacity(line.len());
132    let mut rest = line;
133    while let Some(pos) = rest.find("://") {
134        // Copy through the scheme separator.
135        out.push_str(&rest[..pos + 3]);
136        let after = &rest[pos + 3..];
137        // Authority ends at the first '/', '?', '#', or whitespace.
138        let auth_end = after
139            .find(|c: char| matches!(c, '/' | '?' | '#') || c.is_whitespace())
140            .unwrap_or(after.len());
141        let authority = &after[..auth_end];
142        match (authority.find('@'), authority.find(':')) {
143            (Some(at), Some(colon)) if colon < at => {
144                out.push_str(&authority[..colon]);
145                out.push_str(":***");
146                out.push_str(&authority[at..]);
147            }
148            _ => out.push_str(authority),
149        }
150        rest = &after[auth_end..];
151    }
152    out.push_str(rest);
153    out
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn always_redacted_headers_match_case_insensitively() {
162        for h in ["Cookie", "COOKIE", "cookie", "Set-Cookie", "SET-COOKIE"] {
163            assert!(should_redact(h), "{h}");
164        }
165        assert!(should_redact("Authorization"));
166        assert!(should_redact("Proxy-Authorization"));
167    }
168
169    #[test]
170    fn suffix_match_catches_token_and_secret_headers() {
171        assert!(should_redact("X-Api-Token"));
172        assert!(should_redact("x-csrf-token"));
173        assert!(should_redact("X-Shared-Secret"));
174        assert!(should_redact("x-bearer-secret"));
175    }
176
177    #[test]
178    fn unrelated_headers_pass_through() {
179        for h in ["Content-Type", "User-Agent", "Accept", "X-Trace-Id"] {
180            assert!(!should_redact(h), "{h}");
181        }
182    }
183
184    #[test]
185    fn url_redaction_covers_userinfo_and_legacy_query_names() {
186        assert_eq!(
187            redact_url("https://user:pass@example.test/path?token=abc&safe=ok&next_secret=hidden"),
188            "https://user:***@example.test/path?token=***&safe=ok&next_secret=***"
189        );
190    }
191
192    #[test]
193    fn url_compatibility_names_do_not_redact_ordinary_fields() {
194        let redacted = redact_value(&serde_json::json!({
195            "code": "fetch",
196            "token": "public field",
197            "request_url": "https://example.test/?code=otp&token=bearer&safe=ok",
198            "token_secret": "private"
199        }));
200        assert_eq!(redacted["code"], "fetch");
201        assert_eq!(redacted["token"], "public field");
202        assert_eq!(
203            redacted["request_url"],
204            "https://example.test/?code=***&token=***&safe=ok"
205        );
206        assert_eq!(redacted["token_secret"], "***");
207    }
208
209    #[test]
210    fn userinfo_password_is_masked_in_text() {
211        assert_eq!(
212            redact_userinfo_passwords("launch --proxy-server=http://user:pass@proxy:8080 done"),
213            "launch --proxy-server=http://user:***@proxy:8080 done"
214        );
215        // username-only userinfo, host:port, and non-URL text are untouched.
216        assert_eq!(
217            redact_userinfo_passwords("http://user@host/x"),
218            "http://user@host/x"
219        );
220        assert_eq!(
221            redact_userinfo_passwords("connect socks5://10.0.0.5:1080 now"),
222            "connect socks5://10.0.0.5:1080 now"
223        );
224        assert_eq!(redact_userinfo_passwords("no url here"), "no url here");
225    }
226}