Skip to main content

arbiter_credential/
inject.rs

1//! Credential injection middleware.
2//!
3//! Scans outgoing HTTP request bodies and headers for credential reference
4//! patterns (`${CRED:ref_name}`) and substitutes them with resolved secret
5//! values. Also scans response bodies to ensure credentials never leak back
6//! to the agent.
7//!
8//! This module provides standalone async functions. Wiring into the full proxy
9//! pipeline is done in the main binary crate.
10
11use regex::Regex;
12use secrecy::{ExposeSecret, SecretString};
13use std::sync::LazyLock;
14use tracing::{debug, warn};
15
16use crate::error::CredentialError;
17use crate::provider::CredentialProvider;
18
19/// Pattern matching `${CRED:reference_name}`. The reference name is captured
20/// in group 1 and may contain alphanumerics, underscores, hyphens, and dots.
21static CRED_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
22    Regex::new(r"\$\{CRED:([A-Za-z0-9_.\-]+)\}").expect("credential pattern is valid regex")
23});
24
25/// The sentinel string used to mask leaked credentials in response bodies.
26const REDACTED: &str = "[CREDENTIAL]";
27
28/// Result of injecting credentials into a request.
29#[derive(Debug)]
30pub struct InjectedRequest {
31    /// The rewritten request body with credential references substituted.
32    pub body: String,
33    /// Rewritten headers (name, value) with credential references substituted.
34    pub headers: Vec<(String, String)>,
35    /// Which credential references were resolved during injection.
36    pub resolved_refs: Vec<String>,
37    /// The actual resolved secret values for response scrubbing.
38    /// Stored as [`SecretString`] so they are zeroized on drop and never
39    /// accidentally exposed through `Debug` or `Display`.
40    pub resolved_values: Vec<SecretString>,
41}
42
43/// Inject credentials into a request body and a set of headers.
44///
45/// Every occurrence of `${CRED:ref}` in `body` and in the header values is
46/// replaced with the resolved secret. Returns an error if any referenced
47/// credential cannot be resolved.
48pub async fn inject_credentials(
49    body: &str,
50    headers: &[(String, String)],
51    provider: &dyn CredentialProvider,
52) -> Result<InjectedRequest, CredentialError> {
53    let mut resolved_refs: Vec<String> = Vec::new();
54    let mut resolved_values: Vec<SecretString> = Vec::new();
55
56    // --- body ---
57    let new_body = replace_refs(body, provider, &mut resolved_refs, &mut resolved_values).await?;
58
59    // --- headers ---
60    let mut new_headers = Vec::with_capacity(headers.len());
61    for (name, value) in headers {
62        let new_value =
63            replace_refs(value, provider, &mut resolved_refs, &mut resolved_values).await?;
64        new_headers.push((name.clone(), new_value));
65    }
66
67    debug!(count = resolved_refs.len(), "credential injection complete");
68
69    Ok(InjectedRequest {
70        body: new_body,
71        headers: new_headers,
72        resolved_refs,
73        resolved_values,
74    })
75}
76
77/// Encoding-aware response scrubbing using [`SecretString`] values.
78///
79/// The plaintext is only exposed for the duration of the scrub operation and
80/// is not stored in any intermediate `String` that outlives this call.
81///
82/// `known_values` should contain the secret values that were injected into the
83/// outgoing request so we can detect them if the upstream echoes them back.
84pub fn scrub_response(body: &str, known_values: &[SecretString]) -> String {
85    // Sort credentials by length (longest first) to prevent
86    // partial-match interference. If credential A = "abc" and B = "abcdef",
87    // scrubbing A first would mangle B's value, preventing its detection.
88    let mut sorted_values: Vec<&SecretString> = known_values.iter().collect();
89    sorted_values.sort_by_key(|v| std::cmp::Reverse(v.expose_secret().len()));
90
91    let mut scrubbed = body.to_string();
92    for secret in sorted_values {
93        let value = secret.expose_secret();
94        if value.is_empty() {
95            continue;
96        }
97        if value.len() < 4 {
98            // Warn about short credentials that risk over-scrubbing.
99            // Values under 4 chars match too many substrings, but we still
100            // scrub them because failing to scrub a real credential is worse.
101            tracing::debug!(
102                len = value.len(),
103                "scrubbing short credential value; consider using longer secrets"
104            );
105        }
106        scrub_single_value(&mut scrubbed, value);
107    }
108    scrubbed
109}
110
111/// Encoding-aware response scrubbing for plain `&[String]` values.
112///
113/// Backward-compatible entry point for callers that do not yet use
114/// [`SecretString`]. Prefer [`scrub_response`] with `SecretString` values.
115pub fn scrub_response_plain(body: &str, known_values: &[String]) -> String {
116    let mut sorted_values: Vec<&String> = known_values.iter().collect();
117    sorted_values.sort_by_key(|v| std::cmp::Reverse(v.len()));
118
119    let mut scrubbed = body.to_string();
120    for value in sorted_values {
121        if value.is_empty() {
122            continue;
123        }
124        scrub_single_value(&mut scrubbed, value);
125    }
126    scrubbed
127}
128
129/// Scrub all encoded variants of a single credential value from a response body.
130fn scrub_single_value(scrubbed: &mut String, value: &str) {
131    // Direct match
132    if scrubbed.contains(value) {
133        warn!("credential value detected in response body, scrubbing");
134        *scrubbed = scrubbed.replace(value, REDACTED);
135    }
136    // URL-encoded variant
137    let url_encoded = urlencoding_encode(value);
138    if url_encoded != value && scrubbed.contains(&url_encoded) {
139        warn!("URL-encoded credential value detected in response body, scrubbing");
140        *scrubbed = scrubbed.replace(&url_encoded, REDACTED);
141    }
142    // Lowercase percent-encoded variant (RFC 3986 allows both %2F and %2f).
143    let url_lower = urlencoding_encode_lower(value);
144    if url_lower != url_encoded && url_lower != value && scrubbed.contains(&url_lower) {
145        warn!("lowercase-percent-encoded credential value detected in response body, scrubbing");
146        *scrubbed = scrubbed.replace(&url_lower, REDACTED);
147    }
148    // JSON-escaped variant (handles quotes, backslashes, etc.)
149    if let Ok(json_str) = serde_json::to_string(value) {
150        // Remove surrounding quotes from JSON string
151        let json_escaped = &json_str[1..json_str.len() - 1];
152        if json_escaped != value && scrubbed.contains(json_escaped) {
153            warn!("JSON-escaped credential value detected in response body, scrubbing");
154            *scrubbed = scrubbed.replace(json_escaped, REDACTED);
155        }
156    }
157    // Hex-encoded variant (lowercase)
158    let hex_encoded: String = value.bytes().map(|b| format!("{:02x}", b)).collect();
159    if scrubbed.contains(&hex_encoded) {
160        warn!("hex-encoded credential value detected in response body, scrubbing");
161        *scrubbed = scrubbed.replace(&hex_encoded, REDACTED);
162    }
163    // Uppercase hex variant.
164    let hex_upper: String = value.bytes().map(|b| format!("{:02X}", b)).collect();
165    if hex_upper != hex_encoded && scrubbed.contains(&hex_upper) {
166        warn!("uppercase-hex-encoded credential value detected in response body, scrubbing");
167        *scrubbed = scrubbed.replace(&hex_upper, REDACTED);
168    }
169    // Base64-encoded credential scrubbing.
170    let b64_encoded = base64_encode_value(value);
171    if b64_encoded != value && scrubbed.contains(&b64_encoded) {
172        warn!("base64-encoded credential value detected in response body, scrubbing");
173        *scrubbed = scrubbed.replace(&b64_encoded, REDACTED);
174    }
175    // Base64url variant (RFC 4648 S5: '-' '_', no padding).
176    let b64url_encoded = base64url_encode_value(value);
177    if b64url_encoded != value
178        && b64url_encoded != b64_encoded
179        && scrubbed.contains(&b64url_encoded)
180    {
181        warn!("base64url-encoded credential value detected in response body, scrubbing");
182        *scrubbed = scrubbed.replace(&b64url_encoded, REDACTED);
183    }
184    // Unicode JSON escape sequence variant (\u00XX per character).
185    let unicode_escaped = unicode_json_escape(value);
186    if unicode_escaped != value && scrubbed.contains(&unicode_escaped) {
187        warn!("unicode-escaped credential value detected in response body, scrubbing");
188        *scrubbed = scrubbed.replace(&unicode_escaped, REDACTED);
189    }
190    // Double URL-encoding variant.
191    let double_url_encoded = urlencoding_encode(&url_encoded);
192    if double_url_encoded != url_encoded && scrubbed.contains(&double_url_encoded) {
193        warn!("double-URL-encoded credential value detected in response body, scrubbing");
194        *scrubbed = scrubbed.replace(&double_url_encoded, REDACTED);
195    }
196    // HTML entity encoded variants (decimal and hex).
197    // A malicious upstream could embed credentials as &#78;&#79; or &#x4E;&#x4F;
198    // in HTML responses to evade direct string matching.
199    let html_decimal = html_entity_encode_decimal(value);
200    if scrubbed.contains(&html_decimal) {
201        warn!("HTML-entity-encoded (decimal) credential value detected in response body, scrubbing");
202        *scrubbed = scrubbed.replace(&html_decimal, REDACTED);
203    }
204    let html_hex = html_entity_encode_hex(value);
205    if html_hex != html_decimal && scrubbed.contains(&html_hex) {
206        warn!("HTML-entity-encoded (hex) credential value detected in response body, scrubbing");
207        *scrubbed = scrubbed.replace(&html_hex, REDACTED);
208    }
209}
210
211/// HTML decimal entity encoding (&#DDD; per character).
212fn html_entity_encode_decimal(input: &str) -> String {
213    input.bytes().map(|b| format!("&#{};", b)).collect()
214}
215
216/// HTML hex entity encoding (&#xHH; per character).
217fn html_entity_encode_hex(input: &str) -> String {
218    input.bytes().map(|b| format!("&#x{:02X};", b)).collect()
219}
220
221/// Simple percent-encoding for credential scrubbing.
222fn urlencoding_encode(input: &str) -> String {
223    let mut encoded = String::new();
224    for byte in input.bytes() {
225        match byte {
226            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
227                encoded.push(byte as char);
228            }
229            _ => {
230                encoded.push_str(&format!("%{:02X}", byte));
231            }
232        }
233    }
234    encoded
235}
236
237/// Lowercase percent-encoding variant for credential scrubbing.
238/// RFC 3986 allows both `%2F` and `%2f`; the standard encoder uses uppercase,
239/// but a malicious upstream may use lowercase to evade scrubbing.
240fn urlencoding_encode_lower(input: &str) -> String {
241    let mut encoded = String::new();
242    for byte in input.bytes() {
243        match byte {
244            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
245                encoded.push(byte as char);
246            }
247            _ => {
248                encoded.push_str(&format!("%{:02x}", byte));
249            }
250        }
251    }
252    encoded
253}
254
255/// Unicode JSON escape sequence encoding (\u00XX per byte).
256/// A credential "abc" becomes "\u0061\u0062\u0063" which is valid JSON
257/// and renders identically when parsed.
258fn unicode_json_escape(input: &str) -> String {
259    input.bytes().map(|b| format!("\\u{:04x}", b)).collect()
260}
261
262/// Collect all `${CRED:...}` references found in `input`.
263pub fn find_refs(input: &str) -> Vec<String> {
264    CRED_PATTERN
265        .captures_iter(input)
266        .map(|cap| cap[1].to_string())
267        .collect()
268}
269
270// ---------------------------------------------------------------------------
271// Internal helpers
272// ---------------------------------------------------------------------------
273
274/// Replace all `${CRED:ref}` patterns in `input` by resolving each reference
275/// through the provider. Appends resolved reference names and values.
276async fn replace_refs(
277    input: &str,
278    provider: &dyn CredentialProvider,
279    resolved: &mut Vec<String>,
280    secret_values: &mut Vec<SecretString>,
281) -> Result<String, CredentialError> {
282    // Collect all matches first so we can resolve them.
283    let refs = find_refs(input);
284    if refs.is_empty() {
285        return Ok(input.to_string());
286    }
287
288    // Validate all credential references exist before injecting any.
289    // This prevents partial injection where some refs are resolved but others fail,
290    // which could leak information about which credential names are valid.
291    let mut resolved_pairs: Vec<(String, SecretString)> = Vec::new();
292    for reference in &refs {
293        let value = provider.resolve(reference).await?;
294        resolved_pairs.push((reference.clone(), value));
295    }
296
297    let mut output = input.to_string();
298    for (reference, secret) in &resolved_pairs {
299        let placeholder = format!("${{CRED:{reference}}}");
300        output = output.replace(&placeholder, secret.expose_secret());
301        if !resolved.contains(reference) {
302            resolved.push(reference.clone());
303            // Capture resolved values at injection time.
304            // Previously the handler re-resolved from the provider for scrubbing,
305            // which could return different values after credential rotation.
306            secret_values.push(SecretString::from(secret.expose_secret().to_owned()));
307        }
308    }
309
310    Ok(output)
311}
312
313/// Base64url encoding (RFC 4648 S5) for credential scrubbing.
314/// Uses '-' and '_' instead of '+' and '/', no padding.
315fn base64url_encode_value(input: &str) -> String {
316    let standard = base64_encode_value(input);
317    standard
318        .replace('+', "-")
319        .replace('/', "_")
320        .trim_end_matches('=')
321        .to_string()
322}
323
324/// Simple base64 encoding for credential scrubbing (standard alphabet, with padding).
325fn base64_encode_value(input: &str) -> String {
326    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
327    let bytes = input.as_bytes();
328    let mut result = String::with_capacity(bytes.len().div_ceil(3) * 4);
329    for chunk in bytes.chunks(3) {
330        let b0 = chunk[0] as u32;
331        let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
332        let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
333        let triple = (b0 << 16) | (b1 << 8) | b2;
334        result.push(ALPHABET[((triple >> 18) & 0x3F) as usize] as char);
335        result.push(ALPHABET[((triple >> 12) & 0x3F) as usize] as char);
336        if chunk.len() > 1 {
337            result.push(ALPHABET[((triple >> 6) & 0x3F) as usize] as char);
338        } else {
339            result.push('=');
340        }
341        if chunk.len() > 2 {
342            result.push(ALPHABET[(triple & 0x3F) as usize] as char);
343        } else {
344            result.push('=');
345        }
346    }
347    result
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use crate::provider::{CredentialProvider, CredentialRef};
354    use async_trait::async_trait;
355    use std::collections::HashMap;
356
357    /// A trivial in-memory provider for tests.
358    struct MockProvider {
359        store: HashMap<String, String>,
360    }
361
362    impl MockProvider {
363        fn new(entries: &[(&str, &str)]) -> Self {
364            Self {
365                store: entries
366                    .iter()
367                    .map(|(k, v)| (k.to_string(), v.to_string()))
368                    .collect(),
369            }
370        }
371    }
372
373    #[async_trait]
374    impl CredentialProvider for MockProvider {
375        async fn resolve(&self, reference: &str) -> Result<SecretString, CredentialError> {
376            self.store
377                .get(reference)
378                .map(|v| SecretString::from(v.clone()))
379                .ok_or_else(|| CredentialError::NotFound(reference.to_string()))
380        }
381
382        async fn list_refs(&self) -> Result<Vec<CredentialRef>, CredentialError> {
383            Ok(self
384                .store
385                .keys()
386                .map(|k| CredentialRef {
387                    name: k.clone(),
388                    provider: "mock".into(),
389                    last_rotated: None,
390                })
391                .collect())
392        }
393    }
394
395    /// Helper: create a Vec<SecretString> from plain string slices.
396    fn secret_vec(values: &[&str]) -> Vec<SecretString> {
397        values
398            .iter()
399            .map(|v| SecretString::from(v.to_string()))
400            .collect()
401    }
402
403    #[tokio::test]
404    async fn injects_body_credentials() {
405        let provider = MockProvider::new(&[("api_key", "sk-secret-123")]);
406        let body = r#"{"key": "${CRED:api_key}"}"#;
407
408        let result = inject_credentials(body, &[], &provider).await.unwrap();
409        assert_eq!(result.body, r#"{"key": "sk-secret-123"}"#);
410        assert_eq!(result.resolved_refs, vec!["api_key"]);
411    }
412
413    #[tokio::test]
414    async fn injects_header_credentials() {
415        let provider = MockProvider::new(&[("token", "ghp_abc")]);
416        let headers = vec![
417            (
418                "Authorization".to_string(),
419                "Bearer ${CRED:token}".to_string(),
420            ),
421            ("X-Custom".to_string(), "plain-value".to_string()),
422        ];
423
424        let result = inject_credentials("", &headers, &provider).await.unwrap();
425        assert_eq!(result.headers[0].1, "Bearer ghp_abc");
426        assert_eq!(result.headers[1].1, "plain-value");
427    }
428
429    #[tokio::test]
430    async fn multiple_refs_in_body() {
431        let provider = MockProvider::new(&[("a", "AAA"), ("b", "BBB")]);
432        let body = "first=${CRED:a}&second=${CRED:b}";
433
434        let result = inject_credentials(body, &[], &provider).await.unwrap();
435        assert_eq!(result.body, "first=AAA&second=BBB");
436        assert!(result.resolved_refs.contains(&"a".to_string()));
437        assert!(result.resolved_refs.contains(&"b".to_string()));
438    }
439
440    #[tokio::test]
441    async fn unknown_ref_returns_error() {
442        let provider = MockProvider::new(&[]);
443        let body = "key=${CRED:missing}";
444
445        let err = inject_credentials(body, &[], &provider).await.unwrap_err();
446        assert!(matches!(err, CredentialError::NotFound(_)));
447    }
448
449    #[tokio::test]
450    async fn no_refs_is_passthrough() {
451        let provider = MockProvider::new(&[]);
452        let body = "no credential references here";
453
454        let result = inject_credentials(body, &[], &provider).await.unwrap();
455        assert_eq!(result.body, body);
456        assert!(result.resolved_refs.is_empty());
457    }
458
459    #[test]
460    fn scrub_response_masks_leaked_values() {
461        let body = r#"{"echo": "sk-secret-123", "other": "safe"}"#;
462        let known = secret_vec(&["sk-secret-123"]);
463
464        let scrubbed = scrub_response(body, &known);
465        assert_eq!(scrubbed, r#"{"echo": "[CREDENTIAL]", "other": "safe"}"#);
466    }
467
468    #[test]
469    fn scrub_response_no_match_is_passthrough() {
470        let body = "nothing to see here";
471        let known = secret_vec(&["secret"]);
472        let scrubbed = scrub_response(body, &known);
473        assert_eq!(scrubbed, body);
474    }
475
476    #[test]
477    fn scrub_response_empty_value_ignored() {
478        let body = "some body";
479        let known = secret_vec(&[""]);
480        let scrubbed = scrub_response(body, &known);
481        assert_eq!(scrubbed, body);
482    }
483
484    #[test]
485    fn scrub_response_multiple_values() {
486        let body = "has AAA and BBB in it";
487        let known = secret_vec(&["AAA", "BBB"]);
488        let scrubbed = scrub_response(body, &known);
489        assert_eq!(scrubbed, "has [CREDENTIAL] and [CREDENTIAL] in it");
490    }
491
492    #[test]
493    fn find_refs_extracts_all_references() {
494        let input = "${CRED:one} and ${CRED:two.three} and ${CRED:four-five}";
495        let refs = find_refs(input);
496        assert_eq!(refs, vec!["one", "two.three", "four-five"]);
497    }
498
499    #[test]
500    fn find_refs_empty_on_no_match() {
501        assert!(find_refs("no credentials here").is_empty());
502    }
503
504    // -----------------------------------------------------------------------
505    // Encoding-aware scrubbing tests
506    // -----------------------------------------------------------------------
507
508    #[test]
509    fn scrub_response_url_encoded() {
510        let known = secret_vec(&["p@ss w0rd!"]);
511        let body = "the response contains p%40ss%20w0rd%21 in a query string";
512
513        let scrubbed = scrub_response(body, &known);
514        assert_eq!(
515            scrubbed,
516            "the response contains [CREDENTIAL] in a query string"
517        );
518        assert!(!scrubbed.contains("%40"));
519        assert!(!scrubbed.contains("%20"));
520        assert!(!scrubbed.contains("%21"));
521    }
522
523    #[test]
524    fn scrub_response_json_escaped() {
525        let known = secret_vec(&[r#"pass"word\"#]);
526        let body = r#"{"field": "pass\"word\\"}"#;
527
528        let scrubbed = scrub_response(body, &known);
529        assert_eq!(scrubbed, r#"{"field": "[CREDENTIAL]"}"#);
530        assert!(!scrubbed.contains("pass"));
531        assert!(!scrubbed.contains("word"));
532    }
533
534    #[test]
535    fn scrub_response_hex_encoded() {
536        let hex_of_secret = "736563726574";
537        let body = format!("debug dump: hex={hex_of_secret} end");
538        let known = secret_vec(&["secret"]);
539
540        let scrubbed = scrub_response(&body, &known);
541        assert_eq!(scrubbed, "debug dump: hex=[CREDENTIAL] end");
542        assert!(!scrubbed.contains(hex_of_secret));
543    }
544
545    #[test]
546    fn scrub_response_base64_encoded() {
547        let b64 = base64_encode_value("my-secret-key");
548        assert_eq!(b64, "bXktc2VjcmV0LWtleQ==");
549        let body = format!("Authorization: Basic {b64} is here");
550        let known = secret_vec(&["my-secret-key"]);
551
552        let scrubbed = scrub_response(&body, &known);
553        assert_eq!(scrubbed, "Authorization: Basic [CREDENTIAL] is here");
554        assert!(!scrubbed.contains(&b64));
555    }
556
557    #[test]
558    fn scrub_response_longest_first_ordering() {
559        let known = secret_vec(&["abc", "abcdef"]);
560        let body = "values: abcdef and abc end";
561
562        let scrubbed = scrub_response(body, &known);
563        assert_eq!(scrubbed, "values: [CREDENTIAL] and [CREDENTIAL] end");
564        assert!(!scrubbed.contains("abc"));
565        assert!(!scrubbed.contains("abcdef"));
566    }
567
568    #[test]
569    fn scrub_response_all_encodings_simultaneously() {
570        let secret = "s3cr&t!";
571        let url_enc = urlencoding_encode(secret);
572        let hex_enc: String = secret.bytes().map(|b| format!("{:02x}", b)).collect();
573        let b64_enc = base64_encode_value(secret);
574        let json_enc = {
575            let full = serde_json::to_string(secret).unwrap();
576            full[1..full.len() - 1].to_string()
577        };
578
579        let body =
580            format!("plain={secret} url={url_enc} json={json_enc} hex={hex_enc} b64={b64_enc}");
581        let known = secret_vec(&[secret]);
582
583        let scrubbed = scrub_response(&body, &known);
584
585        assert_eq!(
586            scrubbed,
587            "plain=[CREDENTIAL] url=[CREDENTIAL] json=[CREDENTIAL] hex=[CREDENTIAL] b64=[CREDENTIAL]"
588        );
589        assert!(!scrubbed.contains(secret));
590        assert!(!scrubbed.contains(&url_enc));
591        assert!(!scrubbed.contains(&hex_enc));
592        assert!(!scrubbed.contains(&b64_enc));
593    }
594
595    #[tokio::test]
596    async fn scrub_response_partial_injection_prevented() {
597        let provider = MockProvider::new(&[("valid_key", "resolved-value")]);
598        let body = "first=${CRED:valid_key}&second=${CRED:missing_key}";
599
600        let result = inject_credentials(body, &[], &provider).await;
601        assert!(
602            result.is_err(),
603            "injection must fail when any ref is unresolvable"
604        );
605        assert!(
606            matches!(result.unwrap_err(), CredentialError::NotFound(ref name) if name == "missing_key")
607        );
608    }
609
610    #[tokio::test]
611    async fn resolved_values_captured_at_injection_time() {
612        let provider = MockProvider::new(&[("key_a", "alpha-secret"), ("key_b", "beta-secret")]);
613        let body = "a=${CRED:key_a} b=${CRED:key_b}";
614
615        let result = inject_credentials(body, &[], &provider).await.unwrap();
616
617        assert_eq!(result.body, "a=alpha-secret b=beta-secret");
618
619        let exposed: Vec<&str> = result
620            .resolved_values
621            .iter()
622            .map(|s| s.expose_secret())
623            .collect();
624        assert!(exposed.contains(&"alpha-secret"));
625        assert!(exposed.contains(&"beta-secret"));
626        assert_eq!(result.resolved_values.len(), 2);
627
628        let leaked_response = "upstream echoed alpha-secret back";
629        let scrubbed = scrub_response(leaked_response, &result.resolved_values);
630        assert_eq!(scrubbed, "upstream echoed [CREDENTIAL] back");
631    }
632
633    // -----------------------------------------------------------------------
634    // Recursive credential injection must not expand
635    // -----------------------------------------------------------------------
636
637    #[test]
638    fn recursive_ref_not_expanded() {
639        let input = "${CRED:${CRED:inner}}";
640        let refs = find_refs(input);
641        assert_eq!(
642            refs,
643            vec!["inner"],
644            "only the inner ref 'inner' should be matched. Got: {:?}",
645            refs
646        );
647
648        let input3 = "${CRED:${CRED:${CRED:deep}}}";
649        let refs3 = find_refs(input3);
650        assert_eq!(
651            refs3,
652            vec!["deep"],
653            "only the innermost ref 'deep' should be matched. Got: {:?}",
654            refs3
655        );
656    }
657
658    #[tokio::test]
659    async fn recursive_injection_does_not_re_expand() {
660        let provider = MockProvider::new(&[("outer", "${CRED:inner}"), ("inner", "real-secret")]);
661        let body = "key=${CRED:outer}";
662
663        let result = inject_credentials(body, &[], &provider).await.unwrap();
664
665        assert_eq!(
666            result.body, "key=${CRED:inner}",
667            "credential values containing ${{CRED:...}} must NOT be re-expanded"
668        );
669        assert_eq!(result.resolved_refs, vec!["outer"]);
670    }
671
672    // -----------------------------------------------------------------------
673    // Credential value with regex metacharacters in scrubbing
674    // -----------------------------------------------------------------------
675
676    #[test]
677    fn credential_with_special_chars_in_value() {
678        let special_value = r"p@$$w0rd!.*+";
679        let body = format!("the password is {} here", special_value);
680        let known = secret_vec(&[special_value]);
681
682        let scrubbed = scrub_response(&body, &known);
683        assert_eq!(
684            scrubbed, "the password is [CREDENTIAL] here",
685            "credential with regex metacharacters must be scrubbed literally"
686        );
687        assert!(
688            !scrubbed.contains(special_value),
689            "original credential value must not survive in scrubbed output"
690        );
691    }
692
693    #[test]
694    fn credential_ref_name_injection_rejected() {
695        assert!(find_refs("${CRED:../../etc/passwd}").is_empty());
696        assert!(find_refs("${CRED:..\\..\\windows\\system32}").is_empty());
697        assert!(find_refs("${CRED:ref;rm -rf /}").is_empty());
698        assert!(find_refs("${CRED:ref$(whoami)}").is_empty());
699        assert!(find_refs("${CRED:ref`id`}").is_empty());
700        assert!(find_refs("${CRED:ref name}").is_empty());
701        assert!(find_refs("${CRED:ref&other}").is_empty());
702        assert!(find_refs("${CRED:ref|pipe}").is_empty());
703        assert!(find_refs("${CRED:}").is_empty());
704        assert_eq!(
705            find_refs("${CRED:valid.ref-name_123}"),
706            vec!["valid.ref-name_123"]
707        );
708        assert_eq!(find_refs("${CRED:API_KEY}"), vec!["API_KEY"]);
709        assert_eq!(find_refs("${CRED:my-secret.v2}"), vec!["my-secret.v2"]);
710    }
711
712    // -----------------------------------------------------------------------
713    // Encoding variant bypass tests
714    // -----------------------------------------------------------------------
715
716    fn test_base64url_encode(input: &str) -> String {
717        let standard = base64_encode_value(input);
718        standard
719            .replace('+', "-")
720            .replace('/', "_")
721            .trim_end_matches('=')
722            .to_string()
723    }
724
725    #[test]
726    fn scrub_response_base64url_bypass() {
727        let secret = "secret+key/value";
728        let b64url_encoded = test_base64url_encode(secret);
729        let b64_standard = base64_encode_value(secret);
730        assert_ne!(
731            b64url_encoded, b64_standard,
732            "base64url and standard base64 should differ for this input"
733        );
734
735        let body = format!("token={b64url_encoded} end");
736        let known = secret_vec(&[secret]);
737        let scrubbed = scrub_response(&body, &known);
738
739        assert!(
740            !scrubbed.contains(&b64url_encoded),
741            "SCRUBBING BYPASS: base64url-encoded credential survives in response: {scrubbed}"
742        );
743    }
744
745    #[test]
746    fn scrub_response_uppercase_hex_bypass() {
747        let secret = "sk-key";
748        let upper_hex: String = secret.bytes().map(|b| format!("{:02X}", b)).collect();
749        let lower_hex: String = secret.bytes().map(|b| format!("{:02x}", b)).collect();
750        assert_ne!(
751            upper_hex, lower_hex,
752            "test requires hex representations to differ: upper={upper_hex} lower={lower_hex}"
753        );
754
755        let body = format!("hex={upper_hex} end");
756        let known = secret_vec(&[secret]);
757        let scrubbed = scrub_response(&body, &known);
758
759        assert!(
760            !scrubbed.contains(&upper_hex),
761            "SCRUBBING BYPASS: uppercase-hex credential survives in response: {scrubbed}"
762        );
763    }
764
765    #[test]
766    fn scrub_response_double_url_encoding_bypass() {
767        let secret = "p@ss!";
768        let single_encoded = urlencoding_encode(secret);
769        let double_encoded = urlencoding_encode(&single_encoded);
770
771        assert_ne!(single_encoded, double_encoded);
772
773        let body = format!("reflected={double_encoded} end");
774        let known = secret_vec(&[secret]);
775        let scrubbed = scrub_response(&body, &known);
776
777        assert!(
778            !scrubbed.contains(&double_encoded),
779            "SCRUBBING BYPASS: double-URL-encoded credential survives in response: {scrubbed}"
780        );
781    }
782
783    // -----------------------------------------------------------------------
784    // Memory safety: SecretString Debug/Display never leaks plaintext
785    // -----------------------------------------------------------------------
786
787    #[tokio::test]
788    async fn secret_string_debug_does_not_leak() {
789        let provider = MockProvider::new(&[("api_key", "super-secret-value-12345")]);
790        let body = r#"{"key": "${CRED:api_key}"}"#;
791
792        let result = inject_credentials(body, &[], &provider).await.unwrap();
793
794        // Debug formatting of resolved_values must NOT contain the plaintext.
795        // Note: the body field legitimately contains the injected credential
796        // (it's the rewritten request), so we check resolved_values specifically.
797        let values_debug = format!("{:?}", result.resolved_values);
798        assert!(
799            !values_debug.contains("super-secret-value-12345"),
800            "Debug output of resolved_values must not contain plaintext credential. Got: {values_debug}"
801        );
802        // The SecretString Debug impl should show the redacted wrapper
803        assert!(
804            values_debug.contains("REDACTED") || values_debug.contains("SecretBox"),
805            "Debug output should show redacted wrapper, got: {values_debug}"
806        );
807    }
808
809    #[test]
810    fn scrub_response_with_secret_string() {
811        // Verify that scrub_response works correctly with SecretString values,
812        // scrubbing all encoding variants.
813        let secrets = secret_vec(&["my-api-key-999"]);
814        let b64 = base64_encode_value("my-api-key-999");
815        let hex: String = "my-api-key-999"
816            .bytes()
817            .map(|b| format!("{:02x}", b))
818            .collect();
819
820        let body = format!("plain=my-api-key-999 b64={b64} hex={hex}");
821        let scrubbed = scrub_response(&body, &secrets);
822
823        assert!(
824            !scrubbed.contains("my-api-key-999"),
825            "plaintext must be scrubbed"
826        );
827        assert!(!scrubbed.contains(&b64), "base64 must be scrubbed");
828        assert!(!scrubbed.contains(&hex), "hex must be scrubbed");
829        assert_eq!(
830            scrubbed,
831            "plain=[CREDENTIAL] b64=[CREDENTIAL] hex=[CREDENTIAL]"
832        );
833    }
834
835    #[tokio::test]
836    async fn scrub_response_lowercase_percent_encoding() {
837        let secret = SecretString::from("my-secret/value".to_string());
838        // Lowercase percent-encoded: '/' becomes %2f instead of %2F
839        let response = "result: my-secret%2fvalue";
840        let scrubbed = scrub_response(response, &[secret]);
841        assert!(
842            !scrubbed.contains("my-secret"),
843            "lowercase percent-encoded credential should be scrubbed"
844        );
845        assert!(scrubbed.contains(REDACTED));
846    }
847
848    #[tokio::test]
849    async fn scrub_response_unicode_json_escape() {
850        let secret = SecretString::from("abc".to_string());
851        let response = r#"{"data": "\u0061\u0062\u0063"}"#;
852        let scrubbed = scrub_response(response, &[secret]);
853        assert!(
854            !scrubbed.contains(r"\u0061\u0062\u0063"),
855            "unicode-escaped credential should be scrubbed"
856        );
857        assert!(scrubbed.contains(REDACTED));
858    }
859}