From def3f4f565554d343aee65e45028377eac5260d2 Mon Sep 17 00:00:00 2001 From: Chase Douglas Date: Tue, 5 May 2026 13:49:24 -0700 Subject: [PATCH] aws: add SES transport and shared config Serverless AWS deployments should not need an SMTP service or credentials just to send Vaultwarden mail. Allow mail delivery through Amazon SES when USE_AWS_SES is enabled, while preserving the existing SMTP and sendmail transports. Add the ses feature and an aws umbrella feature. Keep mail configuration validation strict by requiring SMTP_FROM for SES. Treat SES as a configured mail transport for email 2FA. Send MIME messages through the SESv2 SendEmail raw content path. Share AWS SDK configuration with S3 so AWS clients use the same reqwest-backed connector and credential loading behavior. --- Cargo.lock | 30 +++++++++++++++++++++++++++++ Cargo.toml | 3 +++ build.rs | 6 ++++++ src/aws.rs | 22 ++++++++++++++++++++++ src/config.rs | 13 +++++++++---- src/http_client.rs | 2 +- src/mail.rs | 47 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 2 ++ src/storage.rs | 19 +++++-------------- 9 files changed, 125 insertions(+), 19 deletions(-) create mode 100644 src/aws.rs diff --git a/Cargo.lock b/Cargo.lock index 55a7233b..df8da5a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -426,6 +426,32 @@ dependencies = [ "uuid", ] +[[package]] +name = "aws-sdk-sesv2" +version = "1.133.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff3764ca0e000175b6a755d54aaa1873587c2026af02416d4602a4c5f4ce907a" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.5.0", + "regex-lite", + "tracing", +] + [[package]] name = "aws-sdk-sso" version = "1.109.0" @@ -666,6 +692,7 @@ dependencies = [ "base64-simd", "bytes", "bytes-utils", + "futures-core", "http 0.2.12", "http 1.5.0", "http-body 0.4.6", @@ -678,6 +705,8 @@ dependencies = [ "ryu", "serde", "time", + "tokio", + "tokio-util", ] [[package]] @@ -5965,6 +5994,7 @@ dependencies = [ "argon2", "aws-config", "aws-credential-types", + "aws-sdk-sesv2", "aws-smithy-runtime-api", "bigdecimal", "bytes", diff --git a/Cargo.toml b/Cargo.toml index d3a3d5e9..1601dfd2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ vendored_openssl = ["openssl/vendored"] # Enable MiMalloc memory allocator to replace the default malloc # This can improve performance for Alpine builds enable_mimalloc = ["dep:mimalloc"] +aws = ["s3", "ses"] s3 = [ "opendal/services-s3", "dep:opendal-http-transport-reqwest", @@ -48,6 +49,7 @@ s3 = [ "dep:reqsign-aws-v4", "dep:reqsign-core", ] +ses = ["dep:aws-config", "dep:aws-sdk-sesv2", "dep:aws-smithy-runtime-api"] # OIDC specific features oidc-accept-rfc3339-timestamps = ["openidconnect/accept-rfc3339-timestamps"] @@ -268,6 +270,7 @@ aws-config = { version = "1.12.0", optional = true, default-features = false, fe "sso", ] } aws-credential-types = { version = "1.3.0", optional = true } +aws-sdk-sesv2 = { version = "1.133.0", features = ["behavior-version-latest", "rt-tokio"], default-features = false, optional = true } aws-smithy-runtime-api = { version = "1.16.0", optional = true } http = { version = "1.5.0", optional = true } reqsign-aws-v4 = { version = "3.3.0", optional = true } diff --git a/build.rs b/build.rs index 32fcf845..90e9c571 100644 --- a/build.rs +++ b/build.rs @@ -15,6 +15,10 @@ fn main() { #[cfg(feature = "s3")] println!("cargo:rustc-cfg=s3"); + #[cfg(feature = "ses")] + println!("cargo:rustc-cfg=ses"); + #[cfg(feature = "aws")] + println!("cargo:rustc-cfg=aws"); // Use check-cfg to let cargo know which cfg's we define, // and avoid warnings when they are used in the code. @@ -22,6 +26,8 @@ fn main() { println!("cargo::rustc-check-cfg=cfg(mysql)"); println!("cargo::rustc-check-cfg=cfg(postgresql)"); println!("cargo::rustc-check-cfg=cfg(s3)"); + println!("cargo::rustc-check-cfg=cfg(ses)"); + println!("cargo::rustc-check-cfg=cfg(aws)"); // Rerun when these paths are changed. // Someone could have checked-out a tag or specific commit, but no other files changed. diff --git a/src/aws.rs b/src/aws.rs new file mode 100644 index 00000000..f6e787cd --- /dev/null +++ b/src/aws.rs @@ -0,0 +1,22 @@ +use aws_config::{AppName, BehaviorVersion}; +use tokio::sync::OnceCell; + +use crate::http_client::aws::AwsReqwestConnector; + +fn aws_reqwest_connector() -> AwsReqwestConnector { + AwsReqwestConnector::new() +} + +pub(crate) async fn aws_sdk_config() -> &'static aws_config::SdkConfig { + static AWS_CONFIG: OnceCell = OnceCell::const_new(); + + AWS_CONFIG + .get_or_init(async || { + aws_config::defaults(BehaviorVersion::latest()) + .app_name(AppName::new("vaultwarden").expect("Failed to build AWS app name")) + .http_client(aws_reqwest_connector()) + .load() + .await + }) + .await +} diff --git a/src/config.rs b/src/config.rs index 37fc3e85..fb382b9d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -919,12 +919,14 @@ make_config! { smtp_accept_invalid_certs: bool, true, def, false; /// Accept Invalid Hostnames (Know the risks!) |> DANGEROUS: Allow invalid hostnames. This option introduces significant vulnerabilities to man-in-the-middle attacks! smtp_accept_invalid_hostnames: bool, true, def, false; + /// Use AWS SES |> Whether to send mail via AWS Simple Email Service (SES) + use_aws_ses: bool, true, def, false; }, /// Email 2FA Settings email_2fa: _enable_email_2fa { /// Enabled |> Disabling will prevent users from setting up new email 2FA and using existing email 2FA configured - _enable_email_2fa: bool, true, auto, |c| c._enable_smtp && (c.smtp_host.is_some() || c.use_sendmail); + _enable_email_2fa: bool, true, auto, |c| c._enable_smtp && (c.smtp_host.is_some() || c.use_sendmail || c.use_aws_ses); /// Email token size |> Number of digits in an email 2FA token (min: 6, max: 255). Note that the Bitwarden clients are hardcoded to mention 6 digit codes regardless of this setting. email_token_size: u8, true, def, 6; /// Token expiration time |> Maximum time in seconds a token is valid. The time the user has to open email client and copy token. @@ -1169,6 +1171,9 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { } } } + } else if cfg.use_aws_ses { + #[cfg(not(ses))] + err!("`USE_AWS_SES` is set, but the `ses` feature is not enabled in this build"); } else { if cfg.smtp_host.is_some() == cfg.smtp_from.is_empty() { err!("Both `SMTP_HOST` and `SMTP_FROM` need to be set for email support without `USE_SENDMAIL`") @@ -1181,7 +1186,7 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { } } - if (cfg.smtp_host.is_some() || cfg.use_sendmail) && !is_valid_email(&cfg.smtp_from) { + if (cfg.smtp_host.is_some() || cfg.use_sendmail || cfg.use_aws_ses) && !is_valid_email(&cfg.smtp_from) { err!(format!("SMTP_FROM '{}' is not a valid email address", cfg.smtp_from)) } @@ -1190,7 +1195,7 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { } } - if cfg._enable_email_2fa && !(cfg.smtp_host.is_some() || cfg.use_sendmail) { + if cfg._enable_email_2fa && !(cfg.smtp_host.is_some() || cfg.use_sendmail || cfg.use_aws_ses) { err!("To enable email 2FA, a mail transport must be configured") } @@ -1613,7 +1618,7 @@ impl Config { } pub fn mail_enabled(&self) -> bool { let inner = &self.inner.read().unwrap().config; - inner._enable_smtp && (inner.smtp_host.is_some() || inner.use_sendmail) + inner._enable_smtp && (inner.smtp_host.is_some() || inner.use_sendmail || inner.use_aws_ses) } pub async fn get_duo_akey(&self) -> String { diff --git a/src/http_client.rs b/src/http_client.rs index 5ef293fc..d7c64813 100644 --- a/src/http_client.rs +++ b/src/http_client.rs @@ -349,7 +349,7 @@ impl Resolve for CustomDns { } } -#[cfg(s3)] +#[cfg(any(s3, ses))] pub(crate) mod aws { use aws_smithy_runtime_api::client::{ http::{HttpClient, HttpConnector, HttpConnectorFuture, HttpConnectorSettings, SharedHttpConnector}, diff --git a/src/mail.rs b/src/mail.rs index b20f2853..32e8d2ac 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -96,6 +96,44 @@ fn smtp_transport() -> AsyncSmtpTransport { smtp_client.build() } +#[cfg(ses)] +async fn send_with_aws_ses(email: Message) -> std::io::Result<()> { + use std::io::Error; + + use aws_sdk_sesv2::{ + Client, + types::{EmailContent, RawMessage}, + }; + use tokio::sync::OnceCell; + + static AWS_SESV2_CLIENT: OnceCell = OnceCell::const_new(); + + let client = AWS_SESV2_CLIENT + .get_or_init(async || { + let config = crate::aws::aws_sdk_config().await; + Client::new(config) + }) + .await; + + client + .send_email() + .content( + EmailContent::builder() + .raw( + RawMessage::builder() + .data(email.formatted().into()) + .build() + .map_err(|e| Error::other(format!("Failed to build AWS SESv2 RawMessage: {e:?}")))?, + ) + .build(), + ) + .send() + .await + .map_err(Error::other)?; + + Ok(()) +} + // This will sanitize the string values by stripping all the html tags to prevent XSS and HTML Injections fn sanitize_data(data: &mut serde_json::Value) { use regex::Regex; @@ -686,6 +724,15 @@ async fn send_with_selected_transport(email: Message) -> EmptyResult { err!(format!("Sendmail error: {e}")); } } + } else if CONFIG.use_aws_ses() { + #[cfg(ses)] + match send_with_aws_ses(email).await { + Ok(()) => Ok(()), + Err(e) => err!("Failed to send email", format!("Failed to send email using AWS SES: {e:?}")), + } + + #[cfg(not(ses))] + unreachable!("Failed to send email using AWS SES: `ses` feature is not enabled"); } else { match smtp_transport().send(email).await { Ok(_) => Ok(()), diff --git a/src/main.rs b/src/main.rs index 437354af..dd647dad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -51,6 +51,8 @@ use rocket::data::{Limits, ToByteUnit}; mod error; mod api; mod auth; +#[cfg(any(s3, ses))] +mod aws; mod config; mod crypto; #[macro_use] diff --git a/src/storage.rs b/src/storage.rs index 32562a0d..6bd7aaba 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -160,8 +160,6 @@ mod s3 { } pub(super) fn operator_for_path(path: &str) -> Result { - use crate::http_client::aws::AwsReqwestConnector; - use aws_config::{default_provider::credentials::DefaultCredentialsChain, provider_config::ProviderConfig}; use opendal::Configurator; use reqsign_aws_v4::Credential; use reqsign_core::{Context, ProvideCredential, ProvideCredentialChain}; @@ -179,19 +177,12 @@ mod s3 { async fn provide_credential(&self, _ctx: &Context) -> reqsign_core::Result> { use aws_credential_types::provider::ProvideCredentials as _; use reqsign_core::time::Timestamp; - use tokio::sync::OnceCell; - static DEFAULT_CREDENTIAL_CHAIN: OnceCell = OnceCell::const_new(); - - let chain = DEFAULT_CREDENTIAL_CHAIN - .get_or_init(|| { - let conf = ProviderConfig::default().with_http_client(AwsReqwestConnector::new()); - - DefaultCredentialsChain::builder().configure(conf).build() - }) - .await; - - let creds = chain.provide_credentials().await.map_err(|e| { + let credentials_provider = + crate::aws::aws_sdk_config().await.credentials_provider().ok_or_else(|| { + reqsign_core::Error::unexpected("failed to load AWS credentials provider from AWS SDK config") + })?; + let creds = credentials_provider.provide_credentials().await.map_err(|e| { reqsign_core::Error::unexpected("failed to load AWS credentials via AWS SDK").with_source(e) })?;