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#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(tag = "method", rename_all = "lowercase")]
12pub enum VaultAuth {
13 Token { token: Option<String> },
15 Approle {
17 role_id: String,
18 secret_id: Option<String>,
19 mount_path: Option<String>,
21 },
22 Kubernetes {
24 role: String,
25 jwt_path: Option<String>,
27 mount_path: Option<String>,
29 },
30}
31
32impl Default for VaultAuth {
33 fn default() -> Self {
34 VaultAuth::Token { token: None }
35 }
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, Default)]
40pub struct VaultConfig {
41 pub address: Option<String>,
44 #[serde(default)]
46 pub auth: VaultAuth,
47 pub mount: Option<String>,
49 pub role: Option<String>,
51 pub namespace: Option<String>,
53 #[serde(default)]
55 pub tls_skip_verify: bool,
56}
57
58impl VaultConfig {
59 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 pub fn mount_path(&self) -> &str {
82 self.mount.as_deref().unwrap_or("aws")
83 }
84}
85
86fn 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 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
152const 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
170fn 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
185fn 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#[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#[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
247pub struct VaultIssuer {
249 address: String,
250 token: String,
251 mount: String,
252 namespace: Option<String>,
253 token_expires_at: Option<chrono::DateTime<chrono::Utc>>,
255 token_lease_duration: Option<u64>,
257 token_renewable: bool,
259 tls_skip_verify: bool,
261 auth_config: Option<VaultAuth>,
263}
264
265impl VaultIssuer {
266 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 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 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 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 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 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 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 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 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 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(()), };
494
495 let now = chrono::Utc::now();
496 if now >= expires_at {
497 tracing::warn!("Vault auth token expired — attempting re-authentication");
499 return self.re_authenticate().await;
500 }
501
502 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 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 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 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 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 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 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 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
746async 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 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
783async 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 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
816async 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 const MAX_VAULT_RESPONSE: u64 = 2 * 1024 * 1024; 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()); }
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()); }
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 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 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 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 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 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}