Compare commits

..
18 Commits
Author SHA1 Message Date
Daniel GarcíaandGitHub 46ae59eaf4 Trusted proxy support, unauthenticated rate limit & other fixes (#7472)
* Trusted proxies, unauthenticated rate limits and various fixes

* Fix get_groups_data

* Fix get_groups_data when not using full_access

* Fmt

* Fix org import

* deduplicate send validation
2026-07-24 18:27:32 +02:00
Mathijs van VeluwandGitHub a6a88e7929 Update API response, crates and GHA (#7470)
- Updated API response to more closely match v2026.6.0+ server versions.
- Updated all the crates
- Updated Rust to v1.97.1
- Updated the web-vault to v2026.6.4
- Updated GitHub Actions

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-07-24 17:33:10 +02:00
5040bcb7c0 Remove unused fields (#7458)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-07-24 16:40:34 +02:00
TomandGitHub 660faee68e Fix custom role dialog selectors (#7442) 2026-07-21 22:06:45 +02:00
DanielandGitHub 683a23e43c Fix compilation with newer rust-musl version (#7453) 2026-07-21 21:54:10 +02:00
4a9bcb0694 Remove old compatibility code (#7434)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-07-21 19:59:40 +02:00
Mathijs van VeluwandGitHub 169aa5efcc Misc updates and fixes (#7406)
* Misc updates and fixes

- Updated Rust to v1.96.1
- Updated all the crates
- Updated GitHub Actions
- Updated the web-vault to v2026.6.2
- Updated Alpine to v3.24
- Fixed several clippy lints
- The `send` UUID wrappers didn't need the special namespace anymore since an updated crate, so removed this extra mod.

Signed-off-by: BlackDex <black.dex@gmail.com>

* Update MSRV to v1.94.1

Signed-off-by: BlackDex <black.dex@gmail.com>

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-07-08 22:10:29 +02:00
Denis PisarevandGitHub 64d28ab66e improve CI (#6991)
* ci: remove dead BASE_TAGS reference in release bake step

  steps.determine-version doesn't exist in docker-build; the expression
  resolves to empty string. The HCL default (testing) would have
  applied, but it's moot - the bake uses push-by-digest=true so tags are
  only set in merge-manifests. Dead code.

* ci: replace unsecured curl hadolint download with an official action

  hadolint/hadolint-action uses a Docker-based runner with hadolint
  pre-bundled in ghcr.io/hadolint/hadolint:v2.14.0-debian,so no binary
  downloaded at runtime. Pinning the action to a commit SHA covers the
  Dockerfile that specifies the image version, closing the supply-chain
  gap from the previous unverified curl | sudo install.

  Split {debian,alpine}: the action takes a single dockerfile argument,
  so debian and alpine are linted separately.

* ci: pin ubuntu-latest to ubuntu-24.04 in merge-manifests and zizmor

  ubuntu-latest is a moving target that can silently change the runner OS
  on the next GitHub-side update. All other jobs in this repo already pin
  to ubuntu-24.04; this makes merge-manifests and zizmor consistent.

* ci: return BASE_TAGS - it's needed for bake step
2026-07-08 22:08:20 +02:00
4720cdbe86 Add pm-26340-linux-biometrics-v2 feature flag (#7358)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:59:57 +02:00
5447ee6af2 SSO use ClientSecretPost if ClientSecretBasic is not available (#7357)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-07-07 15:59:48 +02:00
5c5e8e1a6f 2026.6.0 send support (#7346)
* 2026.6.0 send support

* Prevent creating and editing a Send with email verification

* Review fixes

---------

Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-07-07 15:59:36 +02:00
7320a1db4b PutPolicy now using vnext format (#7296)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-07-07 15:59:26 +02:00
a058a35ccd [v2026.5.0] Registration request update (#7295)
* Registration request update

* Review fix

---------

Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-07-07 15:59:17 +02:00
a16b5afaaa Org membership delete remove Invitation (#7284)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-07-07 15:59:06 +02:00
kvdbandGitHub fddc16d2b8 fix(sends): emit hideEmail as non-null boolean in sync response (#7283)
The /api/sync response serialized a Send hide_email field directly from
Option<bool>, so a NULL value in the sends table (the column is
Nullable<Bool> with no default) produced "hideEmail": null.

The Bitwarden Android client deserializes SyncResponseJson.Send.hideEmail
as a non-null Kotlin Boolean and aborts the entire sync with a
JsonDecodingException when it encounters null. Web, desktop and CLI
clients coerce null to false, so only accounts with at least one Send
are affected and only on Android.

Default None to false at the serialization boundary, matching the
official Bitwarden server where hideEmail is non-nullable. This needs no
database migration and fixes both legacy NULL rows and any future NULLs.
The hide_email field stays Option<bool> internally.
2026-07-07 15:58:54 +02:00
ec7fa137b7 Admin password recovery endpoint change (#7270)
* Admin password recovery endpoint change

* Use default to keep compatibility

---------

Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-07-07 15:58:41 +02:00
b25f715364 Fix enforce blocked (#7246)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-07-07 15:58:34 +02:00
Mathijs van VeluwandGitHub d6a3d539ed Update Rust, Crates and GHA (#7307)
- Updated Rust to v1.96.0
- Updated all the crates, and adjusted code where needed
- Fixed some nightly reported clippy lints
- Updated all the GitHub actions

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-06-05 21:52:52 +02:00
46 changed files with 1834 additions and 1027 deletions
+16
View File
@@ -324,6 +324,14 @@
## Set to the string "none" (without quotes), to disable any headers and just use the remote IP
# IP_HEADER=X-Real-IP
## Which addresses the header above is accepted from, defaults to "local".
## Anyone able to reach Vaultwarden can set the header, and the client IP is used for the login and
## admin rate limits, so it is only trusted when the request comes from a proxy listed here.
## "local" accepts it from any non global address, which covers a reverse proxy running on the same
## host or container network. Use "all" to accept it from anywhere, or list the addresses of your
## proxy as IPs and CIDR ranges if it connects from a public address.
# IP_HEADER_TRUSTED_PROXIES=local
## Icon service
## The predefined icon services are: internal, bitwarden, duckduckgo, google.
## To specify a custom icon service, set a URL template with exactly one instance of `{}`,
@@ -378,6 +386,7 @@
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Clients >= 2024.12.0)
## - "pm-25373-windows-biometrics-v2": Enable the new implementation of biometrics on Windows. (Desktop >= 2025.11.0)
## - "pm-26340-linux-biometrics-v2": Enable the new implementation of biometrics on Linux. (Desktop >= 2025.11.0)
## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0)
## - "simple-login-self-host-alias": Enable configuring self-hosted Simple Login alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0)
## - "mutual-tls": Enable the use of mutual TLS on Android (Clients >= 2025.2.0)
@@ -460,6 +469,13 @@
## Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2.
# LOGIN_RATELIMIT_MAX_BURST=10
## Number of seconds, on average, between requests from the same IP address to one of the rate limited
## unauthenticated endpoints, like the password hint, the account recovery mails or accessing a Send.
# UNAUTHENTICATED_RATELIMIT_SECONDS=60
## Allow a burst of requests of up to this size, while maintaining the average indicated by `UNAUTHENTICATED_RATELIMIT_SECONDS`.
## This budget is shared between all of those endpoints, so it is more lenient than the login one.
# UNAUTHENTICATED_RATELIMIT_MAX_BURST=50
## BETA FEATURE: Groups
## Controls whether group support is enabled for organizations
## This setting applies to organizations.
+1 -1
View File
@@ -62,7 +62,7 @@ jobs:
# Checkout the repo
- name: "Checkout"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
steps:
# Checkout the repo
- name: "Checkout"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# End Checkout the repo
+13 -12
View File
@@ -20,7 +20,7 @@ jobs:
steps:
# Start Docker Buildx
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
# https://github.com/moby/buildkit/issues/3969
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
with:
@@ -30,24 +30,25 @@ jobs:
driver-opts: |
network=host
# Download hadolint - https://github.com/hadolint/hadolint/releases
- name: Download hadolint
run: |
sudo curl -L https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION}/hadolint-$(uname -s)-$(uname -m) -o /usr/local/bin/hadolint && \
sudo chmod +x /usr/local/bin/hadolint
env:
HADOLINT_VERSION: 2.14.0
# End Download hadolint
# Checkout the repo
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# End Checkout the repo
# Test Dockerfiles with hadolint
- name: Run hadolint
run: hadolint docker/Dockerfile.{debian,alpine}
# Uses the Docker-based action (hadolint pre-bundled in ghcr.io/hadolint/hadolint:v2.14.0-debian)
# so no binary is downloaded at runtime. Pinned by commit SHA for supply-chain safety.
- name: Run hadolint on Dockerfile.debian
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
with:
dockerfile: docker/Dockerfile.debian
- name: Run hadolint on Dockerfile.alpine
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
with:
dockerfile: docker/Dockerfile.alpine
# End Test Dockerfiles with hadolint
# Test Dockerfiles with docker build checks
+15 -15
View File
@@ -58,13 +58,13 @@ jobs:
steps:
- name: Initialize QEMU binfmt support
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
with:
platforms: "arm64,arm"
# Start Docker Buildx
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
# https://github.com/moby/buildkit/issues/3969
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
with:
@@ -77,7 +77,7 @@ jobs:
# Checkout the repo
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# We need fetch-depth of 0 so we also get all the tag metadata
with:
persist-credentials: false
@@ -106,7 +106,7 @@ jobs:
# Login to Docker Hub
- name: Login to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -121,7 +121,7 @@ jobs:
# Login to GitHub Container Registry
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -137,7 +137,7 @@ jobs:
# Login to Quay.io
- name: Login to Quay.io
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
@@ -185,7 +185,7 @@ jobs:
- name: Bake ${{ matrix.base_image }} containers
id: bake_vw
uses: docker/bake-action@a66e1c87e2eca0503c343edf1d208c716d54b8a8 # v7.1.0
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7.3.0
env:
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
@@ -237,7 +237,7 @@ jobs:
# Upload artifacts to Github Actions and Attest the binaries
- name: Attest binaries
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
with:
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
@@ -249,7 +249,7 @@ jobs:
merge-manifests:
name: Merge manifests
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
needs: docker-build
environment:
name: release
@@ -272,7 +272,7 @@ jobs:
# Login to Docker Hub
- name: Login to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -287,7 +287,7 @@ jobs:
# Login to GitHub Container Registry
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -303,7 +303,7 @@ jobs:
# Login to Quay.io
- name: Login to Quay.io
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
@@ -365,7 +365,7 @@ jobs:
# Attest container images
- name: Attest - docker.io - ${{ matrix.base_image }}
if: ${{ vars.DOCKERHUB_REPO != '' && env.DIGEST_SHA != ''}}
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
with:
subject-name: ${{ vars.DOCKERHUB_REPO }}
subject-digest: ${{ env.DIGEST_SHA }}
@@ -373,7 +373,7 @@ jobs:
- name: Attest - ghcr.io - ${{ matrix.base_image }}
if: ${{ vars.GHCR_REPO != '' && env.DIGEST_SHA != ''}}
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
with:
subject-name: ${{ vars.GHCR_REPO }}
subject-digest: ${{ env.DIGEST_SHA }}
@@ -381,7 +381,7 @@ jobs:
- name: Attest - quay.io - ${{ matrix.base_image }}
if: ${{ vars.QUAY_REPO != '' && env.DIGEST_SHA != ''}}
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
with:
subject-name: ${{ vars.QUAY_REPO }}
subject-digest: ${{ env.DIGEST_SHA }}
+2 -2
View File
@@ -33,7 +33,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -50,6 +50,6 @@ jobs:
severity: CRITICAL,HIGH
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
with:
sarif_file: 'trivy-results.sarif'
+2 -2
View File
@@ -16,11 +16,11 @@ jobs:
steps:
# Checkout the repo
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# End Checkout the repo
# When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too
- name: Spell Check Repo
uses: crate-ci/typos@5374cbf686e897b15713110e233094e2874de7ef # v1.46.1
uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.48.0
+3 -3
View File
@@ -14,17 +14,17 @@ on:
jobs:
zizmor:
name: Run zizmor
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
permissions:
security-events: write # To write the security report
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@b572f7b1a1c2d41efaab43d504f68d215c3cd727 # v0.5.4
uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
with:
# intentionally not scanning the entire repository,
# since it contains integration tests.
+1 -1
View File
@@ -18,7 +18,7 @@ repos:
# When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too
- repo: https://github.com/crate-ci/typos
rev: 5374cbf686e897b15713110e233094e2874de7ef # v1.46.1
rev: bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.48.0
hooks:
- id: typos
Generated
+610 -676
View File
File diff suppressed because it is too large Load Diff
+38 -36
View File
@@ -1,6 +1,6 @@
[workspace.package]
edition = "2024"
rust-version = "1.93.0"
rust-version = "1.95.0"
license = "AGPL-3.0-only"
repository = "https://github.com/dani-garcia/vaultwarden"
publish = false
@@ -14,7 +14,6 @@ version = "1.0.0"
authors = ["Daniel García <dani-garcia@users.noreply.github.com>"]
readme = "README.md"
build = "build.rs"
resolver = "2"
repository.workspace = true
edition.workspace = true
rust-version.workspace = true
@@ -66,7 +65,7 @@ syslog = "7.0.0"
macros = { path = "./macros" }
# Logging
log = "0.4.29"
log = "0.4.33"
fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] }
# We need the `log` feature for `tracing` to enable logging for several crates to work, like lettre or webauthn-rs
tracing = { version = "0.1.44", features = ["log"] }
@@ -76,7 +75,7 @@ dotenvy = { version = "0.15.7", default-features = false }
# Numerical libraries
num-traits = "0.2.19"
num-derive = "0.4.2"
num-derive = "0.5.1"
bigdecimal = "0.4.10"
# Web framework
@@ -87,11 +86,11 @@ rocket_ws = { version = "0.1.1" }
rmpv = "1.3.1" # MessagePack library
# Concurrent HashMap used for WebSocket messaging and favicons
dashmap = "6.1.0"
dashmap = "6.2.1"
# Async futures
futures = "0.3.32"
tokio = { version = "1.52.3", features = [
futures = "0.3.33"
tokio = { version = "1.53.1", features = [
"fs",
"io-util",
"net",
@@ -100,14 +99,14 @@ tokio = { version = "1.52.3", features = [
"signal",
"time",
] }
tokio-util = { version = "0.7.18", features = ["compat"] }
tokio-util = { version = "0.7.19", features = ["compat"] }
# A generic serialization/deserialization framework
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
# A safe, extensible ORM and Query builder
diesel = { version = "2.3.9", features = ["chrono", "r2d2", "numeric"] }
diesel = { version = "2.3.11", features = ["chrono", "r2d2", "numeric"] }
diesel_migrations = "2.3.2"
derive_more = { version = "2.1.1", features = [
@@ -117,24 +116,24 @@ derive_more = { version = "2.1.1", features = [
"from",
"into",
] }
diesel-derive-newtype = "2.1.2"
diesel-derive-newtype = "2.1.3"
# SQLite, statically bundled unless the `sqlite_system` feature is enabled
libsqlite3-sys = { version = "0.37.0", optional = true }
# Crypto-related libraries
rand = "0.10.1"
rand = "0.10.2"
ring = "0.17.14"
rustls = { version = "0.23.40", features = ["ring", "std"], default-features = false }
rustls = { version = "0.23.42", features = ["ring", "std"], default-features = false }
subtle = "2.6.1"
# UUID generation
uuid = { version = "1.23.1", features = ["v4"] }
uuid = { version = "1.24.0", features = ["v4"] }
# Date and time libraries
chrono = { version = "0.4.44", default-features = false, features = ["clock", "serde"] }
chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] }
chrono-tz = "0.10.4"
time = "0.3.47"
time = "0.3.54"
# Job scheduler
job_scheduler_ng = "2.4.0"
@@ -143,7 +142,7 @@ job_scheduler_ng = "2.4.0"
data-encoding = "2.11.0"
# JWT library
jsonwebtoken = { version = "10.4.0", default-features = false, features = ["rust_crypto", "use_pem"] }
jsonwebtoken = { version = "11.0.0", default-features = false, features = ["rust_crypto", "use_pem"] }
# TOTP library
totp-lite = "2.0.1"
@@ -180,10 +179,10 @@ percent-encoding = "2.3.2" # URL encoding library used for URL's in the emails
email_address = "0.2.9"
# HTML Template library
handlebars = { version = "6.4.0", features = ["dir_source"] }
handlebars = { version = "6.4.3", features = ["dir_source"] }
# HTTP client (Used for favicons, version check, DUO and HIBP API)
reqwest = { version = "0.13.3", default-features = false, features = [
reqwest = { version = "0.13.4", default-features = false, features = [
# Misc
"charset",
"cookies",
@@ -204,33 +203,36 @@ reqwest = { version = "0.13.3", default-features = false, features = [
hickory-resolver = "0.26.1"
# Favicon extraction libraries
html5gum = "0.8.3"
regex = { version = "1.12.3", default-features = false, features = [
html5gum = "0.8.4"
regex = { version = "1.13.1", default-features = false, features = [
"perf",
"std",
"unicode-perl",
] }
data-url = "0.3.2"
bytes = "1.11.1"
bytes = "1.12.1"
svg-hush = "0.9.6"
# Cache function results (Used for version check and favicon fetching)
cached = { version = "0.59.0", features = ["async"] }
cached = { version = "2.0.2", features = ["async"] }
# Used for custom short lived cookie jar during favicon extraction
cookie = "0.18.1"
cookie_store = "0.22.1"
# Used by U2F, JWT and PostgreSQL
openssl = "0.10.79"
openssl = "0.10.81"
# CLI argument parsing
pico-args = "0.5.0"
# Macro ident concatenation
pastey = "0.2.2"
pastey = "0.2.3"
governor = "0.10.4"
# CIDR parsing for the trusted proxies of the client IP header
ipnet = "2.12.0"
# OIDC for SSO
openidconnect = { version = "4.0.1", default-features = false }
moka = { version = "0.12.15", features = ["future"] }
@@ -240,34 +242,34 @@ semver = "1.0.28"
# Allow overriding the default memory allocator
# Mainly used for the musl builds, since the default musl malloc is very slow
mimalloc = { version = "0.1.50", optional = true, default-features = false, features = ["secure"] }
mimalloc = { version = "0.1.52", optional = true, default-features = false, features = ["secure"] }
which = "8.0.2"
which = "8.0.5"
# Argon2 library with support for the PHC format
argon2 = "0.5.3"
# Reading a password from the cli for generating the Argon2id ADMIN_TOKEN
rpassword = "7.5.2"
rpassword = "7.5.4"
# Loading a dynamic CSS Stylesheet
grass_compiler = { version = "0.13.4", default-features = false }
# File are accessed through Apache OpenDAL
opendal = { version = "0.56.0", default-features = false, features = ["services-fs"] }
opendal = { version = "0.57.0", default-features = false, features = ["services-fs"] }
# For retrieving AWS credentials, including temporary SSO credentials
aws-config = { version = "1.8.16", optional = true, default-features = false, features = [
aws-config = { version = "1.10.0", optional = true, default-features = false, features = [
"behavior-version-latest",
"credentials-process",
"rt-tokio",
"sso",
] }
aws-credential-types = { version = "1.2.14", 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 }
reqsign-core = { version = "3.0.0", optional = true }
aws-credential-types = { version = "1.3.0", optional = true }
aws-smithy-runtime-api = { version = "1.14.0", optional = true }
http = { version = "1.4.2", optional = true }
reqsign-aws-v4 = { version = "3.0.2", optional = true }
reqsign-core = { version = "3.1.0", optional = true }
# Strip debuginfo from the release builds
# The debug symbols are to provide better panic traces
+4 -4
View File
@@ -1,13 +1,13 @@
---
vault_version: "v2026.4.1"
vault_image_digest: "sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe"
vault_version: "v2026.6.4"
vault_image_digest: "sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427"
# Cross Compile Docker Helper Scripts v1.9.0
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
rust_version: 1.95.0 # Rust version to be used
rust_version: 1.97.1 # Rust version to be used
debian_version: trixie # Debian release name to be used
alpine_version: "3.23" # Alpine version to be used
alpine_version: "3.24" # Alpine version to be used
# For which platforms/architectures will we try to build images
platforms: ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"]
# Determine the build images per OS/Arch
+12 -12
View File
@@ -19,23 +19,23 @@
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
# click the tag name to view the digest of the image it currently points to.
# - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2026.4.1
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.4.1
# [docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe]
# $ docker pull docker.io/vaultwarden/web-vault:v2026.6.4
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.6.4
# [docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427]
#
# - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe
# [docker.io/vaultwarden/web-vault:v2026.4.1]
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427
# [docker.io/vaultwarden/web-vault:v2026.6.4]
#
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe AS vault
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427 AS vault
########################## ALPINE BUILD IMAGES ##########################
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
## And for Alpine we define all build images here, they will only be loaded when actually used
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.95.0 AS build_amd64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.95.0 AS build_arm64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.95.0 AS build_armv7
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.95.0 AS build_armv6
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.97.1 AS build_amd64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.97.1 AS build_arm64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.97.1 AS build_armv7
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.97.1 AS build_armv6
########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006
@@ -66,7 +66,7 @@ RUN USER=root cargo new --bin /app
WORKDIR /app
# Environment variables for Cargo on Alpine based builds
RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \
RUN echo "export CARGO_TARGET=${CARGO_BUILD_TARGET}" >> /env-cargo && \
# Output the current contents of the file
cat /env-cargo
@@ -126,7 +126,7 @@ RUN source /env-cargo && \
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
#
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.23
FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.24
ENV ROCKET_PROFILE="release" \
ROCKET_ADDRESS=0.0.0.0 \
+7 -7
View File
@@ -19,15 +19,15 @@
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
# click the tag name to view the digest of the image it currently points to.
# - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2026.4.1
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.4.1
# [docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe]
# $ docker pull docker.io/vaultwarden/web-vault:v2026.6.4
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.6.4
# [docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427]
#
# - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe
# [docker.io/vaultwarden/web-vault:v2026.4.1]
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427
# [docker.io/vaultwarden/web-vault:v2026.6.4]
#
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe AS vault
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427 AS vault
########################## Cross Compile Docker Helper Scripts ##########################
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
@@ -36,7 +36,7 @@ FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f
########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.95.0-slim-trixie AS build
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.97.1-slim-trixie AS build
COPY --from=xx / /
ARG TARGETARCH
ARG TARGETVARIANT
+1 -1
View File
@@ -106,7 +106,7 @@ WORKDIR /app
{% if base == "alpine" %}
# Environment variables for Cargo on Alpine based builds
RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \
RUN echo "export CARGO_TARGET=${CARGO_BUILD_TARGET}" >> /env-cargo && \
# Output the current contents of the file
cat /env-cargo
+2 -2
View File
@@ -13,8 +13,8 @@ path = "src/lib.rs"
proc-macro = true
[dependencies]
quote = "1.0.45"
syn = "2.0.117"
quote = "1.0.47"
syn = "3.0.3"
[lints]
workspace = true
@@ -40,6 +40,16 @@ test('Invite users', async ({ page }) => {
await createAccount(test, page, users.user1, mail1Buffer);
await orgs.create(test, page, 'Test');
await test.step(`Set account recovery`, async () => {
await orgs.policies(test, page, 'Test');
await page.getByRole('button', { name: 'Account recovery' }).click();
await page.getByRole('checkbox', { name: 'Turn on' }).check();
await page.getByRole('checkbox', { name: 'Require new members' }).check();
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Edited policy Account recovery');
});
await orgs.members(test, page, 'Test');
await orgs.invite(test, page, 'Test', users.user2.email);
await orgs.invite(test, page, 'Test', users.user3.email, {
@@ -117,3 +127,27 @@ test('Organization is visible', async ({ page }) => {
await page.getByRole('button', { name: 'vault: Test', exact: true }).click();
await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
});
test('Recover user password', async ({ page }) => {
await logUser(test, page, users.user1, mail1Buffer);
let newPassword = "TotoNewPassword";
await orgs.members(test, page, 'Test');
await test.step(`Rrcover ${users.user2.email}`, async () => {
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
await page.getByRole('row').filter({hasText: users.user2.email}).getByLabel('Options').click();
await page.getByRole('menuitem', { name: 'Recover account' }).click();
await page.getByRole('textbox', { name: 'New master password (required)', exact: true }).fill(newPassword);
await page.getByRole('textbox', { name: 'Confirm new master password (' }).fill(newPassword);
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Password reset success');
});
let user2 = {
email: users.user2.email,
name: users.user2.name,
password: newPassword,
};
await logUser(test, page, user2, mail2Buffer);
});
+72
View File
@@ -0,0 +1,72 @@
import { test, expect, type Page, type TestInfo } from '@playwright/test';
import * as OTPAuth from "otpauth";
import * as utils from "../global-utils";
import { createAccount } from './setups/user';
let users = utils.loadEnv();
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo, {});
});
test.afterAll('Teardown', async ({}) => {
utils.stopVault();
});
test('Send', async ({ browser, page }) => {
await createAccount(test, page, users.user1);
const send_url = await test.step('Create', async () => {
await page.getByRole('link', { name: 'Send' }).click();
await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'New', exact: true }).click();
await page.getByRole('menuitem', { name: 'Text' }).click();
await page.getByRole('textbox', { name: 'Send name (required)' }).fill('Test');
await page.getByRole('textbox', { name: 'Text to share (required)' }).fill('test');
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('footer').getByRole('button', { name: 'Copy link' }).click();
return await page.evaluate(() => navigator.clipboard.readText());
});
const context2 = await browser.newContext();
const page2 = await context2.newPage();
await test.step('View', async () => {
await page2.goto(send_url, { waitUntil: 'domcontentloaded' });
await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible();
await expect(await page2.getByRole('paragraph').filter({ hasText: 'Test' })).toBeVisible();
});
const pwd_url = await test.step('Create with password', async () => {
await page.getByRole('link', { name: 'Send' }).click();
await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'New', exact: true }).click();
await page.getByRole('menuitem', { name: 'Text' }).click();
await page.getByRole('textbox', { name: 'Send name (required)' }).fill('Password');
await page.getByRole('textbox', { name: 'Text to share (required)' }).fill('password');
await page.getByRole('combobox', { name: 'Who can view' }).click();
await page.getByText('Anyone with a password set by you').click();
await page.getByRole('textbox', { name: 'Password (required)' }).fill('password');
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('footer').getByRole('button', { name: 'Copy link' }).click();
return await page.evaluate(() => navigator.clipboard.readText());
});
await test.step('View with password', async () => {
await page2.goto(pwd_url, { waitUntil: 'domcontentloaded' });
await expect(page2.getByRole('heading', { name: 'Enter the password to view' })).toBeVisible();
await page2.getByRole('textbox', { name: 'Password (required)' }).fill('password');
await page2.getByRole('button', { name: 'Continue' }).click();
await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible();
await expect(await page2.getByRole('paragraph').filter({ hasText: 'Password' })).toBeVisible();
});
});
+1 -1
View File
@@ -1,4 +1,4 @@
[toolchain]
channel = "1.95.0"
channel = "1.97.1"
components = [ "rustfmt", "clippy" ]
profile = "minimal"
+2 -2
View File
@@ -643,11 +643,11 @@ async fn has_http_access() -> bool {
}
}
use cached::proc_macro::cached;
use cached::macros::cached;
/// Cache this function to prevent API call rate limit. Github only allows 60 requests per hour, and we use 3 here already
/// It will cache this function for 600 seconds (10 minutes) which should prevent the exhaustion of the rate limit
/// Any cache will be lost if Vaultwarden is restarted
#[cached(time = 600, sync_writes = "default")]
#[cached(ttl = 600, sync_writes = "default")]
async fn get_release_info(has_http_access: bool) -> (String, String, String) {
// If the HTTP Check failed, do not even attempt to check for new versions since we were not able to connect with github.com anyway.
if has_http_access {
+124 -25
View File
@@ -15,7 +15,7 @@ use crate::{
core::{accept_org_invite, log_user_event, two_factor::email},
master_password_policy, register_push_device, unregister_push_device,
},
auth::{ClientHeaders, Headers, decode_delete, decode_invite, decode_verify_email},
auth::{ClientHeaders, ClientIp, Headers, decode_delete, decode_invite, decode_verify_email},
crypto,
db::{
DbConn, DbPool,
@@ -97,14 +97,11 @@ pub struct RegisterData {
email: String,
#[serde(flatten)]
kdf: KDFData,
compat: RegisterDataCompat,
#[serde(alias = "userSymmetricKey")]
key: String,
#[serde(alias = "userAsymmetricKeys")]
keys: Option<KeysData>,
master_password_hash: String,
master_password_hint: Option<String>,
name: Option<String>,
@@ -119,6 +116,102 @@ pub struct RegisterData {
org_invite_token: Option<String>,
}
impl RegisterData {
fn hash(&self) -> String {
self.compat.fold(|rdc| &rdc.master_password_hash, |rdcu| &rdcu.master_password_authentication.hash).to_owned()
}
fn kdf(&self) -> &KDFData {
self.compat.fold(|rdc| &rdc.kdf, |rdcu| &rdcu.master_password_authentication.kdf)
}
fn key(&self) -> String {
self.compat.fold(|rdc| &rdc.key, |rdcu| &rdcu.master_password_unlock.key).to_owned()
}
// When comparing with salt, email need to be normalized:
// - https://github.com/bitwarden/clients/blob/web-v2026.5.0/libs/common/src/key-management/master-password/services/master-password.service.ts#L171
fn unprocessable(&self) -> bool {
let mut unprocessable = false;
*self.compat.fold(
|_| &false,
|rdcu| {
let email = self.email.trim().to_lowercase();
unprocessable = rdcu.master_password_authentication.kdf != rdcu.master_password_unlock.kdf
|| rdcu.master_password_authentication.salt != email
|| rdcu.master_password_unlock.salt != email;
&unprocessable
},
)
}
}
#[derive(Debug, Deserialize)]
struct RegisterDataOld {
#[serde(flatten)]
kdf: KDFData,
#[serde(alias = "userSymmetricKey")]
key: String,
#[serde(alias = "masterPasswordHash")]
master_password_hash: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RegisterDataCur {
master_password_authentication: MasterPasswordAuthentication,
master_password_unlock: MasterPasswordUnlock,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum RegisterDataCompat {
RegisterDataOld(RegisterDataOld),
RegisterDataCur(RegisterDataCur),
}
impl RegisterDataCompat {
fn fold<'a, T>(
&'a self,
fct: impl FnOnce(&'a RegisterDataOld) -> &'a T,
fcu: impl FnOnce(&'a RegisterDataCur) -> &'a T,
) -> &'a T {
match self {
RegisterDataCompat::RegisterDataOld(rdc) => fct(rdc),
RegisterDataCompat::RegisterDataCur(rdcu) => fcu(rdcu),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KeysData {
encrypted_private_key: String,
public_key: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MasterPasswordAuthentication {
kdf: KDFData,
salt: String,
#[serde(alias = "masterPasswordAuthenticationHash")]
hash: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MasterPasswordUnlock {
kdf: KDFData,
salt: String,
#[serde(alias = "masterKeyWrappedUserKey")]
key: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetPasswordData {
@@ -132,13 +225,6 @@ pub struct SetPasswordData {
org_identifier: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KeysData {
encrypted_private_key: String,
public_key: String,
}
/// Trims whitespace from password hints, and converts blank password hints to `None`.
fn clean_password_hint(password_hint: Option<&String>) -> Option<String> {
match password_hint {
@@ -177,6 +263,10 @@ pub async fn register(data: Json<RegisterData>, email_verification: bool, conn:
let mut pending_emergency_access = None;
if data.unprocessable() {
err_code!("Unexpected RegisterData format", Status::UnprocessableEntity.code);
}
// First, validate the provided verification tokens
if email_verification {
match (
@@ -257,8 +347,8 @@ pub async fn register(data: Json<RegisterData>, email_verification: bool, conn:
err!("Registration not allowed or user already exists")
}
if let Some(token) = data.org_invite_token {
let claims = decode_invite(&token)?;
if let Some(token) = data.org_invite_token.as_ref() {
let claims = decode_invite(token)?;
if claims.email == email {
// Verify the email address when signing up via a valid invite token
email_verified = true;
@@ -296,9 +386,9 @@ pub async fn register(data: Json<RegisterData>, email_verification: bool, conn:
// Make sure we don't leave a lingering invitation.
Invitation::take(&email, &conn).await;
set_kdf_data(&mut user, &data.kdf)?;
set_kdf_data(&mut user, data.kdf())?;
user.set_password(&data.master_password_hash, Some(data.key), true, None, &conn).await?;
user.set_password(&data.hash(), Some(data.key()), true, None, &conn).await?;
user.password_hint = password_hint;
// Add extra fields if present
@@ -603,10 +693,6 @@ struct UnlockData {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ChangeKdfData {
#[allow(dead_code)]
new_master_password_hash: String,
#[allow(dead_code)]
key: String,
authentication_data: AuthenticationData,
unlock_data: UnlockData,
master_password_hash: String,
@@ -1107,7 +1193,9 @@ struct DeleteRecoverData {
}
#[post("/accounts/delete-recover", data = "<data>")]
async fn post_delete_recover(data: Json<DeleteRecoverData>, conn: DbConn) -> EmptyResult {
async fn post_delete_recover(data: Json<DeleteRecoverData>, ip: ClientIp, conn: DbConn) -> EmptyResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
let data: DeleteRecoverData = data.into_inner();
if CONFIG.mail_enabled() {
@@ -1180,9 +1268,11 @@ struct PasswordHintData {
}
#[post("/accounts/password-hint", data = "<data>")]
async fn password_hint(data: Json<PasswordHintData>, conn: DbConn) -> EmptyResult {
async fn password_hint(data: Json<PasswordHintData>, ip: ClientIp, conn: DbConn) -> EmptyResult {
const NO_HINT: &str = "Sorry, you have no password hint...";
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
if !CONFIG.password_hints_allowed() || (!CONFIG.mail_enabled() && !CONFIG.show_password_hint()) {
err!("This server is not configured to provide password hints.");
}
@@ -1244,6 +1334,13 @@ pub async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
"kdfIterations": kdf_iter,
"kdfMemory": kdf_mem,
"kdfParallelism": kdf_para,
"kdfSettings": {
"iterations": kdf_iter,
"kdfType": kdf_type,
"memory": kdf_mem,
"parallelism": kdf_para
},
"salt": null,
}))
}
@@ -1420,7 +1517,9 @@ async fn put_device_token(device_id: DeviceId, data: Json<PushToken>, headers: H
}
#[put("/devices/identifier/<device_id>/clear-token")]
async fn put_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResult {
async fn put_clear_device_token(device_id: DeviceId, ip: ClientIp, conn: DbConn) -> EmptyResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
// This only clears push token
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Controllers/DevicesController.cs#L215
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Services/Implementations/DeviceService.cs#L37
@@ -1442,8 +1541,8 @@ async fn put_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResul
// On upstream server, both PUT and POST are declared. Implementing the POST method in case it would be useful somewhere
#[post("/devices/identifier/<device_id>/clear-token")]
async fn post_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResult {
put_clear_device_token(device_id, conn).await
async fn post_clear_device_token(device_id: DeviceId, ip: ClientIp, conn: DbConn) -> EmptyResult {
put_clear_device_token(device_id, ip, conn).await
}
#[get("/tasks")]
+14 -5
View File
@@ -450,7 +450,9 @@ pub async fn update_cipher_from_data(
match Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &org_id, conn).await {
None => err!("You don't have permission to add item to organization"),
Some(member) => {
if shared_to_collections.is_some()
// A non-empty list of collections implies the caller already validated the user's write
// access to them, so we can move the cipher into the organization on that basis.
if shared_to_collections.as_ref().is_some_and(|cols| !cols.is_empty())
|| member.has_full_access()
|| cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await
{
@@ -629,7 +631,7 @@ async fn post_ciphers_import(data: Json<ImportData>, headers: Headers, conn: DbC
// Read and create the ciphers
for (index, mut cipher_data) in data.ciphers.into_iter().enumerate() {
let folder_id = relations_map.get(&index).map(|i| folders[*i].clone());
let folder_id = relations_map.get(&index).and_then(|i| folders.get(*i).cloned());
cipher_data.folder_id = folder_id;
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
@@ -1043,6 +1045,13 @@ async fn share_cipher_by_uuid(
err!("Cipher doesn't exist")
};
// `update_cipher_from_data()` rejects this too, but only after the collections below were
// already linked. There are no transactions, so that would leave the cipher linked to a
// collection of another organization.
if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.cipher.organization_id {
err!("Organization mismatch. Please resync the client before updating the cipher")
}
let mut shared_to_collections = vec![];
if let Some(organization_id) = &data.cipher.organization_id {
@@ -2135,9 +2144,9 @@ impl CipherSyncData {
// Organization Sync does not support Folders, Favorites, or Archives.
// If these are set, it will cause issues in the web-vault.
CipherSyncType::Organization => {
cipher_folders = HashMap::with_capacity(0);
cipher_favorites = HashSet::with_capacity(0);
cipher_archives = HashMap::with_capacity(0);
cipher_folders = HashMap::new();
cipher_favorites = HashSet::new();
cipher_archives = HashMap::new();
}
}
+10 -4
View File
@@ -52,7 +52,7 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin
.map(Event::to_json)
.collect()
} else {
Vec::with_capacity(0)
Vec::new()
};
Ok(Json(json!({
@@ -78,7 +78,7 @@ async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Heade
Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn).await.iter().map(Event::to_json).collect()
} else {
Vec::with_capacity(0)
Vec::new()
};
Ok(Json(json!({
@@ -115,7 +115,7 @@ async fn get_user_events(
.map(Event::to_json)
.collect()
} else {
Vec::with_capacity(0)
Vec::new()
};
Ok(Json(json!({
@@ -182,7 +182,10 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
.await;
}
1600..=1699 => {
if let Some(org_id) = &event.organization_id {
// Only allow logging events for an organization the user is actually a member of.
if let Some(org_id) = &event.organization_id
&& Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await.is_some()
{
log_event_impl(
event.r#type,
org_id,
@@ -197,8 +200,11 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
}
}
_ => {
// The cipher determines the organization the event is logged to, so make sure the
// user can actually access it instead of trusting the provided cipher uuid.
if let Some(cipher_uuid) = &event.cipher_id
&& let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await
&& cipher.is_accessible_to_user(&headers.user.uuid, &conn).await
&& let Some(org_id) = cipher.organization_uuid
{
log_event_impl(
+9 -2
View File
@@ -228,14 +228,17 @@ fn config() -> Json<Value> {
// Version history:
// - Individual cipher key encryption: 2024.2.0
// - Mobile app support for MasterPasswordUnlockData: 2025.8.0
"version": "2025.12.0",
"version": "2026.6.0",
"gitHash": option_env!("GIT_REV"),
"server": {
"name": "Vaultwarden",
"url": "https://github.com/dani-garcia/vaultwarden"
},
"settings": {
"disableUserRegistration": CONFIG.is_signup_disabled()
"disableUserRegistration": CONFIG.is_signup_disabled(),
// When enabled, this setting signals to clients that onboarding interstitials
// (post-login welcome dialogs, extension install prompts, setup extension redirects, and premium upsell modals) should be suppressed
"suppressOnboardingInterstitials": false
},
"environment": {
"vault": domain,
@@ -251,6 +254,10 @@ fn config() -> Json<Value> {
"vapidPublicKey": null
},
"featureStates": feature_states,
// Not supported right now
// Used for by clients to learn if the server requires extra work to establish a connection.
// See: https://github.com/bitwarden/server/pull/6892 | https://github.com/bitwarden/server/commit/52955d1860b4dfb905f67bbe39d9b10bbd61ded0
"communication": null,
"object": "config",
}))
}
+119 -34
View File
@@ -96,6 +96,7 @@ pub fn routes() -> Vec<Route> {
put_reset_password_enrollment,
get_reset_password_details,
put_reset_password,
put_recover_account,
get_org_export,
post_api_key,
rotate_api_key,
@@ -469,7 +470,7 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea
.map(CollectionGroup::to_json_details_for_group)
.collect()
} else {
Vec::with_capacity(0)
Vec::new()
};
let mut json_object = col.to_json_details(&headers.user.uuid, None, &conn).await;
@@ -576,6 +577,13 @@ async fn post_bulk_access_collections(
err!("Can't find organization details")
}
// The collections and members are checked below, the groups only here.
let org_groups = Group::find_by_organization(&org_id, &conn).await;
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|g| &g.uuid).collect();
if let Some(g) = data.groups.iter().find(|g| !org_group_ids.contains(&g.id)) {
err!("Invalid group", format!("Group {} does not belong to organization {}!", g.id, org_id))
}
for col_id in data.collection_ids {
let Some(collection) = Collection::find_by_uuid_and_org(&col_id, &org_id, &conn).await else {
err!("Collection not found")
@@ -805,7 +813,7 @@ async fn get_org_collection_detail(
} else {
// The Bitwarden clients seem to call this API regardless of whether groups are enabled,
// so just act as if there are no groups.
Vec::with_capacity(0)
Vec::new()
};
// Generate a HashMap to get the correct MembershipType per user to determine the manage permission
@@ -945,6 +953,11 @@ async fn get_members(
if org_id != headers.membership.org_uuid {
err!("Organization not found", "Organization id's do not match");
}
if !headers.membership.has_full_access() {
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code);
}
let mut users_json = Vec::new();
for u in Membership::find_by_org(&org_id, &conn).await {
users_json.push(
@@ -1089,9 +1102,13 @@ async fn send_invite(
err!(format!("User already in organization: {email}"))
}
// automatically accept existing users if mail is disabled
if !CONFIG.mail_enabled() && !user.password_hash.is_empty() {
member_status = MembershipStatus::Accepted as i32;
if !CONFIG.mail_enabled() {
if user.password_hash.is_empty() {
Invitation::new(email).save(&conn).await?;
} else {
// automatically accept existing users if mail is disabled
member_status = MembershipStatus::Accepted as i32;
}
}
user
}
@@ -1162,6 +1179,9 @@ async fn send_invite(
}
for group_id in &data.groups {
if Group::find_by_uuid_and_org(group_id, &org_id, &conn).await.is_none() {
err!("Group not found in Organization")
}
let mut group_entry = GroupUser::new(group_id.clone(), new_member.uuid.clone());
group_entry.save(&conn).await?;
}
@@ -1609,6 +1629,9 @@ async fn edit_member(
GroupUser::delete_all_by_member(&member_to_edit.uuid, &conn).await?;
for group_id in data.groups.iter().flatten() {
if Group::find_by_uuid_and_org(group_id, &org_id, &conn).await.is_none() {
err!("Group not found in Organization")
}
let mut group_entry = GroupUser::new(group_id.clone(), member_to_edit.uuid.clone());
group_entry.save(&conn).await?;
}
@@ -1713,6 +1736,15 @@ async fn delete_member_impl(
if let Some(user) = User::find_by_uuid(&member_to_delete.user_uuid, conn).await {
nt.send_user_update(UpdateType::SyncOrgKeys, &user, headers.device.push_uuid.as_ref(), conn).await;
if !CONFIG.mail_enabled()
&& !Membership::find_invited_by_user(&user.uuid, conn)
.await
.into_iter()
.any(|m| m.uuid != member_to_delete.uuid)
{
Invitation::take(&user.email, conn).await;
}
}
member_to_delete.delete(conn).await
@@ -1799,19 +1831,19 @@ async fn post_org_import(
// TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks.
Cipher::validate_cipher_data(&data.ciphers)?;
let existing_collections: HashSet<Option<CollectionId>> =
Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| Some(c.uuid)).collect();
let existing_collections: HashMap<CollectionId, Collection> =
Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| (c.uuid.clone(), c)).collect();
let mut collections: Vec<CollectionId> = Vec::with_capacity(data.collections.len());
for col in data.collections {
let collection_uuid = if existing_collections.contains(&col.id) {
let col_id = col.id.unwrap();
// When not an Owner or Admin, check if the member is allowed to access the collection.
let existing = col.id.as_ref().and_then(|col_id| existing_collections.get(col_id));
let collection_uuid = if let Some(collection) = existing {
// When not an Owner or Admin, check if the member is allowed to write to the collection.
if headers.membership.atype < MembershipType::Admin
&& !Collection::can_access_collection(&headers.membership, &col_id, &conn).await
&& !collection.is_writable_by_user(&headers.membership.user_uuid, &conn).await
{
err!(Compact, "The current user isn't allowed to manage this collection")
}
col_id
collection.uuid.clone()
} else {
// We do not allow users or managers which can not manage all collections to create new collections
// If there is any collection other than an existing import collection, abort the import.
@@ -1839,6 +1871,8 @@ async fn post_org_import(
for mut cipher_data in data.ciphers {
// Always clear folder_id's via an organization import
cipher_data.folder_id = None;
// Replace the client-provided, unvalidated organizationId with the real target org
cipher_data.organization_id = Some(org_id.clone());
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
update_cipher_from_data(
&mut cipher,
@@ -1856,8 +1890,9 @@ async fn post_org_import(
// Assign the collections
for (cipher_index, col_index) in relations {
let cipher_id = &ciphers[cipher_index];
let col_id = &collections[col_index];
let (Some(cipher_id), Some(col_id)) = (ciphers.get(cipher_index), collections.get(col_index)) else {
err!(Compact, "Invalid collection relationship")
};
CollectionCipher::save(cipher_id, col_id, &conn).await?;
}
@@ -2020,18 +2055,27 @@ struct PolicyData {
data: Option<Value>,
}
#[derive(Deserialize)]
struct PutPolicy {
policy: PolicyData,
// Ignore metadata for now as we do not yet support this
// "metadata": {
// "defaultUserCollectionName": "2.xx|xx==|xx="
// }
}
#[put("/organizations/<org_id>/policies/<pol_type>", data = "<data>")]
async fn put_policy(
org_id: OrganizationId,
pol_type: i32,
data: Json<PolicyData>,
data: Json<PutPolicy>,
headers: AdminHeaders,
conn: DbConn,
) -> JsonResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
let data: PolicyData = data.into_inner();
let data: PolicyData = data.into_inner().policy;
let Some(pol_type_enum) = OrgPolicyType::from_i32(pol_type) else {
err!("Invalid or unsupported policy type")
@@ -2139,26 +2183,16 @@ async fn put_policy(
Ok(Json(policy.to_json()))
}
#[derive(Deserialize)]
struct PolicyDataVnext {
policy: PolicyData,
// Ignore metadata for now as we do not yet support this
// "metadata": {
// "defaultUserCollectionName": "2.xx|xx==|xx="
// }
}
// Deprecated with client v2026.5.0
#[put("/organizations/<org_id>/policies/<pol_type>/vnext", data = "<data>")]
async fn put_policy_vnext(
org_id: OrganizationId,
pol_type: i32,
data: Json<PolicyDataVnext>,
data: Json<PutPolicy>,
headers: AdminHeaders,
conn: DbConn,
) -> JsonResult {
let data: PolicyDataVnext = data.into_inner();
let policy: PolicyData = data.policy;
put_policy(org_id, pol_type, Json(policy), headers, conn).await
put_policy(org_id, pol_type, data, headers, conn).await
}
#[get("/plans")]
@@ -2428,6 +2462,23 @@ async fn get_groups_data(
if org_id != headers.membership.org_uuid {
err!("Organization not found", "Organization id's do not match");
}
// The details view (group→collection/user mappings) needs full org access; the plain list only
// needs manage access to a collection, so a manager of a collection (directly or via a group)
// can load it to assign groups.
let has_full_access = headers.membership.has_full_access()
|| (CONFIG.org_groups_enabled()
&& GroupUser::has_full_access_by_member(&org_id, &headers.membership.uuid, &conn).await);
let allowed = if details {
has_full_access
} else {
has_full_access
|| Collection::has_manageable_collection_by_user(&org_id, &headers.membership.user_uuid, &conn).await
};
if !allowed {
err_code!("Resource not found.", "User does not have access", rocket::http::Status::NotFound.code);
}
let groups: Vec<Value> = if CONFIG.org_groups_enabled() {
let groups = Group::find_by_organization(&org_id, &conn).await;
let mut groups_json = Vec::with_capacity(groups.len());
@@ -2445,7 +2496,7 @@ async fn get_groups_data(
} else {
// The Bitwarden clients seem to call this API regardless of whether groups are enabled,
// so just act as if there are no groups.
Vec::with_capacity(0)
Vec::new()
};
Ok(Json(json!({
@@ -2875,9 +2926,14 @@ struct OrganizationUserResetPasswordEnrollmentRequest {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct OrganizationUserResetPasswordRequest {
struct OrganizationUserRecoverAccountRequest {
new_master_password_hash: String,
key: String,
#[serde(default)]
reset_master_password: bool,
#[serde(default)]
reset_two_factor: bool,
}
// Upstream reports this is the renamed endpoint instead of `/keys`
@@ -2905,12 +2961,43 @@ async fn get_organization_keys(org_id: OrganizationId, headers: OrgMemberHeaders
get_organization_public_key(org_id, headers, conn).await
}
// Will allow to reset 2FA too
// https://github.com/bitwarden/clients/blob/web-v2026.4.2/libs/admin-console/src/common/organization-user/models/requests/organization-user-reset-password.request.ts
#[put("/organizations/<org_id>/users/<member_id>/recover-account", data = "<data>")]
async fn put_recover_account(
org_id: OrganizationId,
member_id: MembershipId,
headers: AdminHeaders,
data: Json<OrganizationUserRecoverAccountRequest>,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
let req = data.into_inner();
if req.reset_master_password && !req.reset_two_factor {
recover_account(org_id, member_id, headers, req, conn, nt).await
} else {
err!("Unsupported operation")
}
}
// Deprecated since `v2026.4.2`
#[put("/organizations/<org_id>/users/<member_id>/reset-password", data = "<data>")]
async fn put_reset_password(
org_id: OrganizationId,
member_id: MembershipId,
headers: AdminHeaders,
data: Json<OrganizationUserResetPasswordRequest>,
data: Json<OrganizationUserRecoverAccountRequest>,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
recover_account(org_id, member_id, headers, data.into_inner(), conn, nt).await
}
async fn recover_account(
org_id: OrganizationId,
member_id: MembershipId,
headers: AdminHeaders,
reset_request: OrganizationUserRecoverAccountRequest,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
@@ -2944,8 +3031,6 @@ async fn put_reset_password(
err!(format!("Error sending user reset password email: {e:#?}"));
}
let reset_request = data.into_inner();
let mut user = user;
user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None, &conn)
.await?;
+9 -2
View File
@@ -14,7 +14,7 @@ use crate::{
db::{
DbConn,
models::{
Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, Organization,
Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, OrgPolicy, Organization,
OrganizationApiKey, OrganizationId, User,
},
},
@@ -84,8 +84,15 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
}
// If user is part of the organization, restore it
} else if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await {
let restored = member.restore();
let mut restored = member.restore();
let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
// Enforce org policies as every other restore path does.
// If the user is not allowed, we revoke again and continue so the external_id is still updated.
if restored && let Err(e) = OrgPolicy::check_user_allowed(&member, "restore", &conn).await {
warn!("Not restoring {}: {e:?}", user_data.email);
member.revoke();
restored = false;
}
if restored || ext_modified {
member.save(&conn).await?;
}
+67 -31
View File
@@ -12,7 +12,7 @@ use serde_json::Value;
use crate::{
CONFIG,
api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType},
auth::{ClientIp, Headers, Host},
auth::{ClientIp, Headers, Host, SendHeaders},
config::PathType,
db::{
DbConn, DbPool,
@@ -48,7 +48,9 @@ pub fn routes() -> Vec<rocket::Route> {
post_send,
post_send_file,
post_access,
post_access_legacy,
post_access_file,
post_access_file_legacy,
put_send,
delete_send,
put_remove_password,
@@ -78,6 +80,7 @@ pub struct SendData {
deletion_date: DateTime<Utc>,
disabled: bool,
hide_email: Option<bool>,
emails: Option<String>,
// Data field
name: String,
@@ -148,6 +151,10 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
);
}
if data.emails.is_some() {
err!("Sends with email verification is not supported");
}
let mut send = Send::new(data.r#type, data.name, data_str, data.key, data.deletion_date.naive_utc());
send.user_uuid = Some(user_id);
send.notes = data.notes;
@@ -371,7 +378,7 @@ pub struct SendFileData {
}
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L195
#[post("/sends/<send_id>/file/<file_id>", format = "multipart/form-data", data = "<data>")]
#[post("/sends/<send_id>/file/<file_id>", format = "multipart/form-data", data = "<data>", rank = 2)]
async fn post_send_file_v2_data(
send_id: SendId,
file_id: SendFileId,
@@ -441,20 +448,34 @@ async fn post_send_file_v2_data(
Ok(())
}
#[post("/sends/access")]
async fn post_access(headers: SendHeaders, conn: DbConn, nt: Notify<'_>) -> JsonResult {
let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404)
};
if !send.is_accessible() {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
process_access(send, conn, nt).await
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SendAccessData {
pub password: Option<String>,
}
// Legacy since web-2026.6.0
#[post("/sends/access/<access_id>", data = "<data>")]
async fn post_access(
async fn post_access_legacy(
access_id: &str,
data: Json<SendAccessData>,
conn: DbConn,
ip: ClientIp,
nt: Notify<'_>,
) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
let Some(mut send) = Send::find_by_access_id(access_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404)
};
@@ -465,17 +486,7 @@ async fn post_access(
err_code!(SEND_INACCESSIBLE_MSG, 404);
}
if let Some(expiration) = send.expiration_date
&& Utc::now().naive_utc() >= expiration
{
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
if Utc::now().naive_utc() >= send.deletion_date {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
if send.disabled {
if !send.is_accessible() {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
@@ -489,11 +500,17 @@ async fn post_access(
// Files are incremented during the download
if send.atype == SendType::Text as i32 {
send.access_count += 1;
if !send.register_access(&conn).await? {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
} else {
send.save(&conn).await?;
}
send.save(&conn).await?;
process_access(send, conn, nt).await
}
async fn process_access(send: Send, conn: DbConn, nt: Notify<'_>) -> JsonResult {
nt.send_send_update(
UpdateType::SyncSendUpdate,
&send,
@@ -506,15 +523,36 @@ async fn post_access(
Ok(Json(send.to_json_access(&conn).await))
}
#[post("/sends/<send_id>/access/file/<file_id>", data = "<data>")]
#[post("/sends/access/file/<file_id>", rank = 1)]
async fn post_access_file(
file_id: SendFileId,
headers: SendHeaders,
host: Host,
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404)
};
if !send.is_accessible() {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
process_access_file(send, file_id, host, conn, nt).await
}
// Legacy since web-2026.6.0
#[post("/sends/<send_id>/access/file/<file_id>", data = "<data>")]
async fn post_access_file_legacy(
send_id: SendId,
file_id: SendFileId,
data: Json<SendAccessData>,
host: Host,
conn: DbConn,
ip: ClientIp,
nt: Notify<'_>,
) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
let Some(mut send) = Send::find_by_uuid(&send_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404)
};
@@ -525,17 +563,7 @@ async fn post_access_file(
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
if let Some(expiration) = send.expiration_date
&& Utc::now().naive_utc() >= expiration
{
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
if Utc::now().naive_utc() >= send.deletion_date {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
if send.disabled {
if !send.is_accessible() {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
@@ -547,10 +575,14 @@ async fn post_access_file(
}
}
send.access_count += 1;
if !send.register_access(&conn).await? {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
send.save(&conn).await?;
process_access_file(send, file_id, host, conn, nt).await
}
async fn process_access_file(send: Send, file_id: SendFileId, host: Host, conn: DbConn, nt: Notify<'_>) -> JsonResult {
nt.send_send_update(
UpdateType::SyncSendUpdate,
&send,
@@ -563,7 +595,7 @@ async fn post_access_file(
Ok(Json(json!({
"object": "send-fileDownload",
"id": file_id,
"url": download_url(&host, &send_id, &file_id).await?,
"url": download_url(&host, &send.uuid, &file_id).await?,
})))
}
@@ -601,6 +633,10 @@ async fn put_send(send_id: SendId, data: Json<SendData>, headers: Headers, conn:
err!("Send not found", "Send send_id is invalid or does not belong to user")
};
if data.emails.is_some() {
err!("Sends with email verification is not supported");
}
update_send_from_data(&mut send, data, &headers, &conn, &nt, UpdateType::SyncSendUpdate).await?;
Ok(Json(send.to_json()))
+17 -1
View File
@@ -65,7 +65,7 @@ static CLIENT: LazyLock<Client> = LazyLock::new(|| {
let icon_download_timeout = Duration::from_secs(CONFIG.icon_download_timeout());
let pool_idle_timeout = Duration::from_secs(10);
// Reuse the client between requests
get_reqwest_client_builder()
get_reqwest_client_builder(true)
.cookie_provider(Arc::clone(&cookie_store))
.timeout(icon_download_timeout)
.pool_max_idle_per_host(5) // Configure the Hyper Pool to only have max 5 idle connections
@@ -405,6 +405,22 @@ async fn get_page(url: &str) -> Result<Response, Error> {
}
async fn get_page_with_referer(url: &str, referer: &str) -> Result<Response, Error> {
// The resolver only sees hosts needing name resolution, so IP-literal hrefs from
// attacker-controlled HTML never reach `post_resolve()`. Check them here.
let Ok(parsed_url) = url::Url::parse(url) else {
err_silent!("Invalid URL", url)
};
if !matches!(parsed_url.scheme(), "http" | "https") {
err_silent!("Invalid scheme", url)
}
let Some(host) = parsed_url.host() else {
err_silent!("Invalid host", url)
};
should_block_host(&host)?;
let mut client = CLIENT.get(url);
if !referer.is_empty() {
client = client.header("Referer", referer);
+23 -2
View File
@@ -31,8 +31,8 @@ use crate::{
DbConn,
models::{
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError,
OrganizationApiKey, OrganizationId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User,
UserId,
OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete,
TwoFactorType, User, UserId,
},
},
error::MapResult,
@@ -108,6 +108,20 @@ async fn login(
sso_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await
}
"authorization_code" => err!("SSO sign-in is not available"),
"send_access" => {
crate::ratelimit::check_limit_unauthenticated(&client_header.ip.ip)?;
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
check_is_some(data.send_id.as_ref(), "send_id cannot be blank")?;
let tokens = auth::SendTokens::generate_tokens(
data.send_id.as_ref().unwrap(),
data.password_hash_b64,
&client_header.ip,
&conn,
)
.await?;
Ok(Json(tokens.to_json()))
}
t => err!("Invalid type", t),
};
@@ -1042,8 +1056,11 @@ enum RegisterVerificationResponse {
#[post("/accounts/register/send-verification-email", data = "<data>")]
async fn register_verification_email(
data: Json<RegisterVerificationData>,
ip: ClientIp,
conn: DbConn,
) -> ApiResult<RegisterVerificationResponse> {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
let data = data.into_inner();
// the registration can only continue if signup is allowed or there exists an invitation
@@ -1144,6 +1161,10 @@ struct ConnectData {
code: Option<OIDCCode>,
#[field(name = uncased("code_verifier"))]
code_verifier: Option<OIDCCodeVerifier>,
// Needed for send access
send_id: Option<SendId>,
password_hash_b64: Option<String>,
}
fn check_is_some<T>(value: Option<&T>, msg: &str) -> EmptyResult {
if value.is_none() {
+60 -10
View File
@@ -33,9 +33,14 @@ pub static WS_USERS: LazyLock<Arc<WebSocketUsers>> = LazyLock::new(|| {
pub static WS_ANONYMOUS_SUBSCRIPTIONS: LazyLock<Arc<AnonymousWebSocketSubscriptions>> = LazyLock::new(|| {
Arc::new(AnonymousWebSocketSubscriptions {
map: Arc::new(dashmap::DashMap::new()),
connections: Arc::new(dashmap::DashMap::new()),
})
});
/// The anonymous hub needs no authentication, so bound how much a single client can hold open.
/// One connection is needed per pending login request, several at once are only expected behind NAT.
const MAX_ANONYMOUS_CONNECTIONS_PER_IP: u32 = 25;
static NOTIFICATIONS_DISABLED: LazyLock<bool> = LazyLock::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled());
pub fn routes() -> Vec<Route> {
@@ -82,14 +87,21 @@ impl Drop for WSEntryMapGuard {
struct WSAnonymousEntryMapGuard {
subscriptions: Arc<AnonymousWebSocketSubscriptions>,
token: String,
entry_uuid: uuid::Uuid,
addr: IpAddr,
}
impl WSAnonymousEntryMapGuard {
fn new(subscriptions: Arc<AnonymousWebSocketSubscriptions>, token: String, addr: IpAddr) -> Self {
fn new(
subscriptions: Arc<AnonymousWebSocketSubscriptions>,
token: String,
entry_uuid: uuid::Uuid,
addr: IpAddr,
) -> Self {
Self {
subscriptions,
token,
entry_uuid,
addr,
}
}
@@ -98,7 +110,11 @@ impl WSAnonymousEntryMapGuard {
impl Drop for WSAnonymousEntryMapGuard {
fn drop(&mut self) {
info!("Closing WS connection from {}", self.addr);
self.subscriptions.map.remove(&self.token);
if let Some(mut entry) = self.subscriptions.map.get_mut(&self.token) {
entry.retain(|(uuid, _)| uuid != &self.entry_uuid);
}
self.subscriptions.map.remove_if(&self.token, |_, senders| senders.is_empty());
self.subscriptions.release(self.addr);
}
}
@@ -194,12 +210,19 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R
let (mut rx, guard) = {
let subscriptions = Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS);
// Add a channel to send messages to this client to the map
if !subscriptions.try_reserve(ip.ip) {
err_code!("Too many connections", 429)
}
// Add a channel to send messages to this client to the map.
// Clients reconnect with the same token while a login request is still pending, so keep
// every subscriber instead of replacing, otherwise the older one takes the newer one down.
let (tx, rx) = tokio::sync::mpsc::channel::<Message>(100);
subscriptions.map.insert(token.clone(), tx);
let entry_uuid = uuid::Uuid::new_v4();
subscriptions.map.entry(token.clone()).or_default().push((entry_uuid, tx));
// Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map
(rx, WSAnonymousEntryMapGuard::new(subscriptions, token, ip.ip))
(rx, WSAnonymousEntryMapGuard::new(subscriptions, token, entry_uuid, ip.ip))
};
Ok({
@@ -534,15 +557,42 @@ impl WebSocketUsers {
#[derive(Clone)]
pub struct AnonymousWebSocketSubscriptions {
map: Arc<dashmap::DashMap<String, Sender<Message>>>,
map: Arc<dashmap::DashMap<String, Vec<UserSenders>>>,
connections: Arc<dashmap::DashMap<IpAddr, u32>>,
}
impl AnonymousWebSocketSubscriptions {
/// Takes a connection slot for this address, returns false when it already reached the limit.
fn try_reserve(&self, addr: IpAddr) -> bool {
let mut count = self.connections.entry(addr).or_insert(0);
if *count >= MAX_ANONYMOUS_CONNECTIONS_PER_IP {
return false;
}
*count += 1;
true
}
/// Releases a slot taken by `try_reserve`.
fn release(&self, addr: IpAddr) {
let empty = if let Some(mut count) = self.connections.get_mut(&addr) {
*count = count.saturating_sub(1);
*count == 0
} else {
false
};
// Only remove once the guard above is dropped, otherwise this deadlocks.
if empty {
self.connections.remove_if(&addr, |_, count| *count == 0);
}
}
async fn send_update(&self, token: &str, data: &[u8]) {
if let Some(sender) = self.map.get(token).map(|v| v.clone())
&& let Err(e) = sender.send(Message::binary(data)).await
{
error!("Error sending WS update {e}");
// Clone the senders so the map isn't kept locked while sending.
let senders = self.map.get(token).map(|v| v.clone()).unwrap_or_default();
for (_, sender) in senders {
if let Err(e) = sender.send(Message::binary(data)).await {
error!("Error sending WS update {e}");
}
}
}
+57 -16
View File
@@ -1,3 +1,8 @@
#[path = "auth/send.rs"]
pub mod send;
pub type SendTokens = send::SendTokens;
pub type SendHeaders = send::SendHeaders;
use std::{
env,
net::IpAddr,
@@ -5,6 +10,7 @@ use std::{
};
use chrono::{DateTime, TimeDelta, Utc};
use ipnet::IpNet;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, errors::ErrorKind};
use num_traits::FromPrimitive;
use openssl::rsa::Rsa;
@@ -487,6 +493,16 @@ pub struct BasicJwtClaims {
pub sub: String,
}
impl BasicJwtClaims {
pub fn expires_in(&self) -> i64 {
self.exp - Utc::now().timestamp()
}
pub fn token(&self) -> String {
encode_jwt(&self)
}
}
pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims {
let time_now = Utc::now();
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
@@ -1039,12 +1055,44 @@ pub struct ClientIp {
pub ip: IpAddr,
}
/// Parses a single entry of `ip_header_trusted_proxies`, which can be a CIDR range or a plain IP.
pub fn parse_trusted_proxy(entry: &str) -> Option<IpNet> {
let entry = entry.trim();
match entry.parse::<IpNet>() {
Ok(net) => Some(net),
// Without a prefix length it is a single address, which is a valid way to write this.
Err(_) => entry.parse::<IpAddr>().ok().map(IpNet::from),
}
}
/// The client IP header can be set by anyone able to reach us, so only accept it from a proxy we trust.
fn ip_header_is_trusted(remote: Option<IpAddr>) -> bool {
let trusted = CONFIG.ip_header_trusted_proxies();
let trusted = trusted.trim();
if trusted.eq_ignore_ascii_case("all") {
return true;
}
let Some(remote) = remote else {
return false;
};
// A dual stack listener reports IPv4 clients as IPv4-mapped IPv6, which `is_global()` reports as
// non global. That is what we want when blocking outgoing requests, but here it would trust them.
let remote = remote.to_canonical();
if trusted.eq_ignore_ascii_case("local") {
return !crate::util::is_global(remote);
}
trusted.split(',').filter_map(parse_trusted_proxy).any(|net| net.contains(&remote))
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for ClientIp {
type Error = ();
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let ip = if CONFIG._ip_header_enabled() {
let remote = req.remote().map(|r| r.ip());
let ip = if CONFIG._ip_header_enabled() && ip_header_is_trusted(remote) {
req.headers().get_one(&CONFIG.ip_header()).and_then(|ip| {
match ip.find(',') {
Some(idx) => &ip[..idx],
@@ -1055,10 +1103,15 @@ impl<'r> FromRequest<'r> for ClientIp {
.ok()
})
} else {
if CONFIG._ip_header_enabled() && req.headers().get_one(&CONFIG.ip_header()).is_some() {
// Log the canonical IP, which is what the user filter will need to match against
let remote = remote.map(|ip| ip.to_canonical());
debug!("Ignoring the '{}' header, {remote:?} is not a trusted proxy", CONFIG.ip_header());
}
None
};
let ip = ip.or_else(|| req.remote().map(|r| r.ip())).unwrap_or_else(|| "0.0.0.0".parse().unwrap());
let ip = ip.or(remote).unwrap_or_else(|| "0.0.0.0".parse().unwrap());
Outcome::Success(ClientIp {
ip,
@@ -1253,20 +1306,8 @@ pub async fn refresh_tokens(
) -> ApiResult<(Device, AuthTokens)> {
let refresh_claims = match decode_refresh(refresh_token) {
Err(err) => {
error!("Failed to decode {} refresh_token: {refresh_token}: {err:?}", ip.ip);
//err_silent!(format!("Impossible to read refresh_token: {}", err.message()))
// If the token failed to decode, it was probably one of the old style tokens that was just a Base64 string.
// We can generate a claim for them for backwards compatibility. Note that the password refresh claims don't
// check expiration or issuer, so they're not included here.
RefreshJwtClaims {
nbf: 0,
exp: 0,
iss: String::new(),
sub: AuthMethod::Password,
device_token: refresh_token.into(),
token: None,
}
error!("Failed to decode refresh_token from {}: {err:?}", ip.ip);
err_silent!("Invalid refresh token")
}
Ok(claims) => claims,
};
+147
View File
@@ -0,0 +1,147 @@
use chrono::{TimeDelta, Utc};
use rocket::request::{FromRequest, Outcome, Request};
use crate::{
api::ApiResult,
auth,
auth::{BasicJwtClaims, ClientIp},
db::{
DbConn,
models::{Send, SendId},
},
error::{Error, ErrorKind},
};
fn generate_send_access_claims(send_id: &SendId) -> BasicJwtClaims {
let time_now = Utc::now();
BasicJwtClaims {
nbf: time_now.timestamp(),
exp: (time_now + TimeDelta::try_minutes(2).unwrap()).timestamp(),
iss: auth::JWT_SEND_ISSUER.to_string(),
sub: format!("{send_id}"),
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SendTokens {
pub access_claims: BasicJwtClaims,
}
impl SendTokens {
pub fn as_send_id(access_id: &str) -> Option<SendId> {
data_encoding::BASE64URL_NOPAD
.decode(access_id.as_bytes())
.ok()
.and_then(|uuid_vec| uuid::Uuid::from_slice(&uuid_vec).ok().map(|u| SendId::from(u.to_string())))
}
pub fn to_json(&self) -> serde_json::Value {
json!({
"access_token": self.access_claims.token(),
"expires_in": self.access_claims.expires_in(),
"token_type": "Bearer",
"scope": "api.send.access",
})
}
fn expected_error(msg: &str, error_type: &str) -> ApiResult<SendTokens> {
let err = json!({
"kind": "expected_server",
"error": "invalid_request",
"send_access_error_type": error_type,
});
Err(Error::new_msg(msg).with_kind(ErrorKind::Json(err)).silent())
}
fn invalid_error(msg: &str, error_type: &str, silent: bool) -> ApiResult<SendTokens> {
let err = json!({
"kind": "expected_server",
"error": "invalid_grant",
"send_access_error_type": error_type,
});
Err(Error::new_msg(msg).with_kind(ErrorKind::Json(err)).with_code(404).with_silent(silent))
}
pub async fn generate_tokens(
access_id: &str,
password: Option<String>,
ip: &ClientIp,
conn: &DbConn,
) -> ApiResult<SendTokens> {
let Some(send_id) = Self::as_send_id(access_id) else {
return Self::invalid_error(&format!("Can't convert {access_id}"), "send_id_invalid", false);
};
let Some(mut send) = Send::find_by_uuid(&send_id, conn).await else {
return Self::invalid_error(&format!("Can't find {send_id}"), "send_id_invalid", false);
};
if let Some(max_access_count) = send.max_access_count
&& send.access_count >= max_access_count
{
return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true);
}
if !send.is_accessible() {
return Self::invalid_error(&format!("Send {send_id}, not accessible"), "send_id_invalid", true);
}
if send.password_hash.is_some() {
match password {
Some(ref p) if send.check_password(p) => { /* Nothing to do here */ }
Some(_) => {
return Self::invalid_error(
&format!("Send {send_id}, Invalid password from {}", ip.ip),
"password_hash_b64_invalid",
false,
);
}
None => return Self::expected_error("Password required", "password_hash_b64_required"),
}
}
if !send.register_access(conn).await? {
return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true);
}
Ok(Self {
access_claims: generate_send_access_claims(&send_id),
})
}
}
pub struct SendHeaders {
pub send_id: SendId,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for SendHeaders {
type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = request.headers();
// Get access_token
let access_token: &str = if let Some(a) = headers.get_one("Authorization") {
if let Some(split) = a.rsplit("Bearer ").next() {
split
} else {
err_handler!("No access token provided")
}
} else {
err_handler!("No access token provided")
};
// Check JWT token is valid and get send_id
let Ok(claims) = auth::decode_send(access_token) else {
err_handler!("Invalid claim")
};
Outcome::Success(SendHeaders {
send_id: claims.sub.into(),
})
}
}
+25 -1
View File
@@ -666,6 +666,12 @@ make_config! {
ip_header: String, true, def, "X-Real-IP".to_owned();
/// Internal IP header property, used to avoid recomputing each time
_ip_header_enabled: bool, false, generated, |c| &c.ip_header.trim().to_lowercase() != "none";
/// Trusted proxies |> Which addresses the client IP header is accepted from. Requests from any
/// other address use the remote IP instead, so a client can't spoof the header.
/// Either the string "local" (the default, any non-global address, which covers a reverse proxy
/// running on the same host or container network), the string "all" to accept it from anywhere,
/// or a comma separated list of IPs and CIDR ranges.
ip_header_trusted_proxies: String, true, def, "local".to_owned();
/// Icon service |> The predefined icon services are: internal, bitwarden, duckduckgo, google.
/// To specify a custom icon service, set a URL template with exactly one instance of `{}`,
/// which is replaced with the domain. For example: `https://icon.example.com/domain/{}`.
@@ -768,6 +774,11 @@ make_config! {
/// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2
login_ratelimit_max_burst: u32, false, def, 10;
/// Seconds between unauthenticated requests |> Number of seconds, on average, between requests from the same IP address to any of the rate limited unauthenticated endpoints
unauthenticated_ratelimit_seconds: u64, false, def, 60;
/// Max burst size for unauthenticated requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `unauthenticated_ratelimit_seconds`. This is shared between several endpoints, so it needs to be more lenient than the login one
unauthenticated_ratelimit_max_burst: u32, false, def, 50;
/// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in
admin_ratelimit_seconds: u64, false, def, 300;
/// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds`
@@ -942,6 +953,18 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
}
}
let trusted_proxies = cfg.ip_header_trusted_proxies.trim();
if !trusted_proxies.eq_ignore_ascii_case("all") && !trusted_proxies.eq_ignore_ascii_case("local") {
for entry in trusted_proxies.split(',').filter(|e| !e.trim().is_empty()) {
if crate::auth::parse_trusted_proxy(entry).is_none() {
err!(format!(
"Invalid IP_HEADER_TRUSTED_PROXIES entry `{}`, expected an IP or CIDR range",
entry.trim()
));
}
}
}
if cfg.password_iterations < 100_000 {
err!("PASSWORD_ITERATIONS should be at least 100000 or higher. The default is 600000!");
}
@@ -956,7 +979,7 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
}
if cfg.database_min_conns > cfg.database_max_conns {
err!(format!("`DATABASE_MIN_CONNS` must be smaller than or equal to `DATABASE_MAX_CONNS`.",));
err!("`DATABASE_MIN_CONNS` must be smaller than or equal to `DATABASE_MAX_CONNS`.");
}
if let Some(log_file) = &cfg.log_file
@@ -1404,6 +1427,7 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
// Key Management Team
"ssh-key-vault-item",
"pm-25373-windows-biometrics-v2",
"pm-26340-linux-biometrics-v2",
// Mobile Team
"anon-addy-self-host-alias",
"simple-login-self-host-alias",
+16 -20
View File
@@ -42,13 +42,15 @@ pub struct Cipher {
pub key: Option<String>,
/*
Login = 1,
SecureNote = 2,
Card = 3,
Identity = 4,
SshKey = 5
*/
// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/Vault/Enums/CipherType.cs
// Login = 1,
// SecureNote = 2,
// Card = 3,
// Identity = 4,
// SSHKey = 5
// BankAccount = 6,
// DriversLicense = 7,
// Passport = 8,
pub atype: i32,
pub name: String,
pub notes: Option<String>,
@@ -306,21 +308,11 @@ impl Cipher {
type_data_json = Value::Null;
}
// Clone the type_data and add some default value.
let mut data_json = type_data_json.clone();
// NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream
// data_json should always contain the following keys with every atype
data_json["fields"] = json!(fields_json);
data_json["name"] = json!(self.name);
data_json["notes"] = json!(self.notes);
data_json["passwordHistory"] = Value::Array(password_history_json.clone());
let collection_ids = if let Some(cipher_sync_data) = cipher_sync_data {
if let Some(cipher_collections) = cipher_sync_data.cipher_collections.get(&self.uuid) {
Cow::from(cipher_collections)
} else {
Cow::from(Vec::with_capacity(0))
Cow::from(Vec::new())
}
} else {
Cow::from(self.get_admin_collections(user_uuid.clone(), conn).await)
@@ -355,8 +347,6 @@ impl Cipher {
"notes": self.notes,
"fields": fields_json,
"data": data_json,
"passwordHistory": password_history_json,
// All Cipher types are included by default as null, but only the matching one will be populated
@@ -365,6 +355,9 @@ impl Cipher {
"card": null,
"identity": null,
"sshKey": null,
"bankAccount": null,
"driversLicense": null,
"passport": null,
});
// These values are only needed for user/default syncs
@@ -404,6 +397,9 @@ impl Cipher {
3 => "card",
4 => "identity",
5 => "sshKey",
6 => "bankAccount",
7 => "driversLicense",
8 => "passport",
_ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)),
};
+47
View File
@@ -21,6 +21,7 @@ use super::{
User, UserId,
};
// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = collections)]
#[diesel(treat_none_as_null = true)]
@@ -71,6 +72,11 @@ impl Collection {
"id": self.uuid,
"organizationId": self.org_uuid,
"name": self.name,
// Collection types are either 0: SharedCollection or 1: DefaultUserCollection, of which we do not yet support DefaultUserCollection.
// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Enums/CollectionType.cs
"type": 0,
// This is only used together with MyItems/DefaultUserCollection, which we do not yet support.
"defaultUserCollectionEmail": null,
"object": "collection",
})
}
@@ -623,6 +629,47 @@ impl Collection {
pub async fn is_manageable_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool {
Self::is_coll_manageable_by_user(&self.uuid, user_uuid, conn).await
}
// Whether the user has manage access to at least one collection in the org, directly or via a
// group. Org-scoped counterpart of is_coll_manageable_by_user.
pub async fn has_manageable_collection_by_user(
org_uuid: &OrganizationId,
user_uuid: &UserId,
conn: &DbConn,
) -> bool {
let org_uuid = org_uuid.to_string();
let user_uuid = user_uuid.to_string();
conn.run(move |conn| {
collections::table
.left_join(
users_collections::table.on(users_collections::collection_uuid
.eq(collections::uuid)
.and(users_collections::user_uuid.eq(user_uuid.clone()))),
)
.left_join(
users_organizations::table.on(collections::org_uuid
.eq(users_organizations::org_uuid)
.and(users_organizations::user_uuid.eq(user_uuid))),
)
.left_join(groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)))
.left_join(
collections_groups::table.on(collections_groups::groups_uuid
.eq(groups_users::groups_uuid)
.and(collections_groups::collections_uuid.eq(collections::uuid))),
)
.filter(collections::org_uuid.eq(&org_uuid))
.filter(
// Manage permission on a collection assigned directly or via a group.
users_collections::manage.eq(true).or(collections_groups::manage.eq(true)),
)
.count()
.first::<i64>(conn)
.ok()
.unwrap_or(0)
!= 0
})
.await
}
}
/// Database methods
+3 -1
View File
@@ -271,7 +271,9 @@ impl Group {
groups::table
.inner_join(groups_users::table.on(groups_users::groups_uuid.eq(groups::uuid)))
.inner_join(
users_organizations::table.on(users_organizations::uuid.eq(groups_users::users_organizations_uuid)),
users_organizations::table.on(users_organizations::uuid
.eq(groups_users::users_organizations_uuid)
.and(users_organizations::org_uuid.eq(groups::organizations_uuid))),
)
.filter(users_organizations::user_uuid.eq(user_uuid))
.filter(groups::organizations_uuid.eq(org_uuid))
+1 -4
View File
@@ -34,10 +34,7 @@ pub use self::organization::{
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey,
OrganizationId,
};
pub use self::send::{
Send, SendType,
id::{SendFileId, SendId},
};
pub use self::send::{Send, SendFileId, SendId, SendType};
pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth};
pub use self::two_factor::{TwoFactor, TwoFactorType};
pub use self::two_factor_duo_context::TwoFactorDuoContext;
+15 -2
View File
@@ -217,11 +217,18 @@ impl Organization {
"useSecretsManager": false, // Not supported (Not AGPLv3 Licensed)
"selfHost": true,
"useApi": true,
"useDisableSMAdsForUsers": true, // Hide Secrets Manager ads
"useInviteLinks": false, // Not (yet) supported
"useMyItems": false, // Not (yet) supported
"useOrganizationDomains": false, // Not supported (Linked to SSO)
"usePam": false, // Not supported
"usePhishingBlocker": false,
"hasPublicAndPrivateKeys": self.private_key.is_some() && self.public_key.is_some(),
"useResetPassword": CONFIG.mail_enabled(),
"allowAdminAccessToAllCollectionItems": true,
"limitCollectionCreation": true,
"limitCollectionDeletion": true,
"limitItemDeletion": false,
"businessName": self.name,
"businessAddress1": null,
@@ -495,6 +502,12 @@ impl Membership {
"useActivateAutofillPolicy": false,
"useAdminSponsoredFamilies": false,
"useRiskInsights": false, // Not supported (Not AGPLv3 Licensed)
"useDisableSMAdsForUsers": true, // Hide Secrets Manager ads
"useInviteLinks": false, // Not (yet) supported
"useMyItems": false, // Not (yet) supported
"useOrganizationDomains": false, // Not supported (Linked to SSO)
"usePam": false, // Not supported
"usePhishingBlocker": false,
"organizationUserId": self.uuid,
"providerId": null,
@@ -550,7 +563,7 @@ impl Membership {
} else {
// The Bitwarden clients seem to call this API regardless of whether groups are enabled,
// so just act as if there are no groups.
Vec::with_capacity(0)
Vec::new()
};
// Check if a user is in a group which has access to all collections
@@ -604,7 +617,7 @@ impl Membership {
})
.collect()
} else {
Vec::with_capacity(0)
Vec::new()
};
// HACK: Convert the manager type to a custom type
+85 -43
View File
@@ -1,6 +1,10 @@
use std::path::Path;
use chrono::{NaiveDateTime, Utc};
use data_encoding::BASE64URL_NOPAD;
use derive_more::{AsRef, Deref, Display, From};
use diesel::prelude::*;
use macros::{IdFromParam, UuidFromParam};
use serde_json::Value;
use uuid::Uuid;
@@ -14,7 +18,6 @@ use crate::{
};
use super::{OrganizationId, User, UserId};
use id::SendId;
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = sends)]
@@ -161,7 +164,7 @@ impl Send {
"password": self.password_hash.as_deref().map(|h| BASE64URL_NOPAD.encode(h)),
"authType": if self.password_hash.is_some() { SendAuthType::Password as i32 } else { SendAuthType::None as i32 },
"disabled": self.disabled,
"hideEmail": self.hide_email,
"hideEmail": self.hide_email.unwrap_or(false),
"revisionDate": format_date(&self.revision_date),
"expirationDate": self.expiration_date.as_ref().map(format_date),
@@ -228,6 +231,53 @@ impl Send {
}
}
/// Registers an access, incrementing `access_count` only while below `max_access_count`.
/// Returns false when the limit was already reached. The check and the increment are a single
/// statement, otherwise concurrent accesses can both pass the check and exceed the limit.
pub async fn register_access(&mut self, conn: &DbConn) -> Result<bool, crate::Error> {
self.update_users_revision(conn).await;
let revision_date = Utc::now().naive_utc();
let uuid = self.uuid.clone();
let updated = conn
.run(move |conn| {
diesel::update(sends::table)
.filter(sends::uuid.eq(uuid))
.filter(
sends::max_access_count
.is_null()
.or(sends::access_count.nullable().lt(sends::max_access_count)),
)
.set((sends::access_count.eq(sends::access_count + 1), sends::revision_date.eq(revision_date)))
.execute(conn)
})
.await?;
if updated == 0 {
return Ok(false);
}
self.access_count += 1;
self.revision_date = revision_date;
Ok(true)
}
/// Whether the Send is currently within its validity window: not disabled, not past its
/// expiration date, and not past its deletion date. Does not consider `max_access_count`
/// (consumed at token issuance) or the password.
pub fn is_accessible(&self) -> bool {
let now = Utc::now().naive_utc();
if self.disabled {
return false;
}
if let Some(expiration) = self.expiration_date
&& now >= expiration
{
return false;
}
now < self.deletion_date
}
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
self.update_users_revision(conn).await;
@@ -335,47 +385,39 @@ impl Send {
}
}
// separate namespace to avoid name collision with std::marker::Send
pub mod id {
use derive_more::{AsRef, Deref, Display, From};
use macros::{IdFromParam, UuidFromParam};
use std::marker::Send;
use std::path::Path;
#[derive(
Clone,
Debug,
AsRef,
Deref,
DieselNewType,
Display,
From,
FromForm,
Hash,
PartialEq,
Eq,
Serialize,
Deserialize,
UuidFromParam,
)]
pub struct SendId(String);
#[derive(
Clone,
Debug,
AsRef,
Deref,
DieselNewType,
Display,
From,
FromForm,
Hash,
PartialEq,
Eq,
Serialize,
Deserialize,
UuidFromParam,
)]
pub struct SendId(String);
impl AsRef<Path> for SendId {
#[inline]
fn as_ref(&self) -> &Path {
Path::new(&self.0)
}
}
#[derive(
Clone, Debug, AsRef, Deref, Display, From, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize, IdFromParam,
)]
pub struct SendFileId(String);
impl AsRef<Path> for SendFileId {
#[inline]
fn as_ref(&self) -> &Path {
Path::new(&self.0)
}
impl AsRef<Path> for SendId {
#[inline]
fn as_ref(&self) -> &Path {
Path::new(&self.0)
}
}
#[derive(
Clone, Debug, AsRef, Deref, Display, From, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize, IdFromParam,
)]
pub struct SendFileId(String);
impl AsRef<Path> for SendFileId {
#[inline]
fn as_ref(&self) -> &Path {
Path::new(&self.0)
}
}
+17
View File
@@ -268,8 +268,25 @@ impl User {
UserStatus::Enabled
};
let account_keys = if self.private_key.is_some() {
json!({
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": self.private_key,
"publicKey": self.public_key,
"signedPublicKey": null,
"object": "publicKeyEncryptionKeyPair",
},
"securityState": null,
"signatureKeyPair": null,
"object": "privateKeys"
})
} else {
Value::Null
};
json!({
"_status": status as i32,
"accountKeys": account_keys,
"id": self.uuid,
"name": self.name,
"email": self.email,
+19 -5
View File
@@ -15,14 +15,14 @@ macro_rules! make_error {
#[derive(Debug)]
pub struct ErrorEvent { pub event: EventType }
pub struct Error { message: String, kind: ErrorKind, code: u16, event: Option<ErrorEvent> }
pub struct Error { message: String, kind: ErrorKind, code: u16, event: Option<ErrorEvent>, silent: bool }
$(impl From<$ty> for Error {
fn from(err: $ty) -> Self { Error::from((stringify!($name), err)) }
})+
$(impl<S: Into<String>> From<(S, $ty)> for Error {
fn from(val: (S, $ty)) -> Self {
Error { message: val.0.into(), kind: ErrorKind::$name(val.1), code: BAD_REQUEST, event: None }
Error { message: val.0.into(), kind: ErrorKind::$name(val.1), code: BAD_REQUEST, event: None, silent: false }
}
})+
impl StdError for Error {
@@ -172,6 +172,18 @@ impl Error {
pub fn message(&self) -> &str {
&self.message
}
#[must_use]
pub fn silent(mut self) -> Self {
self.silent = true;
self
}
#[must_use]
pub fn with_silent(mut self, silent: bool) -> Self {
self.silent = silent;
self
}
}
pub trait MapResult<S> {
@@ -309,9 +321,11 @@ use rocket::{
impl Responder<'_, 'static> for Error {
fn respond_to(self, _: &Request<'_>) -> response::Result<'static> {
match self.kind {
ErrorKind::Empty(_) | ErrorKind::Simple(_) | ErrorKind::Compact(_) => {} // Don't print the error in this situation
_ => error!(target: "error", "{self:#?}"),
if !self.silent {
match self.kind {
ErrorKind::Empty(_) | ErrorKind::Simple(_) | ErrorKind::Compact(_) => {} // Don't print the error in this situation
_ => error!(target: "error", "{self:#?}"),
}
}
let code = Status::from_code(self.code).unwrap_or(Status::BadRequest);
+57 -24
View File
@@ -18,7 +18,7 @@ use crate::{CONFIG, util::is_global};
pub fn make_http_request(method: reqwest::Method, url: &str) -> Result<reqwest::RequestBuilder, crate::Error> {
static INSTANCE: LazyLock<Client> =
LazyLock::new(|| get_reqwest_client_builder().build().expect("Failed to build client"));
LazyLock::new(|| get_reqwest_client_builder(true).build().expect("Failed to build client"));
let Ok(url) = url::Url::parse(url) else {
err!("Invalid URL");
@@ -32,7 +32,7 @@ pub fn make_http_request(method: reqwest::Method, url: &str) -> Result<reqwest::
Ok(INSTANCE.request(method, url))
}
pub fn get_reqwest_client_builder() -> ClientBuilder {
pub fn get_reqwest_client_builder(enforce_block: bool) -> ClientBuilder {
let mut headers = header::HeaderMap::new();
headers.insert(header::USER_AGENT, header::HeaderValue::from_static("Vaultwarden"));
@@ -55,7 +55,7 @@ pub fn get_reqwest_client_builder() -> ClientBuilder {
Client::builder()
.default_headers(headers)
.redirect(redirect_policy)
.dns_resolver(CustomDnsResolver::instance())
.dns_resolver(CustomDns::instance(enforce_block))
.timeout(Duration::from_secs(10))
}
@@ -174,6 +174,27 @@ pub enum CustomHttpClientError {
}
impl CustomHttpClientError {
/// Attach the domain that resolved to this address, which `should_block_host()` can't know.
fn with_domain(self, name: &str) -> Self {
match self {
Self::NonGlobalIp {
ip,
..
} => Self::NonGlobalIp {
domain: Some(name.to_owned()),
ip,
},
Self::Blocked {
domain,
} => Self::Blocked {
domain: format!("{name} ({domain})"),
},
other @ Self::Invalid {
..
} => other,
}
}
pub fn downcast_ref(e: &dyn std::error::Error) -> Option<&Self> {
let mut source = e.source();
@@ -210,6 +231,11 @@ impl fmt::Display for CustomHttpClientError {
impl std::error::Error for CustomHttpClientError {}
pub struct CustomDns {
enforce_block: bool,
resolver: Arc<CustomDnsResolver>,
}
#[derive(Debug, Clone)]
enum CustomDnsResolver {
Default(),
@@ -217,12 +243,18 @@ enum CustomDnsResolver {
}
type BoxError = Box<dyn std::error::Error + Send + Sync>;
impl CustomDnsResolver {
fn instance() -> Arc<Self> {
impl CustomDns {
fn instance(enforce_block: bool) -> Self {
static INSTANCE: LazyLock<Arc<CustomDnsResolver>> = LazyLock::new(CustomDnsResolver::new);
Arc::clone(&*INSTANCE)
}
CustomDns {
enforce_block,
resolver: Arc::clone(&*INSTANCE),
}
}
}
impl CustomDnsResolver {
fn new() -> Arc<Self> {
TokioResolver::builder(TokioRuntimeProvider::default())
.and_then(|mut builder| {
@@ -239,30 +271,32 @@ impl CustomDnsResolver {
}
// Note that we get an iterator of addresses, but we only grab the first one for convenience
async fn resolve_domain(&self, name: &str) -> Result<Vec<SocketAddr>, BoxError> {
pre_resolve(name)?;
async fn resolve_domain(&self, name: &str, enforce_block: bool) -> Result<Vec<SocketAddr>, BoxError> {
pre_resolve(name, enforce_block)?;
let results: Vec<SocketAddr> = match self {
Self::Default() => tokio::net::lookup_host((name, 0)).await?.collect(),
Self::Hickory(r) => r.lookup_ip(name).await?.iter().map(|i| SocketAddr::new(i, 0)).collect(),
};
for addr in &results {
post_resolve(name, addr.ip())?;
if enforce_block {
for addr in &results {
post_resolve(name, addr.ip())?;
}
}
Ok(results)
}
}
fn pre_resolve(name: &str) -> Result<(), CustomHttpClientError> {
fn pre_resolve(name: &str, enforce_block: bool) -> Result<(), CustomHttpClientError> {
let Ok(host) = get_valid_host(name) else {
return Err(CustomHttpClientError::Invalid {
domain: name.to_owned(),
});
};
if should_block_host(&host).is_err() {
if enforce_block && should_block_host(&host).is_err() {
return Err(CustomHttpClientError::Blocked {
domain: name.to_owned(),
});
@@ -272,22 +306,21 @@ fn pre_resolve(name: &str) -> Result<(), CustomHttpClientError> {
}
fn post_resolve(name: &str, ip: IpAddr) -> Result<(), CustomHttpClientError> {
if should_block_ip(ip) {
Err(CustomHttpClientError::NonGlobalIp {
domain: Some(name.to_owned()),
ip,
})
} else {
Ok(())
}
let host: Host<&str> = match ip {
IpAddr::V4(ip) => Host::Ipv4(ip),
IpAddr::V6(ip) => Host::Ipv6(ip),
};
should_block_host(&host).map_err(|e| e.with_domain(name))
}
impl Resolve for CustomDnsResolver {
impl Resolve for CustomDns {
fn resolve(&self, name: Name) -> Resolving {
let this = self.clone();
let enforce_block = self.enforce_block;
let this = Arc::clone(&self.resolver);
Box::pin(async move {
let name = name.as_str();
let results = this.resolve_domain(name).await?;
let results = this.resolve_domain(name, enforce_block).await?;
if results.is_empty() {
warn!("Unable to resolve {name} to any valid IP address");
}
+1 -1
View File
@@ -2,7 +2,7 @@
// The recursion_limit is mainly triggered by the json!() macro.
// The more key/value pairs there are the more recursion occurs.
// We want to keep this as low as possible!
#![recursion_limit = "165"]
#![recursion_limit = "192"]
// When enabled use MiMalloc as malloc instead of the default malloc
#[cfg(feature = "enable_mimalloc")]
+18
View File
@@ -18,6 +18,24 @@ static LIMITER_ADMIN: LazyLock<Limiter> = LazyLock::new(|| {
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst))
});
static LIMITER_UNAUTHENTICATED: LazyLock<Limiter> = LazyLock::new(|| {
let seconds = Duration::from_secs(CONFIG.unauthenticated_ratelimit_seconds());
let burst = NonZeroU32::new(CONFIG.unauthenticated_ratelimit_max_burst())
.expect("Non-zero unauthenticated ratelimit burst");
RateLimiter::keyed(
Quota::with_period(seconds).expect("Non-zero unauthenticated ratelimit seconds").allow_burst(burst),
)
});
pub fn check_limit_unauthenticated(ip: &IpAddr) -> Result<(), Error> {
match LIMITER_UNAUTHENTICATED.check_key(ip) {
Ok(()) => Ok(()),
Err(_e) => {
err_code!("Too many requests", 429);
}
}
}
pub fn check_limit_login(ip: &IpAddr) -> Result<(), Error> {
match LIMITER_LOGIN.check_key(ip) {
Ok(()) => Ok(()),
+35 -14
View File
@@ -1,16 +1,16 @@
use std::{borrow::Cow, future::Future, pin::Pin, sync::LazyLock, time::Duration};
use std::{borrow::Cow, collections::HashSet, future::Future, pin::Pin, sync::LazyLock, time::Duration};
use openidconnect::{
AccessToken, AsyncHttpClient, AuthDisplay, AuthPrompt, AuthenticationFlow, AuthorizationCode, AuthorizationRequest,
ClientId, ClientSecret, CsrfToken, EmptyAdditionalClaims, EmptyExtraTokenFields, EndpointNotSet, EndpointSet,
HttpClientError, HttpRequest, HttpResponse, IdTokenClaims, IdTokenFields, Nonce, OAuth2TokenResponse,
PkceCodeChallenge, PkceCodeVerifier, RefreshToken, ResponseType, Scope, StandardErrorResponse,
AccessToken, AsyncHttpClient, AuthDisplay, AuthPrompt, AuthType, AuthenticationFlow, AuthorizationCode,
AuthorizationRequest, ClientId, ClientSecret, CsrfToken, EmptyAdditionalClaims, EmptyExtraTokenFields,
EndpointNotSet, EndpointSet, HttpClientError, HttpRequest, HttpResponse, IdTokenClaims, IdTokenFields, Nonce,
OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RefreshToken, ResponseType, Scope, StandardErrorResponse,
StandardTokenResponse,
core::{
CoreAuthDisplay, CoreAuthPrompt, CoreClient, CoreErrorResponseType, CoreGenderClaim, CoreIdTokenVerifier,
CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreJwsSigningAlgorithm, CoreProviderMetadata,
CoreResponseType, CoreRevocableToken, CoreRevocationErrorResponse, CoreTokenIntrospectionResponse,
CoreTokenResponse, CoreTokenType, CoreUserInfoClaims,
CoreAuthDisplay, CoreAuthPrompt, CoreClient, CoreClientAuthMethod, CoreErrorResponseType, CoreGenderClaim,
CoreIdTokenVerifier, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreJwsSigningAlgorithm,
CoreProviderMetadata, CoreResponseType, CoreRevocableToken, CoreRevocationErrorResponse,
CoreTokenIntrospectionResponse, CoreTokenResponse, CoreTokenType, CoreUserInfoClaims,
},
http, url,
};
@@ -71,7 +71,7 @@ pub struct OidcHttpClient {
impl OidcHttpClient {
fn new() -> Result<Self, reqwest::Error> {
get_reqwest_client_builder().redirect(reqwest::redirect::Policy::none()).build().map(|client| Self {
get_reqwest_client_builder(false).redirect(reqwest::redirect::Policy::none()).build().map(|client| Self {
client,
})
}
@@ -83,7 +83,10 @@ impl<'c> AsyncHttpClient<'c> for OidcHttpClient {
fn call(&'c self, request: HttpRequest) -> Self::Future {
Box::pin(async move {
let response = self.client.execute(request.try_into().map_err(Box::new)?).await.map_err(Box::new)?;
let response = self.client.execute(request.try_into().map_err(Box::new)?).await.map_err(|e| {
debug!("Request failed {e:?}");
Box::new(e)
})?;
let mut builder = http::Response::builder().status(response.status()).version(response.version());
@@ -91,7 +94,11 @@ impl<'c> AsyncHttpClient<'c> for OidcHttpClient {
builder = builder.header(name, value);
}
builder.body(response.bytes().await.map_err(Box::new)?.to_vec()).map_err(HttpClientError::Http)
let body = response.bytes().await.map_err(Box::new)?;
if CONFIG.sso_debug_tokens() {
debug!("Response body {}", String::from_utf8_lossy(&body));
}
builder.body(body.to_vec()).map_err(HttpClientError::Http)
})
}
}
@@ -114,7 +121,21 @@ impl Client {
Ok(metadata) => metadata,
};
let base_client = CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret));
let auth_methods: Option<HashSet<CoreClientAuthMethod>> = provider_metadata
.token_endpoint_auth_methods_supported()
.map(|v| v.iter().map(ToOwned::to_owned).collect());
let mut base_client = CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret));
if let Some(am) = auth_methods {
if am.contains(&CoreClientAuthMethod::ClientSecretBasic) {
base_client = base_client.set_auth_type(AuthType::BasicAuth); // Default
} else if am.contains(&CoreClientAuthMethod::ClientSecretPost) {
base_client = base_client.set_auth_type(AuthType::RequestBody);
} else {
err!(format!("No supported auth_methods (only basic or request body), advertised: {am:?}"));
}
}
let token_uri = if let Some(uri) = base_client.token_uri() {
uri.clone()
@@ -219,7 +240,7 @@ impl Client {
} else {
let challenge = PkceCodeChallenge::from_code_verifier_sha256(&verifier);
if challenge.as_str() != String::from(sso_auth.client_challenge.clone()) {
err!(format!("PKCE client challenge failed"))
err!("PKCE client challenge failed")
// Might need to notify admin ? how ?
}
}
@@ -116,8 +116,8 @@ app-security > app-two-factor-setup > form {
}
/* Hide unsupported Custom Role options */
bit-dialog div.tw-ml-4:has(bit-form-control input),
bit-dialog div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) {
:is(bit-dialog, [bit-dialog]) div.tw-ml-4:has(bit-form-control input),
:is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) {
@extend %vw-hide;
}