mirror of
https://github.com/dani-garcia/vaultwarden.git
synced 2026-05-08 12:34:43 +02:00
mail: add AWS SES transport
Serverless AWS deployments should not need an SMTP service or SMTP 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 config validation strict by requiring SMTP_FROM for SES, and 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.
This commit is contained in:
parent
cc166e877c
commit
ed5ee5f25e
9 changed files with 127 additions and 24 deletions
28
Cargo.lock
generated
28
Cargo.lock
generated
|
|
@ -427,6 +427,30 @@ dependencies = [
|
|||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-sesv2"
|
||||
version = "1.118.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8d0642857f4fe76cd9a3d8c4f2b393546f7561f7725052dd9f268005fda92b7"
|
||||
dependencies = [
|
||||
"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-types",
|
||||
"aws-types",
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"http 0.2.12",
|
||||
"http 1.4.0",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-sso"
|
||||
version = "1.98.0"
|
||||
|
|
@ -644,6 +668,7 @@ dependencies = [
|
|||
"base64-simd",
|
||||
"bytes",
|
||||
"bytes-utils",
|
||||
"futures-core",
|
||||
"http 0.2.12",
|
||||
"http 1.4.0",
|
||||
"http-body 0.4.6",
|
||||
|
|
@ -656,6 +681,8 @@ dependencies = [
|
|||
"ryu",
|
||||
"serde",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5959,6 +5986,7 @@ dependencies = [
|
|||
"argon2",
|
||||
"aws-config",
|
||||
"aws-credential-types",
|
||||
"aws-sdk-sesv2",
|
||||
"aws-smithy-runtime-api",
|
||||
"bigdecimal",
|
||||
"bytes",
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ 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"]
|
||||
ses = ["dep:aws-config", "dep:aws-sdk-sesv2", "dep:aws-smithy-runtime-api"]
|
||||
s3 = ["opendal/services-s3", "opendal/reqwest-rustls-tls", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-runtime-api", "dep:http", "dep:reqsign-aws-v4", "dep:reqsign-core"]
|
||||
|
||||
# OIDC specific features
|
||||
|
|
@ -201,6 +203,7 @@ opendal = { version = "0.56.0", features = ["services-fs"], default-features = f
|
|||
# For retrieving AWS credentials, including temporary SSO credentials
|
||||
aws-config = { version = "1.8.16", features = ["behavior-version-latest", "rt-tokio", "credentials-process", "sso"], default-features = false, optional = true }
|
||||
aws-credential-types = { version = "1.2.14", optional = true }
|
||||
aws-sdk-sesv2 = { version = "1.118.0", features = ["behavior-version-latest", "rt-tokio"], default-features = false, optional = true }
|
||||
aws-smithy-runtime-api = { version = "1.12.0", optional = true }
|
||||
http = { version = "1.4.0", optional = true }
|
||||
reqsign-aws-v4 = { version = "3.0.0", optional = true }
|
||||
|
|
|
|||
6
build.rs
6
build.rs
|
|
@ -16,6 +16,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.
|
||||
|
|
@ -23,6 +27,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.
|
||||
|
|
|
|||
26
src/aws.rs
Normal file
26
src/aws.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
use aws_config::{AppName, BehaviorVersion};
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
use crate::http_client::aws::AwsReqwestConnector;
|
||||
|
||||
fn aws_reqwest_connector() -> AwsReqwestConnector {
|
||||
let reqwest_client = reqwest::Client::builder().build().expect("Failed to build reqwest client");
|
||||
|
||||
AwsReqwestConnector {
|
||||
client: reqwest_client,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn aws_sdk_config() -> &'static aws_config::SdkConfig {
|
||||
static AWS_CONFIG: OnceCell<aws_config::SdkConfig> = 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
|
||||
}
|
||||
|
|
@ -901,12 +901,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.
|
||||
|
|
@ -1130,6 +1132,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`")
|
||||
|
|
@ -1140,7 +1145,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))
|
||||
}
|
||||
|
||||
|
|
@ -1149,7 +1154,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")
|
||||
}
|
||||
|
||||
|
|
@ -1553,7 +1558,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 {
|
||||
|
|
|
|||
|
|
@ -295,7 +295,7 @@ impl Resolve for CustomDnsResolver {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(s3)]
|
||||
#[cfg(any(s3, ses))]
|
||||
pub(crate) mod aws {
|
||||
use aws_smithy_runtime_api::client::{
|
||||
http::{HttpClient, HttpConnector, HttpConnectorFuture, HttpConnectorSettings, SharedHttpConnector},
|
||||
|
|
|
|||
47
src/mail.rs
47
src/mail.rs
|
|
@ -95,6 +95,44 @@ fn smtp_transport() -> AsyncSmtpTransport<Tokio1Executor> {
|
|||
smtp_client.build()
|
||||
}
|
||||
|
||||
#[cfg(ses)]
|
||||
async fn send_with_aws_ses(email: Message) -> std::io::Result<()> {
|
||||
use std::io::Error;
|
||||
|
||||
use aws_sdk_sesv2::{
|
||||
types::{EmailContent, RawMessage},
|
||||
Client,
|
||||
};
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
static AWS_SESV2_CLIENT: OnceCell<Client> = 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;
|
||||
|
|
@ -670,6 +708,15 @@ async fn send_with_selected_transport(email: Message) -> EmptyResult {
|
|||
}
|
||||
}
|
||||
}
|
||||
} 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(()),
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ use tokio::signal::unix::SignalKind;
|
|||
mod error;
|
||||
mod api;
|
||||
mod auth;
|
||||
#[cfg(any(s3, ses))]
|
||||
mod aws;
|
||||
mod config;
|
||||
mod crypto;
|
||||
#[macro_use]
|
||||
|
|
|
|||
|
|
@ -152,8 +152,6 @@ mod s3 {
|
|||
}
|
||||
|
||||
pub(super) fn operator_for_path(path: &str) -> Result<opendal::Operator, Error> {
|
||||
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};
|
||||
|
|
@ -171,24 +169,12 @@ mod s3 {
|
|||
async fn provide_credential(&self, _ctx: &Context) -> reqsign_core::Result<Option<Self::Credential>> {
|
||||
use aws_credential_types::provider::ProvideCredentials as _;
|
||||
use reqsign_core::time::Timestamp;
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
static DEFAULT_CREDENTIAL_CHAIN: OnceCell<DefaultCredentialsChain> = OnceCell::const_new();
|
||||
|
||||
let chain = DEFAULT_CREDENTIAL_CHAIN
|
||||
.get_or_init(|| {
|
||||
let reqwest_client = reqwest::Client::builder().build().unwrap();
|
||||
let connector = AwsReqwestConnector {
|
||||
client: reqwest_client,
|
||||
};
|
||||
|
||||
let conf = ProviderConfig::default().with_http_client(connector);
|
||||
|
||||
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)
|
||||
})?;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue