Gustavo Oliveira 1 week ago
committed by GitHub
parent
commit
44dae59fea
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 28
      .env.template
  2. 69
      .github/workflows/build.yml
  3. 63
      README.md
  4. 105
      docs/s3-compatible-object-storage.md
  5. 300
      src/config.rs

28
.env.template

@ -18,7 +18,27 @@
## This can be a path to local folder or a path to an external location
## depending on features enabled at build time. Possible external locations:
##
## - AWS S3 Bucket (via `s3` feature): s3://bucket-name/path/to/folder
## - S3-compatible bucket (via `s3` feature): s3://bucket-name/path/to/folder
##
## Optional query parameters are supported for S3-compatible providers:
## - endpoint (MinIO/R2/Ceph RGW): ?endpoint=https%3A%2F%2Fs3.example.internal
## - enable_virtual_host_style (set false for path-style): ?enable_virtual_host_style=false
## - default_storage_class: ?default_storage_class=STANDARD
## Use an empty value to omit the storage-class header:
## ?default_storage_class=
## - region (provider/signing specific): ?region=us-east-1
##
## Examples:
## - AWS S3 defaults: s3://bucket-name/path/to/folder
## - MinIO path-style: s3://bucket-name/path/to/folder?endpoint=http%3A%2F%2Fminio%3A9000&enable_virtual_host_style=false&default_storage_class=STANDARD
## - Cloudflare R2: s3://bucket-name/path/to/folder?endpoint=https%3A%2F%2F<accountid>.r2.cloudflarestorage.com&region=auto&default_storage_class=
##
## Credentials in URI query params are supported as a last resort, but it is
## strongly recommended to use environment credentials/IAM instead.
##
## Note: For S3 paths to work, the container/binary must be built with both
## a DB backend and the `s3` feature (for example: `sqlite,s3`,
## `postgresql,s3`, or `mysql,s3`).
##
## When using an external location, make sure to set TMP_FOLDER,
## TEMPLATES_FOLDER, and DATABASE_URL to local paths and/or a remote database
@ -451,6 +471,12 @@
## This adds the configured value to the 'Content-Security-Policy' headers 'connect-src' value.
## Multiple values must be separated with a whitespace. And only HTTPS values are allowed.
## Example: "https://my-addy-io.domain.tld https://my-simplelogin.domain.tld"
## For S3-compatible attachment downloads, include your object storage origin
## (for example Cloudflare R2 endpoint):
## "https://<accountid>.r2.cloudflarestorage.com"
## Note: This only configures CSP on Vaultwarden. You also need a CORS policy
## on the object storage bucket/provider that allows your Vaultwarden DOMAIN
## origin for download requests.
# ALLOWED_CONNECT_SRC=""
## Number of seconds, on average, between login requests from the same IP address before rate limiting kicks in.

69
.github/workflows/build.yml

@ -218,3 +218,72 @@ jobs:
run: |
echo "### :tada: Checks Passed!" >> "${GITHUB_STEP_SUMMARY}"
echo "" >> "${GITHUB_STEP_SUMMARY}"
s3-compatible-minio:
name: S3-Compatible Integration (MinIO)
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- name: "Install dependencies Ubuntu"
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends curl openssl build-essential libmariadb-dev-compat libpq-dev libssl-dev pkg-config
- name: "Checkout"
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
with:
persist-credentials: false
fetch-depth: 0
- name: "Install rust-toolchain version"
uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # master @ Dec 16, 2025, 6:11 PM GMT+1
with:
toolchain: stable
- name: "Show environment"
run: |
rustc -vV
cargo -vV
- name: Rust Caching
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
with:
prefix-key: "v2025.09-rust"
- name: Start MinIO
run: |
docker pull minio/minio:latest
docker pull minio/mc:latest
docker run -d --name minio \
-p 9000:9000 \
-e MINIO_ROOT_USER=minioadmin \
-e MINIO_ROOT_PASSWORD=minioadmin \
minio/minio:latest \
server /data --console-address ":9001"
for i in {1..30}; do
if curl -fsS "http://127.0.0.1:9000/minio/health/live" >/dev/null; then
break
fi
sleep 1
done
if ! curl -fsS "http://127.0.0.1:9000/minio/health/live" >/dev/null; then
docker ps -a
docker logs minio || true
exit 1
fi
docker run --rm --network host --entrypoint /bin/sh minio/mc:latest -c \
"mc alias set local http://127.0.0.1:9000 minioadmin minioadmin && mc mb --ignore-existing local/vaultwarden-test"
- name: Run MinIO integration test
env:
VW_S3_MINIO_ENDPOINT: "http://127.0.0.1:9000"
VW_S3_MINIO_BUCKET: "vaultwarden-test"
VW_S3_MINIO_ROOT: "/vaultwarden-integration"
VW_S3_MINIO_REGION: "auto"
VW_S3_MINIO_ACCESS_KEY: "minioadmin"
VW_S3_MINIO_SECRET_KEY: "minioadmin"
run: |
cargo test --profile ci --features sqlite,s3 test_s3_minio_integration_put_get_delete -- --ignored

63
README.md

@ -111,6 +111,69 @@ services:
<br>
### S3-Compatible Object Storage
When built with the `s3` feature, storage paths like `DATA_FOLDER`, `ATTACHMENTS_FOLDER`, `ICON_CACHE_FOLDER` and `SENDS_FOLDER` can use `s3://` URIs with query parameters:
```text
s3://bucket/prefix?endpoint=https%3A%2F%2Fs3.example.internal&enable_virtual_host_style=false&default_storage_class=STANDARD
```
- AWS S3 works with defaults (no extra parameters required).
- MinIO/Ceph usually require `endpoint` and `enable_virtual_host_style=false`.
- Cloudflare R2 usually requires `endpoint` and often `region=auto`.
- To omit `x-amz-storage-class`, set `default_storage_class=` (empty value).
- Container images must include both a DB backend feature and `s3` (for example `sqlite,s3`, `postgresql,s3`, or `mysql,s3`).
Kubernetes example:
```yaml
env:
- name: DATA_FOLDER
value: "s3://vaultwarden-data/prod?endpoint=https%3A%2F%2Fs3.example.internal&enable_virtual_host_style=false&default_storage_class=STANDARD"
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: vaultwarden-db
key: url
```
Use IAM/service account/environment credentials when possible. URI credentials are supported as a last resort.
### Browser Attachment Downloads (CSP + CORS)
For S3-compatible backends, attachment downloads from the Web Vault use presigned URLs. The browser downloads directly from the object storage endpoint.
Configure both sides:
- Vaultwarden CSP: allow the object-storage origin in `ALLOWED_CONNECT_SRC`.
- Object storage CORS policy: allow your Vaultwarden origin (`DOMAIN`) for `GET`/`HEAD`.
R2 example:
```text
ALLOWED_CONNECT_SRC="https://<accountid>.r2.cloudflarestorage.com"
```
```json
[
{
"AllowedOrigins": ["https://vault.example.com"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag", "Content-Length", "Content-Type", "Content-Disposition"],
"MaxAgeSeconds": 3600
}
]
```
Troubleshooting:
- `violates the document's Content Security Policy`: set `ALLOWED_CONNECT_SRC` correctly.
- `No 'Access-Control-Allow-Origin' header`: fix CORS policy on the bucket/provider.
<br>
## Get in touch
Have a question, suggestion or need help? Join our community on [Matrix](https://matrix.to/#/#vaultwarden:matrix.org), [GitHub Discussions](https://github.com/dani-garcia/vaultwarden/discussions) or [Discourse Forums](https://vaultwarden.discourse.group/).

105
docs/s3-compatible-object-storage.md

@ -0,0 +1,105 @@
# S3-Compatible Object Storage
This page documents Vaultwarden's S3-compatible storage support based on `s3://` URIs with query parameters (OpenDAL S3 config).
## Scope
Supported providers (via S3 API):
- AWS S3
- MinIO
- Cloudflare R2
- Ceph RGW and similar S3-compatible services
The same URI format applies to:
- `DATA_FOLDER`
- `ATTACHMENTS_FOLDER`
- `ICON_CACHE_FOLDER`
- `SENDS_FOLDER`
## URI Format
```text
s3://bucket/prefix?endpoint=https%3A%2F%2Fs3.example.com&enable_virtual_host_style=false&default_storage_class=STANDARD&region=us-east-1
```
Supported query parameters:
- `endpoint`
- `region`
- `enable_virtual_host_style`
- `default_storage_class`
- `disable_virtual_host_style` (alias)
Notes:
- AWS S3 works with defaults.
- For path-style providers, set `enable_virtual_host_style=false`.
- To omit storage class header, set `default_storage_class=` (empty).
- Unknown parameters are rejected.
## Build Requirement
Use images/binaries built with both:
1. a DB backend feature (`sqlite`, `postgresql`, or `mysql`)
2. `s3`
Examples:
- `sqlite,s3`
- `postgresql,s3`
- `mysql,s3`
## Cloudflare R2 Example
```env
ATTACHMENTS_FOLDER=s3://vaultwarden/attachments?endpoint=https://<accountid>.r2.cloudflarestorage.com&region=auto&enable_virtual_host_style=false&default_storage_class=
ICON_CACHE_FOLDER=s3://vaultwarden/icon_cache?endpoint=https://<accountid>.r2.cloudflarestorage.com&region=auto&enable_virtual_host_style=false&default_storage_class=
SENDS_FOLDER=s3://vaultwarden/sends?endpoint=https://<accountid>.r2.cloudflarestorage.com&region=auto&enable_virtual_host_style=false&default_storage_class=
```
## Browser Downloads: CSP + CORS
When attachments are stored in object storage, Web Vault downloads use presigned URLs and the browser fetches objects directly from the storage endpoint.
You must configure both sides:
1. Vaultwarden CSP (`ALLOWED_CONNECT_SRC`)
2. Bucket/provider CORS policy
### 1) Vaultwarden CSP
```env
ALLOWED_CONNECT_SRC=https://<accountid>.r2.cloudflarestorage.com
```
### 2) Bucket CORS Policy (example)
```json
[
{
"AllowedOrigins": ["https://vault.example.com"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag", "Content-Length", "Content-Type", "Content-Disposition"],
"MaxAgeSeconds": 3600
}
]
```
## Troubleshooting
- `violates the document's Content Security Policy`
- Configure/fix `ALLOWED_CONNECT_SRC`.
- `No 'Access-Control-Allow-Origin' header`
- Configure/fix CORS on the bucket/provider.
- `S3 support is not enabled`
- Image/binary was built without `s3` feature.
## Security Notes
- Prefer IAM/service account/environment credentials.
- URI credentials are supported only as a last resort.
- If credentials were exposed in logs/chats, rotate them immediately.

300
src/config.rs

@ -1391,16 +1391,175 @@ fn opendal_operator_for_path(path: &str) -> Result<opendal::Operator, Error> {
Ok(operator)
}
#[cfg(s3)]
fn is_s3_secret_param(param_name: &str) -> bool {
matches!(param_name, "access_key_id" | "secret_access_key" | "session_token")
}
#[cfg(s3)]
fn parse_s3_bool(value: &str) -> Option<bool> {
match value.to_ascii_lowercase().as_str() {
"true" | "1" | "yes" => Some(true),
"false" | "0" | "no" => Some(false),
_ => None,
}
}
#[cfg(s3)]
fn is_s3_implicit_bool_param(param_name: &str) -> bool {
param_name.starts_with("enable_") || param_name.starts_with("disable_") || param_name.starts_with("allow_")
}
#[cfg(s3)]
/// Set S3Config fields from query parameters using serde.
fn set_s3_config_param(
config: opendal::services::S3Config,
param_name: &str,
param_value: Option<&str>,
) -> Result<opendal::services::S3Config, Error> {
use serde_json::{json, Value};
// Special handling for blocked parameters
const BLOCKED_PARAMS: &[&str] = &["bucket", "root"];
if BLOCKED_PARAMS.contains(&param_name) {
return Err(format!("S3 OpenDAL Parameter '{param_name}' cannot be overridden via query string").into());
}
// Parse the parameter value
let json_value = match param_value {
None => {
// For boolean fields that default to true when present without value
// This includes fields starting with enable_, disable_, or allow_
if is_s3_implicit_bool_param(param_name) {
json!(true)
} else {
return Err(format!("S3 OpenDAL Parameter '{param_name}' requires a value").into());
}
}
Some(value) => {
// Try to parse as boolean first
if let Some(bool_value) = parse_s3_bool(value) {
json!(bool_value)
} else if let Ok(num) = value.parse::<usize>() {
// Try to parse as number (for fields like delete_max_size, batch_max_operations)
json!(num)
} else {
// Default to string
json!(value)
}
}
};
// Convert current config to JSON
let config_json =
serde_json::to_value(config).map_err(|e| Error::from(format!("Failed to serialize S3Config to JSON: {e}")))?;
// Merge with the new field and deserialize
if let Value::Object(mut config_obj) = config_json {
if !config_obj.contains_key(param_name) {
return Err(format!("Unknown S3 OpenDAL parameter '{param_name}'").into());
}
// Insert the new field
config_obj.insert(param_name.to_string(), json_value.clone());
// Try to deserialize with the new field
let display_json_value = if is_s3_secret_param(param_name) {
json!("***")
} else {
json_value
};
let new_config = serde_json::from_value::<opendal::services::S3Config>(Value::Object(config_obj))
.map_err(|e| Error::from(format!("Failed to deserialize S3Config from JSON after updating parameter '{param_name}' to value {display_json_value}: {e}")))?;
Ok(new_config)
} else {
unreachable!("S3Config should always serialize to an object");
}
}
#[cfg(s3)]
fn parse_s3_config_for_path(path: &str) -> Result<opendal::services::S3Config, Error> {
use opendal::services::S3Config;
let url = Url::parse(path).map_err(|e| format!("Invalid path S3 URL path {path:?}: {e}"))?;
let bucket = url.host_str().ok_or_else(|| format!("Missing Bucket name in data folder S3 URL {path:?}"))?;
// Create S3Config and set base configuration based on best practices for
// the official AWS S3 service.
let mut config = S3Config::default();
config.bucket = bucket.to_string();
config.root = Some(url.path().to_string());
// Default to virtual host style enabled (AWS S3 has deprecated path style)
//
// Note: Some providers may not support virtual host style
config.enable_virtual_host_style = true;
// Default to AWS S3's Intelligent Tiering storage class for optimal
// cost/performance
//
// Note: Some providers may not support this storage class
config.default_storage_class = Some("INTELLIGENT_TIERING".to_string());
// Process query parameters
for (param_name, param_value) in url.query_pairs() {
let param_name = param_name.as_ref();
let mut param_value = if param_value.is_empty() {
None
} else {
Some(param_value.as_ref())
};
if param_name == "disable_virtual_host_style" {
let value = param_value.unwrap_or("true");
let bool_value = parse_s3_bool(value).ok_or_else(|| {
format!("S3 OpenDAL Parameter 'disable_virtual_host_style' has invalid boolean value {value:?}")
})?;
let enabled_value = if bool_value {
"false"
} else {
"true"
};
config = set_s3_config_param(config, "enable_virtual_host_style", Some(enabled_value))?;
continue;
}
if param_name == "default_storage_class" && param_value.is_none() {
param_value = Some("");
}
// Use the generated setter function to handle parameters
config = set_s3_config_param(config, param_name, param_value)?;
}
if config.access_key_id.is_some() || config.secret_access_key.is_some() || config.session_token.is_some() {
warn!(
"S3 static credentials provided through path query parameters. This works, but using environment credentials or IAM is recommended."
);
}
if config.default_storage_class.as_deref() == Some("") {
config.default_storage_class = None;
}
Ok(config)
}
#[cfg(s3)]
fn opendal_s3_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::{services::S3Config, Configurator};
// This is a custom AWS credential loader that uses the official AWS Rust
// SDK config crate to load credentials. This ensures maximum compatibility
// with AWS credential configurations. For example, OpenDAL doesn't support
// AWS SSO temporary credentials yet.
struct OpenDALS3CredentialLoader {}
struct OpenDALS3CredentialLoader {
config: S3Config,
}
#[async_trait]
impl reqsign::AwsCredentialLoad for OpenDALS3CredentialLoader {
@ -1408,6 +1567,23 @@ fn opendal_s3_operator_for_path(path: &str) -> Result<opendal::Operator, Error>
use aws_credential_types::provider::ProvideCredentials as _;
use tokio::sync::OnceCell;
// If static credentials are provided, use them directly
match (&self.config.access_key_id, &self.config.secret_access_key) {
(Some(access_key_id), Some(secret_access_key)) => {
return Ok(Some(reqsign::AwsCredential {
access_key_id: access_key_id.clone(),
secret_access_key: secret_access_key.clone(),
session_token: self.config.session_token.clone(),
expires_in: None,
}));
}
(None, None) if self.config.session_token.is_none() => (),
_ => anyhow::bail!(
"s3 path must have access_key_id and secret_access_key both set, optionally with session_token set, or all three must be unset"
),
};
// Use the default credentials chain from the AWS SDK (especially useful for SSO)
static DEFAULT_CREDENTIAL_CHAIN: OnceCell<DefaultCredentialsChain> = OnceCell::const_new();
let chain = DEFAULT_CREDENTIAL_CHAIN
@ -1434,22 +1610,124 @@ fn opendal_s3_operator_for_path(path: &str) -> Result<opendal::Operator, Error>
}
}
const OPEN_DAL_S3_CREDENTIAL_LOADER: OpenDALS3CredentialLoader = OpenDALS3CredentialLoader {};
let url = Url::parse(path).map_err(|e| format!("Invalid path S3 URL path {path:?}: {e}"))?;
let config = parse_s3_config_for_path(path)?;
let bucket = url.host_str().ok_or_else(|| format!("Missing Bucket name in data folder S3 URL {path:?}"))?;
let credential_loader = OpenDALS3CredentialLoader {
config: config.clone(),
};
let builder = opendal::services::S3::default()
.customized_credential_load(Box::new(OPEN_DAL_S3_CREDENTIAL_LOADER))
.enable_virtual_host_style()
.bucket(bucket)
.root(url.path())
.default_storage_class("INTELLIGENT_TIERING");
// Convert config to builder and add custom credential loader
let builder = config.into_builder().customized_credential_load(Box::new(credential_loader));
Ok(opendal::Operator::new(builder)?.finish())
}
#[cfg(all(test, s3))]
mod s3_tests {
use super::{opendal_s3_operator_for_path, parse_s3_config_for_path};
#[test]
fn test_parse_s3_config_defaults() {
let config = parse_s3_config_for_path("s3://vaultwarden-data/path/to/root").expect("config should parse");
assert_eq!(config.bucket, "vaultwarden-data");
assert_eq!(config.root.as_deref(), Some("/path/to/root"));
assert!(config.enable_virtual_host_style);
assert_eq!(config.default_storage_class.as_deref(), Some("INTELLIGENT_TIERING"));
}
#[test]
fn test_parse_s3_config_custom_endpoint_and_path_style() {
let config = parse_s3_config_for_path(
"s3://vw/path?endpoint=http%3A%2F%2F127.0.0.1%3A9000&enable_virtual_host_style=false&default_storage_class=STANDARD&region=us-east-1",
)
.expect("config should parse");
assert_eq!(config.endpoint.as_deref(), Some("http://127.0.0.1:9000"));
assert!(!config.enable_virtual_host_style);
assert_eq!(config.default_storage_class.as_deref(), Some("STANDARD"));
assert_eq!(config.region.as_deref(), Some("us-east-1"));
}
#[test]
fn test_parse_s3_config_disable_virtual_host_style_alias() {
let config =
parse_s3_config_for_path("s3://vw/path?disable_virtual_host_style=true").expect("config should parse");
assert!(!config.enable_virtual_host_style);
}
#[test]
fn test_parse_s3_config_storage_class_can_be_omitted() {
let config = parse_s3_config_for_path("s3://vw/path?default_storage_class=").expect("config should parse");
assert_eq!(config.default_storage_class, None);
}
#[test]
fn test_parse_s3_config_implicit_boolean_flag() {
let config = parse_s3_config_for_path("s3://vw/path?enable_virtual_host_style").expect("config should parse");
assert!(config.enable_virtual_host_style);
}
#[test]
fn test_parse_s3_config_boolean_variants() {
let config = parse_s3_config_for_path("s3://vw/path?enable_virtual_host_style=0").expect("config should parse");
assert!(!config.enable_virtual_host_style);
}
#[test]
fn test_parse_s3_config_percent_encoded_prefix() {
let config = parse_s3_config_for_path("s3://vw/path%20with%20spaces").expect("config should parse");
assert_eq!(config.root.as_deref(), Some("/path%20with%20spaces"));
}
#[test]
fn test_parse_s3_config_rejects_unknown_parameter() {
let error = parse_s3_config_for_path("s3://vw/path?region=auto&unknown_param=value")
.expect_err("unknown params should fail");
let error_message = format!("{error:?}");
assert!(
error_message.contains("Unknown S3 OpenDAL parameter") && error_message.contains("unknown_param"),
"error message: {error_message}"
);
}
#[test]
#[ignore]
fn test_s3_minio_integration_put_get_delete() {
let endpoint = std::env::var("VW_S3_MINIO_ENDPOINT").unwrap_or_else(|_| "http://127.0.0.1:9000".to_string());
let bucket = std::env::var("VW_S3_MINIO_BUCKET").unwrap_or_else(|_| "vaultwarden-test".to_string());
let mut root = std::env::var("VW_S3_MINIO_ROOT").unwrap_or_else(|_| "/vaultwarden-s3-test".to_string());
if !root.starts_with('/') {
root = format!("/{root}");
}
let access_key = std::env::var("VW_S3_MINIO_ACCESS_KEY").unwrap_or_else(|_| "minioadmin".to_string());
let secret_key = std::env::var("VW_S3_MINIO_SECRET_KEY").unwrap_or_else(|_| "minioadmin".to_string());
let region = std::env::var("VW_S3_MINIO_REGION").unwrap_or_else(|_| "auto".to_string());
let mut query = url::form_urlencoded::Serializer::new(String::new());
query.append_pair("endpoint", &endpoint);
query.append_pair("region", &region);
query.append_pair("enable_virtual_host_style", "false");
query.append_pair("default_storage_class", "STANDARD");
query.append_pair("access_key_id", &access_key);
query.append_pair("secret_access_key", &secret_key);
let s3_path = format!("s3://{bucket}{root}?{}", query.finish());
let rt =
tokio::runtime::Builder::new_current_thread().enable_all().build().expect("tokio runtime should build");
rt.block_on(async move {
let operator = opendal_s3_operator_for_path(&s3_path).expect("operator should be created");
let key = format!("integration/{}.txt", uuid::Uuid::new_v4());
let payload = b"vaultwarden-opendal-s3-compatible";
operator.write(&key, payload.as_slice()).await.expect("object upload should succeed");
let buffer = operator.read(&key).await.expect("object download should succeed");
assert_eq!(buffer.to_vec(), payload.as_slice());
operator.delete(&key).await.expect("object delete should succeed");
});
}
}
pub enum PathType {
Data,
IconCache,

Loading…
Cancel
Save