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