Skip to main content

tryaudex_core/
vault.rs

1use std::collections::HashMap;
2use std::time::Duration;
3
4use serde::{Deserialize, Serialize};
5
6use crate::credentials::TempCredentials;
7use crate::error::{AvError, Result};
8
9/// Vault authentication method.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(tag = "method", rename_all = "lowercase")]
12pub enum VaultAuth {
13    /// Use a static Vault token (or VAULT_TOKEN env var).
14    Token { token: Option<String> },
15    /// AppRole authentication.
16    Approle {
17        role_id: String,
18        secret_id: Option<String>,
19        /// Auth mount path (default: "approle"). Change if mounted elsewhere.
20        mount_path: Option<String>,
21    },
22    /// Kubernetes service account auth.
23    Kubernetes {
24        role: String,
25        /// Path to the service account token (default: /var/run/secrets/kubernetes.io/serviceaccount/token)
26        jwt_path: Option<String>,
27        /// Auth mount path (default: "kubernetes"). Change if mounted elsewhere.
28        mount_path: Option<String>,
29    },
30}
31
32impl Default for VaultAuth {
33    fn default() -> Self {
34        VaultAuth::Token { token: None }
35    }
36}
37
38/// Configuration for the Vault credential backend.
39#[derive(Debug, Clone, Serialize, Deserialize, Default)]
40pub struct VaultConfig {
41    /// Vault server address (e.g. "https://vault.example.com:8200").
42    /// Falls back to VAULT_ADDR env var.
43    pub address: Option<String>,
44    /// Authentication method (default: token).
45    #[serde(default)]
46    pub auth: VaultAuth,
47    /// AWS secrets engine mount path (default: "aws").
48    pub mount: Option<String>,
49    /// Vault role name for generating credentials.
50    pub role: Option<String>,
51    /// Vault namespace (for Vault Enterprise).
52    pub namespace: Option<String>,
53    /// Skip TLS verification (NOT recommended for production).
54    #[serde(default)]
55    pub tls_skip_verify: bool,
56}
57
58impl VaultConfig {
59    /// Resolve the Vault address from config or VAULT_ADDR env var.
60    ///
61    /// R6-M47: validate scheme. Accept `https://` unconditionally, and
62    /// `http://` only when the host is a loopback literal or `localhost`.
63    /// A plaintext HTTP endpoint to a non-loopback host would send the
64    /// Vault auth token in the clear and is rejected outright.
65    pub fn resolve_address(&self) -> Result<String> {
66        let raw = self
67            .address
68            .clone()
69            .or_else(|| std::env::var("VAULT_ADDR").ok())
70            .ok_or_else(|| {
71                AvError::InvalidPolicy(
72                    "Vault address not set. Set vault.address in config or VAULT_ADDR env var"
73                        .to_string(),
74                )
75            })?;
76        validate_vault_address(&raw)?;
77        Ok(raw)
78    }
79
80    /// Resolve the secrets engine mount path (default: "aws").
81    pub fn mount_path(&self) -> &str {
82        self.mount.as_deref().unwrap_or("aws")
83    }
84}
85
86/// Validate a Vault address: scheme must be http/https, and http is only
87/// permitted when the host is loopback.
88fn validate_vault_address(raw: &str) -> Result<()> {
89    use std::net::IpAddr;
90    use std::str::FromStr;
91
92    let (scheme_is_https, without_scheme) = if let Some(rest) = raw.strip_prefix("https://") {
93        (true, rest)
94    } else if let Some(rest) = raw.strip_prefix("http://") {
95        (false, rest)
96    } else {
97        return Err(AvError::InvalidPolicy(format!(
98            "Vault address must use http:// or https://: {}",
99            raw
100        )));
101    };
102
103    let host_port = match without_scheme.find('/') {
104        Some(idx) => &without_scheme[..idx],
105        None => without_scheme,
106    };
107    if host_port.is_empty() {
108        return Err(AvError::InvalidPolicy(format!(
109            "Vault address has empty host: {}",
110            raw
111        )));
112    }
113
114    // Strip optional port for the loopback check. IPv6 hosts must be bracketed.
115    let host_only: String = if let Some(rest) = host_port.strip_prefix('[') {
116        match rest.find(']') {
117            Some(end) => rest[..end].to_string(),
118            None => {
119                return Err(AvError::InvalidPolicy(format!(
120                    "Vault address has unterminated IPv6 bracket: {}",
121                    raw
122                )));
123            }
124        }
125    } else {
126        match host_port.rfind(':') {
127            Some(idx) => host_port[..idx].to_string(),
128            None => host_port.to_string(),
129        }
130    };
131
132    if scheme_is_https {
133        return Ok(());
134    }
135
136    let is_loopback = if let Ok(ip) = IpAddr::from_str(&host_only) {
137        ip.is_loopback()
138    } else {
139        host_only.eq_ignore_ascii_case("localhost")
140    };
141    if !is_loopback {
142        return Err(AvError::InvalidPolicy(format!(
143            "Vault address '{}' uses plaintext HTTP to a non-loopback host. \
144             The Vault auth token would be sent in cleartext. Use https:// \
145             or a loopback address.",
146            raw
147        )));
148    }
149    Ok(())
150}
151
152/// R6-M49: cap lease durations received from Vault so a hostile or
153/// misconfigured server cannot return `u64::MAX` seconds (which would
154/// wrap when cast to i64 for chrono::Duration and produce a past expiry
155/// that the auto-renewal logic would interpret as "token valid forever").
156/// 90 days is more than any real Vault lease configuration and well
157/// under i64::MAX seconds.
158const MAX_LEASE_DURATION_SECS: u64 = 90 * 24 * 60 * 60;
159
160fn validated_lease_duration(raw: u64, context: &str) -> Result<u64> {
161    if raw > MAX_LEASE_DURATION_SECS {
162        return Err(AvError::Sts(format!(
163            "Vault {} lease_duration {} exceeds max allowed ({}s)",
164            context, raw, MAX_LEASE_DURATION_SECS
165        )));
166    }
167    Ok(raw)
168}
169
170/// R6-M50: reject CR/LF/NUL in a Vault token before putting it on the
171/// X-Vault-Token header. A VAULT_TOKEN env var containing CRLF could
172/// otherwise smuggle additional headers into every Vault request.
173fn validate_vault_token(token: &str) -> Result<()> {
174    if token.is_empty() {
175        return Err(AvError::InvalidPolicy("Vault token is empty".to_string()));
176    }
177    if token.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0) {
178        return Err(AvError::InvalidPolicy(
179            "Vault token contains forbidden characters (CR/LF/NUL)".to_string(),
180        ));
181    }
182    Ok(())
183}
184
185/// R6-M48: bounded read of the Kubernetes service-account JWT. The file
186/// lives at an attacker-controllable path if the pod is compromised, and
187/// symlinks or FIFOs could point at /dev/urandom or a terabyte log.
188/// Real service-account tokens are a few KB; 64 KiB is generous.
189fn read_k8s_jwt_capped(path: &str) -> Result<String> {
190    use std::io::Read;
191    const MAX_JWT_BYTES: u64 = 64 * 1024;
192
193    let mut f = std::fs::File::open(path).map_err(|e| {
194        AvError::InvalidPolicy(format!(
195            "Failed to open Kubernetes service account token {}: {}",
196            path, e
197        ))
198    })?;
199    let mut buf = String::new();
200    f.by_ref()
201        .take(MAX_JWT_BYTES + 1)
202        .read_to_string(&mut buf)
203        .map_err(|e| {
204            AvError::InvalidPolicy(format!(
205                "Failed to read Kubernetes service account token {}: {}",
206                path, e
207            ))
208        })?;
209    if buf.len() as u64 > MAX_JWT_BYTES {
210        return Err(AvError::InvalidPolicy(format!(
211            "Kubernetes service account token {} exceeds {} bytes",
212            path, MAX_JWT_BYTES
213        )));
214    }
215    Ok(buf)
216}
217
218/// Response from Vault's auth endpoints.
219#[derive(Debug, Deserialize)]
220struct VaultAuthResponse {
221    auth: Option<VaultAuthData>,
222}
223
224#[derive(Debug, Deserialize)]
225struct VaultAuthData {
226    client_token: String,
227    #[serde(default)]
228    lease_duration: u64,
229    #[serde(default)]
230    renewable: bool,
231}
232
233/// Response from Vault's AWS secrets engine.
234#[derive(Debug, Deserialize)]
235struct VaultSecretResponse {
236    data: Option<VaultAwsCredentials>,
237    lease_duration: Option<u64>,
238}
239
240#[derive(Debug, Deserialize)]
241struct VaultAwsCredentials {
242    access_key: String,
243    secret_key: String,
244    security_token: Option<String>,
245}
246
247/// Client for issuing credentials via HashiCorp Vault's AWS secrets engine.
248pub struct VaultIssuer {
249    address: String,
250    token: String,
251    mount: String,
252    namespace: Option<String>,
253    /// When the auth token expires (None for static tokens).
254    token_expires_at: Option<chrono::DateTime<chrono::Utc>>,
255    /// Original token lease duration in seconds (for computing renewal threshold).
256    token_lease_duration: Option<u64>,
257    /// Whether the token is renewable.
258    token_renewable: bool,
259    /// Whether to skip TLS certificate verification.
260    tls_skip_verify: bool,
261    /// Original auth config for re-authentication if renewal fails.
262    auth_config: Option<VaultAuth>,
263}
264
265impl VaultIssuer {
266    /// Create a new VaultIssuer by authenticating with the configured method.
267    pub async fn new(config: &VaultConfig) -> Result<Self> {
268        let address = config.resolve_address()?;
269        let mount = config.mount_path().to_string();
270        let namespace = config.namespace.clone();
271
272        // R6-H25: validate all mount paths up front rather than letting
273        // them surface inside auth/issue calls later.  Previously:
274        //
275        //   - `self.mount` (the secrets engine mount) was only validated
276        //     inside `read_sts_creds`/`issue`, so a config with mount = ""
277        //     or mount = "/" would authenticate successfully, hand out
278        //     a live token, then fail every `issue()` call — leaving the
279        //     caller with a half-authed issuer whose `token_expires_at`
280        //     marched toward expiry without any credential ever flowing.
281        //
282        //   - The auth-method mount_path was validated inside
283        //     `auth_approle`/`auth_kubernetes`, which meant a renewal
284        //     triggered after the original auth ticket expired could
285        //     suddenly start rejecting the same mount_path that the
286        //     initial auth had accepted — any intermediate code path
287        //     that constructed the mount differently (trimming, env
288        //     expansion, future refactors) would produce a silent
289        //     post-renewal outage instead of a clear startup error.
290        //
291        // Do the validation here so the issuer either works for its
292        // whole lifetime or refuses to come up at all.
293        crate::validate::path_component(&mount, "vault secrets engine mount")?;
294        match &config.auth {
295            VaultAuth::Approle { mount_path, .. } => {
296                let m = mount_path.as_deref().unwrap_or("approle");
297                crate::validate::path_component(m, "vault approle auth mount")?;
298            }
299            VaultAuth::Kubernetes { mount_path, .. } => {
300                let m = mount_path.as_deref().unwrap_or("kubernetes");
301                crate::validate::path_component(m, "vault kubernetes auth mount")?;
302            }
303            VaultAuth::Token { .. } => {}
304        }
305
306        // Validate tls_skip_verify *before* any auth request so that the
307        // confirmation gate applies to the initial auth call as well.
308        if config.tls_skip_verify {
309            if std::env::var("AUDEX_DANGER_SKIP_TLS").as_deref() != Ok("1") {
310                return Err(AvError::InvalidPolicy(
311                    "tls_skip_verify requires AUDEX_DANGER_SKIP_TLS=1 environment variable as confirmation"
312                        .to_string(),
313                ));
314            }
315            // R3-L14: Emit a structured security warning so that TLS bypass is
316            // visible in log aggregators and audit trails. The env-var gate
317            // above requires explicit operator opt-in, but this warn ensures
318            // the decision is recorded with context.
319            tracing::warn!(
320                security_event = "tls_verification_disabled",
321                vault_address = %config.resolve_address().unwrap_or_default(),
322                "Vault TLS verification disabled via AUDEX_DANGER_SKIP_TLS — \
323                 connections are vulnerable to MITM attacks and this action is being logged"
324            );
325        }
326
327        let (token, token_expires_at, token_lease_duration, token_renewable) = match &config.auth {
328            VaultAuth::Token { token } => {
329                let t = token
330                    .clone()
331                    .or_else(|| std::env::var("VAULT_TOKEN").ok())
332                    .ok_or_else(|| {
333                        AvError::InvalidPolicy(
334                            "Vault token not set. Set vault.auth.token in config or VAULT_TOKEN env var".to_string(),
335                        )
336                    })?;
337                // R6-M50: catch a bad token at construction time rather
338                // than on the first request — avoids the confusing "half-
339                // authed issuer" pattern.
340                validate_vault_token(&t)?;
341                (t, None, None, false)
342            }
343            VaultAuth::Approle {
344                role_id,
345                secret_id,
346                mount_path,
347            } => {
348                let mount = mount_path.as_deref().unwrap_or("approle");
349                Self::auth_approle(
350                    &address,
351                    namespace.as_deref(),
352                    role_id,
353                    secret_id.as_deref(),
354                    mount,
355                    config.tls_skip_verify,
356                )
357                .await?
358            }
359            VaultAuth::Kubernetes {
360                role,
361                jwt_path,
362                mount_path,
363            } => {
364                let auth_mount = mount_path.as_deref().unwrap_or("kubernetes");
365                let default_path = "/var/run/secrets/kubernetes.io/serviceaccount/token";
366                let path = jwt_path.as_deref().unwrap_or(default_path);
367                let jwt = read_k8s_jwt_capped(path)?;
368                Self::auth_kubernetes(
369                    &address,
370                    namespace.as_deref(),
371                    role,
372                    &jwt,
373                    auth_mount,
374                    config.tls_skip_verify,
375                )
376                .await?
377            }
378        };
379
380        tracing::info!(address = %address, mount = %mount, "Connected to Vault");
381
382        Ok(Self {
383            address,
384            token,
385            mount,
386            namespace,
387            token_expires_at,
388            token_lease_duration,
389            token_renewable,
390            tls_skip_verify: config.tls_skip_verify,
391            auth_config: Some(config.auth.clone()),
392        })
393    }
394
395    /// Authenticate via AppRole and return a client token with TTL info.
396    async fn auth_approle(
397        address: &str,
398        namespace: Option<&str>,
399        role_id: &str,
400        secret_id: Option<&str>,
401        mount: &str,
402        tls_skip_verify: bool,
403    ) -> Result<(
404        String,
405        Option<chrono::DateTime<chrono::Utc>>,
406        Option<u64>,
407        bool,
408    )> {
409        crate::validate::path_component(mount, "vault auth mount")?;
410        let url = format!("{}/v1/auth/{}/login", address, mount);
411        let mut body = HashMap::new();
412        body.insert("role_id", role_id);
413        if let Some(sid) = secret_id {
414            body.insert("secret_id", sid);
415        }
416
417        let resp = vault_post(&url, namespace, None, &body, tls_skip_verify).await?;
418        let auth_resp: VaultAuthResponse = serde_json::from_str(&resp)
419            .map_err(|e| AvError::Sts(format!("Failed to parse Vault auth response: {}", e)))?;
420
421        let auth_data = auth_resp
422            .auth
423            .ok_or_else(|| AvError::Sts("Vault AppRole auth returned no token".to_string()))?;
424
425        let (expires_at, lease_dur) = if auth_data.lease_duration > 0 {
426            // R6-M49: cap before casting u64→i64.
427            let lease = validated_lease_duration(auth_data.lease_duration, "auth")?;
428            let expires = chrono::Utc::now() + chrono::Duration::seconds(lease as i64);
429            (Some(expires), Some(lease))
430        } else {
431            (None, None)
432        };
433
434        Ok((
435            auth_data.client_token,
436            expires_at,
437            lease_dur,
438            auth_data.renewable,
439        ))
440    }
441
442    /// Authenticate via Kubernetes service account and return a client token with TTL info.
443    async fn auth_kubernetes(
444        address: &str,
445        namespace: Option<&str>,
446        role: &str,
447        jwt: &str,
448        mount: &str,
449        tls_skip_verify: bool,
450    ) -> Result<(
451        String,
452        Option<chrono::DateTime<chrono::Utc>>,
453        Option<u64>,
454        bool,
455    )> {
456        crate::validate::path_component(mount, "vault auth mount")?;
457        let url = format!("{}/v1/auth/{}/login", address, mount);
458        let mut body = HashMap::new();
459        body.insert("role", role);
460        body.insert("jwt", jwt);
461
462        let resp = vault_post(&url, namespace, None, &body, tls_skip_verify).await?;
463        let auth_resp: VaultAuthResponse = serde_json::from_str(&resp)
464            .map_err(|e| AvError::Sts(format!("Failed to parse Vault auth response: {}", e)))?;
465
466        let auth_data = auth_resp
467            .auth
468            .ok_or_else(|| AvError::Sts("Vault Kubernetes auth returned no token".to_string()))?;
469
470        let (expires_at, lease_dur) = if auth_data.lease_duration > 0 {
471            // R6-M49: cap before casting u64→i64.
472            let lease = validated_lease_duration(auth_data.lease_duration, "auth")?;
473            let expires = chrono::Utc::now() + chrono::Duration::seconds(lease as i64);
474            (Some(expires), Some(lease))
475        } else {
476            (None, None)
477        };
478
479        Ok((
480            auth_data.client_token,
481            expires_at,
482            lease_dur,
483            auth_data.renewable,
484        ))
485    }
486
487    /// Ensure the Vault auth token is still valid, renewing or re-authenticating
488    /// if it has reached 80% of its TTL.
489    async fn ensure_token_valid(&mut self) -> Result<()> {
490        let expires_at = match self.token_expires_at {
491            Some(e) => e,
492            None => return Ok(()), // static token, no expiry
493        };
494
495        let now = chrono::Utc::now();
496        if now >= expires_at {
497            // Token expired — try re-authentication
498            tracing::warn!("Vault auth token expired — attempting re-authentication");
499            return self.re_authenticate().await;
500        }
501
502        // Renew at 80% of original TTL
503        let threshold_secs = self
504            .token_lease_duration
505            .map(|d| (d as f64 * 0.2) as i64)
506            .unwrap_or(300);
507        let remaining = (expires_at - now).num_seconds();
508
509        if remaining < threshold_secs {
510            if self.token_renewable {
511                match self.renew_token().await {
512                    Ok(()) => {
513                        tracing::info!("Vault auth token renewed successfully");
514                        return Ok(());
515                    }
516                    Err(e) => {
517                        tracing::warn!(error = %e, "Token renewal failed — attempting re-authentication");
518                        return self.re_authenticate().await;
519                    }
520                }
521            } else {
522                // Not renewable — try re-authentication
523                tracing::warn!(
524                    remaining_secs = remaining,
525                    "Vault auth token expires soon and is not renewable — re-authenticating"
526                );
527                return self.re_authenticate().await;
528            }
529        }
530
531        Ok(())
532    }
533
534    /// Renew the current token via `/v1/auth/token/renew-self`.
535    async fn renew_token(&mut self) -> Result<()> {
536        let url = format!("{}/v1/auth/token/renew-self", self.address);
537        let body = HashMap::new();
538
539        let resp = vault_post(
540            &url,
541            self.namespace.as_deref(),
542            Some(&self.token),
543            &body,
544            self.tls_skip_verify,
545        )
546        .await?;
547
548        let auth_resp: VaultAuthResponse = serde_json::from_str(&resp)
549            .map_err(|e| AvError::Sts(format!("Failed to parse token renewal response: {}", e)))?;
550
551        let auth_data = auth_resp
552            .auth
553            .ok_or_else(|| AvError::Sts("Token renewal returned no auth data".to_string()))?;
554
555        self.token = auth_data.client_token;
556        self.token_renewable = auth_data.renewable;
557        if auth_data.lease_duration > 0 {
558            // R6-M49: cap before casting u64→i64.
559            let lease = validated_lease_duration(auth_data.lease_duration, "renew")?;
560            self.token_lease_duration = Some(lease);
561            self.token_expires_at =
562                Some(chrono::Utc::now() + chrono::Duration::seconds(lease as i64));
563        }
564
565        Ok(())
566    }
567
568    /// Re-authenticate using the stored auth config.
569    async fn re_authenticate(&mut self) -> Result<()> {
570        let auth =
571            match &self.auth_config {
572                Some(a) => a.clone(),
573                None => return Err(AvError::Sts(
574                    "Vault auth token expired and no auth config available for re-authentication"
575                        .to_string(),
576                )),
577            };
578
579        let (token, expires_at, lease_dur, renewable) = match &auth {
580            VaultAuth::Token { .. } => {
581                return Err(AvError::Sts(
582                    "Vault static token expired. Provide a new token or use AppRole/Kubernetes auth."
583                        .to_string(),
584                ));
585            }
586            VaultAuth::Approle {
587                role_id,
588                secret_id,
589                mount_path,
590            } => {
591                let mount = mount_path.as_deref().unwrap_or("approle");
592                Self::auth_approle(
593                    &self.address,
594                    self.namespace.as_deref(),
595                    role_id,
596                    secret_id.as_deref(),
597                    mount,
598                    self.tls_skip_verify,
599                )
600                .await?
601            }
602            VaultAuth::Kubernetes {
603                role,
604                jwt_path,
605                mount_path,
606            } => {
607                let auth_mount = mount_path.as_deref().unwrap_or("kubernetes");
608                let default_path = "/var/run/secrets/kubernetes.io/serviceaccount/token";
609                let path = jwt_path.as_deref().unwrap_or(default_path);
610                let jwt = read_k8s_jwt_capped(path)?;
611                Self::auth_kubernetes(
612                    &self.address,
613                    self.namespace.as_deref(),
614                    role,
615                    &jwt,
616                    auth_mount,
617                    self.tls_skip_verify,
618                )
619                .await?
620            }
621        };
622
623        self.token = token;
624        self.token_expires_at = expires_at;
625        self.token_lease_duration = lease_dur;
626        self.token_renewable = renewable;
627        tracing::info!("Vault re-authentication successful");
628
629        Ok(())
630    }
631
632    /// Issue temporary AWS credentials via Vault's AWS secrets engine.
633    ///
634    /// Uses the `/v1/{mount}/creds/{role}` endpoint to generate dynamic credentials.
635    /// The `ttl` parameter is passed as a request parameter to control credential lifetime.
636    pub async fn issue(&mut self, vault_role: &str, ttl: Duration) -> Result<TempCredentials> {
637        self.ensure_token_valid().await?;
638        crate::validate::path_component(&self.mount, "vault mount")?;
639        crate::validate::path_component(vault_role, "vault role")?;
640        let url = format!("{}/v1/{}/creds/{}", self.address, self.mount, vault_role);
641
642        let ttl_str = format!("{}s", ttl.as_secs());
643        let mut body = HashMap::new();
644        body.insert("ttl", ttl_str.as_str());
645
646        tracing::info!(
647            vault_role = %vault_role,
648            ttl = %ttl_str,
649            "Requesting credentials from Vault AWS secrets engine"
650        );
651
652        let resp = vault_post(
653            &url,
654            self.namespace.as_deref(),
655            Some(&self.token),
656            &body,
657            self.tls_skip_verify,
658        )
659        .await?;
660        let secret: VaultSecretResponse = serde_json::from_str(&resp).map_err(|e| {
661            AvError::Sts(format!("Failed to parse Vault credential response: {}", e))
662        })?;
663
664        let creds = secret
665            .data
666            .ok_or_else(|| AvError::Sts("Vault returned no credential data".to_string()))?;
667
668        let lease_secs =
669            validated_lease_duration(secret.lease_duration.unwrap_or(ttl.as_secs()), "creds")?;
670        let expires_at = chrono::Utc::now() + chrono::Duration::seconds(lease_secs as i64);
671
672        Ok(TempCredentials {
673            access_key_id: creds.access_key,
674            secret_access_key: creds.secret_key,
675            session_token: creds.security_token.unwrap_or_default(),
676            expires_at,
677        })
678    }
679
680    /// Read a Vault secret from an arbitrary path (for STS credential generation
681    /// where the Vault role uses assumed_role or federation_token type).
682    pub async fn read_sts_creds(
683        &mut self,
684        vault_role: &str,
685        ttl: Duration,
686    ) -> Result<TempCredentials> {
687        crate::validate::path_component(&self.mount, "vault mount")?;
688        crate::validate::path_component(vault_role, "vault role")?;
689        self.ensure_token_valid().await?;
690        let url = format!("{}/v1/{}/sts/{}", self.address, self.mount, vault_role);
691
692        let ttl_str = format!("{}s", ttl.as_secs());
693        let mut body = HashMap::new();
694        body.insert("ttl", ttl_str.as_str());
695
696        tracing::info!(
697            vault_role = %vault_role,
698            ttl = %ttl_str,
699            "Requesting STS credentials from Vault"
700        );
701
702        let resp = vault_post(
703            &url,
704            self.namespace.as_deref(),
705            Some(&self.token),
706            &body,
707            self.tls_skip_verify,
708        )
709        .await?;
710        let secret: VaultSecretResponse = serde_json::from_str(&resp)
711            .map_err(|e| AvError::Sts(format!("Failed to parse Vault STS response: {}", e)))?;
712
713        let creds = secret
714            .data
715            .ok_or_else(|| AvError::Sts("Vault returned no STS credential data".to_string()))?;
716
717        let lease_secs =
718            validated_lease_duration(secret.lease_duration.unwrap_or(ttl.as_secs()), "creds")?;
719        let expires_at = chrono::Utc::now() + chrono::Duration::seconds(lease_secs as i64);
720
721        Ok(TempCredentials {
722            access_key_id: creds.access_key,
723            secret_access_key: creds.secret_key,
724            session_token: creds.security_token.unwrap_or_default(),
725            expires_at,
726        })
727    }
728
729    /// Check if Vault is healthy and the secrets engine is accessible.
730    pub async fn health_check(&self) -> Result<bool> {
731        let url = format!("{}/v1/sys/health", self.address);
732        match vault_get(
733            &url,
734            self.namespace.as_deref(),
735            Some(&self.token),
736            self.tls_skip_verify,
737        )
738        .await
739        {
740            Ok(_) => Ok(true),
741            Err(_) => Ok(false),
742        }
743    }
744}
745
746/// Make a POST request to Vault.
747async fn vault_post(
748    url: &str,
749    namespace: Option<&str>,
750    token: Option<&str>,
751    body: &HashMap<&str, &str>,
752    tls_skip_verify: bool,
753) -> Result<String> {
754    let body_json = serde_json::to_string(body)
755        .map_err(|e| AvError::Sts(format!("Failed to serialize Vault request: {}", e)))?;
756
757    let mut headers = vec![("Content-Type", "application/json")];
758    let ns_header;
759    if let Some(ns) = namespace {
760        if ns.contains('\r') || ns.contains('\n') || ns.contains('\0') {
761            return Err(AvError::InvalidPolicy(
762                "Vault namespace contains invalid characters (CR, LF, or NUL)".to_string(),
763            ));
764        }
765        crate::validate::path_component(ns, "vault namespace")?;
766        ns_header = ns.to_string();
767        headers.push(("X-Vault-Namespace", &ns_header));
768    }
769    let token_header;
770    if let Some(t) = token {
771        // R6-M50: CRLF/NUL in an attacker-controlled VAULT_TOKEN would
772        // allow header injection into every Vault request. Validate at
773        // the boundary — reqwest rejects most bad values but we want a
774        // clear error with the right blame attribution.
775        validate_vault_token(t)?;
776        token_header = t.to_string();
777        headers.push(("X-Vault-Token", &token_header));
778    }
779
780    http_request("POST", url, &headers, Some(&body_json), tls_skip_verify).await
781}
782
783/// Make a GET request to Vault.
784async fn vault_get(
785    url: &str,
786    namespace: Option<&str>,
787    token: Option<&str>,
788    tls_skip_verify: bool,
789) -> Result<String> {
790    let mut headers: Vec<(&str, &str)> = vec![];
791    let ns_header;
792    if let Some(ns) = namespace {
793        if ns.contains('\r') || ns.contains('\n') || ns.contains('\0') {
794            return Err(AvError::InvalidPolicy(
795                "Vault namespace contains invalid characters (CR, LF, or NUL)".to_string(),
796            ));
797        }
798        crate::validate::path_component(ns, "vault namespace")?;
799        ns_header = ns.to_string();
800        headers.push(("X-Vault-Namespace", &ns_header));
801    }
802    let token_header;
803    if let Some(t) = token {
804        // R6-M50: CRLF/NUL in an attacker-controlled VAULT_TOKEN would
805        // allow header injection into every Vault request. Validate at
806        // the boundary — reqwest rejects most bad values but we want a
807        // clear error with the right blame attribution.
808        validate_vault_token(t)?;
809        token_header = t.to_string();
810        headers.push(("X-Vault-Token", &token_header));
811    }
812
813    http_request("GET", url, &headers, None, tls_skip_verify).await
814}
815
816/// HTTP client using reqwest with proper TLS support for https:// URLs.
817async fn http_request(
818    method: &str,
819    url: &str,
820    headers: &[(&str, &str)],
821    body: Option<&str>,
822    danger_accept_invalid_certs: bool,
823) -> Result<String> {
824    let client = reqwest::Client::builder()
825        .timeout(Duration::from_secs(30))
826        .danger_accept_invalid_certs(danger_accept_invalid_certs)
827        .build()
828        .map_err(|e| AvError::Sts(format!("Failed to create HTTP client: {}", e)))?;
829
830    let mut req = match method {
831        "POST" => client.post(url),
832        "PUT" => client.put(url),
833        "DELETE" => client.delete(url),
834        _ => client.get(url),
835    };
836
837    for (key, value) in headers {
838        req = req.header(*key, *value);
839    }
840
841    if let Some(b) = body {
842        req = req.body(b.to_string());
843    }
844
845    let response = req
846        .send()
847        .await
848        .map_err(|e| AvError::Sts(format!("Failed to connect to Vault at {}: {}", url, e)))?;
849
850    let status = response.status().as_u16();
851
852    // Guard against unbounded response bodies from a compromised Vault.
853    const MAX_VAULT_RESPONSE: u64 = 2 * 1024 * 1024; // 2 MB
854    if let Some(len) = response.content_length() {
855        if len > MAX_VAULT_RESPONSE {
856            return Err(AvError::Sts(format!(
857                "Vault response too large: {} bytes (max {})",
858                len, MAX_VAULT_RESPONSE
859            )));
860        }
861    }
862    let body_bytes = response
863        .bytes()
864        .await
865        .map_err(|e| AvError::Sts(format!("Failed to read Vault response: {}", e)))?;
866    if body_bytes.len() as u64 > MAX_VAULT_RESPONSE {
867        return Err(AvError::Sts(format!(
868            "Vault response too large: {} bytes (max {})",
869            body_bytes.len(),
870            MAX_VAULT_RESPONSE
871        )));
872    }
873    let body_text = String::from_utf8_lossy(&body_bytes).into_owned();
874
875    if status >= 400 {
876        return Err(AvError::Sts(format!(
877            "Vault returned HTTP {}: {}",
878            status, body_text
879        )));
880    }
881
882    Ok(body_text)
883}
884
885#[cfg(test)]
886struct ParsedUrl {
887    host: String,
888    port: u16,
889    path: String,
890}
891
892#[cfg(test)]
893fn parse_url(url: &str) -> Result<ParsedUrl> {
894    let without_scheme = if let Some(rest) = url.strip_prefix("https://") {
895        rest
896    } else if let Some(rest) = url.strip_prefix("http://") {
897        rest
898    } else {
899        return Err(AvError::InvalidPolicy(format!(
900            "Invalid Vault URL: {}",
901            url
902        )));
903    };
904
905    let default_port: u16 = 8200;
906
907    let (host_port, path) = match without_scheme.find('/') {
908        Some(idx) => (&without_scheme[..idx], &without_scheme[idx..]),
909        None => (without_scheme, "/"),
910    };
911
912    let (host, port) = match host_port.rfind(':') {
913        Some(idx) => {
914            let port_str = &host_port[idx + 1..];
915            let port = port_str.parse::<u16>().unwrap_or(default_port);
916            (host_port[..idx].to_string(), port)
917        }
918        None => (host_port.to_string(), default_port),
919    };
920
921    Ok(ParsedUrl {
922        host,
923        port,
924        path: path.to_string(),
925    })
926}
927
928#[cfg(test)]
929mod tests {
930    use super::*;
931
932    #[test]
933    fn test_parse_url_with_port() {
934        let parsed = parse_url("http://vault.example.com:8200/v1/aws/creds/my-role").unwrap();
935        assert_eq!(parsed.host, "vault.example.com");
936        assert_eq!(parsed.port, 8200);
937        assert_eq!(parsed.path, "/v1/aws/creds/my-role");
938    }
939
940    #[test]
941    fn test_parse_url_without_port() {
942        let parsed = parse_url("https://vault.example.com/v1/sys/health").unwrap();
943        assert_eq!(parsed.host, "vault.example.com");
944        assert_eq!(parsed.port, 8200);
945        assert_eq!(parsed.path, "/v1/sys/health");
946    }
947
948    #[test]
949    fn test_parse_url_localhost() {
950        let parsed = parse_url("http://127.0.0.1:8200/v1/auth/approle/login").unwrap();
951        assert_eq!(parsed.host, "127.0.0.1");
952        assert_eq!(parsed.port, 8200);
953    }
954
955    #[test]
956    fn test_parse_url_invalid() {
957        assert!(parse_url("ftp://vault.example.com").is_err());
958    }
959
960    #[test]
961    fn test_vault_config_defaults() {
962        let config = VaultConfig::default();
963        assert_eq!(config.mount_path(), "aws");
964        assert!(!config.tls_skip_verify);
965        assert!(config.address.is_none());
966    }
967
968    #[test]
969    fn test_vault_config_resolve_address_env() {
970        let config = VaultConfig {
971            address: Some("http://localhost:8200".to_string()),
972            ..Default::default()
973        };
974        assert_eq!(config.resolve_address().unwrap(), "http://localhost:8200");
975    }
976
977    #[test]
978    fn test_vault_config_custom_mount() {
979        let config = VaultConfig {
980            mount: Some("aws-prod".to_string()),
981            ..Default::default()
982        };
983        assert_eq!(config.mount_path(), "aws-prod");
984    }
985
986    #[test]
987    fn test_vault_config_deserialize() {
988        let toml_str = r#"
989address = "https://vault.internal:8200"
990mount = "aws-prod"
991role = "audex-agent"
992namespace = "engineering"
993
994[auth]
995method = "approle"
996role_id = "abc-123"
997secret_id = "def-456"
998"#;
999        let config: VaultConfig = toml::from_str(toml_str).unwrap();
1000        assert_eq!(
1001            config.address.as_deref(),
1002            Some("https://vault.internal:8200")
1003        );
1004        assert_eq!(config.mount_path(), "aws-prod");
1005        assert_eq!(config.role.as_deref(), Some("audex-agent"));
1006        assert_eq!(config.namespace.as_deref(), Some("engineering"));
1007        match config.auth {
1008            VaultAuth::Approle {
1009                role_id,
1010                secret_id,
1011                mount_path,
1012            } => {
1013                assert_eq!(role_id, "abc-123");
1014                assert_eq!(secret_id.unwrap(), "def-456");
1015                assert!(mount_path.is_none()); // defaults to "approle"
1016            }
1017            _ => panic!("Expected AppRole auth"),
1018        }
1019    }
1020
1021    #[test]
1022    fn test_vault_config_kubernetes_auth() {
1023        let toml_str = r#"
1024address = "http://vault:8200"
1025
1026[auth]
1027method = "kubernetes"
1028role = "audex"
1029jwt_path = "/var/run/secrets/token"
1030"#;
1031        let config: VaultConfig = toml::from_str(toml_str).unwrap();
1032        match config.auth {
1033            VaultAuth::Kubernetes {
1034                role,
1035                jwt_path,
1036                mount_path,
1037            } => {
1038                assert_eq!(role, "audex");
1039                assert_eq!(jwt_path.unwrap(), "/var/run/secrets/token");
1040                assert!(mount_path.is_none()); // defaults to "kubernetes"
1041            }
1042            _ => panic!("Expected Kubernetes auth"),
1043        }
1044    }
1045
1046    #[test]
1047    fn test_validate_vault_address_https_ok() {
1048        assert!(validate_vault_address("https://vault.example.com:8200").is_ok());
1049        assert!(validate_vault_address("https://vault.example.com").is_ok());
1050    }
1051
1052    #[test]
1053    fn test_validate_vault_address_http_loopback_ok() {
1054        assert!(validate_vault_address("http://localhost:8200").is_ok());
1055        assert!(validate_vault_address("http://127.0.0.1:8200").is_ok());
1056        assert!(validate_vault_address("http://127.0.0.2:8200").is_ok());
1057        assert!(validate_vault_address("http://[::1]:8200").is_ok());
1058    }
1059
1060    #[test]
1061    fn test_validate_vault_address_http_non_loopback_rejected() {
1062        // R6-M47: plain http to a non-loopback host would send the Vault
1063        // token in cleartext.
1064        let err = validate_vault_address("http://vault.internal:8200").unwrap_err();
1065        assert!(err.to_string().contains("plaintext HTTP"));
1066
1067        let err = validate_vault_address("http://10.0.0.5:8200").unwrap_err();
1068        assert!(err.to_string().contains("plaintext HTTP"));
1069    }
1070
1071    #[test]
1072    fn test_validate_vault_address_bad_scheme_rejected() {
1073        assert!(validate_vault_address("ftp://vault").is_err());
1074        assert!(validate_vault_address("file:///etc/passwd").is_err());
1075        assert!(validate_vault_address("javascript:alert(1)").is_err());
1076    }
1077
1078    #[test]
1079    fn test_validate_vault_token_rejects_crlf() {
1080        // R6-M50
1081        assert!(validate_vault_token("s.abcdef").is_ok());
1082        assert!(validate_vault_token("abc\r\nX-Evil: 1").is_err());
1083        assert!(validate_vault_token("abc\nevil").is_err());
1084        assert!(validate_vault_token("abc\0nul").is_err());
1085        assert!(validate_vault_token("").is_err());
1086    }
1087
1088    #[test]
1089    fn test_validated_lease_duration_cap() {
1090        // R6-M49: attacker-controlled huge leases are rejected before
1091        // the u64→i64 cast that would wrap.
1092        assert_eq!(validated_lease_duration(3600, "test").unwrap(), 3600);
1093        assert_eq!(
1094            validated_lease_duration(MAX_LEASE_DURATION_SECS, "test").unwrap(),
1095            MAX_LEASE_DURATION_SECS
1096        );
1097        assert!(validated_lease_duration(MAX_LEASE_DURATION_SECS + 1, "test").is_err());
1098        assert!(validated_lease_duration(u64::MAX, "test").is_err());
1099    }
1100
1101    #[test]
1102    fn test_read_k8s_jwt_capped_enforces_size() {
1103        // R6-M48
1104        let dir = tempfile::tempdir().unwrap();
1105        let path = dir.path().join("token");
1106        std::fs::write(&path, b"valid.jwt.token").unwrap();
1107        let jwt = read_k8s_jwt_capped(path.to_str().unwrap()).unwrap();
1108        assert_eq!(jwt, "valid.jwt.token");
1109
1110        // Oversize rejected.
1111        let big_path = dir.path().join("big");
1112        let big = vec![b'a'; 65 * 1024];
1113        std::fs::write(&big_path, &big).unwrap();
1114        assert!(read_k8s_jwt_capped(big_path.to_str().unwrap()).is_err());
1115    }
1116}