Browse Source

Merge branch 'main' into jueti-patch-1

pull/7236/head
Jason Yang 6 days ago
committed by GitHub
parent
commit
c7d6abe849
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 35
      .env.template
  2. 1
      .gitattributes
  3. 37
      .github/workflows/build.yml
  4. 2
      .github/workflows/check-templates.yml
  5. 4
      .github/workflows/hadolint.yml
  6. 93
      .github/workflows/release.yml
  7. 6
      .github/workflows/trivy.yml
  8. 4
      .github/workflows/typos.yml
  9. 4
      .github/workflows/zizmor.yml
  10. 108
      .pre-commit-config.yaml
  11. 2
      .typos.toml
  12. 1733
      Cargo.lock
  13. 77
      Cargo.toml
  14. 5
      README.md
  15. 12
      build.rs
  16. 6
      docker/DockerSettings.yaml
  17. 21
      docker/Dockerfile.alpine
  18. 58
      docker/Dockerfile.debian
  19. 54
      docker/Dockerfile.j2
  20. 4
      macros/Cargo.toml
  21. 1
      migrations/mysql/2026-03-09-005927_add_archives/down.sql
  22. 10
      migrations/mysql/2026-03-09-005927_add_archives/up.sql
  23. 1
      migrations/mysql/2026-04-25-120000_sso_auth_binding/down.sql
  24. 1
      migrations/mysql/2026-04-25-120000_sso_auth_binding/up.sql
  25. 1
      migrations/mysql/2026-05-05-120000_sso_auth_error/down.sql
  26. 1
      migrations/mysql/2026-05-05-120000_sso_auth_error/up.sql
  27. 1
      migrations/postgresql/2026-03-09-005927_add_archives/down.sql
  28. 8
      migrations/postgresql/2026-03-09-005927_add_archives/up.sql
  29. 1
      migrations/postgresql/2026-04-25-120000_sso_auth_binding/down.sql
  30. 1
      migrations/postgresql/2026-04-25-120000_sso_auth_binding/up.sql
  31. 1
      migrations/postgresql/2026-05-05-120000_sso_auth_error/down.sql
  32. 1
      migrations/postgresql/2026-05-05-120000_sso_auth_error/up.sql
  33. 1
      migrations/sqlite/2026-03-09-005927_add_archives/down.sql
  34. 8
      migrations/sqlite/2026-03-09-005927_add_archives/up.sql
  35. 1
      migrations/sqlite/2026-04-25-120000_sso_auth_binding/down.sql
  36. 1
      migrations/sqlite/2026-04-25-120000_sso_auth_binding/up.sql
  37. 1
      migrations/sqlite/2026-05-05-120000_sso_auth_error/down.sql
  38. 1
      migrations/sqlite/2026-05-05-120000_sso_auth_error/up.sql
  39. 2
      rust-toolchain.toml
  40. 33
      src/api/admin.rs
  41. 77
      src/api/core/accounts.rs
  42. 318
      src/api/core/ciphers.rs
  43. 2
      src/api/core/emergency_access.rs
  44. 2
      src/api/core/events.rs
  45. 2
      src/api/core/folders.rs
  46. 33
      src/api/core/mod.rs
  47. 295
      src/api/core/organizations.rs
  48. 2
      src/api/core/public.rs
  49. 4
      src/api/core/sends.rs
  50. 7
      src/api/core/two_factor/email.rs
  51. 49
      src/api/core/two_factor/mod.rs
  52. 29
      src/api/core/two_factor/webauthn.rs
  53. 120
      src/api/icons.rs
  54. 256
      src/api/identity.rs
  55. 9
      src/api/notifications.rs
  56. 14
      src/api/push.rs
  57. 89
      src/auth.rs
  58. 230
      src/config.rs
  59. 7
      src/crypto.rs
  60. 52
      src/db/mod.rs
  61. 91
      src/db/models/archive.rs
  62. 4
      src/db/models/attachment.rs
  63. 74
      src/db/models/cipher.rs
  64. 22
      src/db/models/collection.rs
  65. 34
      src/db/models/device.rs
  66. 5
      src/db/models/emergency_access.rs
  67. 82
      src/db/models/group.rs
  68. 4
      src/db/models/mod.rs
  69. 2
      src/db/models/org_policy.rs
  70. 7
      src/db/models/organization.rs
  71. 13
      src/db/models/send.rs
  72. 38
      src/db/models/sso_auth.rs
  73. 1
      src/db/models/two_factor.rs
  74. 12
      src/db/models/user.rs
  75. 13
      src/db/schema.rs
  76. 327
      src/http_client.rs
  77. 51
      src/main.rs
  78. 35
      src/sso.rs
  79. 92
      src/sso_client.rs
  80. 11
      src/static/global_domains.json
  81. 15
      src/static/scripts/admin.css
  82. 7
      src/static/scripts/admin_diagnostics.js
  83. 4
      src/static/scripts/datatables.css
  84. 52
      src/static/scripts/datatables.js
  85. 2
      src/static/templates/admin/base.hbs
  86. 10
      src/static/templates/admin/diagnostics.hbs
  87. 2
      src/static/templates/admin/login.hbs
  88. 2
      src/static/templates/admin/organizations.hbs
  89. 2
      src/static/templates/admin/settings.hbs
  90. 4
      src/static/templates/admin/users.hbs
  91. 8
      src/static/templates/scss/vaultwarden.scss.hbs
  92. 297
      src/storage.rs
  93. 77
      src/util.rs
  94. 1
      tools/global_domains.py

35
.env.template

@ -50,10 +50,11 @@
######################### #########################
## Database URL ## Database URL
## When using SQLite, this is the path to the DB file, and it defaults to ## When using SQLite, this should use the sqlite:// scheme followed by the path
## %DATA_FOLDER%/db.sqlite3. If DATA_FOLDER is set to an external location, this ## to the DB file. It defaults to sqlite://%DATA_FOLDER%/db.sqlite3.
## must be set to a local sqlite3 file path. ## Bare paths without the sqlite:// scheme are supported for backwards compatibility,
# DATABASE_URL=data/db.sqlite3 ## but only if the database file already exists.
# DATABASE_URL=sqlite://data/db.sqlite3
## When using MySQL, specify an appropriate connection URI. ## When using MySQL, specify an appropriate connection URI.
## Details: https://docs.diesel.rs/2.1.x/diesel/mysql/struct.MysqlConnection.html ## Details: https://docs.diesel.rs/2.1.x/diesel/mysql/struct.MysqlConnection.html
# DATABASE_URL=mysql://user:password@host[:port]/database_name # DATABASE_URL=mysql://user:password@host[:port]/database_name
@ -372,16 +373,22 @@
## Note that clients cache the /api/config endpoint for about 1 hour and it could take some time before they are enabled or disabled! ## Note that clients cache the /api/config endpoint for about 1 hour and it could take some time before they are enabled or disabled!
## ##
## The following flags are available: ## The following flags are available:
## - "inline-menu-positioning-improvements": Enable the use of inline menu password generator and identity suggestions in the browser extension. ## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0)
## - "inline-menu-totp": Enable the use of inline menu TOTP codes in the browser extension. ## - "ssh-agent": Enable SSH agent support on Desktop. (Desktop >= 2024.12.0)
## - "ssh-agent": Enable SSH agent support on Desktop. (Needs desktop >=2024.12.0) ## - "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. (Needs clients >=2024.12.0) ## - "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. (Needs desktop >= 2025.11.0) ## - "pm-25373-windows-biometrics-v2": Enable the new implementation of biometrics on Windows. (Desktop >= 2025.11.0)
## - "export-attachments": Enable support for exporting attachments (Clients >=2025.4.0) ## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0)
## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Needs 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)
## - "simple-login-self-host-alias": Enable configuring self-hosted Simple Login alias generator. (Needs Android >=2025.3.0, iOS >=2025.4.0) ## - "mutual-tls": Enable the use of mutual TLS on Android (Clients >= 2025.2.0)
## - "mutual-tls": Enable the use of mutual TLS on Android (Client >= 2025.2.0) ## - "cxp-import-mobile": Enable the import via CXP on iOS (Clients >= 2025.9.2)
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=fido2-vault-credentials ## - "cxp-export-mobile": Enable the export via CXP on iOS (Clients >= 2025.9.2)
## - "pm-30529-webauthn-related-origins":
## - "desktop-ui-migration-milestone-1": Special feature flag for desktop UI (Desktop >= 2026.2.0)
## - "desktop-ui-migration-milestone-2": Special feature flag for desktop UI (Desktop >= 2026.2.0)
## - "desktop-ui-migration-milestone-3": Special feature flag for desktop UI (Desktop >= 2026.2.0)
## - "desktop-ui-migration-milestone-4": Special feature flag for desktop UI (Desktop >= 2026.2.0)
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
## Require new device emails. When a user logs in an email is required to be sent. ## Require new device emails. When a user logs in an email is required to be sent.
## If sending the email fails the login attempt will fail!! ## If sending the email fails the login attempt will fail!!

1
.gitattributes

@ -1,3 +1,2 @@
# Ignore vendored scripts in GitHub stats # Ignore vendored scripts in GitHub stats
src/static/scripts/* linguist-vendored src/static/scripts/* linguist-vendored

37
.github/workflows/build.yml

@ -64,7 +64,7 @@ jobs:
# Checkout the repo # Checkout the repo
- name: "Checkout" - name: "Checkout"
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
fetch-depth: 0 fetch-depth: 0
@ -87,32 +87,23 @@ jobs:
# End Determine rust-toolchain version # End Determine rust-toolchain version
# Only install the clippy and rustfmt components on the default rust-toolchain - name: "Install toolchain ${{steps.toolchain.outputs.RUST_TOOLCHAIN}} as default"
- name: "Install rust-toolchain version"
uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # master @ Feb 13, 2026, 3:46 AM GMT+1
if: ${{ matrix.channel == 'rust-toolchain' }}
with:
toolchain: "${{steps.toolchain.outputs.RUST_TOOLCHAIN}}"
components: clippy, rustfmt
# End Uses the rust-toolchain file to determine version
# Install the any other channel to be used for which we do not execute clippy and rustfmt
- name: "Install MSRV version"
uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9 # master @ Feb 13, 2026, 3:46 AM GMT+1
if: ${{ matrix.channel != 'rust-toolchain' }}
with:
toolchain: "${{steps.toolchain.outputs.RUST_TOOLCHAIN}}"
# End Install the MSRV channel to be used
# Set the current matrix toolchain version as default
- name: "Set toolchain ${{steps.toolchain.outputs.RUST_TOOLCHAIN}} as default"
env: env:
CHANNEL: ${{ matrix.channel }}
RUST_TOOLCHAIN: ${{steps.toolchain.outputs.RUST_TOOLCHAIN}} RUST_TOOLCHAIN: ${{steps.toolchain.outputs.RUST_TOOLCHAIN}}
run: | run: |
# Remove the rust-toolchain.toml # Remove the rust-toolchain.toml
rm rust-toolchain.toml rm rust-toolchain.toml
# Set the default
# Install the correct toolchain version
rustup toolchain install "${RUST_TOOLCHAIN}" --profile minimal --no-self-update
# If this matrix is the `rust-toolchain` flow, also install rustfmt and clippy
if [[ "${CHANNEL}" == 'rust-toolchain' ]]; then
rustup component add --toolchain "${RUST_TOOLCHAIN}" rustfmt clippy
fi
# Set as the default toolchain
rustup default "${RUST_TOOLCHAIN}" rustup default "${RUST_TOOLCHAIN}"
# Show environment # Show environment
@ -124,7 +115,7 @@ jobs:
# Enable Rust Caching # Enable Rust Caching
- name: Rust Caching - name: Rust Caching
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with: with:
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes. # Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes.
# Like changing the build host from Ubuntu 20.04 to 22.04 for example. # Like changing the build host from Ubuntu 20.04 to 22.04 for example.

2
.github/workflows/check-templates.yml

@ -20,7 +20,7 @@ jobs:
steps: steps:
# Checkout the repo # Checkout the repo
- name: "Checkout" - name: "Checkout"
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
# End Checkout the repo # End Checkout the repo

4
.github/workflows/hadolint.yml

@ -20,7 +20,7 @@ jobs:
steps: steps:
# Start Docker Buildx # Start Docker Buildx
- name: Setup Docker Buildx - name: Setup Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
# https://github.com/moby/buildkit/issues/3969 # https://github.com/moby/buildkit/issues/3969
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills # Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
with: with:
@ -40,7 +40,7 @@ jobs:
# End Download hadolint # End Download hadolint
# Checkout the repo # Checkout the repo
- name: Checkout - name: Checkout
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
# End Checkout the repo # End Checkout the repo

93
.github/workflows/release.yml

@ -20,23 +20,27 @@ defaults:
run: run:
shell: bash shell: bash
env: # A "release" environment must be created in the repository settings
# The *_REPO variables need to be configured as repository variables # (Settings > Environments > New environment) with the following
# Append `/settings/variables/actions` to your repo url # variables and secrets configured as needed.
# DOCKERHUB_REPO needs to be 'index.docker.io/<user>/<repo>' #
# Check for Docker hub credentials in secrets # Variables (only set the ones for registries you want to push to):
HAVE_DOCKERHUB_LOGIN: ${{ vars.DOCKERHUB_REPO != '' && secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }} # DOCKERHUB_REPO: 'index.docker.io/<user>/<repo>'
# GHCR_REPO needs to be 'ghcr.io/<user>/<repo>' # QUAY_REPO: 'quay.io/<user>/<repo>'
# Check for Github credentials in secrets # GHCR_REPO: 'ghcr.io/<user>/<repo>'
HAVE_GHCR_LOGIN: ${{ vars.GHCR_REPO != '' && github.repository_owner != '' && secrets.GITHUB_TOKEN != '' }} #
# QUAY_REPO needs to be 'quay.io/<user>/<repo>' # Secrets (only required when the corresponding *_REPO variable is set):
# Check for Quay.io credentials in secrets # DOCKERHUB_REPO => DOCKERHUB_USERNAME, DOCKERHUB_TOKEN
HAVE_QUAY_LOGIN: ${{ vars.QUAY_REPO != '' && secrets.QUAY_USERNAME != '' && secrets.QUAY_TOKEN != '' }} # QUAY_REPO => QUAY_USERNAME, QUAY_TOKEN
# GITHUB_TOKEN is provided automatically
jobs: jobs:
docker-build: docker-build:
name: Build Vaultwarden containers name: Build Vaultwarden containers
# if: ${{ github.repository == 'dani-garcia/vaultwarden' }} # if: ${{ github.repository == 'dani-garcia/vaultwarden' }}
environment:
name: release
deployment: false
permissions: permissions:
packages: write # Needed to upload packages and artifacts packages: write # Needed to upload packages and artifacts
contents: read contents: read
@ -54,13 +58,13 @@ jobs:
steps: steps:
- name: Initialize QEMU binfmt support - name: Initialize QEMU binfmt support
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
with: with:
platforms: "arm64,arm" platforms: "arm64,arm"
# Start Docker Buildx # Start Docker Buildx
- name: Setup Docker Buildx - name: Setup Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
# https://github.com/moby/buildkit/issues/3969 # https://github.com/moby/buildkit/issues/3969
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills # Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
with: with:
@ -73,7 +77,7 @@ jobs:
# Checkout the repo # Checkout the repo
- name: Checkout - name: Checkout
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# We need fetch-depth of 0 so we also get all the tag metadata # We need fetch-depth of 0 so we also get all the tag metadata
with: with:
persist-credentials: false persist-credentials: false
@ -110,14 +114,14 @@ jobs:
# Login to Docker Hub # Login to Docker Hub
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' }} if: ${{ vars.DOCKERHUB_REPO != '' }}
- name: Add registry for DockerHub - name: Add registry for DockerHub
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' }} if: ${{ vars.DOCKERHUB_REPO != '' }}
env: env:
DOCKERHUB_REPO: ${{ vars.DOCKERHUB_REPO }} DOCKERHUB_REPO: ${{ vars.DOCKERHUB_REPO }}
run: | run: |
@ -125,15 +129,15 @@ jobs:
# Login to GitHub Container Registry # Login to GitHub Container Registry
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
if: ${{ env.HAVE_GHCR_LOGIN == 'true' }} if: ${{ vars.GHCR_REPO != '' }}
- name: Add registry for ghcr.io - name: Add registry for ghcr.io
if: ${{ env.HAVE_GHCR_LOGIN == 'true' }} if: ${{ vars.GHCR_REPO != '' }}
env: env:
GHCR_REPO: ${{ vars.GHCR_REPO }} GHCR_REPO: ${{ vars.GHCR_REPO }}
run: | run: |
@ -141,15 +145,15 @@ jobs:
# Login to Quay.io # Login to Quay.io
- name: Login to Quay.io - name: Login to Quay.io
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with: with:
registry: quay.io registry: quay.io
username: ${{ secrets.QUAY_USERNAME }} username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_TOKEN }} password: ${{ secrets.QUAY_TOKEN }}
if: ${{ env.HAVE_QUAY_LOGIN == 'true' }} if: ${{ vars.QUAY_REPO != '' }}
- name: Add registry for Quay.io - name: Add registry for Quay.io
if: ${{ env.HAVE_QUAY_LOGIN == 'true' }} if: ${{ vars.QUAY_REPO != '' }}
env: env:
QUAY_REPO: ${{ vars.QUAY_REPO }} QUAY_REPO: ${{ vars.QUAY_REPO }}
run: | run: |
@ -163,7 +167,7 @@ jobs:
run: | run: |
# #
# Check if there is a GitHub Container Registry Login and use it for caching # Check if there is a GitHub Container Registry Login and use it for caching
#if [[ -n "${HAVE_GHCR_LOGIN}" ]]; then #if [[ -n "${GHCR_REPO}" ]]; then
# echo "BAKE_CACHE_FROM=type=registry,ref=${GHCR_REPO}-buildcache:${BASE_IMAGE}-${NORMALIZED_ARCH}" | tee -a "${GITHUB_ENV}" # echo "BAKE_CACHE_FROM=type=registry,ref=${GHCR_REPO}-buildcache:${BASE_IMAGE}-${NORMALIZED_ARCH}" | tee -a "${GITHUB_ENV}"
# echo "BAKE_CACHE_TO=type=registry,ref=${GHCR_REPO}-buildcache:${BASE_IMAGE}-${NORMALIZED_ARCH},compression=zstd,mode=max" | tee -a "${GITHUB_ENV}" # echo "BAKE_CACHE_TO=type=registry,ref=${GHCR_REPO}-buildcache:${BASE_IMAGE}-${NORMALIZED_ARCH},compression=zstd,mode=max" | tee -a "${GITHUB_ENV}"
#else #else
@ -189,7 +193,7 @@ jobs:
- name: Bake ${{ matrix.base_image }} containers - name: Bake ${{ matrix.base_image }} containers
id: bake_vw id: bake_vw
uses: docker/bake-action@5be5f02ff8819ecd3092ea6b2e6261c31774f2b4 # v6.10.0 uses: docker/bake-action@a66e1c87e2eca0503c343edf1d208c716d54b8a8 # v7.1.0
env: env:
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}" BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}" SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
@ -224,7 +228,7 @@ jobs:
touch "${RUNNER_TEMP}/digests/${digest#sha256:}" touch "${RUNNER_TEMP}/digests/${digest#sha256:}"
- name: Upload digest - name: Upload digest
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: digests-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }} name: digests-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
path: ${{ runner.temp }}/digests/* path: ${{ runner.temp }}/digests/*
@ -289,6 +293,9 @@ jobs:
name: Merge manifests name: Merge manifests
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: docker-build needs: docker-build
environment:
name: release
deployment: false
permissions: permissions:
packages: write # Needed to upload packages and artifacts packages: write # Needed to upload packages and artifacts
attestations: write # Needed to generate an artifact attestation for a build attestations: write # Needed to generate an artifact attestation for a build
@ -299,7 +306,7 @@ jobs:
steps: steps:
- name: Download digests - name: Download digests
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
path: ${{ runner.temp }}/digests path: ${{ runner.temp }}/digests
pattern: digests-*-${{ matrix.base_image }} pattern: digests-*-${{ matrix.base_image }}
@ -307,14 +314,14 @@ jobs:
# Login to Docker Hub # Login to Docker Hub
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' }} if: ${{ vars.DOCKERHUB_REPO != '' }}
- name: Add registry for DockerHub - name: Add registry for DockerHub
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' }} if: ${{ vars.DOCKERHUB_REPO != '' }}
env: env:
DOCKERHUB_REPO: ${{ vars.DOCKERHUB_REPO }} DOCKERHUB_REPO: ${{ vars.DOCKERHUB_REPO }}
run: | run: |
@ -322,15 +329,15 @@ jobs:
# Login to GitHub Container Registry # Login to GitHub Container Registry
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
if: ${{ env.HAVE_GHCR_LOGIN == 'true' }} if: ${{ vars.GHCR_REPO != '' }}
- name: Add registry for ghcr.io - name: Add registry for ghcr.io
if: ${{ env.HAVE_GHCR_LOGIN == 'true' }} if: ${{ vars.GHCR_REPO != '' }}
env: env:
GHCR_REPO: ${{ vars.GHCR_REPO }} GHCR_REPO: ${{ vars.GHCR_REPO }}
run: | run: |
@ -338,15 +345,15 @@ jobs:
# Login to Quay.io # Login to Quay.io
- name: Login to Quay.io - name: Login to Quay.io
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with: with:
registry: quay.io registry: quay.io
username: ${{ secrets.QUAY_USERNAME }} username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_TOKEN }} password: ${{ secrets.QUAY_TOKEN }}
if: ${{ env.HAVE_QUAY_LOGIN == 'true' }} if: ${{ vars.QUAY_REPO != '' }}
- name: Add registry for Quay.io - name: Add registry for Quay.io
if: ${{ env.HAVE_QUAY_LOGIN == 'true' }} if: ${{ vars.QUAY_REPO != '' }}
env: env:
QUAY_REPO: ${{ vars.QUAY_REPO }} QUAY_REPO: ${{ vars.QUAY_REPO }}
run: | run: |
@ -399,24 +406,24 @@ jobs:
# Attest container images # Attest container images
- name: Attest - docker.io - ${{ matrix.base_image }} - name: Attest - docker.io - ${{ matrix.base_image }}
if: ${{ env.HAVE_DOCKERHUB_LOGIN == 'true' && env.DIGEST_SHA != ''}} if: ${{ vars.DOCKERHUB_REPO != '' && env.DIGEST_SHA != ''}}
uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
with: with:
subject-name: ${{ vars.DOCKERHUB_REPO }} subject-name: ${{ vars.DOCKERHUB_REPO }}
subject-digest: ${{ env.DIGEST_SHA }} subject-digest: ${{ env.DIGEST_SHA }}
push-to-registry: true push-to-registry: true
- name: Attest - ghcr.io - ${{ matrix.base_image }} - name: Attest - ghcr.io - ${{ matrix.base_image }}
if: ${{ env.HAVE_GHCR_LOGIN == 'true' && env.DIGEST_SHA != ''}} if: ${{ vars.GHCR_REPO != '' && env.DIGEST_SHA != ''}}
uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
with: with:
subject-name: ${{ vars.GHCR_REPO }} subject-name: ${{ vars.GHCR_REPO }}
subject-digest: ${{ env.DIGEST_SHA }} subject-digest: ${{ env.DIGEST_SHA }}
push-to-registry: true push-to-registry: true
- name: Attest - quay.io - ${{ matrix.base_image }} - name: Attest - quay.io - ${{ matrix.base_image }}
if: ${{ env.HAVE_QUAY_LOGIN == 'true' && env.DIGEST_SHA != ''}} if: ${{ vars.QUAY_REPO != '' && env.DIGEST_SHA != ''}}
uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
with: with:
subject-name: ${{ vars.QUAY_REPO }} subject-name: ${{ vars.QUAY_REPO }}
subject-digest: ${{ env.DIGEST_SHA }} subject-digest: ${{ env.DIGEST_SHA }}

6
.github/workflows/trivy.yml

@ -33,12 +33,12 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
- name: Run Trivy vulnerability scanner - name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@c1824fd6edce30d7ab345a9989de00bbd46ef284 # 0.34.0 uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
env: env:
TRIVY_DB_REPOSITORY: docker.io/aquasec/trivy-db:2,public.ecr.aws/aquasecurity/trivy-db:2,ghcr.io/aquasecurity/trivy-db:2 TRIVY_DB_REPOSITORY: docker.io/aquasec/trivy-db:2,public.ecr.aws/aquasecurity/trivy-db:2,ghcr.io/aquasecurity/trivy-db:2
TRIVY_JAVA_DB_REPOSITORY: docker.io/aquasec/trivy-java-db:1,public.ecr.aws/aquasecurity/trivy-java-db:1,ghcr.io/aquasecurity/trivy-java-db:1 TRIVY_JAVA_DB_REPOSITORY: docker.io/aquasec/trivy-java-db:1,public.ecr.aws/aquasecurity/trivy-java-db:1,ghcr.io/aquasecurity/trivy-java-db:1
@ -50,6 +50,6 @@ jobs:
severity: CRITICAL,HIGH severity: CRITICAL,HIGH
- name: Upload Trivy scan results to GitHub Security tab - name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3 uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
with: with:
sarif_file: 'trivy-results.sarif' sarif_file: 'trivy-results.sarif'

4
.github/workflows/typos.yml

@ -16,11 +16,11 @@ jobs:
steps: steps:
# Checkout the repo # Checkout the repo
- name: Checkout - name: Checkout
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
# End Checkout the repo # End Checkout the repo
# When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too # When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too
- name: Spell Check Repo - name: Spell Check Repo
uses: crate-ci/typos@57b11c6b7e54c402ccd9cda953f1072ec4f78e33 # v1.43.5 uses: crate-ci/typos@5374cbf686e897b15713110e233094e2874de7ef # v1.46.1

4
.github/workflows/zizmor.yml

@ -19,12 +19,12 @@ jobs:
security-events: write # To write the security report security-events: write # To write the security report
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
- name: Run zizmor - name: Run zizmor
uses: zizmorcore/zizmor-action@0dce2577a4760a2749d8cfb7a84b7d5585ebcb7d # v0.5.0 uses: zizmorcore/zizmor-action@b572f7b1a1c2d41efaab43d504f68d215c3cd727 # v0.5.4
with: with:
# intentionally not scanning the entire repository, # intentionally not scanning the entire repository,
# since it contains integration tests. # since it contains integration tests.

108
.pre-commit-config.yaml

@ -1,58 +1,60 @@
--- ---
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0 rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0
hooks: hooks:
- id: check-yaml - id: check-yaml
- id: check-json - id: check-json
- id: check-toml - id: check-toml
- id: mixed-line-ending - id: mixed-line-ending
args: ["--fix=no"] args: [ "--fix=no" ]
- id: end-of-file-fixer - id: end-of-file-fixer
exclude: "(.*js$|.*css$)" exclude: "(.*js$|.*css$)"
- id: check-case-conflict - id: check-case-conflict
- id: check-merge-conflict - id: check-merge-conflict
- id: detect-private-key - id: detect-private-key
- id: check-symlinks - id: check-symlinks
- id: forbid-submodules - id: forbid-submodules
- repo: local
# 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
hooks: hooks:
- id: fmt - id: typos
name: fmt
description: Format files with cargo fmt. - repo: local
entry: cargo fmt hooks:
language: system - id: fmt
always_run: true name: fmt
pass_filenames: false description: Format files with cargo fmt.
args: ["--", "--check"] entry: cargo fmt
- id: cargo-test language: system
name: cargo test always_run: true
description: Test the package for errors. pass_filenames: false
entry: cargo test args: [ "--", "--check" ]
language: system - id: cargo-test
args: ["--features", "sqlite,mysql,postgresql", "--"] name: cargo test
types_or: [rust, file] description: Test the package for errors.
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$) entry: cargo test
pass_filenames: false language: system
- id: cargo-clippy args: [ "--features", "sqlite,mysql,postgresql", "--" ]
name: cargo clippy types_or: [ rust, file ]
description: Lint Rust sources files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
entry: cargo clippy pass_filenames: false
language: system - id: cargo-clippy
args: ["--features", "sqlite,mysql,postgresql", "--", "-D", "warnings"] name: cargo clippy
types_or: [rust, file] description: Lint Rust sources
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$) entry: cargo clippy
pass_filenames: false language: system
- id: check-docker-templates args: [ "--features", "sqlite,mysql,postgresql", "--", "-D", "warnings" ]
name: check docker templates types_or: [ rust, file ]
description: Check if the Docker templates are updated files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
language: system pass_filenames: false
entry: sh - id: check-docker-templates
args: name: check docker templates
- "-c" description: Check if the Docker templates are updated
- "cd docker && make" language: system
# When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too entry: sh
- repo: https://github.com/crate-ci/typos args:
rev: 57b11c6b7e54c402ccd9cda953f1072ec4f78e33 # v1.43.5 - "-c"
hooks: - "cd docker && make"
- id: typos

2
.typos.toml

@ -23,4 +23,6 @@ extend-ignore-re = [
# https://github.com/bitwarden/server/blob/dff9f1cf538198819911cf2c20f8cda3307701c5/src/Notifications/HubHelpers.cs#L86 # https://github.com/bitwarden/server/blob/dff9f1cf538198819911cf2c20f8cda3307701c5/src/Notifications/HubHelpers.cs#L86
# https://github.com/bitwarden/clients/blob/9612a4ac45063e372a6fbe87eb253c7cb3c588fb/libs/common/src/auth/services/anonymous-hub.service.ts#L45 # https://github.com/bitwarden/clients/blob/9612a4ac45063e372a6fbe87eb253c7cb3c588fb/libs/common/src/auth/services/anonymous-hub.service.ts#L45
"AuthRequestResponseRecieved", "AuthRequestResponseRecieved",
# Ignore Punycode/IDN tests
"xn--.+"
] ]

1733
Cargo.lock

File diff suppressed because it is too large

77
Cargo.toml

@ -1,6 +1,6 @@
[workspace.package] [workspace.package]
edition = "2021" edition = "2021"
rust-version = "1.91.0" rust-version = "1.93.0"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
repository = "https://github.com/dani-garcia/vaultwarden" repository = "https://github.com/dani-garcia/vaultwarden"
publish = false publish = false
@ -23,21 +23,23 @@ publish.workspace = true
[features] [features]
default = [ default = [
# "sqlite", # "sqlite" or "sqlite_system",
# "mysql", # "mysql",
# "postgresql", # "postgresql",
] ]
# Empty to keep compatibility, prefer to set USE_SYSLOG=true # Empty to keep compatibility, prefer to set USE_SYSLOG=true
enable_syslog = [] enable_syslog = []
# Please enable at least one of these DB backends.
mysql = ["diesel/mysql", "diesel_migrations/mysql"] mysql = ["diesel/mysql", "diesel_migrations/mysql"]
postgresql = ["diesel/postgres", "diesel_migrations/postgres"] postgresql = ["diesel/postgres", "diesel_migrations/postgres"]
sqlite = ["diesel/sqlite", "diesel_migrations/sqlite", "dep:libsqlite3-sys"] sqlite_system = ["diesel/sqlite", "diesel_migrations/sqlite"]
sqlite = ["sqlite_system", "libsqlite3-sys/bundled"] # Alternative to the above, statically linked SQLite into the binary instead of dynamically.
# Enable to use a vendored and statically linked openssl # Enable to use a vendored and statically linked openssl
vendored_openssl = ["openssl/vendored"] vendored_openssl = ["openssl/vendored"]
# Enable MiMalloc memory allocator to replace the default malloc # Enable MiMalloc memory allocator to replace the default malloc
# This can improve performance for Alpine builds # This can improve performance for Alpine builds
enable_mimalloc = ["dep:mimalloc"] enable_mimalloc = ["dep:mimalloc"]
s3 = ["opendal/services-s3", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-runtime-api", "dep:anyhow", "dep:http", "dep:reqsign"] s3 = ["opendal/services-s3", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-runtime-api", "dep:http", "dep:reqsign-aws-v4", "dep:reqsign-core"]
# OIDC specific features # OIDC specific features
oidc-accept-rfc3339-timestamps = ["openidconnect/accept-rfc3339-timestamps"] oidc-accept-rfc3339-timestamps = ["openidconnect/accept-rfc3339-timestamps"]
@ -79,7 +81,7 @@ dashmap = "6.1.0"
# Async futures # Async futures
futures = "0.3.32" futures = "0.3.32"
tokio = { version = "1.49.0", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal", "net"] } tokio = { version = "1.52.3", features = ["rt-multi-thread", "fs", "io-util", "parking_lot", "time", "signal", "net"] }
tokio-util = { version = "0.7.18", features = ["compat"]} tokio-util = { version = "0.7.18", features = ["compat"]}
# A generic serialization/deserialization framework # A generic serialization/deserialization framework
@ -88,25 +90,26 @@ serde_json = "1.0.149"
# A safe, extensible ORM and Query builder # A safe, extensible ORM and Query builder
# Currently pinned diesel to v2.3.3 as newer version break MySQL/MariaDB compatibility # Currently pinned diesel to v2.3.3 as newer version break MySQL/MariaDB compatibility
diesel = { version = "2.3.6", features = ["chrono", "r2d2", "numeric"] } diesel = { version = "2.3.9", features = ["chrono", "r2d2", "numeric"] }
diesel_migrations = "2.3.1" diesel_migrations = "2.3.2"
derive_more = { version = "2.1.1", features = ["from", "into", "as_ref", "deref", "display"] } derive_more = { version = "2.1.1", features = ["from", "into", "as_ref", "deref", "display"] }
diesel-derive-newtype = "2.1.2" diesel-derive-newtype = "2.1.2"
# Bundled/Static SQLite # SQLite, statically bundled unless the `sqlite_system` feature is enabled
libsqlite3-sys = { version = "0.35.0", features = ["bundled"], optional = true } libsqlite3-sys = { version = "0.37.0", optional = true }
# Crypto-related libraries # Crypto-related libraries
rand = "0.10.0" rand = "0.10.1"
ring = "0.17.14" ring = "0.17.14"
rustls = { version = "0.23.40", features = ["ring", "std"], default-features = false }
subtle = "2.6.1" subtle = "2.6.1"
# UUID generation # UUID generation
uuid = { version = "1.21.0", features = ["v4"] } uuid = { version = "1.23.1", features = ["v4"] }
# Date and time libraries # Date and time libraries
chrono = { version = "0.4.43", features = ["clock", "serde"], default-features = false } chrono = { version = "0.4.44", features = ["clock", "serde"], default-features = false }
chrono-tz = "0.10.4" chrono-tz = "0.10.4"
time = "0.3.47" time = "0.3.47"
@ -114,29 +117,29 @@ time = "0.3.47"
job_scheduler_ng = "2.4.0" job_scheduler_ng = "2.4.0"
# Data encoding library Hex/Base32/Base64 # Data encoding library Hex/Base32/Base64
data-encoding = "2.10.0" data-encoding = "2.11.0"
# JWT library # JWT library
jsonwebtoken = { version = "10.3.0", features = ["use_pem", "rust_crypto"], default-features = false } jsonwebtoken = { version = "10.4.0", features = ["use_pem", "rust_crypto"], default-features = false }
# TOTP library # TOTP library
totp-lite = "2.0.1" totp-lite = "2.0.1"
# Yubico Library # Yubico Library
yubico = { package = "yubico_ng", version = "0.14.1", features = ["online-tokio"], default-features = false } yubico = { package = "yubico_ng", version = "0.15.0", features = ["online-tokio"], default-features = false }
# WebAuthn libraries # WebAuthn libraries
# danger-allow-state-serialisation is needed to save the state in the db # danger-allow-state-serialisation is needed to save the state in the db
# danger-credential-internals is needed to support U2F to Webauthn migration # danger-credential-internals is needed to support U2F to Webauthn migration
webauthn-rs = { version = "0.5.4", features = ["danger-allow-state-serialisation", "danger-credential-internals"] } webauthn-rs = { version = "0.5.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
webauthn-rs-proto = "0.5.4" webauthn-rs-proto = "0.5.5"
webauthn-rs-core = "0.5.4" webauthn-rs-core = "0.5.5"
# Handling of URL's for WebAuthn and favicons # Handling of URL's for WebAuthn and favicons
url = "2.5.8" url = "2.5.8"
# Email libraries # Email libraries
lettre = { version = "0.11.19", features = ["smtp-transport", "sendmail-transport", "builder", "serde", "hostname", "tracing", "tokio1-rustls", "ring", "rustls-native-certs"], default-features = false } lettre = { version = "0.11.22", features = ["smtp-transport", "sendmail-transport", "builder", "serde", "hostname", "tracing", "tokio1-rustls", "ring", "rustls-native-certs"], default-features = false }
percent-encoding = "2.3.2" # URL encoding library used for URL's in the emails percent-encoding = "2.3.2" # URL encoding library used for URL's in the emails
email_address = "0.2.9" email_address = "0.2.9"
@ -144,8 +147,8 @@ email_address = "0.2.9"
handlebars = { version = "6.4.0", features = ["dir_source"] } handlebars = { version = "6.4.0", features = ["dir_source"] }
# HTTP client (Used for favicons, version check, DUO and HIBP API) # HTTP client (Used for favicons, version check, DUO and HIBP API)
reqwest = { version = "0.12.28", features = ["rustls-tls", "rustls-tls-native-roots", "stream", "json", "deflate", "gzip", "brotli", "zstd", "socks", "cookies", "charset", "http2", "system-proxy"], default-features = false} reqwest = { version = "0.13.3", features = ["rustls-no-provider", "stream", "json", "form", "deflate", "gzip", "brotli", "zstd", "socks", "cookies", "charset", "http2", "system-proxy"], default-features = false}
hickory-resolver = "0.25.2" hickory-resolver = "0.26.1"
# Favicon extraction libraries # Favicon extraction libraries
html5gum = "0.8.3" html5gum = "0.8.3"
@ -155,54 +158,54 @@ bytes = "1.11.1"
svg-hush = "0.9.6" svg-hush = "0.9.6"
# Cache function results (Used for version check and favicon fetching) # Cache function results (Used for version check and favicon fetching)
cached = { version = "0.56.0", features = ["async"] } cached = { version = "0.59.0", features = ["async"] }
# Used for custom short lived cookie jar during favicon extraction # Used for custom short lived cookie jar during favicon extraction
cookie = "0.18.1" cookie = "0.18.1"
cookie_store = "0.22.1" cookie_store = "0.22.1"
# Used by U2F, JWT and PostgreSQL # Used by U2F, JWT and PostgreSQL
openssl = "0.10.75" openssl = "0.10.79"
# CLI argument parsing # CLI argument parsing
pico-args = "0.5.0" pico-args = "0.5.0"
# Macro ident concatenation # Macro ident concatenation
pastey = "0.2.1" pastey = "0.2.2"
governor = "0.10.4" governor = "0.10.4"
# OIDC for SSO # OIDC for SSO
openidconnect = { version = "4.0.1", features = ["reqwest", "rustls-tls"] } openidconnect = { version = "4.0.1", default-features = false }
mini-moka = "0.10.3" moka = { version = "0.12.15", features = ["future"] }
# Check client versions for specific features. # Check client versions for specific features.
semver = "1.0.27" semver = "1.0.28"
# Allow overriding the default memory allocator # Allow overriding the default memory allocator
# Mainly used for the musl builds, since the default musl malloc is very slow # Mainly used for the musl builds, since the default musl malloc is very slow
mimalloc = { version = "0.1.48", features = ["secure"], default-features = false, optional = true } mimalloc = { version = "0.1.50", features = ["secure"], default-features = false, optional = true }
which = "8.0.0" which = "8.0.2"
# Argon2 library with support for the PHC format # Argon2 library with support for the PHC format
argon2 = "0.5.3" argon2 = "0.5.3"
# Reading a password from the cli for generating the Argon2id ADMIN_TOKEN # Reading a password from the cli for generating the Argon2id ADMIN_TOKEN
rpassword = "7.4.0" rpassword = "7.5.2"
# Loading a dynamic CSS Stylesheet # Loading a dynamic CSS Stylesheet
grass_compiler = { version = "0.13.4", default-features = false } grass_compiler = { version = "0.13.4", default-features = false }
# File are accessed through Apache OpenDAL # File are accessed through Apache OpenDAL
opendal = { version = "0.55.0", features = ["services-fs"], default-features = false } opendal = { version = "0.56.0", features = ["services-fs"], default-features = false }
# For retrieving AWS credentials, including temporary SSO credentials # For retrieving AWS credentials, including temporary SSO credentials
anyhow = { version = "1.0.101", optional = true } aws-config = { version = "1.8.16", features = ["behavior-version-latest", "rt-tokio", "credentials-process", "sso"], default-features = false, optional = true }
aws-config = { version = "1.8.14", features = ["behavior-version-latest", "rt-tokio", "credentials-process", "sso"], default-features = false, optional = true } aws-credential-types = { version = "1.2.14", optional = true }
aws-credential-types = { version = "1.2.13", optional = true } aws-smithy-runtime-api = { version = "1.12.0", optional = true }
aws-smithy-runtime-api = { version = "1.11.5", optional = true }
http = { version = "1.4.0", optional = true } http = { version = "1.4.0", optional = true }
reqsign = { version = "0.16.5", optional = true } reqsign-aws-v4 = { version = "3.0.0", optional = true }
reqsign-core = { version = "3.0.0", optional = true }
# Strip debuginfo from the release builds # Strip debuginfo from the release builds
# The debug symbols are to provide better panic traces # The debug symbols are to provide better panic traces
@ -301,6 +304,7 @@ branches_sharing_code = "deny"
case_sensitive_file_extension_comparisons = "deny" case_sensitive_file_extension_comparisons = "deny"
cast_lossless = "deny" cast_lossless = "deny"
clone_on_ref_ptr = "deny" clone_on_ref_ptr = "deny"
duration_suboptimal_units = "deny"
equatable_if_let = "deny" equatable_if_let = "deny"
excessive_precision = "deny" excessive_precision = "deny"
filter_map_next = "deny" filter_map_next = "deny"
@ -322,6 +326,7 @@ needless_continue = "deny"
needless_lifetimes = "deny" needless_lifetimes = "deny"
option_option = "deny" option_option = "deny"
redundant_clone = "deny" redundant_clone = "deny"
ref_option = "deny"
string_add_assign = "deny" string_add_assign = "deny"
unnecessary_join = "deny" unnecessary_join = "deny"
unnecessary_self_imports = "deny" unnecessary_self_imports = "deny"

5
README.md

@ -59,8 +59,9 @@ A nearly complete implementation of the Bitwarden Client API is provided, includ
## Usage ## Usage
> [!IMPORTANT] > [!IMPORTANT]
> The web-vault requires the use a secure context for the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API). > The web-vault requires the use of HTTPS and a secure context for the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API). <br>
> That means it will only work via `http://localhost:8000` (using the port from the example below) or if you [enable HTTPS](https://github.com/dani-garcia/vaultwarden/wiki/Enabling-HTTPS). > That means it will only work if you [enable HTTPS](https://github.com/dani-garcia/vaultwarden/wiki/Enabling-HTTPS). <br>
> We also suggest to use a [reverse proxy](https://github.com/dani-garcia/vaultwarden/wiki/Proxy-examples).
The recommended way to install and use Vaultwarden is via our container images which are published to [ghcr.io](https://github.com/dani-garcia/vaultwarden/pkgs/container/vaultwarden), [docker.io](https://hub.docker.com/r/vaultwarden/server) and [quay.io](https://quay.io/repository/vaultwarden/server). The recommended way to install and use Vaultwarden is via our container images which are published to [ghcr.io](https://github.com/dani-garcia/vaultwarden/pkgs/container/vaultwarden), [docker.io](https://hub.docker.com/r/vaultwarden/server) and [quay.io](https://quay.io/repository/vaultwarden/server).
See [which container image to use](https://github.com/dani-garcia/vaultwarden/wiki/Which-container-image-to-use) for an explanation of the provided tags. See [which container image to use](https://github.com/dani-garcia/vaultwarden/wiki/Which-container-image-to-use) for an explanation of the provided tags.

12
build.rs

@ -2,21 +2,21 @@ use std::env;
use std::process::Command; use std::process::Command;
fn main() { fn main() {
// This allow using #[cfg(sqlite)] instead of #[cfg(feature = "sqlite")], which helps when trying to add them through macros // These allow using e.g. #[cfg(mysql)] instead of #[cfg(feature = "mysql")], which helps when trying to add them through macros
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite_system")] // The `sqlite` feature implies this one.
println!("cargo:rustc-cfg=sqlite"); println!("cargo:rustc-cfg=sqlite");
#[cfg(feature = "mysql")] #[cfg(feature = "mysql")]
println!("cargo:rustc-cfg=mysql"); println!("cargo:rustc-cfg=mysql");
#[cfg(feature = "postgresql")] #[cfg(feature = "postgresql")]
println!("cargo:rustc-cfg=postgresql"); println!("cargo:rustc-cfg=postgresql");
#[cfg(feature = "s3")] #[cfg(not(any(feature = "sqlite_system", feature = "mysql", feature = "postgresql")))]
println!("cargo:rustc-cfg=s3");
#[cfg(not(any(feature = "sqlite", feature = "mysql", feature = "postgresql")))]
compile_error!( compile_error!(
"You need to enable one DB backend. To build with previous defaults do: cargo build --features sqlite" "You need to enable one DB backend. To build with previous defaults do: cargo build --features sqlite"
); );
#[cfg(feature = "s3")]
println!("cargo:rustc-cfg=s3");
// Use check-cfg to let cargo know which cfg's we define, // Use check-cfg to let cargo know which cfg's we define,
// and avoid warnings when they are used in the code. // and avoid warnings when they are used in the code.
println!("cargo::rustc-check-cfg=cfg(sqlite)"); println!("cargo::rustc-check-cfg=cfg(sqlite)");

6
docker/DockerSettings.yaml

@ -1,11 +1,11 @@
--- ---
vault_version: "v2026.1.1" vault_version: "v2026.4.1"
vault_image_digest: "sha256:062fcf0d5dc37247dae61b0ee1ba5d20f9296e290d7ad1f6114ea5888f5738a7" vault_image_digest: "sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe"
# Cross Compile Docker Helper Scripts v1.9.0 # 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 # 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 # https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707" xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
rust_version: 1.93.1 # Rust version to be used rust_version: 1.95.0 # Rust version to be used
debian_version: bookworm # Debian release name to be used debian_version: bookworm # Debian release name to be used
alpine_version: "3.23" # Alpine version to be used alpine_version: "3.23" # Alpine version to be used
# For which platforms/architectures will we try to build images # For which platforms/architectures will we try to build images

21
docker/Dockerfile.alpine

@ -19,23 +19,23 @@
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags, # - 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. # click the tag name to view the digest of the image it currently points to.
# - From the command line: # - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2026.1.1 # $ docker pull docker.io/vaultwarden/web-vault:v2026.4.1
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.1.1 # $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.4.1
# [docker.io/vaultwarden/web-vault@sha256:062fcf0d5dc37247dae61b0ee1ba5d20f9296e290d7ad1f6114ea5888f5738a7] # [docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe]
# #
# - Conversely, to get the tag name from the digest: # - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:062fcf0d5dc37247dae61b0ee1ba5d20f9296e290d7ad1f6114ea5888f5738a7 # $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe
# [docker.io/vaultwarden/web-vault:v2026.1.1] # [docker.io/vaultwarden/web-vault:v2026.4.1]
# #
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:062fcf0d5dc37247dae61b0ee1ba5d20f9296e290d7ad1f6114ea5888f5738a7 AS vault FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe AS vault
########################## ALPINE BUILD IMAGES ########################## ########################## ALPINE BUILD IMAGES ##########################
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64 ## 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 ## 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.93.1 AS build_amd64 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.93.1 AS build_arm64 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.93.1 AS build_armv7 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.93.1 AS build_armv6 FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.95.0 AS build_armv6
########################## BUILD IMAGE ########################## ########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006 # hadolint ignore=DL3006
@ -57,7 +57,6 @@ ENV DEBIAN_FRONTEND=noninteractive \
# Debian Trixie uses libpq v17 # Debian Trixie uses libpq v17
PQ_LIB_DIR="/usr/local/musl/pq17/lib" PQ_LIB_DIR="/usr/local/musl/pq17/lib"
# Create CARGO_HOME folder and don't download rust docs # Create CARGO_HOME folder and don't download rust docs
RUN mkdir -pv "${CARGO_HOME}" && \ RUN mkdir -pv "${CARGO_HOME}" && \
rustup set profile minimal rustup set profile minimal

58
docker/Dockerfile.debian

@ -19,15 +19,15 @@
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags, # - 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. # click the tag name to view the digest of the image it currently points to.
# - From the command line: # - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2026.1.1 # $ docker pull docker.io/vaultwarden/web-vault:v2026.4.1
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.1.1 # $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.4.1
# [docker.io/vaultwarden/web-vault@sha256:062fcf0d5dc37247dae61b0ee1ba5d20f9296e290d7ad1f6114ea5888f5738a7] # [docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe]
# #
# - Conversely, to get the tag name from the digest: # - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:062fcf0d5dc37247dae61b0ee1ba5d20f9296e290d7ad1f6114ea5888f5738a7 # $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe
# [docker.io/vaultwarden/web-vault:v2026.1.1] # [docker.io/vaultwarden/web-vault:v2026.4.1]
# #
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:062fcf0d5dc37247dae61b0ee1ba5d20f9296e290d7ad1f6114ea5888f5738a7 AS vault FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe AS vault
########################## Cross Compile Docker Helper Scripts ########################## ########################## Cross Compile Docker Helper Scripts ##########################
## We use the linux/amd64 no matter which Build Platform, since these are all bash 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 ########################## ########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006 # hadolint ignore=DL3006
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.93.1-slim-bookworm AS build FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.95.0-slim-bookworm AS build
COPY --from=xx / / COPY --from=xx / /
ARG TARGETARCH ARG TARGETARCH
ARG TARGETVARIANT ARG TARGETVARIANT
@ -51,7 +51,7 @@ ENV DEBIAN_FRONTEND=noninteractive \
TERM=xterm-256color \ TERM=xterm-256color \
CARGO_HOME="/root/.cargo" \ CARGO_HOME="/root/.cargo" \
USER="root" USER="root"
# Install clang to get `xx-cargo` working # Install clang && xx-c-essentials to get `xx-cargo` working
# Install pkg-config to allow amd64 builds to find all libraries # Install pkg-config to allow amd64 builds to find all libraries
# Install git so build.rs can determine the correct version # Install git so build.rs can determine the correct version
# Install the libc cross packages based upon the debian-arch # Install the libc cross packages based upon the debian-arch
@ -59,19 +59,16 @@ RUN apt-get update && \
apt-get install -y \ apt-get install -y \
--no-install-recommends \ --no-install-recommends \
clang \ clang \
pkg-config \ git && \
git \
"libc6-$(xx-info debian-arch)-cross" \
"libc6-dev-$(xx-info debian-arch)-cross" \
"linux-libc-dev-$(xx-info debian-arch)-cross" && \
xx-apt-get install -y \ xx-apt-get install -y \
--no-install-recommends \ --no-install-recommends \
gcc \
libpq-dev \ libpq-dev \
libpq5 \ libpq5 \
libssl-dev \ libssl-dev \
libmariadb-dev \ libmariadb-dev \
zlib1g-dev && \ pkg-config \
zlib1g-dev \
xx-c-essentials && \
# Run xx-cargo early, since it sometimes seems to break when run at a later stage # Run xx-cargo early, since it sometimes seems to break when run at a later stage
echo "export CARGO_TARGET=$(xx-cargo --print-target-triple)" >> /env-cargo echo "export CARGO_TARGET=$(xx-cargo --print-target-triple)" >> /env-cargo
@ -83,29 +80,6 @@ RUN mkdir -pv "${CARGO_HOME}" && \
RUN USER=root cargo new --bin /app RUN USER=root cargo new --bin /app
WORKDIR /app WORKDIR /app
# Environment variables for Cargo on Debian based builds
ARG TARGET_PKG_CONFIG_PATH
RUN source /env-cargo && \
if xx-info is-cross ; then \
# We can't use xx-cargo since that uses clang, which doesn't work for our libraries.
# Because of this we generate the needed environment variables here which we can load in the needed steps.
echo "export CC_$(echo "${CARGO_TARGET}" | tr '[:upper:]' '[:lower:]' | tr - _)=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \
echo "export CARGO_TARGET_$(echo "${CARGO_TARGET}" | tr '[:lower:]' '[:upper:]' | tr - _)_LINKER=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \
echo "export CROSS_COMPILE=1" >> /env-cargo && \
echo "export PKG_CONFIG_ALLOW_CROSS=1" >> /env-cargo && \
# For some architectures `xx-info` returns a triple which doesn't matches the path on disk
# In those cases you can override this by setting the `TARGET_PKG_CONFIG_PATH` build-arg
if [[ -n "${TARGET_PKG_CONFIG_PATH}" ]]; then \
echo "export TARGET_PKG_CONFIG_PATH=${TARGET_PKG_CONFIG_PATH}" >> /env-cargo ; \
else \
echo "export PKG_CONFIG_PATH=/usr/lib/$(xx-info)/pkgconfig" >> /env-cargo ; \
fi && \
echo "# End of env-cargo" >> /env-cargo ; \
fi && \
# Output the current contents of the file
cat /env-cargo
RUN source /env-cargo && \ RUN source /env-cargo && \
rustup target add "${CARGO_TARGET}" rustup target add "${CARGO_TARGET}"
@ -122,7 +96,9 @@ ARG DB=sqlite,mysql,postgresql
# dummy project, except the target folder # dummy project, except the target folder
# This folder contains the compiled dependencies # This folder contains the compiled dependencies
RUN source /env-cargo && \ RUN source /env-cargo && \
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \ # Workaround for xx related build issues
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
find . -not -path "./target*" -delete find . -not -path "./target*" -delete
# Copies the complete project # Copies the complete project
@ -137,7 +113,9 @@ RUN source /env-cargo && \
# Also do this for build.rs to ensure the version is rechecked # Also do this for build.rs to ensure the version is rechecked
touch build.rs src/main.rs && \ touch build.rs src/main.rs && \
# Create a symlink to the binary target folder to easy copy the binary in the final stage # Create a symlink to the binary target folder to easy copy the binary in the final stage
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \ # Workaround for xx related build issues
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \ if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \ ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
else \ else \

54
docker/Dockerfile.j2

@ -27,6 +27,11 @@
# $ docker image inspect --format "{{ '{{' }}.RepoTags}}" docker.io/vaultwarden/web-vault@{{ vault_image_digest }} # $ docker image inspect --format "{{ '{{' }}.RepoTags}}" docker.io/vaultwarden/web-vault@{{ vault_image_digest }}
# [docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}] # [docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}]
# #
{% macro xx_cargo_config() -%}
# Workaround for xx related build issues
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}"
{%- endmacro %}
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@{{ vault_image_digest }} AS vault FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@{{ vault_image_digest }} AS vault
{% if base == "debian" %} {% if base == "debian" %}
@ -66,10 +71,10 @@ ENV DEBIAN_FRONTEND=noninteractive \
# Use PostgreSQL v17 during Alpine/MUSL builds instead of the default v16 # Use PostgreSQL v17 during Alpine/MUSL builds instead of the default v16
# Debian Trixie uses libpq v17 # Debian Trixie uses libpq v17
PQ_LIB_DIR="/usr/local/musl/pq17/lib" PQ_LIB_DIR="/usr/local/musl/pq17/lib"
{% endif %} {%- endif %}
{% if base == "debian" %} {% if base == "debian" %}
# Install clang to get `xx-cargo` working # Install clang && xx-c-essentials to get `xx-cargo` working
# Install pkg-config to allow amd64 builds to find all libraries # Install pkg-config to allow amd64 builds to find all libraries
# Install git so build.rs can determine the correct version # Install git so build.rs can determine the correct version
# Install the libc cross packages based upon the debian-arch # Install the libc cross packages based upon the debian-arch
@ -77,19 +82,16 @@ RUN apt-get update && \
apt-get install -y \ apt-get install -y \
--no-install-recommends \ --no-install-recommends \
clang \ clang \
pkg-config \ git && \
git \
"libc6-$(xx-info debian-arch)-cross" \
"libc6-dev-$(xx-info debian-arch)-cross" \
"linux-libc-dev-$(xx-info debian-arch)-cross" && \
xx-apt-get install -y \ xx-apt-get install -y \
--no-install-recommends \ --no-install-recommends \
gcc \
libpq-dev \ libpq-dev \
libpq5 \ libpq5 \
libssl-dev \ libssl-dev \
libmariadb-dev \ libmariadb-dev \
zlib1g-dev && \ pkg-config \
zlib1g-dev \
xx-c-essentials && \
# Run xx-cargo early, since it sometimes seems to break when run at a later stage # Run xx-cargo early, since it sometimes seems to break when run at a later stage
echo "export CARGO_TARGET=$(xx-cargo --print-target-triple)" >> /env-cargo echo "export CARGO_TARGET=$(xx-cargo --print-target-triple)" >> /env-cargo
{% endif %} {% endif %}
@ -102,31 +104,7 @@ RUN mkdir -pv "${CARGO_HOME}" && \
RUN USER=root cargo new --bin /app RUN USER=root cargo new --bin /app
WORKDIR /app WORKDIR /app
{% if base == "debian" %} {% if base == "alpine" %}
# Environment variables for Cargo on Debian based builds
ARG TARGET_PKG_CONFIG_PATH
RUN source /env-cargo && \
if xx-info is-cross ; then \
# We can't use xx-cargo since that uses clang, which doesn't work for our libraries.
# Because of this we generate the needed environment variables here which we can load in the needed steps.
echo "export CC_$(echo "${CARGO_TARGET}" | tr '[:upper:]' '[:lower:]' | tr - _)=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \
echo "export CARGO_TARGET_$(echo "${CARGO_TARGET}" | tr '[:lower:]' '[:upper:]' | tr - _)_LINKER=/usr/bin/$(xx-info)-gcc" >> /env-cargo && \
echo "export CROSS_COMPILE=1" >> /env-cargo && \
echo "export PKG_CONFIG_ALLOW_CROSS=1" >> /env-cargo && \
# For some architectures `xx-info` returns a triple which doesn't matches the path on disk
# In those cases you can override this by setting the `TARGET_PKG_CONFIG_PATH` build-arg
if [[ -n "${TARGET_PKG_CONFIG_PATH}" ]]; then \
echo "export TARGET_PKG_CONFIG_PATH=${TARGET_PKG_CONFIG_PATH}" >> /env-cargo ; \
else \
echo "export PKG_CONFIG_PATH=/usr/lib/$(xx-info)/pkgconfig" >> /env-cargo ; \
fi && \
echo "# End of env-cargo" >> /env-cargo ; \
fi && \
# Output the current contents of the file
cat /env-cargo
{% elif base == "alpine" %}
# Environment variables for Cargo on Alpine based builds # Environment variables for Cargo on Alpine based builds
RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \ RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \
# Output the current contents of the file # Output the current contents of the file
@ -154,7 +132,11 @@ ARG DB=sqlite,mysql,postgresql,enable_mimalloc
# dummy project, except the target folder # dummy project, except the target folder
# This folder contains the compiled dependencies # This folder contains the compiled dependencies
RUN source /env-cargo && \ RUN source /env-cargo && \
{% if base == "debian" %}
{{ xx_cargo_config() }} && \
{% elif base == "alpine" %}
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \ cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
{% endif %}
find . -not -path "./target*" -delete find . -not -path "./target*" -delete
# Copies the complete project # Copies the complete project
@ -169,7 +151,11 @@ RUN source /env-cargo && \
# Also do this for build.rs to ensure the version is rechecked # Also do this for build.rs to ensure the version is rechecked
touch build.rs src/main.rs && \ touch build.rs src/main.rs && \
# Create a symlink to the binary target folder to easy copy the binary in the final stage # Create a symlink to the binary target folder to easy copy the binary in the final stage
{% if base == "debian" %}
{{ xx_cargo_config() }} && \
{% elif base == "alpine" %}
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \ cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
{% endif %}
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \ if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \ ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
else \ else \

4
macros/Cargo.toml

@ -13,8 +13,8 @@ path = "src/lib.rs"
proc-macro = true proc-macro = true
[dependencies] [dependencies]
quote = "1.0.44" quote = "1.0.45"
syn = "2.0.114" syn = "2.0.117"
[lints] [lints]
workspace = true workspace = true

1
migrations/mysql/2026-03-09-005927_add_archives/down.sql

@ -0,0 +1 @@
DROP TABLE IF EXISTS archives;

10
migrations/mysql/2026-03-09-005927_add_archives/up.sql

@ -0,0 +1,10 @@
DROP TABLE IF EXISTS archives;
CREATE TABLE archives (
user_uuid CHAR(36) NOT NULL,
cipher_uuid CHAR(36) NOT NULL,
archived_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_uuid, cipher_uuid),
FOREIGN KEY (user_uuid) REFERENCES users (uuid) ON DELETE CASCADE,
FOREIGN KEY (cipher_uuid) REFERENCES ciphers (uuid) ON DELETE CASCADE
);

1
migrations/mysql/2026-04-25-120000_sso_auth_binding/down.sql

@ -0,0 +1 @@
ALTER TABLE sso_auth DROP COLUMN binding_hash;

1
migrations/mysql/2026-04-25-120000_sso_auth_binding/up.sql

@ -0,0 +1 @@
ALTER TABLE sso_auth ADD COLUMN binding_hash TEXT;

1
migrations/mysql/2026-05-05-120000_sso_auth_error/down.sql

@ -0,0 +1 @@
ALTER TABLE sso_auth DROP COLUMN code_response_error;

1
migrations/mysql/2026-05-05-120000_sso_auth_error/up.sql

@ -0,0 +1 @@
ALTER TABLE sso_auth ADD COLUMN code_response_error TEXT;

1
migrations/postgresql/2026-03-09-005927_add_archives/down.sql

@ -0,0 +1 @@
DROP TABLE IF EXISTS archives;

8
migrations/postgresql/2026-03-09-005927_add_archives/up.sql

@ -0,0 +1,8 @@
DROP TABLE IF EXISTS archives;
CREATE TABLE archives (
user_uuid CHAR(36) NOT NULL REFERENCES users (uuid) ON DELETE CASCADE,
cipher_uuid CHAR(36) NOT NULL REFERENCES ciphers (uuid) ON DELETE CASCADE,
archived_at TIMESTAMP NOT NULL DEFAULT now(),
PRIMARY KEY (user_uuid, cipher_uuid)
);

1
migrations/postgresql/2026-04-25-120000_sso_auth_binding/down.sql

@ -0,0 +1 @@
ALTER TABLE sso_auth DROP COLUMN binding_hash;

1
migrations/postgresql/2026-04-25-120000_sso_auth_binding/up.sql

@ -0,0 +1 @@
ALTER TABLE sso_auth ADD COLUMN binding_hash TEXT;

1
migrations/postgresql/2026-05-05-120000_sso_auth_error/down.sql

@ -0,0 +1 @@
ALTER TABLE sso_auth DROP COLUMN IF EXISTS code_response_error;

1
migrations/postgresql/2026-05-05-120000_sso_auth_error/up.sql

@ -0,0 +1 @@
ALTER TABLE sso_auth ADD COLUMN IF NOT EXISTS code_response_error TEXT;

1
migrations/sqlite/2026-03-09-005927_add_archives/down.sql

@ -0,0 +1 @@
DROP TABLE IF EXISTS archives;

8
migrations/sqlite/2026-03-09-005927_add_archives/up.sql

@ -0,0 +1,8 @@
DROP TABLE IF EXISTS archives;
CREATE TABLE archives (
user_uuid CHAR(36) NOT NULL REFERENCES users (uuid) ON DELETE CASCADE,
cipher_uuid CHAR(36) NOT NULL REFERENCES ciphers (uuid) ON DELETE CASCADE,
archived_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_uuid, cipher_uuid)
);

1
migrations/sqlite/2026-04-25-120000_sso_auth_binding/down.sql

@ -0,0 +1 @@
ALTER TABLE sso_auth DROP COLUMN binding_hash;

1
migrations/sqlite/2026-04-25-120000_sso_auth_binding/up.sql

@ -0,0 +1 @@
ALTER TABLE sso_auth ADD COLUMN binding_hash TEXT;

1
migrations/sqlite/2026-05-05-120000_sso_auth_error/down.sql

@ -0,0 +1 @@
ALTER TABLE sso_auth DROP COLUMN code_response_error;

1
migrations/sqlite/2026-05-05-120000_sso_auth_error/up.sql

@ -0,0 +1 @@
ALTER TABLE sso_auth ADD COLUMN code_response_error TEXT;

2
rust-toolchain.toml

@ -1,4 +1,4 @@
[toolchain] [toolchain]
channel = "1.93.1" channel = "1.95.0"
components = [ "rustfmt", "clippy" ] components = [ "rustfmt", "clippy" ]
profile = "minimal" profile = "minimal"

33
src/api/admin.rs

@ -30,9 +30,10 @@ use crate::{
error::{Error, MapResult}, error::{Error, MapResult},
http_client::make_http_request, http_client::make_http_request,
mail, mail,
sso::FAKE_SSO_IDENTIFIER,
util::{ util::{
container_base_image, format_naive_datetime_local, get_active_web_release, get_display_size, container_base_image, format_naive_datetime_local, get_active_web_release, get_display_size,
is_running_in_container, NumberOrString, is_running_in_container, parse_experimental_client_feature_flags, FeatureFlagFilter, NumberOrString,
}, },
CONFIG, VERSION, CONFIG, VERSION,
}; };
@ -315,7 +316,11 @@ async fn invite_user(data: Json<InviteData>, _token: AdminToken, conn: DbConn) -
async fn _generate_invite(user: &User, conn: &DbConn) -> EmptyResult { async fn _generate_invite(user: &User, conn: &DbConn) -> EmptyResult {
if CONFIG.mail_enabled() { if CONFIG.mail_enabled() {
let org_id: OrganizationId = FAKE_ADMIN_UUID.to_string().into(); let org_id: OrganizationId = if CONFIG.sso_enabled() {
FAKE_SSO_IDENTIFIER.into()
} else {
FAKE_ADMIN_UUID.into()
};
let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into(); let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into();
mail::send_invite(user, org_id, member_id, &CONFIG.invitation_org_name(), None).await mail::send_invite(user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
} else { } else {
@ -464,7 +469,7 @@ async fn deauth_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Noti
if CONFIG.push_enabled() { if CONFIG.push_enabled() {
for device in Device::find_push_devices_by_user(&user.uuid, &conn).await { for device in Device::find_push_devices_by_user(&user.uuid, &conn).await {
match unregister_push_device(&device.push_uuid).await { match unregister_push_device(device.push_uuid.as_ref()).await {
Ok(r) => r, Ok(r) => r,
Err(e) => error!("Unable to unregister devices from Bitwarden server: {e}"), Err(e) => error!("Unable to unregister devices from Bitwarden server: {e}"),
}; };
@ -472,7 +477,7 @@ async fn deauth_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Noti
} }
Device::delete_all_by_user(&user.uuid, &conn).await?; Device::delete_all_by_user(&user.uuid, &conn).await?;
user.reset_security_stamp(); user.reset_security_stamp(&conn).await?;
user.save(&conn).await user.save(&conn).await
} }
@ -480,14 +485,15 @@ async fn deauth_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Noti
#[post("/users/<user_id>/disable", format = "application/json")] #[post("/users/<user_id>/disable", format = "application/json")]
async fn disable_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Notify<'_>) -> EmptyResult { async fn disable_user(user_id: UserId, _token: AdminToken, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
let mut user = get_user_or_404(&user_id, &conn).await?; let mut user = get_user_or_404(&user_id, &conn).await?;
Device::delete_all_by_user(&user.uuid, &conn).await?; user.reset_security_stamp(&conn).await?;
user.reset_security_stamp();
user.enabled = false; user.enabled = false;
let save_result = user.save(&conn).await; let save_result = user.save(&conn).await;
nt.send_logout(&user, None, &conn).await; nt.send_logout(&user, None, &conn).await;
Device::delete_all_by_user(&user.uuid, &conn).await?;
save_result save_result
} }
@ -517,7 +523,11 @@ async fn resend_user_invite(user_id: UserId, _token: AdminToken, conn: DbConn) -
} }
if CONFIG.mail_enabled() { if CONFIG.mail_enabled() {
let org_id: OrganizationId = FAKE_ADMIN_UUID.to_string().into(); let org_id: OrganizationId = if CONFIG.sso_enabled() {
FAKE_SSO_IDENTIFIER.into()
} else {
FAKE_ADMIN_UUID.into()
};
let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into(); let member_id: MembershipId = FAKE_ADMIN_UUID.to_string().into();
mail::send_invite(&user, org_id, member_id, &CONFIG.invitation_org_name(), None).await mail::send_invite(&user, org_id, member_id, &CONFIG.invitation_org_name(), None).await
} else { } else {
@ -637,7 +647,6 @@ use cached::proc_macro::cached;
/// Cache this function to prevent API call rate limit. Github only allows 60 requests per hour, and we use 3 here already /// 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 /// 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 /// Any cache will be lost if Vaultwarden is restarted
use std::time::Duration; // Needed for cached
#[cached(time = 600, sync_writes = "default")] #[cached(time = 600, sync_writes = "default")]
async fn get_release_info(has_http_access: bool) -> (String, String, String) { 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 the HTTP Check failed, do not even attempt to check for new versions since we were not able to connect with github.com anyway.
@ -734,6 +743,13 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
let ip_header_name = &ip_header.0.unwrap_or_default(); let ip_header_name = &ip_header.0.unwrap_or_default();
let invalid_feature_flags: Vec<String> = parse_experimental_client_feature_flags(
&CONFIG.experimental_client_feature_flags(),
FeatureFlagFilter::InvalidOnly,
)
.into_keys()
.collect();
let diagnostics_json = json!({ let diagnostics_json = json!({
"dns_resolved": dns_resolved, "dns_resolved": dns_resolved,
"current_release": VERSION, "current_release": VERSION,
@ -756,6 +772,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
"db_version": get_sql_server_version(&conn).await, "db_version": get_sql_server_version(&conn).await,
"admin_url": format!("{}/diagnostics", admin_url()), "admin_url": format!("{}/diagnostics", admin_url()),
"overrides": &CONFIG.get_overrides().join(", "), "overrides": &CONFIG.get_overrides().join(", "),
"invalid_feature_flags": invalid_feature_flags,
"host_arch": env::consts::ARCH, "host_arch": env::consts::ARCH,
"host_os": env::consts::OS, "host_os": env::consts::OS,
"tz_env": env::var("TZ").unwrap_or_default(), "tz_env": env::var("TZ").unwrap_or_default(),

77
src/api/core/accounts.rs

@ -22,7 +22,7 @@ use crate::{
DbConn, DbConn,
}, },
mail, mail,
util::{format_date, NumberOrString}, util::{deser_opt_nonempty_str, format_date, NumberOrString},
CONFIG, CONFIG,
}; };
@ -106,7 +106,6 @@ pub struct RegisterData {
name: Option<String>, name: Option<String>,
#[allow(dead_code)]
organization_user_id: Option<MembershipId>, organization_user_id: Option<MembershipId>,
// Used only from the register/finish endpoint // Used only from the register/finish endpoint
@ -138,7 +137,7 @@ struct KeysData {
} }
/// Trims whitespace from password hints, and converts blank password hints to `None`. /// Trims whitespace from password hints, and converts blank password hints to `None`.
fn clean_password_hint(password_hint: &Option<String>) -> Option<String> { fn clean_password_hint(password_hint: Option<&String>) -> Option<String> {
match password_hint { match password_hint {
None => None, None => None,
Some(h) => match h.trim() { Some(h) => match h.trim() {
@ -148,7 +147,7 @@ fn clean_password_hint(password_hint: &Option<String>) -> Option<String> {
} }
} }
fn enforce_password_hint_setting(password_hint: &Option<String>) -> EmptyResult { fn enforce_password_hint_setting(password_hint: Option<&String>) -> EmptyResult {
if password_hint.is_some() && !CONFIG.password_hints_allowed() { if password_hint.is_some() && !CONFIG.password_hints_allowed() {
err!("Password hints have been disabled by the administrator. Remove the hint and try again."); err!("Password hints have been disabled by the administrator. Remove the hint and try again.");
} }
@ -246,8 +245,8 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn:
// Check against the password hint setting here so if it fails, the user // Check against the password hint setting here so if it fails, the user
// can retry without losing their invitation below. // can retry without losing their invitation below.
let password_hint = clean_password_hint(&data.master_password_hint); let password_hint = clean_password_hint(data.master_password_hint.as_ref());
enforce_password_hint_setting(&password_hint)?; enforce_password_hint_setting(password_hint.as_ref())?;
let mut user = match User::find_by_mail(&email, &conn).await { let mut user = match User::find_by_mail(&email, &conn).await {
Some(user) => { Some(user) => {
@ -296,7 +295,7 @@ pub async fn _register(data: Json<RegisterData>, email_verification: bool, conn:
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); user.set_password(&data.master_password_hash, Some(data.key), true, None, &conn).await?;
user.password_hint = password_hint; user.password_hint = password_hint;
// Add extra fields if present // Add extra fields if present
@ -354,8 +353,8 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
// Check against the password hint setting here so if it fails, // Check against the password hint setting here so if it fails,
// the user can retry without losing their invitation below. // the user can retry without losing their invitation below.
let password_hint = clean_password_hint(&data.master_password_hint); let password_hint = clean_password_hint(data.master_password_hint.as_ref());
enforce_password_hint_setting(&password_hint)?; enforce_password_hint_setting(password_hint.as_ref())?;
set_kdf_data(&mut user, &data.kdf)?; set_kdf_data(&mut user, &data.kdf)?;
@ -364,7 +363,9 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
Some(data.key), Some(data.key),
false, false,
Some(vec![String::from("revision_date")]), // We need to allow revision-date to use the old security_timestamp Some(vec![String::from("revision_date")]), // We need to allow revision-date to use the old security_timestamp
); &conn,
)
.await?;
user.password_hint = password_hint; user.password_hint = password_hint;
if let Some(keys) = data.keys { if let Some(keys) = data.keys {
@ -373,15 +374,13 @@ async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, conn:
} }
if let Some(identifier) = data.org_identifier { if let Some(identifier) = data.org_identifier {
if identifier != crate::sso::FAKE_IDENTIFIER && identifier != crate::api::admin::FAKE_ADMIN_UUID { if identifier != crate::sso::FAKE_SSO_IDENTIFIER && identifier != crate::api::admin::FAKE_ADMIN_UUID {
let org = match Organization::find_by_uuid(&identifier.into(), &conn).await { let Some(org) = Organization::find_by_uuid(&identifier.into(), &conn).await else {
None => err!("Failed to retrieve the associated organization"), err!("Failed to retrieve the associated organization")
Some(org) => org,
}; };
let membership = match Membership::find_by_user_and_org(&user.uuid, &org.uuid, &conn).await { let Some(membership) = Membership::find_by_user_and_org(&user.uuid, &org.uuid, &conn).await else {
None => err!("Failed to retrieve the invitation"), err!("Failed to retrieve the invitation")
Some(org) => org,
}; };
accept_org_invite(&user, membership, None, &conn).await?; accept_org_invite(&user, membership, None, &conn).await?;
@ -516,8 +515,8 @@ async fn post_password(data: Json<ChangePassData>, headers: Headers, conn: DbCon
err!("Invalid password") err!("Invalid password")
} }
user.password_hint = clean_password_hint(&data.master_password_hint); user.password_hint = clean_password_hint(data.master_password_hint.as_ref());
enforce_password_hint_setting(&user.password_hint)?; enforce_password_hint_setting(user.password_hint.as_ref())?;
log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await; .await;
@ -532,14 +531,16 @@ async fn post_password(data: Json<ChangePassData>, headers: Headers, conn: DbCon
String::from("get_public_keys"), String::from("get_public_keys"),
String::from("get_api_webauthn"), String::from("get_api_webauthn"),
]), ]),
); &conn,
)
.await?;
let save_result = user.save(&conn).await; let save_result = user.save(&conn).await;
// Prevent logging out the client where the user requested this endpoint from. // Prevent logging out the client where the user requested this endpoint from.
// If you do logout the user it will causes issues at the client side. // If you do logout the user it will causes issues at the client side.
// Adding the device uuid will prevent this. // Adding the device uuid will prevent this.
nt.send_logout(&user, Some(headers.device.uuid.clone()), &conn).await; nt.send_logout(&user, Some(&headers.device), &conn).await;
save_result save_result
} }
@ -579,7 +580,6 @@ fn set_kdf_data(user: &mut User, data: &KDFData) -> EmptyResult {
Ok(()) Ok(())
} }
#[allow(dead_code)]
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct AuthenticationData { struct AuthenticationData {
@ -588,7 +588,6 @@ struct AuthenticationData {
master_password_authentication_hash: String, master_password_authentication_hash: String,
} }
#[allow(dead_code)]
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct UnlockData { struct UnlockData {
@ -597,11 +596,12 @@ struct UnlockData {
master_key_wrapped_user_key: String, master_key_wrapped_user_key: String,
} }
#[allow(dead_code)]
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct ChangeKdfData { struct ChangeKdfData {
#[allow(dead_code)]
new_master_password_hash: String, new_master_password_hash: String,
#[allow(dead_code)]
key: String, key: String,
authentication_data: AuthenticationData, authentication_data: AuthenticationData,
unlock_data: UnlockData, unlock_data: UnlockData,
@ -633,10 +633,12 @@ async fn post_kdf(data: Json<ChangeKdfData>, headers: Headers, conn: DbConn, nt:
Some(data.unlock_data.master_key_wrapped_user_key), Some(data.unlock_data.master_key_wrapped_user_key),
true, true,
None, None,
); &conn,
)
.await?;
let save_result = user.save(&conn).await; let save_result = user.save(&conn).await;
nt.send_logout(&user, Some(headers.device.uuid.clone()), &conn).await; nt.send_logout(&user, Some(&headers.device), &conn).await;
save_result save_result
} }
@ -647,6 +649,7 @@ struct UpdateFolderData {
// There is a bug in 2024.3.x which adds a `null` item. // There is a bug in 2024.3.x which adds a `null` item.
// To bypass this we allow a Option here, but skip it during the updates // To bypass this we allow a Option here, but skip it during the updates
// See: https://github.com/bitwarden/clients/issues/8453 // See: https://github.com/bitwarden/clients/issues/8453
#[serde(default, deserialize_with = "deser_opt_nonempty_str")]
id: Option<FolderId>, id: Option<FolderId>,
name: String, name: String,
} }
@ -900,14 +903,16 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
Some(data.account_unlock_data.master_password_unlock_data.master_key_encrypted_user_key), Some(data.account_unlock_data.master_password_unlock_data.master_key_encrypted_user_key),
true, true,
None, None,
); &conn,
)
.await?;
let save_result = user.save(&conn).await; let save_result = user.save(&conn).await;
// Prevent logging out the client where the user requested this endpoint from. // Prevent logging out the client where the user requested this endpoint from.
// If you do logout the user it will causes issues at the client side. // If you do logout the user it will causes issues at the client side.
// Adding the device uuid will prevent this. // Adding the device uuid will prevent this.
nt.send_logout(&user, Some(headers.device.uuid.clone()), &conn).await; nt.send_logout(&user, Some(&headers.device), &conn).await;
save_result save_result
} }
@ -919,12 +924,13 @@ async fn post_sstamp(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbCo
data.validate(&user, true, &conn).await?; data.validate(&user, true, &conn).await?;
Device::delete_all_by_user(&user.uuid, &conn).await?; user.reset_security_stamp(&conn).await?;
user.reset_security_stamp();
let save_result = user.save(&conn).await; let save_result = user.save(&conn).await;
nt.send_logout(&user, None, &conn).await; nt.send_logout(&user, None, &conn).await;
Device::delete_all_by_user(&user.uuid, &conn).await?;
save_result save_result
} }
@ -1042,7 +1048,7 @@ async fn post_email(data: Json<ChangeEmailData>, headers: Headers, conn: DbConn,
user.email_new = None; user.email_new = None;
user.email_new_token = None; user.email_new_token = None;
user.set_password(&data.new_master_password_hash, Some(data.key), true, None); user.set_password(&data.new_master_password_hash, Some(data.key), true, None, &conn).await?;
let save_result = user.save(&conn).await; let save_result = user.save(&conn).await;
@ -1254,7 +1260,7 @@ struct SecretVerificationRequest {
pub async fn kdf_upgrade(user: &mut User, pwd_hash: &str, conn: &DbConn) -> ApiResult<()> { pub async fn kdf_upgrade(user: &mut User, pwd_hash: &str, conn: &DbConn) -> ApiResult<()> {
if user.password_iterations < CONFIG.password_iterations() { if user.password_iterations < CONFIG.password_iterations() {
user.password_iterations = CONFIG.password_iterations(); user.password_iterations = CONFIG.password_iterations();
user.set_password(pwd_hash, None, false, None); user.set_password(pwd_hash, None, false, None, conn).await?;
if let Err(e) = user.save(conn).await { if let Err(e) = user.save(conn).await {
error!("Error updating user: {e:#?}"); error!("Error updating user: {e:#?}");
@ -1328,6 +1334,11 @@ impl<'r> FromRequest<'r> for KnownDevice {
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> { async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let email = if let Some(email_b64) = req.headers().get_one("X-Request-Email") { let email = if let Some(email_b64) = req.headers().get_one("X-Request-Email") {
// Bitwarden seems to send padded Base64 strings since 2026.2.1
// Since these values are not streamed and Headers are always split by newlines
// we can safely ignore padding here and remove any '=' appended.
let email_b64 = email_b64.trim_end_matches('=');
let Ok(email_bytes) = data_encoding::BASE64URL_NOPAD.decode(email_b64.as_bytes()) else { let Ok(email_bytes) = data_encoding::BASE64URL_NOPAD.decode(email_b64.as_bytes()) else {
return Outcome::Error((Status::BadRequest, "X-Request-Email value failed to decode as base64url")); return Outcome::Error((Status::BadRequest, "X-Request-Email value failed to decode as base64url"));
}; };
@ -1427,7 +1438,7 @@ async fn put_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResul
if let Some(device) = Device::find_by_uuid(&device_id, &conn).await { if let Some(device) = Device::find_by_uuid(&device_id, &conn).await {
Device::clear_push_token_by_uuid(&device_id, &conn).await?; Device::clear_push_token_by_uuid(&device_id, &conn).await?;
unregister_push_device(&device.push_uuid).await?; unregister_push_device(device.push_uuid.as_ref()).await?;
} }
Ok(()) Ok(())

318
src/api/core/ciphers.rs

@ -11,17 +11,17 @@ use rocket::{
use serde_json::Value; use serde_json::Value;
use crate::auth::ClientVersion; use crate::auth::ClientVersion;
use crate::util::{save_temp_file, NumberOrString}; use crate::util::{deser_opt_nonempty_str, save_temp_file, NumberOrString};
use crate::{ use crate::{
api::{self, core::log_event, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType}, api::{self, core::log_event, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType},
auth::Headers, auth::{Headers, OrgIdGuard, OwnerHeaders},
config::PathType, config::PathType,
crypto, crypto,
db::{ db::{
models::{ models::{
Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup,
CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, Membership, MembershipType, CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, Membership,
OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId,
}, },
DbConn, DbPool, DbConn, DbPool,
}, },
@ -86,7 +86,8 @@ pub fn routes() -> Vec<Route> {
restore_cipher_put_admin, restore_cipher_put_admin,
restore_cipher_selected, restore_cipher_selected,
restore_cipher_selected_admin, restore_cipher_selected_admin,
delete_all, purge_org_vault,
purge_personal_vault,
move_cipher_selected, move_cipher_selected,
move_cipher_selected_put, move_cipher_selected_put,
put_collections2_update, put_collections2_update,
@ -95,6 +96,10 @@ pub fn routes() -> Vec<Route> {
post_collections_update, post_collections_update,
post_collections_admin, post_collections_admin,
put_collections_admin, put_collections_admin,
archive_cipher_put,
archive_cipher_selected,
unarchive_cipher_put,
unarchive_cipher_selected,
] ]
} }
@ -247,6 +252,7 @@ pub struct CipherData {
// Id is optional as it is included only in bulk share // Id is optional as it is included only in bulk share
pub id: Option<CipherId>, pub id: Option<CipherId>,
// Folder id is not included in import // Folder id is not included in import
#[serde(default, deserialize_with = "deser_opt_nonempty_str")]
pub folder_id: Option<FolderId>, pub folder_id: Option<FolderId>,
// TODO: Some of these might appear all the time, no need for Option // TODO: Some of these might appear all the time, no need for Option
#[serde(alias = "organizationID")] #[serde(alias = "organizationID")]
@ -291,11 +297,13 @@ pub struct CipherData {
// when using older client versions, or if the operation doesn't involve // when using older client versions, or if the operation doesn't involve
// updating an existing cipher. // updating an existing cipher.
last_known_revision_date: Option<String>, last_known_revision_date: Option<String>,
archived_date: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PartialCipherData { pub struct PartialCipherData {
#[serde(default, deserialize_with = "deser_opt_nonempty_str")]
folder_id: Option<FolderId>, folder_id: Option<FolderId>,
favorite: bool, favorite: bool,
} }
@ -425,7 +433,7 @@ pub async fn update_cipher_from_data(
let transfer_cipher = cipher.organization_uuid.is_none() && data.organization_id.is_some(); let transfer_cipher = cipher.organization_uuid.is_none() && data.organization_id.is_some();
if let Some(org_id) = data.organization_id { if let Some(org_id) = data.organization_id {
match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, conn).await { 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"), None => err!("You don't have permission to add item to organization"),
Some(member) => { Some(member) => {
if shared_to_collections.is_some() if shared_to_collections.is_some()
@ -531,6 +539,13 @@ pub async fn update_cipher_from_data(
cipher.move_to_folder(data.folder_id, &headers.user.uuid, conn).await?; cipher.move_to_folder(data.folder_id, &headers.user.uuid, conn).await?;
cipher.set_favorite(data.favorite, &headers.user.uuid, conn).await?; cipher.set_favorite(data.favorite, &headers.user.uuid, conn).await?;
if let Some(dt_str) = data.archived_date {
match NaiveDateTime::parse_from_str(&dt_str, "%+") {
Ok(dt) => cipher.set_archived_at(dt, &headers.user.uuid, conn).await?,
Err(err) => warn!("Error parsing ArchivedDate '{dt_str}': {err}"),
}
}
if ut != UpdateType::None { if ut != UpdateType::None {
// Only log events for organizational ciphers // Only log events for organizational ciphers
if let Some(org_id) = &cipher.organization_uuid { if let Some(org_id) = &cipher.organization_uuid {
@ -627,7 +642,7 @@ async fn post_ciphers_import(data: Json<ImportData>, headers: Headers, conn: DbC
let mut user = headers.user; let mut user = headers.user;
user.update_revision(&conn).await?; user.update_revision(&conn).await?;
nt.send_user_update(UpdateType::SyncVault, &user, &headers.device.push_uuid, &conn).await; nt.send_user_update(UpdateType::SyncVault, &user, headers.device.push_uuid.as_ref(), &conn).await;
Ok(()) Ok(())
} }
@ -799,12 +814,16 @@ async fn post_collections_update(
err!("Collection cannot be changed") err!("Collection cannot be changed")
} }
let Some(ref org_uuid) = cipher.organization_uuid else {
err!("Cipher is not owned by an organization")
};
let posted_collections = HashSet::<CollectionId>::from_iter(data.collection_ids); let posted_collections = HashSet::<CollectionId>::from_iter(data.collection_ids);
let current_collections = let current_collections =
HashSet::<CollectionId>::from_iter(cipher.get_collections(headers.user.uuid.clone(), &conn).await); HashSet::<CollectionId>::from_iter(cipher.get_collections(headers.user.uuid.clone(), &conn).await);
for collection in posted_collections.symmetric_difference(&current_collections) { for collection in posted_collections.symmetric_difference(&current_collections) {
match Collection::find_by_uuid_and_org(collection, cipher.organization_uuid.as_ref().unwrap(), &conn).await { match Collection::find_by_uuid_and_org(collection, org_uuid, &conn).await {
None => err!("Invalid collection ID provided"), None => err!("Invalid collection ID provided"),
Some(collection) => { Some(collection) => {
if collection.is_writable_by_user(&headers.user.uuid, &conn).await { if collection.is_writable_by_user(&headers.user.uuid, &conn).await {
@ -835,7 +854,7 @@ async fn post_collections_update(
log_event( log_event(
EventType::CipherUpdatedCollections as i32, EventType::CipherUpdatedCollections as i32,
&cipher.uuid, &cipher.uuid,
&cipher.organization_uuid.clone().unwrap(), org_uuid,
&headers.user.uuid, &headers.user.uuid,
headers.device.atype, headers.device.atype,
&headers.ip.ip, &headers.ip.ip,
@ -875,12 +894,16 @@ async fn post_collections_admin(
err!("Collection cannot be changed") err!("Collection cannot be changed")
} }
let Some(ref org_uuid) = cipher.organization_uuid else {
err!("Cipher is not owned by an organization")
};
let posted_collections = HashSet::<CollectionId>::from_iter(data.collection_ids); let posted_collections = HashSet::<CollectionId>::from_iter(data.collection_ids);
let current_collections = let current_collections =
HashSet::<CollectionId>::from_iter(cipher.get_admin_collections(headers.user.uuid.clone(), &conn).await); HashSet::<CollectionId>::from_iter(cipher.get_admin_collections(headers.user.uuid.clone(), &conn).await);
for collection in posted_collections.symmetric_difference(&current_collections) { for collection in posted_collections.symmetric_difference(&current_collections) {
match Collection::find_by_uuid_and_org(collection, cipher.organization_uuid.as_ref().unwrap(), &conn).await { match Collection::find_by_uuid_and_org(collection, org_uuid, &conn).await {
None => err!("Invalid collection ID provided"), None => err!("Invalid collection ID provided"),
Some(collection) => { Some(collection) => {
if collection.is_writable_by_user(&headers.user.uuid, &conn).await { if collection.is_writable_by_user(&headers.user.uuid, &conn).await {
@ -911,7 +934,7 @@ async fn post_collections_admin(
log_event( log_event(
EventType::CipherUpdatedCollections as i32, EventType::CipherUpdatedCollections as i32,
&cipher.uuid, &cipher.uuid,
&cipher.organization_uuid.unwrap(), org_uuid,
&headers.user.uuid, &headers.user.uuid,
headers.device.atype, headers.device.atype,
&headers.ip.ip, &headers.ip.ip,
@ -1002,7 +1025,7 @@ async fn put_cipher_share_selected(
} }
// Multi share actions do not send out a push for each cipher, we need to send a general sync here // Multi share actions do not send out a push for each cipher, we need to send a general sync here
nt.send_user_update(UpdateType::SyncCiphers, &headers.user, &headers.device.push_uuid, &conn).await; nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), &conn).await;
Ok(()) Ok(())
} }
@ -1568,6 +1591,7 @@ async fn restore_cipher_selected(
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct MoveCipherData { struct MoveCipherData {
#[serde(default, deserialize_with = "deser_opt_nonempty_str")]
folder_id: Option<FolderId>, folder_id: Option<FolderId>,
ids: Vec<CipherId>, ids: Vec<CipherId>,
} }
@ -1614,7 +1638,7 @@ async fn move_cipher_selected(
.await; .await;
} else { } else {
// Multi move actions do not send out a push for each cipher, we need to send a general sync here // Multi move actions do not send out a push for each cipher, we need to send a general sync here
nt.send_user_update(UpdateType::SyncCiphers, &headers.user, &headers.device.push_uuid, &conn).await; nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), &conn).await;
} }
if cipher_count != accessible_ciphers_count { if cipher_count != accessible_ciphers_count {
@ -1642,67 +1666,105 @@ struct OrganizationIdData {
org_id: OrganizationId, org_id: OrganizationId,
} }
// Use the OrgIdGuard here, to ensure there an organization id present.
// If there is no organization id present, it should be forwarded to purge_personal_vault.
// This guard needs to be the first argument, else OwnerHeaders will be triggered which will logout the user.
#[post("/ciphers/purge?<organization..>", data = "<data>")] #[post("/ciphers/purge?<organization..>", data = "<data>")]
async fn delete_all( async fn purge_org_vault(
organization: Option<OrganizationIdData>, _org_id_guard: OrgIdGuard,
organization: OrganizationIdData,
data: Json<PasswordOrOtpData>, data: Json<PasswordOrOtpData>,
headers: Headers, headers: OwnerHeaders,
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> EmptyResult { ) -> EmptyResult {
if organization.org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
let data: PasswordOrOtpData = data.into_inner(); let data: PasswordOrOtpData = data.into_inner();
let mut user = headers.user; let user = headers.user;
data.validate(&user, true, &conn).await?; data.validate(&user, true, &conn).await?;
match organization { match Membership::find_confirmed_by_user_and_org(&user.uuid, &organization.org_id, &conn).await {
Some(org_data) => { Some(member) if member.atype == MembershipType::Owner => {
// Organization ID in query params, purging organization vault Cipher::delete_all_by_organization(&organization.org_id, &conn).await?;
match Membership::find_by_user_and_org(&user.uuid, &org_data.org_id, &conn).await { nt.send_user_update(UpdateType::SyncVault, &user, headers.device.push_uuid.as_ref(), &conn).await;
None => err!("You don't have permission to purge the organization vault"),
Some(member) => {
if member.atype == MembershipType::Owner {
Cipher::delete_all_by_organization(&org_data.org_id, &conn).await?;
nt.send_user_update(UpdateType::SyncVault, &user, &headers.device.push_uuid, &conn).await;
log_event(
EventType::OrganizationPurgedVault as i32,
&org_data.org_id,
&org_data.org_id,
&user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
Ok(())
} else {
err!("You don't have permission to purge the organization vault");
}
}
}
}
None => {
// No organization ID in query params, purging user vault
// Delete ciphers and their attachments
for cipher in Cipher::find_owned_by_user(&user.uuid, &conn).await {
cipher.delete(&conn).await?;
}
// Delete folders
for f in Folder::find_by_user(&user.uuid, &conn).await {
f.delete(&conn).await?;
}
user.update_revision(&conn).await?; log_event(
nt.send_user_update(UpdateType::SyncVault, &user, &headers.device.push_uuid, &conn).await; EventType::OrganizationPurgedVault as i32,
&organization.org_id,
&organization.org_id,
&user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
Ok(()) Ok(())
} }
_ => err!("You don't have permission to purge the organization vault"),
} }
} }
#[post("/ciphers/purge", data = "<data>")]
async fn purge_personal_vault(
data: Json<PasswordOrOtpData>,
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
let data: PasswordOrOtpData = data.into_inner();
let mut user = headers.user;
data.validate(&user, true, &conn).await?;
for cipher in Cipher::find_owned_by_user(&user.uuid, &conn).await {
cipher.delete(&conn).await?;
}
for f in Folder::find_by_user(&user.uuid, &conn).await {
f.delete(&conn).await?;
}
user.update_revision(&conn).await?;
nt.send_user_update(UpdateType::SyncVault, &user, headers.device.push_uuid.as_ref(), &conn).await;
Ok(())
}
#[put("/ciphers/<cipher_id>/archive")]
async fn archive_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
archive_cipher(&cipher_id, &headers, false, &conn, &nt).await
}
#[put("/ciphers/archive", data = "<data>")]
async fn archive_cipher_selected(
data: Json<CipherIdsData>,
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
archive_multiple_ciphers(data, &headers, &conn, &nt).await
}
#[put("/ciphers/<cipher_id>/unarchive")]
async fn unarchive_cipher_put(cipher_id: CipherId, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
unarchive_cipher(&cipher_id, &headers, false, &conn, &nt).await
}
#[put("/ciphers/unarchive", data = "<data>")]
async fn unarchive_cipher_selected(
data: Json<CipherIdsData>,
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
unarchive_multiple_ciphers(data, &headers, &conn, &nt).await
}
#[derive(PartialEq)] #[derive(PartialEq)]
pub enum CipherDeleteOptions { pub enum CipherDeleteOptions {
SoftSingle, SoftSingle,
@ -1793,7 +1855,7 @@ async fn _delete_multiple_ciphers(
} }
// Multi delete actions do not send out a push for each cipher, we need to send a general sync here // Multi delete actions do not send out a push for each cipher, we need to send a general sync here
nt.send_user_update(UpdateType::SyncCiphers, &headers.user, &headers.device.push_uuid, &conn).await; nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), &conn).await;
Ok(()) Ok(())
} }
@ -1861,7 +1923,7 @@ async fn _restore_multiple_ciphers(
} }
// Multi move actions do not send out a push for each cipher, we need to send a general sync here // Multi move actions do not send out a push for each cipher, we need to send a general sync here
nt.send_user_update(UpdateType::SyncCiphers, &headers.user, &headers.device.push_uuid, conn).await; nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), conn).await;
Ok(Json(json!({ Ok(Json(json!({
"data": ciphers, "data": ciphers,
@ -1921,6 +1983,122 @@ async fn _delete_cipher_attachment_by_id(
Ok(Json(json!({"cipher":cipher_json}))) Ok(Json(json!({"cipher":cipher_json})))
} }
async fn archive_cipher(
cipher_id: &CipherId,
headers: &Headers,
multi_archive: bool,
conn: &DbConn,
nt: &Notify<'_>,
) -> JsonResult {
let Some(cipher) = Cipher::find_by_uuid(cipher_id, conn).await else {
err!("Cipher doesn't exist")
};
if !cipher.is_accessible_to_user(&headers.user.uuid, conn).await {
err!("Cipher is not accessible for the current user")
}
cipher.set_archived_at(Utc::now().naive_utc(), &headers.user.uuid, conn).await?;
if !multi_archive {
nt.send_cipher_update(
UpdateType::SyncCipherUpdate,
&cipher,
&cipher.update_users_revision(conn).await,
&headers.device,
None,
conn,
)
.await;
}
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?))
}
async fn unarchive_cipher(
cipher_id: &CipherId,
headers: &Headers,
multi_unarchive: bool,
conn: &DbConn,
nt: &Notify<'_>,
) -> JsonResult {
let Some(cipher) = Cipher::find_by_uuid(cipher_id, conn).await else {
err!("Cipher doesn't exist")
};
if !cipher.is_accessible_to_user(&headers.user.uuid, conn).await {
err!("Cipher is not accessible for the current user")
}
cipher.unarchive(&headers.user.uuid, conn).await?;
if !multi_unarchive {
nt.send_cipher_update(
UpdateType::SyncCipherUpdate,
&cipher,
&cipher.update_users_revision(conn).await,
&headers.device,
None,
conn,
)
.await;
}
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?))
}
async fn archive_multiple_ciphers(
data: Json<CipherIdsData>,
headers: &Headers,
conn: &DbConn,
nt: &Notify<'_>,
) -> JsonResult {
let data = data.into_inner();
let mut ciphers: Vec<Value> = Vec::new();
for cipher_id in data.ids {
match archive_cipher(&cipher_id, headers, true, conn, nt).await {
Ok(json) => ciphers.push(json.into_inner()),
err => return err,
}
}
// Multi archive does not send out a push for each cipher, we need to send a general sync here
nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), conn).await;
Ok(Json(json!({
"data": ciphers,
"object": "list",
"continuationToken": null
})))
}
async fn unarchive_multiple_ciphers(
data: Json<CipherIdsData>,
headers: &Headers,
conn: &DbConn,
nt: &Notify<'_>,
) -> JsonResult {
let data = data.into_inner();
let mut ciphers: Vec<Value> = Vec::new();
for cipher_id in data.ids {
match unarchive_cipher(&cipher_id, headers, true, conn, nt).await {
Ok(json) => ciphers.push(json.into_inner()),
err => return err,
}
}
// Multi unarchive does not send out a push for each cipher, we need to send a general sync here
nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), conn).await;
Ok(Json(json!({
"data": ciphers,
"object": "list",
"continuationToken": null
})))
}
/// This will hold all the necessary data to improve a full sync of all the ciphers /// This will hold all the necessary data to improve a full sync of all the ciphers
/// It can be used during the `Cipher::to_json()` call. /// It can be used during the `Cipher::to_json()` call.
/// It will prevent the so called N+1 SQL issue by running just a few queries which will hold all the data needed. /// It will prevent the so called N+1 SQL issue by running just a few queries which will hold all the data needed.
@ -1930,6 +2108,7 @@ pub struct CipherSyncData {
pub cipher_folders: HashMap<CipherId, FolderId>, pub cipher_folders: HashMap<CipherId, FolderId>,
pub cipher_favorites: HashSet<CipherId>, pub cipher_favorites: HashSet<CipherId>,
pub cipher_collections: HashMap<CipherId, Vec<CollectionId>>, pub cipher_collections: HashMap<CipherId, Vec<CollectionId>>,
pub cipher_archives: HashMap<CipherId, NaiveDateTime>,
pub members: HashMap<OrganizationId, Membership>, pub members: HashMap<OrganizationId, Membership>,
pub user_collections: HashMap<CollectionId, CollectionUser>, pub user_collections: HashMap<CollectionId, CollectionUser>,
pub user_collections_groups: HashMap<CollectionId, CollectionGroup>, pub user_collections_groups: HashMap<CollectionId, CollectionGroup>,
@ -1946,20 +2125,25 @@ impl CipherSyncData {
pub async fn new(user_id: &UserId, sync_type: CipherSyncType, conn: &DbConn) -> Self { pub async fn new(user_id: &UserId, sync_type: CipherSyncType, conn: &DbConn) -> Self {
let cipher_folders: HashMap<CipherId, FolderId>; let cipher_folders: HashMap<CipherId, FolderId>;
let cipher_favorites: HashSet<CipherId>; let cipher_favorites: HashSet<CipherId>;
let cipher_archives: HashMap<CipherId, NaiveDateTime>;
match sync_type { match sync_type {
// User Sync supports Folders and Favorites // User Sync supports Folders, Favorites, and Archives
CipherSyncType::User => { CipherSyncType::User => {
// Generate a HashMap with the Cipher UUID as key and the Folder UUID as value // Generate a HashMap with the Cipher UUID as key and the Folder UUID as value
cipher_folders = FolderCipher::find_by_user(user_id, conn).await.into_iter().collect(); cipher_folders = FolderCipher::find_by_user(user_id, conn).await.into_iter().collect();
// Generate a HashSet of all the Cipher UUID's which are marked as favorite // Generate a HashSet of all the Cipher UUID's which are marked as favorite
cipher_favorites = Favorite::get_all_cipher_uuid_by_user(user_id, conn).await.into_iter().collect(); cipher_favorites = Favorite::get_all_cipher_uuid_by_user(user_id, conn).await.into_iter().collect();
// Generate a HashMap with the Cipher UUID as key and the archived date time as value
cipher_archives = Archive::find_by_user(user_id, conn).await.into_iter().collect();
} }
// Organization Sync does not support Folders and Favorites. // Organization Sync does not support Folders, Favorites, or Archives.
// If these are set, it will cause issues in the web-vault. // If these are set, it will cause issues in the web-vault.
CipherSyncType::Organization => { CipherSyncType::Organization => {
cipher_folders = HashMap::with_capacity(0); cipher_folders = HashMap::with_capacity(0);
cipher_favorites = HashSet::with_capacity(0); cipher_favorites = HashSet::with_capacity(0);
cipher_archives = HashMap::with_capacity(0);
} }
} }
@ -1980,8 +2164,11 @@ impl CipherSyncData {
} }
// Generate a HashMap with the Organization UUID as key and the Membership record // Generate a HashMap with the Organization UUID as key and the Membership record
let members: HashMap<OrganizationId, Membership> = let members: HashMap<OrganizationId, Membership> = Membership::find_confirmed_by_user(user_id, conn)
Membership::find_by_user(user_id, conn).await.into_iter().map(|m| (m.org_uuid.clone(), m)).collect(); .await
.into_iter()
.map(|m| (m.org_uuid.clone(), m))
.collect();
// Generate a HashMap with the User_Collections UUID as key and the CollectionUser record // Generate a HashMap with the User_Collections UUID as key and the CollectionUser record
let user_collections: HashMap<CollectionId, CollectionUser> = CollectionUser::find_by_user(user_id, conn) let user_collections: HashMap<CollectionId, CollectionUser> = CollectionUser::find_by_user(user_id, conn)
@ -2019,6 +2206,7 @@ impl CipherSyncData {
}; };
Self { Self {
cipher_archives,
cipher_attachments, cipher_attachments,
cipher_folders, cipher_folders,
cipher_favorites, cipher_favorites,

2
src/api/core/emergency_access.rs

@ -653,7 +653,7 @@ async fn password_emergency_access(
}; };
// change grantor_user password // change grantor_user password
grantor_user.set_password(new_master_password_hash, Some(data.key), true, None); grantor_user.set_password(new_master_password_hash, Some(data.key), true, None, &conn).await?;
grantor_user.save(&conn).await?; grantor_user.save(&conn).await?;
// Disable TwoFactor providers since they will otherwise block logins // Disable TwoFactor providers since they will otherwise block logins

2
src/api/core/events.rs

@ -240,7 +240,7 @@ async fn _log_user_event(
ip: &IpAddr, ip: &IpAddr,
conn: &DbConn, conn: &DbConn,
) { ) {
let memberships = Membership::find_by_user(user_id, conn).await; let memberships = Membership::find_confirmed_by_user(user_id, conn).await;
let mut events: Vec<Event> = Vec::with_capacity(memberships.len() + 1); // We need an event per org and one without an org let mut events: Vec<Event> = Vec::with_capacity(memberships.len() + 1); // We need an event per org and one without an org
// Upstream saves the event also without any org_id. // Upstream saves the event also without any org_id.

2
src/api/core/folders.rs

@ -8,6 +8,7 @@ use crate::{
models::{Folder, FolderId}, models::{Folder, FolderId},
DbConn, DbConn,
}, },
util::deser_opt_nonempty_str,
}; };
pub fn routes() -> Vec<rocket::Route> { pub fn routes() -> Vec<rocket::Route> {
@ -38,6 +39,7 @@ async fn get_folder(folder_id: FolderId, headers: Headers, conn: DbConn) -> Json
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct FolderData { pub struct FolderData {
pub name: String, pub name: String,
#[serde(default, deserialize_with = "deser_opt_nonempty_str")]
pub id: Option<FolderId>, pub id: Option<FolderId>,
} }

33
src/api/core/mod.rs

@ -59,7 +59,8 @@ use crate::{
error::Error, error::Error,
http_client::make_http_request, http_client::make_http_request,
mail, mail,
util::parse_experimental_client_feature_flags, util::{parse_experimental_client_feature_flags, FeatureFlagFilter},
CONFIG,
}; };
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@ -123,7 +124,7 @@ async fn post_eq_domains(data: Json<EquivDomainData>, headers: Headers, conn: Db
user.save(&conn).await?; user.save(&conn).await?;
nt.send_user_update(UpdateType::SyncSettings, &user, &headers.device.push_uuid, &conn).await; nt.send_user_update(UpdateType::SyncSettings, &user, headers.device.push_uuid.as_ref(), &conn).await;
Ok(Json(json!({}))) Ok(Json(json!({})))
} }
@ -136,7 +137,7 @@ async fn put_eq_domains(data: Json<EquivDomainData>, headers: Headers, conn: DbC
#[get("/hibp/breach?<username>")] #[get("/hibp/breach?<username>")]
async fn hibp_breach(username: &str, _headers: Headers) -> JsonResult { async fn hibp_breach(username: &str, _headers: Headers) -> JsonResult {
let username: String = url::form_urlencoded::byte_serialize(username.as_bytes()).collect(); let username: String = url::form_urlencoded::byte_serialize(username.as_bytes()).collect();
if let Some(api_key) = crate::CONFIG.hibp_api_key() { if let Some(api_key) = CONFIG.hibp_api_key() {
let url = format!( let url = format!(
"https://haveibeenpwned.com/api/v3/breachedaccount/{username}?truncateResponse=false&includeUnverified=false" "https://haveibeenpwned.com/api/v3/breachedaccount/{username}?truncateResponse=false&includeUnverified=false"
); );
@ -197,19 +198,17 @@ fn get_api_webauthn(_headers: Headers) -> Json<Value> {
#[get("/config")] #[get("/config")]
fn config() -> Json<Value> { fn config() -> Json<Value> {
let domain = crate::CONFIG.domain(); let domain = CONFIG.domain();
// Official available feature flags can be found here: // Official available feature flags can be found here:
// Server (v2025.6.2): https://github.com/bitwarden/server/blob/d094be3267f2030bd0dc62106bc6871cf82682f5/src/Core/Constants.cs#L103 // Server (v2026.2.1): https://github.com/bitwarden/server/blob/0e42725d0837bd1c0dabd864ff621a579959744b/src/Core/Constants.cs#L135
// Client (web-v2025.6.1): https://github.com/bitwarden/clients/blob/747c2fd6a1c348a57a76e4a7de8128466ffd3c01/libs/common/src/enums/feature-flag.enum.ts#L12 // Client (v2026.2.1): https://github.com/bitwarden/clients/blob/f96380c3138291a028bdd2c7a5fee540d5c98ba5/libs/common/src/enums/feature-flag.enum.ts#L12
// Android (v2025.6.0): https://github.com/bitwarden/android/blob/b5b022caaad33390c31b3021b2c1205925b0e1a2/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt#L22 // Android (v2026.2.1): https://github.com/bitwarden/android/blob/6902c19c0093fa476bbf74ccaa70c9f14afbb82f/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt#L31
// iOS (v2025.6.0): https://github.com/bitwarden/ios/blob/ff06d9c6cc8da89f78f37f376495800201d7261a/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7 // iOS (v2026.2.1): https://github.com/bitwarden/ios/blob/cdd9ba1770ca2ffc098d02d12cc3208e3a830454/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7
let mut feature_states = let mut feature_states = parse_experimental_client_feature_flags(
parse_experimental_client_feature_flags(&crate::CONFIG.experimental_client_feature_flags()); &CONFIG.experimental_client_feature_flags(),
feature_states.insert("duo-redirect".to_string(), true); FeatureFlagFilter::ValidOnly,
feature_states.insert("email-verification".to_string(), true); );
feature_states.insert("unauth-ui-refresh".to_string(), true); feature_states.insert("pm-19148-innovation-archive".to_string(), true);
feature_states.insert("enable-pm-flight-recorder".to_string(), true);
feature_states.insert("mobile-error-reporting".to_string(), true);
Json(json!({ Json(json!({
// Note: The clients use this version to handle backwards compatibility concerns // Note: The clients use this version to handle backwards compatibility concerns
@ -225,7 +224,7 @@ fn config() -> Json<Value> {
"url": "https://github.com/dani-garcia/vaultwarden" "url": "https://github.com/dani-garcia/vaultwarden"
}, },
"settings": { "settings": {
"disableUserRegistration": crate::CONFIG.is_signup_disabled() "disableUserRegistration": CONFIG.is_signup_disabled()
}, },
"environment": { "environment": {
"vault": domain, "vault": domain,
@ -278,7 +277,7 @@ async fn accept_org_invite(
member.save(conn).await?; member.save(conn).await?;
if crate::CONFIG.mail_enabled() { if CONFIG.mail_enabled() {
let org = match Organization::find_by_uuid(&member.org_uuid, conn).await { let org = match Organization::find_by_uuid(&member.org_uuid, conn).await {
Some(org) => org, Some(org) => org,
None => err!("Organization not found."), None => err!("Organization not found."),

295
src/api/core/organizations.rs

@ -20,7 +20,8 @@ use crate::{
DbConn, DbConn,
}, },
mail, mail,
util::{convert_json_key_lcase_first, get_uuid, NumberOrString}, sso::FAKE_SSO_IDENTIFIER,
util::{convert_json_key_lcase_first, NumberOrString},
CONFIG, CONFIG,
}; };
@ -64,6 +65,7 @@ pub fn routes() -> Vec<Route> {
post_org_import, post_org_import,
list_policies, list_policies,
list_policies_token, list_policies_token,
get_dummy_master_password_policy,
get_master_password_policy, get_master_password_policy,
get_policy, get_policy,
put_policy, put_policy,
@ -76,6 +78,7 @@ pub fn routes() -> Vec<Route> {
revoke_member, revoke_member,
bulk_revoke_members, bulk_revoke_members,
restore_member, restore_member,
restore_member_vnext,
bulk_restore_members, bulk_restore_members,
get_groups, get_groups,
get_groups_details, get_groups_details,
@ -99,6 +102,7 @@ pub fn routes() -> Vec<Route> {
get_billing_metadata, get_billing_metadata,
get_billing_warnings, get_billing_warnings,
get_auto_enroll_status, get_auto_enroll_status,
get_self_host_billing_metadata,
] ]
} }
@ -131,6 +135,24 @@ struct FullCollectionData {
external_id: Option<String>, external_id: Option<String>,
} }
impl FullCollectionData {
pub async fn validate(&self, org_id: &OrganizationId, conn: &DbConn) -> EmptyResult {
let org_groups = Group::find_by_organization(org_id, conn).await;
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect();
if let Some(e) = self.groups.iter().find(|g| !org_group_ids.contains(&g.id)) {
err!("Invalid group", format!("Group {} does not belong to organization {}!", e.id, org_id))
}
let org_memberships = Membership::find_by_org(org_id, conn).await;
let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect();
if let Some(e) = self.users.iter().find(|m| !org_membership_ids.contains(&m.id)) {
err!("Invalid member", format!("Member {} does not belong to organization {}!", e.id, org_id))
}
Ok(())
}
}
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct CollectionGroupData { struct CollectionGroupData {
@ -233,30 +255,30 @@ async fn post_delete_organization(
} }
#[post("/organizations/<org_id>/leave")] #[post("/organizations/<org_id>/leave")]
async fn leave_organization(org_id: OrganizationId, headers: Headers, conn: DbConn) -> EmptyResult { async fn leave_organization(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> EmptyResult {
match Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await { if headers.membership.status != MembershipStatus::Confirmed as i32 {
None => err!("User not part of organization"), err!("You need to be a Member of the Organization to call this endpoint")
Some(member) => { }
if member.atype == MembershipType::Owner let membership = headers.membership;
&& Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1
{
err!("The last owner can't leave")
}
log_event(
EventType::OrganizationUserLeft as i32,
&member.uuid,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
member.delete(&conn).await if membership.atype == MembershipType::Owner
} && Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1
{
err!("The last owner can't leave")
} }
log_event(
EventType::OrganizationUserLeft as i32,
&membership.uuid,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
membership.delete(&conn).await
} }
#[get("/organizations/<org_id>")] #[get("/organizations/<org_id>")]
@ -335,7 +357,7 @@ async fn get_user_collections(headers: Headers, conn: DbConn) -> Json<Value> {
// The returned `Id` will then be passed to `get_master_password_policy` which will mainly ignore it // The returned `Id` will then be passed to `get_master_password_policy` which will mainly ignore it
#[get("/organizations/<identifier>/auto-enroll-status")] #[get("/organizations/<identifier>/auto-enroll-status")]
async fn get_auto_enroll_status(identifier: &str, headers: Headers, conn: DbConn) -> JsonResult { async fn get_auto_enroll_status(identifier: &str, headers: Headers, conn: DbConn) -> JsonResult {
let org = if identifier == crate::sso::FAKE_IDENTIFIER { let org = if identifier == FAKE_SSO_IDENTIFIER {
match Membership::find_main_user_org(&headers.user.uuid, &conn).await { match Membership::find_main_user_org(&headers.user.uuid, &conn).await {
Some(member) => Organization::find_by_uuid(&member.org_uuid, &conn).await, Some(member) => Organization::find_by_uuid(&member.org_uuid, &conn).await,
None => None, None => None,
@ -345,7 +367,7 @@ async fn get_auto_enroll_status(identifier: &str, headers: Headers, conn: DbConn
}; };
let (id, identifier, rp_auto_enroll) = match org { let (id, identifier, rp_auto_enroll) = match org {
None => (get_uuid(), identifier.to_string(), false), None => (identifier.to_string(), identifier.to_string(), false),
Some(org) => ( Some(org) => (
org.uuid.to_string(), org.uuid.to_string(),
org.uuid.to_string(), org.uuid.to_string(),
@ -395,7 +417,7 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea
Membership::find_confirmed_by_org(&org_id, &conn).await.into_iter().map(|m| (m.uuid, m.atype)).collect(); Membership::find_confirmed_by_org(&org_id, &conn).await.into_iter().map(|m| (m.uuid, m.atype)).collect();
// check if current user has full access to the organization (either directly or via any group) // check if current user has full access to the organization (either directly or via any group)
let has_full_access_to_org = member.access_all let has_full_access_to_org = member.has_full_access()
|| (CONFIG.org_groups_enabled() && GroupUser::has_full_access_by_member(&org_id, &member.uuid, &conn).await); || (CONFIG.org_groups_enabled() && GroupUser::has_full_access_by_member(&org_id, &member.uuid, &conn).await);
// Get all admins, owners and managers who can manage/access all // Get all admins, owners and managers who can manage/access all
@ -421,6 +443,11 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea
|| (CONFIG.org_groups_enabled() || (CONFIG.org_groups_enabled()
&& GroupUser::has_access_to_collection_by_member(&col.uuid, &member.uuid, &conn).await); && GroupUser::has_access_to_collection_by_member(&col.uuid, &member.uuid, &conn).await);
// If the user is a manager, and is not assigned to this collection, skip this and continue with the next collection
if !assigned {
continue;
}
// get the users assigned directly to the given collection // get the users assigned directly to the given collection
let mut users: Vec<Value> = col_users let mut users: Vec<Value> = col_users
.iter() .iter()
@ -475,12 +502,13 @@ async fn post_organization_collections(
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
} }
let data: FullCollectionData = data.into_inner(); let data: FullCollectionData = data.into_inner();
data.validate(&org_id, &conn).await?;
let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else { if headers.membership.atype == MembershipType::Manager && !headers.membership.access_all {
err!("Can't find organization details") err!("You don't have permission to create collections")
}; }
let collection = Collection::new(org.uuid, data.name, data.external_id); let collection = Collection::new(org_id.clone(), data.name, data.external_id);
collection.save(&conn).await?; collection.save(&conn).await?;
log_event( log_event(
@ -496,7 +524,7 @@ async fn post_organization_collections(
for group in data.groups { for group in data.groups {
CollectionGroup::new(collection.uuid.clone(), group.id, group.read_only, group.hide_passwords, group.manage) CollectionGroup::new(collection.uuid.clone(), group.id, group.read_only, group.hide_passwords, group.manage)
.save(&conn) .save(&org_id, &conn)
.await?; .await?;
} }
@ -520,10 +548,6 @@ async fn post_organization_collections(
.await?; .await?;
} }
if headers.membership.atype == MembershipType::Manager && !headers.membership.access_all {
CollectionUser::save(&headers.membership.user_uuid, &collection.uuid, false, false, false, &conn).await?;
}
Ok(Json(collection.to_json_details(&headers.membership.user_uuid, None, &conn).await)) Ok(Json(collection.to_json_details(&headers.membership.user_uuid, None, &conn).await))
} }
@ -574,10 +598,10 @@ async fn post_bulk_access_collections(
) )
.await; .await;
CollectionGroup::delete_all_by_collection(&col_id, &conn).await?; CollectionGroup::delete_all_by_collection(&col_id, &org_id, &conn).await?;
for group in &data.groups { for group in &data.groups {
CollectionGroup::new(col_id.clone(), group.id.clone(), group.read_only, group.hide_passwords, group.manage) CollectionGroup::new(col_id.clone(), group.id.clone(), group.read_only, group.hide_passwords, group.manage)
.save(&conn) .save(&org_id, &conn)
.await?; .await?;
} }
@ -622,6 +646,7 @@ async fn post_organization_collection_update(
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
} }
let data: FullCollectionData = data.into_inner(); let data: FullCollectionData = data.into_inner();
data.validate(&org_id, &conn).await?;
if Organization::find_by_uuid(&org_id, &conn).await.is_none() { if Organization::find_by_uuid(&org_id, &conn).await.is_none() {
err!("Can't find organization details") err!("Can't find organization details")
@ -650,11 +675,11 @@ async fn post_organization_collection_update(
) )
.await; .await;
CollectionGroup::delete_all_by_collection(&col_id, &conn).await?; CollectionGroup::delete_all_by_collection(&col_id, &org_id, &conn).await?;
for group in data.groups { for group in data.groups {
CollectionGroup::new(col_id.clone(), group.id, group.read_only, group.hide_passwords, group.manage) CollectionGroup::new(col_id.clone(), group.id, group.read_only, group.hide_passwords, group.manage)
.save(&conn) .save(&org_id, &conn)
.await?; .await?;
} }
@ -883,36 +908,21 @@ async fn _get_org_details(
Ok(json!(ciphers_json)) Ok(json!(ciphers_json))
} }
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct OrgDomainDetails {
email: String,
}
// Returning a Domain/Organization here allow to prefill it and prevent prompting the user // Returning a Domain/Organization here allow to prefill it and prevent prompting the user
// So we either return an Org name associated to the user or a dummy value. // So we return a dummy value, since we only support a single SSO integration, and do not use the response anywhere
// In use since `v2025.6.0`, appears to use only the first `organizationIdentifier` // In use since `v2025.6.0`, appears to use only the first `organizationIdentifier`
#[post("/organizations/domain/sso/verified", data = "<data>")] #[post("/organizations/domain/sso/verified")]
async fn get_org_domain_sso_verified(data: Json<OrgDomainDetails>, conn: DbConn) -> JsonResult { fn get_org_domain_sso_verified() -> JsonResult {
let data: OrgDomainDetails = data.into_inner(); // Always return a dummy value, no matter if SSO is enabled or not
let identifiers = match Organization::find_org_user_email(&data.email, &conn)
.await
.into_iter()
.map(|o| (o.name, o.uuid.to_string()))
.collect::<Vec<(String, String)>>()
{
v if !v.is_empty() => v,
_ => vec![(crate::sso::FAKE_IDENTIFIER.to_string(), crate::sso::FAKE_IDENTIFIER.to_string())],
};
Ok(Json(json!({ Ok(Json(json!({
"object": "list", "object": "list",
"data": identifiers.into_iter().map(|(name, identifier)| json!({ "data": [{
"organizationName": name, // appear unused "organizationIdentifier": FAKE_SSO_IDENTIFIER,
"organizationIdentifier": identifier, // These appear to be unused
"domainName": CONFIG.domain(), // appear unused "organizationName": FAKE_SSO_IDENTIFIER,
})).collect::<Vec<Value>>() "domainName": CONFIG.domain()
}],
"continuationToken": null
}))) })))
} }
@ -998,6 +1008,24 @@ struct InviteData {
permissions: HashMap<String, Value>, permissions: HashMap<String, Value>,
} }
impl InviteData {
async fn validate(&self, org_id: &OrganizationId, conn: &DbConn) -> EmptyResult {
let org_collections = Collection::find_by_organization(org_id, conn).await;
let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect();
if let Some(e) = self.collections.iter().flatten().find(|c| !org_collection_ids.contains(&c.id)) {
err!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id))
}
let org_groups = Group::find_by_organization(org_id, conn).await;
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect();
if let Some(e) = self.groups.iter().find(|g| !org_group_ids.contains(g)) {
err!("Invalid group", format!("Group {} does not belong to organization {}!", e, org_id))
}
Ok(())
}
}
#[post("/organizations/<org_id>/users/invite", data = "<data>")] #[post("/organizations/<org_id>/users/invite", data = "<data>")]
async fn send_invite( async fn send_invite(
org_id: OrganizationId, org_id: OrganizationId,
@ -1009,6 +1037,7 @@ async fn send_invite(
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
} }
let data: InviteData = data.into_inner(); let data: InviteData = data.into_inner();
data.validate(&org_id, &conn).await?;
// HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission // HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission
// The from_str() will convert the custom role type into a manager role type // The from_str() will convert the custom role type into a manager role type
@ -1268,20 +1297,20 @@ async fn accept_invite(
// skip invitation logic when we were invited via the /admin panel // skip invitation logic when we were invited via the /admin panel
if **member_id != FAKE_ADMIN_UUID { if **member_id != FAKE_ADMIN_UUID {
let Some(mut member) = Membership::find_by_uuid_and_org(member_id, &claims.org_id, &conn).await else { let Some(mut membership) = Membership::find_by_uuid_and_org(member_id, &claims.org_id, &conn).await else {
err!("Error accepting the invitation") err!("Error accepting the invitation")
}; };
let reset_password_key = match OrgPolicy::org_is_reset_password_auto_enroll(&member.org_uuid, &conn).await { let reset_password_key = match OrgPolicy::org_is_reset_password_auto_enroll(&membership.org_uuid, &conn).await {
true if data.reset_password_key.is_none() => err!("Reset password key is required, but not provided."), true if data.reset_password_key.is_none() => err!("Reset password key is required, but not provided."),
true => data.reset_password_key, true => data.reset_password_key,
false => None, false => None,
}; };
// In case the user was invited before the mail was saved in db. // In case the user was invited before the mail was saved in db.
member.invited_by_email = member.invited_by_email.or(claims.invited_by_email); membership.invited_by_email = membership.invited_by_email.or(claims.invited_by_email);
accept_org_invite(&headers.user, member, reset_password_key, &conn).await?; accept_org_invite(&headers.user, membership, reset_password_key, &conn).await?;
} else if CONFIG.mail_enabled() { } else if CONFIG.mail_enabled() {
// User was invited from /admin, so they are automatically confirmed // User was invited from /admin, so they are automatically confirmed
let org_name = CONFIG.invitation_org_name(); let org_name = CONFIG.invitation_org_name();
@ -1420,7 +1449,7 @@ async fn _confirm_invite(
let save_result = member_to_confirm.save(conn).await; let save_result = member_to_confirm.save(conn).await;
if let Some(user) = User::find_by_uuid(&member_to_confirm.user_uuid, conn).await { if let Some(user) = User::find_by_uuid(&member_to_confirm.user_uuid, conn).await {
nt.send_user_update(UpdateType::SyncOrgKeys, &user, &headers.device.push_uuid, conn).await; nt.send_user_update(UpdateType::SyncOrgKeys, &user, headers.device.push_uuid.as_ref(), conn).await;
} }
save_result save_result
@ -1515,9 +1544,8 @@ async fn edit_member(
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true)) && data.permissions.get("deleteAnyCollection") == Some(&json!(true))
&& data.permissions.get("createNewCollections") == Some(&json!(true))); && data.permissions.get("createNewCollections") == Some(&json!(true)));
let mut member_to_edit = match Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await { let Some(mut member_to_edit) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else {
Some(member) => member, err!("The specified user isn't member of the organization")
None => err!("The specified user isn't member of the organization"),
}; };
if new_type != member_to_edit.atype if new_type != member_to_edit.atype
@ -1679,7 +1707,7 @@ async fn _delete_member(
.await; .await;
if let Some(user) = User::find_by_uuid(&member_to_delete.user_uuid, conn).await { 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, conn).await; nt.send_user_update(UpdateType::SyncOrgKeys, &user, headers.device.push_uuid.as_ref(), conn).await;
} }
member_to_delete.delete(conn).await member_to_delete.delete(conn).await
@ -1834,7 +1862,6 @@ async fn post_org_import(
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct BulkCollectionsData { struct BulkCollectionsData {
organization_id: OrganizationId, organization_id: OrganizationId,
cipher_ids: Vec<CipherId>, cipher_ids: Vec<CipherId>,
@ -1848,6 +1875,10 @@ struct BulkCollectionsData {
async fn post_bulk_collections(data: Json<BulkCollectionsData>, headers: Headers, conn: DbConn) -> EmptyResult { async fn post_bulk_collections(data: Json<BulkCollectionsData>, headers: Headers, conn: DbConn) -> EmptyResult {
let data: BulkCollectionsData = data.into_inner(); let data: BulkCollectionsData = data.into_inner();
if Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await.is_none() {
err!("You need to be a Member of the Organization to call this endpoint")
}
// Get all the collection available to the user in one query // Get all the collection available to the user in one query
// Also filter based upon the provided collections // Also filter based upon the provided collections
let user_collections: HashMap<CollectionId, Collection> = let user_collections: HashMap<CollectionId, Collection> =
@ -1863,7 +1894,7 @@ async fn post_bulk_collections(data: Json<BulkCollectionsData>, headers: Headers
}) })
.collect(); .collect();
// Verify if all the collections requested exists and are writeable for the user, else abort // Verify if all the collections requested exists and are writable for the user, else abort
for collection_uuid in &data.collection_ids { for collection_uuid in &data.collection_ids {
match user_collections.get(collection_uuid) { match user_collections.get(collection_uuid) {
Some(collection) if collection.is_writable_by_user(&headers.user.uuid, &conn).await => (), Some(collection) if collection.is_writable_by_user(&headers.user.uuid, &conn).await => (),
@ -1933,10 +1964,20 @@ async fn list_policies_token(org_id: OrganizationId, token: &str, conn: DbConn)
}))) })))
} }
// Called during the SSO enrollment. // Called during the SSO enrollment return the default policy
// Return the org policy if it exists, otherwise use the default one. #[get("/organizations/00000000-01DC-01DC-01DC-000000000000/policies/master-password", rank = 1)]
#[get("/organizations/<org_id>/policies/master-password", rank = 1)] fn get_dummy_master_password_policy() -> JsonResult {
async fn get_master_password_policy(org_id: OrganizationId, _headers: Headers, conn: DbConn) -> JsonResult { let (enabled, data) = match CONFIG.sso_master_password_policy_value() {
Some(policy) if CONFIG.sso_enabled() => (true, policy.to_string()),
_ => (false, "null".to_string()),
};
let policy = OrgPolicy::new(FAKE_SSO_IDENTIFIER.into(), OrgPolicyType::MasterPassword, enabled, data);
Ok(Json(policy.to_json()))
}
// Called during the SSO enrollment return the org policy if it exists
#[get("/organizations/<org_id>/policies/master-password", rank = 2)]
async fn get_master_password_policy(org_id: OrganizationId, _headers: OrgMemberHeaders, conn: DbConn) -> JsonResult {
let policy = let policy =
OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::MasterPassword, &conn).await.unwrap_or_else(|| { OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::MasterPassword, &conn).await.unwrap_or_else(|| {
let (enabled, data) = match CONFIG.sso_master_password_policy_value() { let (enabled, data) = match CONFIG.sso_master_password_policy_value() {
@ -1950,7 +1991,7 @@ async fn get_master_password_policy(org_id: OrganizationId, _headers: Headers, c
Ok(Json(policy.to_json())) Ok(Json(policy.to_json()))
} }
#[get("/organizations/<org_id>/policies/<pol_type>", rank = 2)] #[get("/organizations/<org_id>/policies/<pol_type>", rank = 3)]
async fn get_policy(org_id: OrganizationId, pol_type: i32, headers: AdminHeaders, conn: DbConn) -> JsonResult { async fn get_policy(org_id: OrganizationId, pol_type: i32, headers: AdminHeaders, conn: DbConn) -> JsonResult {
if org_id != headers.org_id { if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
@ -2144,13 +2185,13 @@ fn get_plans() -> Json<Value> {
} }
#[get("/organizations/<_org_id>/billing/metadata")] #[get("/organizations/<_org_id>/billing/metadata")]
fn get_billing_metadata(_org_id: OrganizationId, _headers: Headers) -> Json<Value> { fn get_billing_metadata(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json<Value> {
// Prevent a 404 error, which also causes Javascript errors. // Prevent a 404 error, which also causes Javascript errors.
Json(_empty_data_json()) Json(_empty_data_json())
} }
#[get("/organizations/<_org_id>/billing/vnext/warnings")] #[get("/organizations/<_org_id>/billing/vnext/warnings")]
fn get_billing_warnings(_org_id: OrganizationId, _headers: Headers) -> Json<Value> { fn get_billing_warnings(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json<Value> {
Json(json!({ Json(json!({
"freeTrial":null, "freeTrial":null,
"inactiveSubscription":null, "inactiveSubscription":null,
@ -2159,6 +2200,15 @@ fn get_billing_warnings(_org_id: OrganizationId, _headers: Headers) -> Json<Valu
})) }))
} }
#[get("/organizations/<_org_id>/billing/vnext/self-host/metadata")]
fn get_self_host_billing_metadata(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json<Value> {
// Prevent a 404 error, which also causes Javascript errors.
Json(json!({
"isOnSecretsManagerStandalone": false, // Secrets Manager is not supported by Vaultwarden
"organizationOccupiedSeats": 0 // Vaultwarden does not count seats
}))
}
fn _empty_data_json() -> Value { fn _empty_data_json() -> Value {
json!({ json!({
"object": "list", "object": "list",
@ -2266,6 +2316,18 @@ async fn _revoke_member(
Ok(()) Ok(())
} }
#[put("/organizations/<org_id>/users/<member_id>/restore/vnext")]
async fn restore_member_vnext(
org_id: OrganizationId,
member_id: MembershipId,
headers: AdminHeaders,
conn: DbConn,
) -> EmptyResult {
// Vaultwarden does not (yet) support the per User Collection linked to the `Enforce organization data ownership` policy.
// Therefor we ignore the `defaultUserCollectionName` data sent and just call restore_member
_restore_member(&org_id, &member_id, &headers, &conn).await
}
#[put("/organizations/<org_id>/users/<member_id>/restore")] #[put("/organizations/<org_id>/users/<member_id>/restore")]
async fn restore_member( async fn restore_member(
org_id: OrganizationId, org_id: OrganizationId,
@ -2422,6 +2484,23 @@ impl GroupRequest {
group group
} }
/// Validate if all the collections and members belong to the provided organization
pub async fn validate(&self, org_id: &OrganizationId, conn: &DbConn) -> EmptyResult {
let org_collections = Collection::find_by_organization(org_id, conn).await;
let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect();
if let Some(e) = self.collections.iter().find(|c| !org_collection_ids.contains(&c.id)) {
err!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id))
}
let org_memberships = Membership::find_by_org(org_id, conn).await;
let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect();
if let Some(e) = self.users.iter().find(|m| !org_membership_ids.contains(m)) {
err!("Invalid member", format!("Member {} does not belong to organization {}!", e, org_id))
}
Ok(())
}
} }
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
@ -2465,6 +2544,8 @@ async fn post_groups(
} }
let group_request = data.into_inner(); let group_request = data.into_inner();
group_request.validate(&org_id, &conn).await?;
let group = group_request.to_group(&org_id); let group = group_request.to_group(&org_id);
log_event( log_event(
@ -2501,10 +2582,12 @@ async fn put_group(
}; };
let group_request = data.into_inner(); let group_request = data.into_inner();
group_request.validate(&org_id, &conn).await?;
let updated_group = group_request.update_group(group); let updated_group = group_request.update_group(group);
CollectionGroup::delete_all_by_group(&group_id, &conn).await?; CollectionGroup::delete_all_by_group(&group_id, &org_id, &conn).await?;
GroupUser::delete_all_by_group(&group_id, &conn).await?; GroupUser::delete_all_by_group(&group_id, &org_id, &conn).await?;
log_event( log_event(
EventType::GroupUpdated as i32, EventType::GroupUpdated as i32,
@ -2532,7 +2615,7 @@ async fn add_update_group(
for col_selection in collections { for col_selection in collections {
let mut collection_group = col_selection.to_collection_group(group.uuid.clone()); let mut collection_group = col_selection.to_collection_group(group.uuid.clone());
collection_group.save(conn).await?; collection_group.save(&org_id, conn).await?;
} }
for assigned_member in members { for assigned_member in members {
@ -2625,7 +2708,7 @@ async fn _delete_group(
) )
.await; .await;
group.delete(conn).await group.delete(org_id, conn).await
} }
#[delete("/organizations/<org_id>/groups", data = "<data>")] #[delete("/organizations/<org_id>/groups", data = "<data>")]
@ -2684,7 +2767,7 @@ async fn get_group_members(
err!("Group could not be found!", "Group uuid is invalid or does not belong to the organization") err!("Group could not be found!", "Group uuid is invalid or does not belong to the organization")
}; };
let group_members: Vec<MembershipId> = GroupUser::find_by_group(&group_id, &conn) let group_members: Vec<MembershipId> = GroupUser::find_by_group(&group_id, &org_id, &conn)
.await .await
.iter() .iter()
.map(|entry| entry.users_organizations_uuid.clone()) .map(|entry| entry.users_organizations_uuid.clone())
@ -2712,9 +2795,15 @@ async fn put_group_members(
err!("Group could not be found!", "Group uuid is invalid or does not belong to the organization") err!("Group could not be found!", "Group uuid is invalid or does not belong to the organization")
}; };
GroupUser::delete_all_by_group(&group_id, &conn).await?;
let assigned_members = data.into_inner(); let assigned_members = data.into_inner();
let org_memberships = Membership::find_by_org(&org_id, &conn).await;
let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect();
if let Some(e) = assigned_members.iter().find(|m| !org_membership_ids.contains(m)) {
err!("Invalid member", format!("Member {} does not belong to organization {}!", e, org_id))
}
GroupUser::delete_all_by_group(&group_id, &org_id, &conn).await?;
for assigned_member in assigned_members { for assigned_member in assigned_members {
let mut user_entry = GroupUser::new(group_id.clone(), assigned_member.clone()); let mut user_entry = GroupUser::new(group_id.clone(), assigned_member.clone());
user_entry.save(&conn).await?; user_entry.save(&conn).await?;
@ -2853,7 +2942,8 @@ async fn put_reset_password(
let reset_request = data.into_inner(); let reset_request = data.into_inner();
let mut user = user; let mut user = user;
user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None); user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None, &conn)
.await?;
user.save(&conn).await?; user.save(&conn).await?;
nt.send_logout(&user, None, &conn).await; nt.send_logout(&user, None, &conn).await;
@ -2945,17 +3035,19 @@ async fn check_reset_password_applicable(org_id: &OrganizationId, conn: &DbConn)
Ok(()) Ok(())
} }
#[put("/organizations/<org_id>/users/<member_id>/reset-password-enrollment", data = "<data>")] #[put("/organizations/<org_id>/users/<user_id>/reset-password-enrollment", data = "<data>")]
async fn put_reset_password_enrollment( async fn put_reset_password_enrollment(
org_id: OrganizationId, org_id: OrganizationId,
member_id: MembershipId, user_id: UserId,
headers: Headers, headers: OrgMemberHeaders,
data: Json<OrganizationUserResetPasswordEnrollmentRequest>, data: Json<OrganizationUserResetPasswordEnrollmentRequest>,
conn: DbConn, conn: DbConn,
) -> EmptyResult { ) -> EmptyResult {
let Some(mut member) = Membership::find_by_user_and_org(&headers.user.uuid, &org_id, &conn).await else { if user_id != headers.user.uuid {
err!("User to enroll isn't member of required organization") err!("User to enroll isn't member of required organization", "The user_id and acting user do not match");
}; }
let mut membership = headers.membership;
check_reset_password_applicable(&org_id, &conn).await?; check_reset_password_applicable(&org_id, &conn).await?;
@ -2980,16 +3072,17 @@ async fn put_reset_password_enrollment(
.await?; .await?;
} }
member.reset_password_key = reset_password_key; membership.reset_password_key = reset_password_key;
member.save(&conn).await?; membership.save(&conn).await?;
let log_id = if member.reset_password_key.is_some() { let event_type = if membership.reset_password_key.is_some() {
EventType::OrganizationUserResetPasswordEnroll as i32 EventType::OrganizationUserResetPasswordEnroll as i32
} else { } else {
EventType::OrganizationUserResetPasswordWithdraw as i32 EventType::OrganizationUserResetPasswordWithdraw as i32
}; };
log_event(log_id, &member_id, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; log_event(event_type, &membership.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await;
Ok(()) Ok(())
} }

2
src/api/core/public.rs

@ -156,7 +156,7 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
} }
}; };
GroupUser::delete_all_by_group(&group_uuid, &conn).await?; GroupUser::delete_all_by_group(&group_uuid, &org_id, &conn).await?;
for ext_id in &group_data.member_external_ids { for ext_id in &group_data.member_external_ids {
if let Some(member) = Membership::find_by_external_id_and_org(ext_id, &org_id, &conn).await { if let Some(member) = Membership::find_by_external_id_and_org(ext_id, &org_id, &conn).await {

4
src/api/core/sends.rs

@ -568,13 +568,13 @@ async fn post_access_file(
async fn download_url(host: &Host, send_id: &SendId, file_id: &SendFileId) -> Result<String, crate::Error> { async fn download_url(host: &Host, send_id: &SendId, file_id: &SendFileId) -> Result<String, crate::Error> {
let operator = CONFIG.opendal_operator_for_path_type(&PathType::Sends)?; let operator = CONFIG.opendal_operator_for_path_type(&PathType::Sends)?;
if operator.info().scheme() == <&'static str>::from(opendal::Scheme::Fs) { if crate::storage::is_fs_operator(&operator) {
let token_claims = crate::auth::generate_send_claims(send_id, file_id); let token_claims = crate::auth::generate_send_claims(send_id, file_id);
let token = crate::auth::encode_jwt(&token_claims); let token = crate::auth::encode_jwt(&token_claims);
Ok(format!("{}/api/sends/{send_id}/{file_id}?t={token}", &host.host)) Ok(format!("{}/api/sends/{send_id}/{file_id}?t={token}", &host.host))
} else { } else {
Ok(operator.presign_read(&format!("{send_id}/{file_id}"), Duration::from_secs(5 * 60)).await?.uri().to_string()) Ok(operator.presign_read(&format!("{send_id}/{file_id}"), Duration::from_mins(5)).await?.uri().to_string())
} }
} }

7
src/api/core/two_factor/email.rs

@ -25,7 +25,7 @@ pub fn routes() -> Vec<Route> {
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct SendEmailLoginData { struct SendEmailLoginData {
#[serde(alias = "DeviceIdentifier")] #[serde(alias = "DeviceIdentifier")]
device_identifier: DeviceId, device_identifier: Option<DeviceId>,
#[serde(alias = "Email")] #[serde(alias = "Email")]
email: Option<String>, email: Option<String>,
#[serde(alias = "MasterPasswordHash")] #[serde(alias = "MasterPasswordHash")]
@ -91,8 +91,11 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
user user
} else { } else {
let Some(device_identifier) = &data.device_identifier else {
err!("No device identifier has been submitted.")
};
// SSO login only sends device id, so we get the user by the most recently used device // SSO login only sends device id, so we get the user by the most recently used device
let Some(user) = User::find_by_device_for_email2fa(&data.device_identifier, &conn).await else { let Some(user) = User::find_by_device_for_email2fa(device_identifier, &conn).await else {
err!("Username or password is incorrect. Try again.") err!("Username or password is incorrect. Try again.")
}; };

49
src/api/core/two_factor/mod.rs

@ -1,7 +1,9 @@
use chrono::{TimeDelta, Utc}; use chrono::{TimeDelta, Utc};
use data_encoding::BASE32; use data_encoding::BASE32;
use num_traits::FromPrimitive;
use rocket::serde::json::Json; use rocket::serde::json::Json;
use rocket::Route; use rocket::Route;
use serde::Deserialize;
use serde_json::Value; use serde_json::Value;
use crate::{ use crate::{
@ -14,7 +16,7 @@ use crate::{
db::{ db::{
models::{ models::{
DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, TwoFactor, DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, TwoFactor,
TwoFactorIncomplete, User, UserId, TwoFactorIncomplete, TwoFactorType, User, UserId,
}, },
DbConn, DbPool, DbConn, DbPool,
}, },
@ -31,6 +33,43 @@ pub mod protected_actions;
pub mod webauthn; pub mod webauthn;
pub mod yubikey; pub mod yubikey;
fn has_global_duo_credentials() -> bool {
CONFIG._enable_duo() && CONFIG.duo_host().is_some() && CONFIG.duo_ikey().is_some() && CONFIG.duo_skey().is_some()
}
pub fn is_twofactor_provider_usable(provider_type: TwoFactorType, provider_data: Option<&str>) -> bool {
#[derive(Deserialize)]
struct DuoProviderData {
host: String,
ik: String,
sk: String,
}
match provider_type {
TwoFactorType::Authenticator => true,
TwoFactorType::Email => CONFIG._enable_email_2fa(),
TwoFactorType::Duo | TwoFactorType::OrganizationDuo => {
provider_data
.and_then(|raw| serde_json::from_str::<DuoProviderData>(raw).ok())
.is_some_and(|duo| !duo.host.is_empty() && !duo.ik.is_empty() && !duo.sk.is_empty())
|| has_global_duo_credentials()
}
TwoFactorType::YubiKey => {
CONFIG._enable_yubico() && CONFIG.yubico_client_id().is_some() && CONFIG.yubico_secret_key().is_some()
}
TwoFactorType::Webauthn => CONFIG.is_webauthn_2fa_supported(),
TwoFactorType::Remember => !CONFIG.disable_2fa_remember(),
TwoFactorType::RecoveryCode => true,
TwoFactorType::U2f
| TwoFactorType::U2fRegisterChallenge
| TwoFactorType::U2fLoginChallenge
| TwoFactorType::EmailVerificationChallenge
| TwoFactorType::WebauthnRegisterChallenge
| TwoFactorType::WebauthnLoginChallenge
| TwoFactorType::ProtectedActions => false,
}
}
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
let mut routes = routes![ let mut routes = routes![
get_twofactor, get_twofactor,
@ -53,7 +92,13 @@ pub fn routes() -> Vec<Route> {
#[get("/two-factor")] #[get("/two-factor")]
async fn get_twofactor(headers: Headers, conn: DbConn) -> Json<Value> { async fn get_twofactor(headers: Headers, conn: DbConn) -> Json<Value> {
let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn).await; let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn).await;
let twofactors_json: Vec<Value> = twofactors.iter().map(TwoFactor::to_json_provider).collect(); let twofactors_json: Vec<Value> = twofactors
.iter()
.filter_map(|tf| {
let provider_type = TwoFactorType::from_i32(tf.atype)?;
is_twofactor_provider_usable(provider_type, Some(&tf.data)).then(|| TwoFactor::to_json_provider(tf))
})
.collect();
Json(json!({ Json(json!({
"data": twofactors_json, "data": twofactors_json,

29
src/api/core/two_factor/webauthn.rs

@ -38,7 +38,7 @@ static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
let webauthn = WebauthnBuilder::new(&rp_id, &rp_origin) let webauthn = WebauthnBuilder::new(&rp_id, &rp_origin)
.expect("Creating WebauthnBuilder failed") .expect("Creating WebauthnBuilder failed")
.rp_name(&domain) .rp_name(&domain)
.timeout(Duration::from_millis(60000)); .timeout(Duration::from_mins(1));
webauthn.build().expect("Building Webauthn failed") webauthn.build().expect("Building Webauthn failed")
}); });
@ -108,8 +108,8 @@ impl WebauthnRegistration {
#[post("/two-factor/get-webauthn", data = "<data>")] #[post("/two-factor/get-webauthn", data = "<data>")]
async fn get_webauthn(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult { async fn get_webauthn(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> JsonResult {
if !CONFIG.domain_set() { if !CONFIG.is_webauthn_2fa_supported() {
err!("`DOMAIN` environment variable is not set. Webauthn disabled") err!("Configured `DOMAIN` is not compatible with Webauthn")
} }
let data: PasswordOrOtpData = data.into_inner(); let data: PasswordOrOtpData = data.into_inner();
@ -438,7 +438,7 @@ pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &Db
// We need to check for and update the backup_eligible flag when needed. // We need to check for and update the backup_eligible flag when needed.
// Vaultwarden did not have knowledge of this flag prior to migrating to webauthn-rs v0.5.x // Vaultwarden did not have knowledge of this flag prior to migrating to webauthn-rs v0.5.x
// Because of this we check the flag at runtime and update the registrations and state when needed // Because of this we check the flag at runtime and update the registrations and state when needed
check_and_update_backup_eligible(user_id, &rsp, &mut registrations, &mut state, conn).await?; let backup_flags_updated = check_and_update_backup_eligible(&rsp, &mut registrations, &mut state)?;
let authentication_result = WEBAUTHN.finish_passkey_authentication(&rsp, &state)?; let authentication_result = WEBAUTHN.finish_passkey_authentication(&rsp, &state)?;
@ -446,7 +446,8 @@ pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &Db
if ct_eq(reg.credential.cred_id(), authentication_result.cred_id()) { if ct_eq(reg.credential.cred_id(), authentication_result.cred_id()) {
// If the cred id matches and the credential is updated, Some(true) is returned // If the cred id matches and the credential is updated, Some(true) is returned
// In those cases, update the record, else leave it alone // In those cases, update the record, else leave it alone
if reg.credential.update_credential(&authentication_result) == Some(true) { let credential_updated = reg.credential.update_credential(&authentication_result) == Some(true);
if credential_updated || backup_flags_updated {
TwoFactor::new(user_id.clone(), TwoFactorType::Webauthn, serde_json::to_string(&registrations)?) TwoFactor::new(user_id.clone(), TwoFactorType::Webauthn, serde_json::to_string(&registrations)?)
.save(conn) .save(conn)
.await?; .await?;
@ -463,13 +464,11 @@ pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &Db
) )
} }
async fn check_and_update_backup_eligible( fn check_and_update_backup_eligible(
user_id: &UserId,
rsp: &PublicKeyCredential, rsp: &PublicKeyCredential,
registrations: &mut Vec<WebauthnRegistration>, registrations: &mut Vec<WebauthnRegistration>,
state: &mut PasskeyAuthentication, state: &mut PasskeyAuthentication,
conn: &DbConn, ) -> Result<bool, Error> {
) -> EmptyResult {
// The feature flags from the response // The feature flags from the response
// For details see: https://www.w3.org/TR/webauthn-3/#sctn-authenticator-data // For details see: https://www.w3.org/TR/webauthn-3/#sctn-authenticator-data
const FLAG_BACKUP_ELIGIBLE: u8 = 0b0000_1000; const FLAG_BACKUP_ELIGIBLE: u8 = 0b0000_1000;
@ -486,16 +485,7 @@ async fn check_and_update_backup_eligible(
let rsp_id = rsp.raw_id.as_slice(); let rsp_id = rsp.raw_id.as_slice();
for reg in &mut *registrations { for reg in &mut *registrations {
if ct_eq(reg.credential.cred_id().as_slice(), rsp_id) { if ct_eq(reg.credential.cred_id().as_slice(), rsp_id) {
// Try to update the key, and if needed also update the database, before the actual state check is done
if reg.set_backup_eligible(backup_eligible, backup_state) { if reg.set_backup_eligible(backup_eligible, backup_state) {
TwoFactor::new(
user_id.clone(),
TwoFactorType::Webauthn,
serde_json::to_string(&registrations)?,
)
.save(conn)
.await?;
// We also need to adjust the current state which holds the challenge used to start the authentication verification // We also need to adjust the current state which holds the challenge used to start the authentication verification
// Because Vaultwarden supports multiple keys, we need to loop through the deserialized state and check which key to update // Because Vaultwarden supports multiple keys, we need to loop through the deserialized state and check which key to update
let mut raw_state = serde_json::to_value(&state)?; let mut raw_state = serde_json::to_value(&state)?;
@ -517,11 +507,12 @@ async fn check_and_update_backup_eligible(
} }
*state = serde_json::from_value(raw_state)?; *state = serde_json::from_value(raw_state)?;
return Ok(true);
} }
break; break;
} }
} }
} }
} }
Ok(()) Ok(false)
} }

120
src/api/icons.rs

@ -19,7 +19,7 @@ use svg_hush::{data_url_filter, Filter};
use crate::{ use crate::{
config::PathType, config::PathType,
error::Error, error::Error,
http_client::{get_reqwest_client_builder, should_block_address, CustomHttpClientError}, http_client::{get_reqwest_client_builder, get_valid_host, should_block_host, CustomHttpClientError},
util::Cached, util::Cached,
CONFIG, CONFIG,
}; };
@ -81,19 +81,19 @@ static ICON_SIZE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?x)(\d+
// The function name `icon_external` is checked in the `on_response` function in `AppHeaders` // The function name `icon_external` is checked in the `on_response` function in `AppHeaders`
// It is used to prevent sending a specific header which breaks icon downloads. // It is used to prevent sending a specific header which breaks icon downloads.
// If this function needs to be renamed, also adjust the code in `util.rs` // If this function needs to be renamed, also adjust the code in `util.rs`
#[get("/<domain>/icon.png")] #[get("/<host>/icon.png")]
fn icon_external(domain: &str) -> Cached<Option<Redirect>> { fn icon_external(host: &str) -> Cached<Option<Redirect>> {
if !is_valid_domain(domain) { let Ok(host) = get_valid_host(host) else {
warn!("Invalid domain: {domain}"); warn!("Invalid host: {host}");
return Cached::ttl(None, CONFIG.icon_cache_negttl(), true); return Cached::ttl(None, CONFIG.icon_cache_negttl(), true);
} };
if should_block_address(domain) { if should_block_host(&host).is_err() {
warn!("Blocked address: {domain}"); warn!("Blocked address: {host}");
return Cached::ttl(None, CONFIG.icon_cache_negttl(), true); return Cached::ttl(None, CONFIG.icon_cache_negttl(), true);
} }
let url = CONFIG._icon_service_url().replace("{}", domain); let url = CONFIG._icon_service_url().replace("{}", &host.to_string());
let redir = match CONFIG.icon_redirect_code() { let redir = match CONFIG.icon_redirect_code() {
301 => Some(Redirect::moved(url)), // legacy permanent redirect 301 => Some(Redirect::moved(url)), // legacy permanent redirect
302 => Some(Redirect::found(url)), // legacy temporary redirect 302 => Some(Redirect::found(url)), // legacy temporary redirect
@ -107,21 +107,21 @@ fn icon_external(domain: &str) -> Cached<Option<Redirect>> {
Cached::ttl(redir, CONFIG.icon_cache_ttl(), true) Cached::ttl(redir, CONFIG.icon_cache_ttl(), true)
} }
#[get("/<domain>/icon.png")] #[get("/<host>/icon.png")]
async fn icon_internal(domain: &str) -> Cached<(ContentType, Vec<u8>)> { async fn icon_internal(host: &str) -> Cached<(ContentType, Vec<u8>)> {
const FALLBACK_ICON: &[u8] = include_bytes!("../static/images/fallback-icon.png"); const FALLBACK_ICON: &[u8] = include_bytes!("../static/images/fallback-icon.png");
if !is_valid_domain(domain) { let Ok(host) = get_valid_host(host) else {
warn!("Invalid domain: {domain}"); warn!("Invalid host: {host}");
return Cached::ttl( return Cached::ttl(
(ContentType::new("image", "png"), FALLBACK_ICON.to_vec()), (ContentType::new("image", "png"), FALLBACK_ICON.to_vec()),
CONFIG.icon_cache_negttl(), CONFIG.icon_cache_negttl(),
true, true,
); );
} };
if should_block_address(domain) { if should_block_host(&host).is_err() {
warn!("Blocked address: {domain}"); warn!("Blocked address: {host}");
return Cached::ttl( return Cached::ttl(
(ContentType::new("image", "png"), FALLBACK_ICON.to_vec()), (ContentType::new("image", "png"), FALLBACK_ICON.to_vec()),
CONFIG.icon_cache_negttl(), CONFIG.icon_cache_negttl(),
@ -129,7 +129,7 @@ async fn icon_internal(domain: &str) -> Cached<(ContentType, Vec<u8>)> {
); );
} }
match get_icon(domain).await { match get_icon(&host.to_string()).await {
Some((icon, icon_type)) => { Some((icon, icon_type)) => {
Cached::ttl((ContentType::new("image", icon_type), icon), CONFIG.icon_cache_ttl(), true) Cached::ttl((ContentType::new("image", icon_type), icon), CONFIG.icon_cache_ttl(), true)
} }
@ -137,42 +137,6 @@ async fn icon_internal(domain: &str) -> Cached<(ContentType, Vec<u8>)> {
} }
} }
/// Returns if the domain provided is valid or not.
///
/// This does some manual checks and makes use of Url to do some basic checking.
/// domains can't be larger then 63 characters (not counting multiple subdomains) according to the RFC's, but we limit the total size to 255.
fn is_valid_domain(domain: &str) -> bool {
const ALLOWED_CHARS: &str = "-.";
// If parsing the domain fails using Url, it will not work with reqwest.
if let Err(parse_error) = url::Url::parse(format!("https://{domain}").as_str()) {
debug!("Domain parse error: '{domain}' - {parse_error:?}");
return false;
} else if domain.is_empty()
|| domain.contains("..")
|| domain.starts_with('.')
|| domain.starts_with('-')
|| domain.ends_with('-')
{
debug!(
"Domain validation error: '{domain}' is either empty, contains '..', starts with an '.', starts or ends with a '-'"
);
return false;
} else if domain.len() > 255 {
debug!("Domain validation error: '{domain}' exceeds 255 characters");
return false;
}
for c in domain.chars() {
if !c.is_alphanumeric() && !ALLOWED_CHARS.contains(c) {
debug!("Domain validation error: '{domain}' contains an invalid character '{c}'");
return false;
}
}
true
}
async fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> { async fn get_icon(domain: &str) -> Option<(Vec<u8>, String)> {
let path = format!("{domain}.png"); let path = format!("{domain}.png");
@ -367,7 +331,7 @@ async fn get_icon_url(domain: &str) -> Result<IconUrlResult, Error> {
tld = domain_parts.next_back().unwrap(), tld = domain_parts.next_back().unwrap(),
base = domain_parts.next_back().unwrap() base = domain_parts.next_back().unwrap()
); );
if is_valid_domain(&base_domain) { if get_valid_host(&base_domain).is_ok() {
let sslbase = format!("https://{base_domain}"); let sslbase = format!("https://{base_domain}");
let httpbase = format!("http://{base_domain}"); let httpbase = format!("http://{base_domain}");
debug!("[get_icon_url]: Trying without subdomains '{base_domain}'"); debug!("[get_icon_url]: Trying without subdomains '{base_domain}'");
@ -378,7 +342,7 @@ async fn get_icon_url(domain: &str) -> Result<IconUrlResult, Error> {
// When the domain is not an IP, and has less then 2 dots, try to add www. infront of it. // When the domain is not an IP, and has less then 2 dots, try to add www. infront of it.
} else if is_ip.is_err() && domain.matches('.').count() < 2 { } else if is_ip.is_err() && domain.matches('.').count() < 2 {
let www_domain = format!("www.{domain}"); let www_domain = format!("www.{domain}");
if is_valid_domain(&www_domain) { if get_valid_host(&www_domain).is_ok() {
let sslwww = format!("https://{www_domain}"); let sslwww = format!("https://{www_domain}");
let httpwww = format!("http://{www_domain}"); let httpwww = format!("http://{www_domain}");
debug!("[get_icon_url]: Trying with www. prefix '{www_domain}'"); debug!("[get_icon_url]: Trying with www. prefix '{www_domain}'");
@ -513,13 +477,11 @@ fn parse_sizes(sizes: &str) -> (u16, u16) {
if !sizes.is_empty() { if !sizes.is_empty() {
match ICON_SIZE_REGEX.captures(sizes.trim()) { match ICON_SIZE_REGEX.captures(sizes.trim()) {
None => {} Some(dimensions) if dimensions.len() >= 3 => {
Some(dimensions) => { width = dimensions[1].parse::<u16>().unwrap_or_default();
if dimensions.len() >= 3 { height = dimensions[2].parse::<u16>().unwrap_or_default();
width = dimensions[1].parse::<u16>().unwrap_or_default();
height = dimensions[2].parse::<u16>().unwrap_or_default();
}
} }
_ => {}
} }
} }
@ -534,7 +496,8 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> {
use data_url::DataUrl; use data_url::DataUrl;
for icon in icon_result.iconlist.iter().take(5) { let mut icons = icon_result.iconlist.iter().take(5).peekable();
while let Some(icon) = icons.next() {
if icon.href.starts_with("data:image") { if icon.href.starts_with("data:image") {
let Ok(datauri) = DataUrl::process(&icon.href) else { let Ok(datauri) = DataUrl::process(&icon.href) else {
continue; continue;
@ -562,11 +525,23 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> {
_ => debug!("Extracted icon from data:image uri is invalid"), _ => debug!("Extracted icon from data:image uri is invalid"),
}; };
} else { } else {
let res = get_page_with_referer(&icon.href, &icon_result.referer).await?; debug!("Trying {}", icon.href);
// Make sure all icons are checked before returning error
let res = match get_page_with_referer(&icon.href, &icon_result.referer).await {
Ok(r) => r,
Err(e) if icons.peek().is_none() => return Err(e),
Err(e) if CustomHttpClientError::downcast_ref(&e).is_some() => return Err(e), // If blacklisted stop immediately instead of checking the rest of the icons. see explanation and actual handling inside get_icon()
Err(e) => {
warn!("Unable to download icon: {e:?}");
// Continue to next icon
continue;
}
};
buffer = stream_to_bytes_limit(res, 5120 * 1024).await?; // 5120KB/5MB for each icon max (Same as icons.bitwarden.net) buffer = stream_to_bytes_limit(res, 5120 * 1024).await?; // 5120KB/5MB for each icon max (Same as icons.bitwarden.net)
// Check if the icon type is allowed, else try an icon from the list. // Check if the icon type is allowed, else try another icon from the list.
icon_type = get_icon_type(&buffer); icon_type = get_icon_type(&buffer);
if icon_type.is_none() { if icon_type.is_none() {
buffer.clear(); buffer.clear();
@ -620,14 +595,17 @@ fn get_icon_type(bytes: &[u8]) -> Option<&'static str> {
None None
} }
// Some details can be found here:
// - https://www.garykessler.net/library/file_sigs_GCK_latest.html
// - https://en.wikipedia.org/wiki/List_of_file_signatures
match bytes { match bytes {
[137, 80, 78, 71, ..] => Some("png"), [137, 80, 78, 71, 13, 10, 26, 10, ..] => Some("png"),
[0, 0, 1, 0, ..] => Some("x-icon"), [0, 0, 1, 0, n1, n2, ..] if u16::from_le_bytes([*n1, *n2]) > 0 => Some("x-icon"), // https://en.wikipedia.org/wiki/ICO_(file_format)
[82, 73, 70, 70, ..] => Some("webp"), [82, 73, 70, 70, _, _, _, _, 87, 69, 66, 80, ..] => Some("webp"), // Only match WebP Images
[255, 216, 255, ..] => Some("jpeg"), [255, 216, 255, b, ..] if *b >= 0xC0 => Some("jpeg"),
[71, 73, 70, 56, ..] => Some("gif"), [71, 73, 70, 56, 55 | 57, 97, ..] => Some("gif"),
[66, 77, ..] => Some("bmp"), [66, 77, _, _, _, _, 0, 0, 0, 0, ..] => Some("bmp"), // https://en.wikipedia.org/wiki/BMP_file_format
[60, 115, 118, 103, ..] => Some("svg+xml"), // Normal svg [60, 115, 118, 103, ..] => Some("svg+xml"), // Normal svg
[60, 63, 120, 109, 108, ..] => check_svg_after_xml_declaration(bytes), // An svg starting with <?xml [60, 63, 120, 109, 108, ..] => check_svg_after_xml_declaration(bytes), // An svg starting with <?xml
_ => None, _ => None,
} }

256
src/api/identity.rs

@ -2,7 +2,7 @@ use chrono::Utc;
use num_traits::FromPrimitive; use num_traits::FromPrimitive;
use rocket::{ use rocket::{
form::{Form, FromForm}, form::{Form, FromForm},
http::Status, http::{Cookie, CookieJar, SameSite},
response::Redirect, response::Redirect,
serde::json::Json, serde::json::Json,
Route, Route,
@ -12,20 +12,25 @@ use serde_json::Value;
use crate::{ use crate::{
api::{ api::{
core::{ core::{
accounts::{PreloginData, RegisterData, _prelogin, _register, kdf_upgrade}, accounts::{_prelogin, _register, kdf_upgrade, PreloginData, RegisterData},
log_user_event, log_user_event,
two_factor::{authenticator, duo, duo_oidc, email, enforce_2fa_policy, webauthn, yubikey}, two_factor::{
authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn,
yubikey,
},
}, },
master_password_policy, master_password_policy,
push::register_push_device, push::register_push_device,
ApiResult, EmptyResult, JsonResult, ApiResult, EmptyResult, JsonResult,
}, },
auth, auth,
auth::{generate_organization_api_key_login_claims, AuthMethod, ClientHeaders, ClientIp, ClientVersion}, auth::{generate_organization_api_key_login_claims, AuthMethod, ClientHeaders, ClientIp, ClientVersion, Secure},
crypto,
db::{ db::{
models::{ models::{
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeWrapper, OrganizationApiKey, AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError,
OrganizationId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId, OrganizationApiKey, OrganizationId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User,
UserId,
}, },
DbConn, DbConn,
}, },
@ -39,6 +44,7 @@ pub fn routes() -> Vec<Route> {
routes![ routes![
login, login,
prelogin, prelogin,
prelogin_password,
identity_register, identity_register,
register_verification_email, register_verification_email,
register_finish, register_finish,
@ -62,43 +68,43 @@ async fn login(
let login_result = match data.grant_type.as_ref() { let login_result = match data.grant_type.as_ref() {
"refresh_token" => { "refresh_token" => {
_check_is_some(&data.refresh_token, "refresh_token cannot be blank")?; _check_is_some(data.refresh_token.as_ref(), "refresh_token cannot be blank")?;
_refresh_login(data, &conn, &client_header.ip).await _refresh_login(data, &conn, &client_header.ip).await
} }
"password" if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO sign-in is required"), "password" if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO sign-in is required"),
"password" => { "password" => {
_check_is_some(&data.client_id, "client_id cannot be blank")?; _check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
_check_is_some(&data.password, "password cannot be blank")?; _check_is_some(data.password.as_ref(), "password cannot be blank")?;
_check_is_some(&data.scope, "scope cannot be blank")?; _check_is_some(data.scope.as_ref(), "scope cannot be blank")?;
_check_is_some(&data.username, "username cannot be blank")?; _check_is_some(data.username.as_ref(), "username cannot be blank")?;
_check_is_some(&data.device_identifier, "device_identifier cannot be blank")?; _check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?;
_check_is_some(&data.device_name, "device_name cannot be blank")?; _check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
_check_is_some(&data.device_type, "device_type cannot be blank")?; _check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
_password_login(data, &mut user_id, &conn, &client_header.ip, &client_version).await _password_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await
} }
"client_credentials" => { "client_credentials" => {
_check_is_some(&data.client_id, "client_id cannot be blank")?; _check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
_check_is_some(&data.client_secret, "client_secret cannot be blank")?; _check_is_some(data.client_secret.as_ref(), "client_secret cannot be blank")?;
_check_is_some(&data.scope, "scope cannot be blank")?; _check_is_some(data.scope.as_ref(), "scope cannot be blank")?;
_check_is_some(&data.device_identifier, "device_identifier cannot be blank")?; _check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?;
_check_is_some(&data.device_name, "device_name cannot be blank")?; _check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
_check_is_some(&data.device_type, "device_type cannot be blank")?; _check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
_api_key_login(data, &mut user_id, &conn, &client_header.ip).await _api_key_login(data, &mut user_id, &conn, &client_header.ip).await
} }
"authorization_code" if CONFIG.sso_enabled() => { "authorization_code" if CONFIG.sso_enabled() => {
_check_is_some(&data.client_id, "client_id cannot be blank")?; _check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
_check_is_some(&data.code, "code cannot be blank")?; _check_is_some(data.code.as_ref(), "code cannot be blank")?;
_check_is_some(&data.code_verifier, "code verifier cannot be blank")?; _check_is_some(data.code_verifier.as_ref(), "code verifier cannot be blank")?;
_check_is_some(&data.device_identifier, "device_identifier cannot be blank")?; _check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?;
_check_is_some(&data.device_name, "device_name cannot be blank")?; _check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
_check_is_some(&data.device_type, "device_type cannot be blank")?; _check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
_sso_login(data, &mut user_id, &conn, &client_header.ip, &client_version).await _sso_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await
} }
"authorization_code" => err!("SSO sign-in is not available"), "authorization_code" => err!("SSO sign-in is not available"),
t => err!("Invalid type", t), t => err!("Invalid type", t),
@ -128,12 +134,14 @@ async fn login(
login_result login_result
} }
// Return Status::Unauthorized to trigger logout
async fn _refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonResult { async fn _refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonResult {
// Extract token // When a refresh token is invalid or missing we need to respond with an HTTP BadRequest (400)
let refresh_token = match data.refresh_token { // It also needs to return a json which holds at least a key `error` with the value `invalid_grant`
Some(token) => token, // See the link below for details
None => err_code!("Missing refresh_token", Status::Unauthorized.code), // https://github.com/bitwarden/clients/blob/2ee158e720a5e7dbe3641caf80b569e97a1dd91b/libs/common/src/services/api.service.ts#L1786-L1797
let Some(refresh_token) = data.refresh_token else {
err_json!(json!({"error": "invalid_grant"}), "Missing refresh_token")
}; };
// --- // ---
@ -144,7 +152,10 @@ async fn _refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> Json
// let members = Membership::find_confirmed_by_user(&user.uuid, conn).await; // let members = Membership::find_confirmed_by_user(&user.uuid, conn).await;
match auth::refresh_tokens(ip, &refresh_token, data.client_id, conn).await { match auth::refresh_tokens(ip, &refresh_token, data.client_id, conn).await {
Err(err) => { Err(err) => {
err_code!(format!("Unable to refresh login credentials: {}", err.message()), Status::Unauthorized.code) err_json!(
json!({"error": "invalid_grant"}),
format!("Unable to refresh login credentials: {}", err.message())
)
} }
Ok((mut device, auth_tokens)) => { Ok((mut device, auth_tokens)) => {
// Save to update `device.updated_at` to track usage and toggle new status // Save to update `device.updated_at` to track usage and toggle new status
@ -169,14 +180,14 @@ async fn _sso_login(
user_id: &mut Option<UserId>, user_id: &mut Option<UserId>,
conn: &DbConn, conn: &DbConn,
ip: &ClientIp, ip: &ClientIp,
client_version: &Option<ClientVersion>, client_version: Option<&ClientVersion>,
) -> JsonResult { ) -> JsonResult {
AuthMethod::Sso.check_scope(data.scope.as_ref())?; AuthMethod::Sso.check_scope(data.scope.as_ref())?;
// Ratelimit the login // Ratelimit the login
crate::ratelimit::check_limit_login(&ip.ip)?; crate::ratelimit::check_limit_login(&ip.ip)?;
let (state, code_verifier) = match (data.code.as_ref(), data.code_verifier.as_ref()) { let (code, code_verifier) = match (data.code.as_ref(), data.code_verifier.as_ref()) {
(None, _) => err!( (None, _) => err!(
"Got no code in OIDC data", "Got no code in OIDC data",
ErrorEvent { ErrorEvent {
@ -192,7 +203,7 @@ async fn _sso_login(
(Some(code), Some(code_verifier)) => (code, code_verifier.clone()), (Some(code), Some(code_verifier)) => (code, code_verifier.clone()),
}; };
let (sso_auth, user_infos) = sso::exchange_code(state, code_verifier, conn).await?; let (sso_auth, user_infos) = sso::exchange_code(code, code_verifier, conn).await?;
let user_with_sso = match SsoUser::find_by_identifier(&user_infos.identifier, conn).await { let user_with_sso = match SsoUser::find_by_identifier(&user_infos.identifier, conn).await {
None => match SsoUser::find_by_mail(&user_infos.email, conn).await { None => match SsoUser::find_by_mail(&user_infos.email, conn).await {
None => None, None => None,
@ -220,7 +231,33 @@ async fn _sso_login(
} }
) )
} }
Some((user, None)) => Some((user, None)), Some((user, None)) => match user_infos.email_verified {
None if !CONFIG.sso_allow_unknown_email_verification() => {
error!(
"Login failure ({}), existing non SSO user ({}) with same email ({}) and email verification status is unknown",
user_infos.identifier, user.uuid, user.email
);
err_silent!(
"Email verification status is unknown",
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
Some(false) => {
error!(
"Login failure ({}), existing non SSO user ({}) with same email ({}) and email is not verified",
user_infos.identifier, user.uuid, user.email
);
err_silent!(
"Email is not verified by the SSO provider",
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
_ => Some((user, None)),
},
}, },
Some((user, sso_user)) => Some((user, Some(sso_user))), Some((user, sso_user)) => Some((user, Some(sso_user))),
}; };
@ -312,7 +349,7 @@ async fn _password_login(
user_id: &mut Option<UserId>, user_id: &mut Option<UserId>,
conn: &DbConn, conn: &DbConn,
ip: &ClientIp, ip: &ClientIp,
client_version: &Option<ClientVersion>, client_version: Option<&ClientVersion>,
) -> JsonResult { ) -> JsonResult {
// Validate scope // Validate scope
AuthMethod::Password.check_scope(data.scope.as_ref())?; AuthMethod::Password.check_scope(data.scope.as_ref())?;
@ -633,6 +670,19 @@ async fn _user_api_key_login(
Value::Null Value::Null
}; };
let account_keys = if user.private_key.is_some() {
json!({
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": user.private_key,
"publicKey": user.public_key,
"Object": "publicKeyEncryptionKeyPair"
},
"Object": "privateKeys"
})
} else {
Value::Null
};
// Note: No refresh_token is returned. The CLI just repeats the // Note: No refresh_token is returned. The CLI just repeats the
// client_credentials login flow when the existing token expires. // client_credentials login flow when the existing token expires.
let result = json!({ let result = json!({
@ -647,7 +697,9 @@ async fn _user_api_key_login(
"KdfMemory": user.client_kdf_memory, "KdfMemory": user.client_kdf_memory,
"KdfParallelism": user.client_kdf_parallelism, "KdfParallelism": user.client_kdf_parallelism,
"ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing "ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing
"ForcePasswordReset": false,
"scope": AuthMethod::UserApiKey.scope(), "scope": AuthMethod::UserApiKey.scope(),
"AccountKeys": account_keys,
"UserDecryptionOptions": { "UserDecryptionOptions": {
"HasMasterPassword": has_master_password, "HasMasterPassword": has_master_password,
"MasterPasswordUnlock": master_password_unlock, "MasterPasswordUnlock": master_password_unlock,
@ -711,7 +763,7 @@ async fn twofactor_auth(
data: &ConnectData, data: &ConnectData,
device: &mut Device, device: &mut Device,
ip: &ClientIp, ip: &ClientIp,
client_version: &Option<ClientVersion>, client_version: Option<&ClientVersion>,
conn: &DbConn, conn: &DbConn,
) -> ApiResult<Option<String>> { ) -> ApiResult<Option<String>> {
let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await; let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await;
@ -724,8 +776,27 @@ async fn twofactor_auth(
TwoFactorIncomplete::mark_incomplete(&user.uuid, &device.uuid, &device.name, device.atype, ip, conn).await?; TwoFactorIncomplete::mark_incomplete(&user.uuid, &device.uuid, &device.name, device.atype, ip, conn).await?;
let twofactor_ids: Vec<_> = twofactors.iter().map(|tf| tf.atype).collect(); let twofactor_ids: Vec<_> = twofactors
.iter()
.filter_map(|tf| {
let provider_type = TwoFactorType::from_i32(tf.atype)?;
(tf.enabled && is_twofactor_provider_usable(provider_type, Some(&tf.data))).then_some(tf.atype)
})
.collect();
if twofactor_ids.is_empty() {
err!("No enabled and usable two factor providers are available for this account")
}
let selected_id = data.two_factor_provider.unwrap_or(twofactor_ids[0]); // If we aren't given a two factor provider, assume the first one let selected_id = data.two_factor_provider.unwrap_or(twofactor_ids[0]); // If we aren't given a two factor provider, assume the first one
// Ignore Remember and RecoveryCode Types during this check, these are special
if ![TwoFactorType::Remember as i32, TwoFactorType::RecoveryCode as i32].contains(&selected_id)
&& !twofactor_ids.contains(&selected_id)
{
err_json!(
_json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
"Invalid two factor provider"
)
}
let twofactor_code = match data.two_factor_token { let twofactor_code = match data.two_factor_token {
Some(ref code) => code, Some(ref code) => code,
@ -742,7 +813,6 @@ async fn twofactor_auth(
use crate::crypto::ct_eq; use crate::crypto::ct_eq;
let selected_data = _selected_data(selected_twofactor); let selected_data = _selected_data(selected_twofactor);
let mut remember = data.two_factor_remember.unwrap_or(0);
match TwoFactorType::from_i32(selected_id) { match TwoFactorType::from_i32(selected_id) {
Some(TwoFactorType::Authenticator) => { Some(TwoFactorType::Authenticator) => {
@ -774,13 +844,23 @@ async fn twofactor_auth(
} }
Some(TwoFactorType::Remember) => { Some(TwoFactorType::Remember) => {
match device.twofactor_remember { match device.twofactor_remember {
Some(ref code) if !CONFIG.disable_2fa_remember() && ct_eq(code, twofactor_code) => { // When a 2FA Remember token is used, check and validate this JWT token, if it is valid, just continue
remember = 1; // Make sure we also return the token here, otherwise it will only remember the first time // If it is invalid we need to trigger the 2FA Login prompt
} Some(ref token)
if !CONFIG.disable_2fa_remember()
&& (ct_eq(token, twofactor_code)
&& auth::decode_2fa_remember(twofactor_code)
.is_ok_and(|t| t.sub == device.uuid && t.user_uuid == user.uuid)) => {}
_ => { _ => {
// Always delete the current twofactor remember token here if it exists
if device.twofactor_remember.is_some() {
device.delete_twofactor_remember();
// We need to save here, since we send a err_json!() which prevents saving `device` at a later stage
device.save(true, conn).await?;
}
err_json!( err_json!(
_json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?, _json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
"2FA Remember token not provided" "2FA Remember token not provided or expired"
) )
} }
} }
@ -811,10 +891,10 @@ async fn twofactor_auth(
TwoFactorIncomplete::mark_complete(&user.uuid, &device.uuid, conn).await?; TwoFactorIncomplete::mark_complete(&user.uuid, &device.uuid, conn).await?;
let remember = data.two_factor_remember.unwrap_or(0);
let two_factor = if !CONFIG.disable_2fa_remember() && remember == 1 { let two_factor = if !CONFIG.disable_2fa_remember() && remember == 1 {
Some(device.refresh_twofactor_remember()) Some(device.refresh_twofactor_remember())
} else { } else {
device.delete_twofactor_remember();
None None
}; };
Ok(two_factor) Ok(two_factor)
@ -828,7 +908,7 @@ async fn _json_err_twofactor(
providers: &[i32], providers: &[i32],
user_id: &UserId, user_id: &UserId,
data: &ConnectData, data: &ConnectData,
client_version: &Option<ClientVersion>, client_version: Option<&ClientVersion>,
conn: &DbConn, conn: &DbConn,
) -> ApiResult<Value> { ) -> ApiResult<Value> {
let mut result = json!({ let mut result = json!({
@ -847,7 +927,7 @@ async fn _json_err_twofactor(
match TwoFactorType::from_i32(*provider) { match TwoFactorType::from_i32(*provider) {
Some(TwoFactorType::Authenticator) => { /* Nothing to do for TOTP */ } Some(TwoFactorType::Authenticator) => { /* Nothing to do for TOTP */ }
Some(TwoFactorType::Webauthn) if CONFIG.domain_set() => { Some(TwoFactorType::Webauthn) if CONFIG.is_webauthn_2fa_supported() => {
let request = webauthn::generate_webauthn_login(user_id, conn).await?; let request = webauthn::generate_webauthn_login(user_id, conn).await?;
result["TwoFactorProviders2"][provider.to_string()] = request.0; result["TwoFactorProviders2"][provider.to_string()] = request.0;
} }
@ -932,6 +1012,11 @@ async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
_prelogin(data, conn).await _prelogin(data, conn).await
} }
#[post("/accounts/prelogin/password", data = "<data>")]
async fn prelogin_password(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
_prelogin(data, conn).await
}
#[post("/accounts/register", data = "<data>")] #[post("/accounts/register", data = "<data>")]
async fn identity_register(data: Json<RegisterData>, conn: DbConn) -> JsonResult { async fn identity_register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
_register(data, false, conn).await _register(data, false, conn).await
@ -1054,11 +1139,11 @@ struct ConnectData {
// Needed for authorization code // Needed for authorization code
#[field(name = uncased("code"))] #[field(name = uncased("code"))]
code: Option<OIDCState>, code: Option<OIDCCode>,
#[field(name = uncased("code_verifier"))] #[field(name = uncased("code_verifier"))]
code_verifier: Option<OIDCCodeVerifier>, code_verifier: Option<OIDCCodeVerifier>,
} }
fn _check_is_some<T>(value: &Option<T>, msg: &str) -> EmptyResult { fn _check_is_some<T>(value: Option<&T>, msg: &str) -> EmptyResult {
if value.is_none() { if value.is_none() {
err!(msg) err!(msg)
} }
@ -1077,33 +1162,32 @@ fn prevalidate() -> JsonResult {
} }
} }
const SSO_BINDING_COOKIE: &str = "VW_SSO_BINDING";
#[get("/connect/oidc-signin?<code>&<state>", rank = 1)] #[get("/connect/oidc-signin?<code>&<state>", rank = 1)]
async fn oidcsignin(code: OIDCCode, state: String, mut conn: DbConn) -> ApiResult<Redirect> { async fn oidcsignin(code: OIDCCode, state: String, cookies: &CookieJar<'_>, mut conn: DbConn) -> ApiResult<Redirect> {
_oidcsignin_redirect( _oidcsignin_redirect(state, code, None, cookies, &mut conn).await
state,
OIDCCodeWrapper::Ok {
code,
},
&mut conn,
)
.await
} }
// Bitwarden client appear to only care for code and state so we pipe it through // Bitwarden client appear to only care for code and state
// cf: https://github.com/bitwarden/clients/blob/80b74b3300e15b4ae414dc06044cc9b02b6c10a6/libs/auth/src/angular/sso/sso.component.ts#L141 // We save the error in the database and set the encoded state as the code to be able to retrieve them later on
// cf: https://github.com/bitwarden/clients/blob/afd36d290ce18fb0048e0575e7d5a8f78b5dbffc/libs/auth/src/angular/sso/sso.component.ts#L156
#[get("/connect/oidc-signin?<state>&<error>&<error_description>", rank = 2)] #[get("/connect/oidc-signin?<state>&<error>&<error_description>", rank = 2)]
async fn oidcsignin_error( async fn oidcsignin_error(
state: String, state: String,
error: String, error: String,
error_description: Option<String>, error_description: Option<String>,
cookies: &CookieJar<'_>,
mut conn: DbConn, mut conn: DbConn,
) -> ApiResult<Redirect> { ) -> ApiResult<Redirect> {
_oidcsignin_redirect( _oidcsignin_redirect(
state, state.clone(),
OIDCCodeWrapper::Error { state.into(),
Some(OIDCCodeResponseError {
error, error,
error_description, error_description,
}, }),
cookies,
&mut conn, &mut conn,
) )
.await .await
@ -1111,10 +1195,11 @@ async fn oidcsignin_error(
// The state was encoded using Base64 to ensure no issue with providers. // The state was encoded using Base64 to ensure no issue with providers.
// iss and scope parameters are needed for redirection to work on IOS. // iss and scope parameters are needed for redirection to work on IOS.
// We pass the state as the code to get it back later on.
async fn _oidcsignin_redirect( async fn _oidcsignin_redirect(
base64_state: String, base64_state: String,
code_response: OIDCCodeWrapper, code: OIDCCode,
error: Option<OIDCCodeResponseError>,
cookies: &CookieJar<'_>,
conn: &mut DbConn, conn: &mut DbConn,
) -> ApiResult<Redirect> { ) -> ApiResult<Redirect> {
let state = sso::decode_state(&base64_state)?; let state = sso::decode_state(&base64_state)?;
@ -1123,7 +1208,20 @@ async fn _oidcsignin_redirect(
None => err!(format!("Cannot retrieve sso_auth for {state}")), None => err!(format!("Cannot retrieve sso_auth for {state}")),
Some(sso_auth) => sso_auth, Some(sso_auth) => sso_auth,
}; };
sso_auth.code_response = Some(code_response);
// Browser-binding check
// The cookie was set on /connect/authorize and must come from the same browser that initiated the flow.
let cookie_value = cookies.get(SSO_BINDING_COOKIE).map(|c| c.value().to_string());
let provided_hash = cookie_value.as_deref().map(|v| crypto::sha256_hex(v.as_bytes()));
match (sso_auth.binding_hash.as_deref(), provided_hash.as_deref()) {
(Some(expected), Some(actual)) if crypto::ct_eq(expected, actual) => {}
_ => err!(format!("SSO session binding mismatch for {state}")),
}
cookies
.remove(Cookie::build(SSO_BINDING_COOKIE).path(format!("{}/identity/connect/", CONFIG.domain_path())).build());
sso_auth.code_response = Some(code.clone());
sso_auth.code_response_error = error;
sso_auth.updated_at = Utc::now().naive_utc(); sso_auth.updated_at = Utc::now().naive_utc();
sso_auth.save(conn).await?; sso_auth.save(conn).await?;
@ -1133,7 +1231,7 @@ async fn _oidcsignin_redirect(
}; };
url.query_pairs_mut() url.query_pairs_mut()
.append_pair("code", &state) .append_pair("code", &code)
.append_pair("state", &state) .append_pair("state", &state)
.append_pair("scope", &AuthMethod::Sso.scope()) .append_pair("scope", &AuthMethod::Sso.scope())
.append_pair("iss", &CONFIG.domain()); .append_pair("iss", &CONFIG.domain());
@ -1169,7 +1267,7 @@ struct AuthorizeData {
// The `redirect_uri` will change depending of the client (web, android, ios ..) // The `redirect_uri` will change depending of the client (web, android, ios ..)
#[get("/connect/authorize?<data..>")] #[get("/connect/authorize?<data..>")]
async fn authorize(data: AuthorizeData, conn: DbConn) -> ApiResult<Redirect> { async fn authorize(data: AuthorizeData, cookies: &CookieJar<'_>, secure: Secure, conn: DbConn) -> ApiResult<Redirect> {
let AuthorizeData { let AuthorizeData {
client_id, client_id,
redirect_uri, redirect_uri,
@ -1183,7 +1281,23 @@ async fn authorize(data: AuthorizeData, conn: DbConn) -> ApiResult<Redirect> {
err!("Unsupported code challenge method"); err!("Unsupported code challenge method");
} }
let auth_url = sso::authorize_url(state, code_challenge, &client_id, &redirect_uri, conn).await?; // Generate browser-binding token. Stored hashed in DB; raw value handed to the browser as a cookie.
// Validated on /connect/oidc-signin
let binding_token = data_encoding::BASE64URL_NOPAD.encode(&crypto::get_random_bytes::<32>());
let binding_hash = crypto::sha256_hex(binding_token.as_bytes());
let auth_url =
sso::authorize_url(state, code_challenge, &client_id, &redirect_uri, Some(binding_hash), conn).await?;
cookies.add(
Cookie::build((SSO_BINDING_COOKIE, binding_token))
.path(format!("{}/identity/connect/", CONFIG.domain_path()))
.max_age(time::Duration::seconds(sso::SSO_AUTH_EXPIRATION.num_seconds()))
.same_site(SameSite::Lax) // Lax is needed because the IdP runs on a different FQDN
.http_only(true)
.secure(secure.https)
.build(),
);
Ok(Redirect::temporary(String::from(auth_url))) Ok(Redirect::temporary(String::from(auth_url)))
} }

9
src/api/notifications.rs

@ -338,7 +338,7 @@ impl WebSocketUsers {
} }
// NOTE: The last modified date needs to be updated before calling these methods // NOTE: The last modified date needs to be updated before calling these methods
pub async fn send_user_update(&self, ut: UpdateType, user: &User, push_uuid: &Option<PushId>, conn: &DbConn) { pub async fn send_user_update(&self, ut: UpdateType, user: &User, push_uuid: Option<&PushId>, conn: &DbConn) {
// Skip any processing if both WebSockets and Push are not active // Skip any processing if both WebSockets and Push are not active
if *NOTIFICATIONS_DISABLED { if *NOTIFICATIONS_DISABLED {
return; return;
@ -358,15 +358,16 @@ impl WebSocketUsers {
} }
} }
pub async fn send_logout(&self, user: &User, acting_device_id: Option<DeviceId>, conn: &DbConn) { pub async fn send_logout(&self, user: &User, acting_device: Option<&Device>, conn: &DbConn) {
// Skip any processing if both WebSockets and Push are not active // Skip any processing if both WebSockets and Push are not active
if *NOTIFICATIONS_DISABLED { if *NOTIFICATIONS_DISABLED {
return; return;
} }
let acting_device_id = acting_device.map(|d| d.uuid.clone());
let data = create_update( let data = create_update(
vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))], vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))],
UpdateType::LogOut, UpdateType::LogOut,
acting_device_id.clone(), acting_device_id,
); );
if CONFIG.enable_websocket() { if CONFIG.enable_websocket() {
@ -374,7 +375,7 @@ impl WebSocketUsers {
} }
if CONFIG.push_enabled() { if CONFIG.push_enabled() {
push_logout(user, acting_device_id.clone(), conn).await; push_logout(user, acting_device, conn).await;
} }
} }

14
src/api/push.rs

@ -13,7 +13,7 @@ use tokio::sync::RwLock;
use crate::{ use crate::{
api::{ApiResult, EmptyResult, UpdateType}, api::{ApiResult, EmptyResult, UpdateType},
db::{ db::{
models::{AuthRequestId, Cipher, Device, DeviceId, Folder, PushId, Send, User, UserId}, models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId},
DbConn, DbConn,
}, },
http_client::make_http_request, http_client::make_http_request,
@ -135,7 +135,7 @@ pub async fn register_push_device(device: &mut Device, conn: &DbConn) -> EmptyRe
Ok(()) Ok(())
} }
pub async fn unregister_push_device(push_id: &Option<PushId>) -> EmptyResult { pub async fn unregister_push_device(push_id: Option<&PushId>) -> EmptyResult {
if !CONFIG.push_enabled() || push_id.is_none() { if !CONFIG.push_enabled() || push_id.is_none() {
return Ok(()); return Ok(());
} }
@ -188,15 +188,13 @@ pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device
} }
} }
pub async fn push_logout(user: &User, acting_device_id: Option<DeviceId>, conn: &DbConn) { pub async fn push_logout(user: &User, acting_device: Option<&Device>, conn: &DbConn) {
let acting_device_id: Value = acting_device_id.map(|v| v.to_string().into()).unwrap_or_else(|| Value::Null);
if Device::check_user_has_push_device(&user.uuid, conn).await { if Device::check_user_has_push_device(&user.uuid, conn).await {
tokio::task::spawn(send_to_push_relay(json!({ tokio::task::spawn(send_to_push_relay(json!({
"userId": user.uuid, "userId": user.uuid,
"organizationId": (), "organizationId": (),
"deviceId": acting_device_id, "deviceId": acting_device.and_then(|d| d.push_uuid.as_ref()),
"identifier": acting_device_id, "identifier": acting_device.map(|d| &d.uuid),
"type": UpdateType::LogOut as i32, "type": UpdateType::LogOut as i32,
"payload": { "payload": {
"userId": user.uuid, "userId": user.uuid,
@ -208,7 +206,7 @@ pub async fn push_logout(user: &User, acting_device_id: Option<DeviceId>, conn:
} }
} }
pub async fn push_user_update(ut: UpdateType, user: &User, push_uuid: &Option<PushId>, conn: &DbConn) { pub async fn push_user_update(ut: UpdateType, user: &User, push_uuid: Option<&PushId>, conn: &DbConn) {
if Device::check_user_has_push_device(&user.uuid, conn).await { if Device::check_user_has_push_device(&user.uuid, conn).await {
tokio::task::spawn(send_to_push_relay(json!({ tokio::task::spawn(send_to_push_relay(json!({
"userId": user.uuid, "userId": user.uuid,

89
src/auth.rs

@ -46,6 +46,7 @@ static JWT_FILE_DOWNLOAD_ISSUER: LazyLock<String> =
LazyLock::new(|| format!("{}|file_download", CONFIG.domain_origin())); LazyLock::new(|| format!("{}|file_download", CONFIG.domain_origin()));
static JWT_REGISTER_VERIFY_ISSUER: LazyLock<String> = static JWT_REGISTER_VERIFY_ISSUER: LazyLock<String> =
LazyLock::new(|| format!("{}|register_verify", CONFIG.domain_origin())); LazyLock::new(|| format!("{}|register_verify", CONFIG.domain_origin()));
static JWT_2FA_REMEMBER_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|2faremember", CONFIG.domain_origin()));
static PRIVATE_RSA_KEY: OnceLock<EncodingKey> = OnceLock::new(); static PRIVATE_RSA_KEY: OnceLock<EncodingKey> = OnceLock::new();
static PUBLIC_RSA_KEY: OnceLock<DecodingKey> = OnceLock::new(); static PUBLIC_RSA_KEY: OnceLock<DecodingKey> = OnceLock::new();
@ -53,12 +54,8 @@ static PUBLIC_RSA_KEY: OnceLock<DecodingKey> = OnceLock::new();
pub async fn initialize_keys() -> Result<(), Error> { pub async fn initialize_keys() -> Result<(), Error> {
use std::io::Error; use std::io::Error;
let rsa_key_filename = std::path::PathBuf::from(CONFIG.private_rsa_key()) let rsa_key_filename = crate::storage::file_name(&CONFIG.private_rsa_key())
.file_name() .ok_or_else(|| Error::other("Private RSA key path missing filename"))?;
.ok_or_else(|| Error::other("Private RSA key path missing filename"))?
.to_str()
.ok_or_else(|| Error::other("Private RSA key path filename is not valid UTF-8"))?
.to_string();
let operator = CONFIG.opendal_operator_for_path_type(&PathType::RsaKey).map_err(Error::other)?; let operator = CONFIG.opendal_operator_for_path_type(&PathType::RsaKey).map_err(Error::other)?;
@ -160,6 +157,10 @@ pub fn decode_register_verify(token: &str) -> Result<RegisterVerifyClaims, Error
decode_jwt(token, JWT_REGISTER_VERIFY_ISSUER.to_string()) decode_jwt(token, JWT_REGISTER_VERIFY_ISSUER.to_string())
} }
pub fn decode_2fa_remember(token: &str) -> Result<TwoFactorRememberClaims, Error> {
decode_jwt(token, JWT_2FA_REMEMBER_ISSUER.to_string())
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct LoginJwtClaims { pub struct LoginJwtClaims {
// Not before // Not before
@ -440,6 +441,31 @@ pub fn generate_register_verify_claims(email: String, name: Option<String>, veri
} }
} }
#[derive(Serialize, Deserialize)]
pub struct TwoFactorRememberClaims {
// Not before
pub nbf: i64,
// Expiration time
pub exp: i64,
// Issuer
pub iss: String,
// Subject
pub sub: DeviceId,
// UserId
pub user_uuid: UserId,
}
pub fn generate_2fa_remember_claims(device_uuid: DeviceId, user_uuid: UserId) -> TwoFactorRememberClaims {
let time_now = Utc::now();
TwoFactorRememberClaims {
nbf: time_now.timestamp(),
exp: (time_now + TimeDelta::try_days(30).unwrap()).timestamp(),
iss: JWT_2FA_REMEMBER_ISSUER.to_string(),
sub: device_uuid,
user_uuid,
}
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct BasicJwtClaims { pub struct BasicJwtClaims {
// Not before // Not before
@ -674,10 +700,9 @@ pub struct OrgHeaders {
impl OrgHeaders { impl OrgHeaders {
fn is_member(&self) -> bool { fn is_member(&self) -> bool {
// NOTE: we don't care about MembershipStatus at the moment because this is only used // Only allow not revoked members, we can not use the Confirmed status here
// where an invited, accepted or confirmed user is expected if this ever changes or // as some endpoints can be triggered by invited users during joining
// if from_i32 is changed to return Some(Revoked) this check needs to be changed accordingly self.membership_status != MembershipStatus::Revoked && self.membership_type >= MembershipType::User
self.membership_type >= MembershipType::User
} }
fn is_confirmed_and_admin(&self) -> bool { fn is_confirmed_and_admin(&self) -> bool {
self.membership_status == MembershipStatus::Confirmed && self.membership_type >= MembershipType::Admin self.membership_status == MembershipStatus::Confirmed && self.membership_type >= MembershipType::Admin
@ -690,6 +715,36 @@ impl OrgHeaders {
} }
} }
// org_id is usually the second path param ("/organizations/<org_id>"),
// but there are cases where it is a query value.
// First check the path, if this is not a valid uuid, try the query values.
fn get_org_id(request: &Request<'_>) -> Option<OrganizationId> {
if let Some(Ok(org_id)) = request.param::<OrganizationId>(1) {
Some(org_id)
} else if let Some(Ok(org_id)) = request.query_value::<OrganizationId>("organizationId") {
Some(org_id)
} else {
None
}
}
// Special Guard to ensure that there is an organization id present
// If there is no org id trigger the Outcome::Forward.
// This is useful for endpoints which work for both organization and personal vaults, like purge.
pub struct OrgIdGuard;
#[rocket::async_trait]
impl<'r> FromRequest<'r> for OrgIdGuard {
type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
match get_org_id(request) {
Some(_) => Outcome::Success(OrgIdGuard),
None => Outcome::Forward(rocket::http::Status::NotFound),
}
}
}
#[rocket::async_trait] #[rocket::async_trait]
impl<'r> FromRequest<'r> for OrgHeaders { impl<'r> FromRequest<'r> for OrgHeaders {
type Error = &'static str; type Error = &'static str;
@ -697,18 +752,8 @@ impl<'r> FromRequest<'r> for OrgHeaders {
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> { async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = try_outcome!(Headers::from_request(request).await); let headers = try_outcome!(Headers::from_request(request).await);
// org_id is usually the second path param ("/organizations/<org_id>"), // Extract the org_id from the request
// but there are cases where it is a query value. let url_org_id = get_org_id(request);
// First check the path, if this is not a valid uuid, try the query values.
let url_org_id: Option<OrganizationId> = {
if let Some(Ok(org_id)) = request.param::<OrganizationId>(1) {
Some(org_id)
} else if let Some(Ok(org_id)) = request.query_value::<OrganizationId>("organizationId") {
Some(org_id)
} else {
None
}
};
match url_org_id { match url_org_id {
Some(org_id) if uuid::Uuid::parse_str(&org_id).is_ok() => { Some(org_id) if uuid::Uuid::parse_str(&org_id).is_ok() => {

230
src/config.rs

@ -14,23 +14,23 @@ use serde::de::{self, Deserialize, Deserializer, MapAccess, Visitor};
use crate::{ use crate::{
error::Error, error::Error,
util::{get_active_web_release, get_env, get_env_bool, is_valid_email, parse_experimental_client_feature_flags}, storage,
util::{
get_active_web_release, get_env, get_env_bool, is_valid_email, parse_experimental_client_feature_flags,
FeatureFlagFilter,
},
}; };
static CONFIG_FILE: LazyLock<String> = LazyLock::new(|| { static CONFIG_FILE: LazyLock<String> = LazyLock::new(|| {
let data_folder = get_env("DATA_FOLDER").unwrap_or_else(|| String::from("data")); let data_folder = get_env("DATA_FOLDER").unwrap_or_else(|| String::from("data"));
get_env("CONFIG_FILE").unwrap_or_else(|| format!("{data_folder}/config.json")) get_env("CONFIG_FILE").unwrap_or_else(|| storage::join_path(&data_folder, "config.json"))
}); });
static CONFIG_FILE_PARENT_DIR: LazyLock<String> = LazyLock::new(|| { static CONFIG_FILE_PARENT_DIR: LazyLock<String> =
let path = std::path::PathBuf::from(&*CONFIG_FILE); LazyLock::new(|| storage::parent(&CONFIG_FILE).unwrap_or_else(|| "data".to_string()));
path.parent().unwrap_or(std::path::Path::new("data")).to_str().unwrap_or("data").to_string()
});
static CONFIG_FILENAME: LazyLock<String> = LazyLock::new(|| { static CONFIG_FILENAME: LazyLock<String> =
let path = std::path::PathBuf::from(&*CONFIG_FILE); LazyLock::new(|| storage::file_name(&CONFIG_FILE).unwrap_or_else(|| "config.json".to_string()));
path.file_name().unwrap_or(std::ffi::OsStr::new("config.json")).to_str().unwrap_or("config.json").to_string()
});
pub static SKIP_CONFIG_VALIDATION: AtomicBool = AtomicBool::new(false); pub static SKIP_CONFIG_VALIDATION: AtomicBool = AtomicBool::new(false);
@ -260,7 +260,7 @@ macro_rules! make_config {
} }
async fn from_file() -> Result<Self, Error> { async fn from_file() -> Result<Self, Error> {
let operator = opendal_operator_for_path(&CONFIG_FILE_PARENT_DIR)?; let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
let config_bytes = operator.read(&CONFIG_FILENAME).await?; let config_bytes = operator.read(&CONFIG_FILENAME).await?;
println!("[INFO] Using saved config from `{}` for configuration.\n", *CONFIG_FILE); println!("[INFO] Using saved config from `{}` for configuration.\n", *CONFIG_FILE);
serde_json::from_slice(&config_bytes.to_vec()).map_err(Into::into) serde_json::from_slice(&config_bytes.to_vec()).map_err(Into::into)
@ -504,19 +504,19 @@ make_config! {
/// Data folder |> Main data folder /// Data folder |> Main data folder
data_folder: String, false, def, "data".to_string(); data_folder: String, false, def, "data".to_string();
/// Database URL /// Database URL
database_url: String, false, auto, |c| format!("{}/db.sqlite3", c.data_folder); database_url: String, false, auto, |c| format!("sqlite://{}", storage::join_path(&c.data_folder, "db.sqlite3"));
/// Icon cache folder /// Icon cache folder
icon_cache_folder: String, false, auto, |c| format!("{}/icon_cache", c.data_folder); icon_cache_folder: String, false, auto, |c| storage::join_path(&c.data_folder, "icon_cache");
/// Attachments folder /// Attachments folder
attachments_folder: String, false, auto, |c| format!("{}/attachments", c.data_folder); attachments_folder: String, false, auto, |c| storage::join_path(&c.data_folder, "attachments");
/// Sends folder /// Sends folder
sends_folder: String, false, auto, |c| format!("{}/sends", c.data_folder); sends_folder: String, false, auto, |c| storage::join_path(&c.data_folder, "sends");
/// Temp folder |> Used for storing temporary file uploads /// Temp folder |> Used for storing temporary file uploads
tmp_folder: String, false, auto, |c| format!("{}/tmp", c.data_folder); tmp_folder: String, false, auto, |c| storage::join_path(&c.data_folder, "tmp");
/// Templates folder /// Templates folder
templates_folder: String, false, auto, |c| format!("{}/templates", c.data_folder); templates_folder: String, false, auto, |c| storage::join_path(&c.data_folder, "templates");
/// Session JWT key /// Session JWT key
rsa_key_filename: String, false, auto, |c| format!("{}/rsa_key", c.data_folder); rsa_key_filename: String, false, auto, |c| storage::join_path(&c.data_folder, "rsa_key");
/// Web vault folder /// Web vault folder
web_vault_folder: String, false, def, "web-vault/".to_string(); web_vault_folder: String, false, def, "web-vault/".to_string();
}, },
@ -920,20 +920,23 @@ make_config! {
}, },
} }
fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
// Validate connection URL is valid and DB feature is enabled // Validate connection URL is valid and DB feature is enabled
#[cfg(sqlite)] #[cfg(sqlite)]
{ {
use crate::db::DbConnType; use crate::db::DbConnType;
let url = &cfg.database_url; let url = &cfg.database_url;
if DbConnType::from_url(url)? == DbConnType::Sqlite && url.contains('/') { if DbConnType::from_url(url)? == DbConnType::Sqlite {
let path = std::path::Path::new(&url); let file_path = url.strip_prefix("sqlite://").unwrap_or(url);
if let Some(parent) = path.parent() { if file_path.contains('/') {
if !parent.is_dir() { let path = std::path::Path::new(file_path);
err!(format!( if let Some(parent) = path.parent() {
"SQLite database directory `{}` does not exist or is not a directory", if !parent.is_dir() {
parent.display() err!(format!(
)); "SQLite database directory `{}` does not exist or is not a directory",
parent.display()
));
}
} }
} }
} }
@ -1026,33 +1029,17 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
} }
} }
// Server (v2025.6.2): https://github.com/bitwarden/server/blob/d094be3267f2030bd0dc62106bc6871cf82682f5/src/Core/Constants.cs#L103 let invalid_flags =
// Client (web-v2025.6.1): https://github.com/bitwarden/clients/blob/747c2fd6a1c348a57a76e4a7de8128466ffd3c01/libs/common/src/enums/feature-flag.enum.ts#L12 parse_experimental_client_feature_flags(&cfg.experimental_client_feature_flags, FeatureFlagFilter::InvalidOnly);
// Android (v2025.6.0): https://github.com/bitwarden/android/blob/b5b022caaad33390c31b3021b2c1205925b0e1a2/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt#L22
// iOS (v2025.6.0): https://github.com/bitwarden/ios/blob/ff06d9c6cc8da89f78f37f376495800201d7261a/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7
//
// NOTE: Move deprecated flags to the utils::parse_experimental_client_feature_flags() DEPRECATED_FLAGS const!
const KNOWN_FLAGS: &[&str] = &[
// Autofill Team
"inline-menu-positioning-improvements",
"inline-menu-totp",
"ssh-agent",
// Key Management Team
"ssh-key-vault-item",
"pm-25373-windows-biometrics-v2",
// Tools
"export-attachments",
// Mobile Team
"anon-addy-self-host-alias",
"simple-login-self-host-alias",
"mutual-tls",
];
let configured_flags = parse_experimental_client_feature_flags(&cfg.experimental_client_feature_flags);
let invalid_flags: Vec<_> = configured_flags.keys().filter(|flag| !KNOWN_FLAGS.contains(&flag.as_str())).collect();
if !invalid_flags.is_empty() { if !invalid_flags.is_empty() {
err!(format!("Unrecognized experimental client feature flags: {invalid_flags:?}.\n\n\ let feature_flags_error = format!("Unrecognized experimental client feature flags: {:?}.\n\
Please ensure all feature flags are spelled correctly and that they are supported in this version.\n\ Please ensure all feature flags are spelled correctly and that they are supported in this version.\n\
Supported flags: {KNOWN_FLAGS:?}")); Supported flags: {:?}\n", invalid_flags, SUPPORTED_FEATURE_FLAGS);
if on_update {
err!(feature_flags_error);
} else {
println!("[WARNING] {feature_flags_error}");
}
} }
const MAX_FILESIZE_KB: i64 = i64::MAX >> 10; const MAX_FILESIZE_KB: i64 = i64::MAX >> 10;
@ -1089,7 +1076,7 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
validate_internal_sso_issuer_url(&cfg.sso_authority)?; validate_internal_sso_issuer_url(&cfg.sso_authority)?;
validate_internal_sso_redirect_url(&cfg.sso_callback_path)?; validate_internal_sso_redirect_url(&cfg.sso_callback_path)?;
validate_sso_master_password_policy(&cfg.sso_master_password_policy)?; validate_sso_master_password_policy(cfg.sso_master_password_policy.as_ref())?;
} }
if cfg._enable_yubico { if cfg._enable_yubico {
@ -1284,7 +1271,7 @@ fn validate_internal_sso_redirect_url(sso_callback_path: &String) -> Result<open
} }
fn validate_sso_master_password_policy( fn validate_sso_master_password_policy(
sso_master_password_policy: &Option<String>, sso_master_password_policy: Option<&String>,
) -> Result<Option<serde_json::Value>, Error> { ) -> Result<Option<serde_json::Value>, Error> {
let policy = sso_master_password_policy.as_ref().map(|mpp| serde_json::from_str::<serde_json::Value>(mpp)); let policy = sso_master_password_policy.as_ref().map(|mpp| serde_json::from_str::<serde_json::Value>(mpp));
@ -1379,90 +1366,6 @@ fn smtp_convert_deprecated_ssl_options(smtp_ssl: Option<bool>, smtp_explicit_tls
"starttls".to_string() "starttls".to_string()
} }
fn opendal_operator_for_path(path: &str) -> Result<opendal::Operator, Error> {
// Cache of previously built operators by path
static OPERATORS_BY_PATH: LazyLock<dashmap::DashMap<String, opendal::Operator>> =
LazyLock::new(dashmap::DashMap::new);
if let Some(operator) = OPERATORS_BY_PATH.get(path) {
return Ok(operator.clone());
}
let operator = if path.starts_with("s3://") {
#[cfg(not(s3))]
return Err(opendal::Error::new(opendal::ErrorKind::ConfigInvalid, "S3 support is not enabled").into());
#[cfg(s3)]
opendal_s3_operator_for_path(path)?
} else {
let builder = opendal::services::Fs::default().root(path);
opendal::Operator::new(builder)?.finish()
};
OPERATORS_BY_PATH.insert(path.to_string(), operator.clone());
Ok(operator)
}
#[cfg(s3)]
fn opendal_s3_operator_for_path(path: &str) -> Result<opendal::Operator, Error> {
use crate::http_client::aws::AwsReqwestConnector;
use aws_config::{default_provider::credentials::DefaultCredentialsChain, provider_config::ProviderConfig};
// This is a custom AWS credential loader that uses the official AWS Rust
// SDK config crate to load credentials. This ensures maximum compatibility
// with AWS credential configurations. For example, OpenDAL doesn't support
// AWS SSO temporary credentials yet.
struct OpenDALS3CredentialLoader {}
#[async_trait]
impl reqsign::AwsCredentialLoad for OpenDALS3CredentialLoader {
async fn load_credential(&self, _client: reqwest::Client) -> anyhow::Result<Option<reqsign::AwsCredential>> {
use aws_credential_types::provider::ProvideCredentials as _;
use tokio::sync::OnceCell;
static DEFAULT_CREDENTIAL_CHAIN: OnceCell<DefaultCredentialsChain> = OnceCell::const_new();
let chain = DEFAULT_CREDENTIAL_CHAIN
.get_or_init(|| {
let reqwest_client = reqwest::Client::builder().build().unwrap();
let connector = AwsReqwestConnector {
client: reqwest_client,
};
let conf = ProviderConfig::default().with_http_client(connector);
DefaultCredentialsChain::builder().configure(conf).build()
})
.await;
let creds = chain.provide_credentials().await?;
Ok(Some(reqsign::AwsCredential {
access_key_id: creds.access_key_id().to_string(),
secret_access_key: creds.secret_access_key().to_string(),
session_token: creds.session_token().map(|s| s.to_string()),
expires_in: creds.expiry().map(|expiration| expiration.into()),
}))
}
}
const OPEN_DAL_S3_CREDENTIAL_LOADER: OpenDALS3CredentialLoader = OpenDALS3CredentialLoader {};
let url = Url::parse(path).map_err(|e| format!("Invalid path S3 URL path {path:?}: {e}"))?;
let bucket = url.host_str().ok_or_else(|| format!("Missing Bucket name in data folder S3 URL {path:?}"))?;
let builder = opendal::services::S3::default()
.customized_credential_load(Box::new(OPEN_DAL_S3_CREDENTIAL_LOADER))
.enable_virtual_host_style()
.bucket(bucket)
.root(url.path())
.default_storage_class("INTELLIGENT_TIERING");
Ok(opendal::Operator::new(builder)?.finish())
}
pub enum PathType { pub enum PathType {
Data, Data,
IconCache, IconCache,
@ -1471,6 +1374,35 @@ pub enum PathType {
RsaKey, RsaKey,
} }
// Official available feature flags can be found here:
// Server (v2026.2.1): https://github.com/bitwarden/server/blob/0e42725d0837bd1c0dabd864ff621a579959744b/src/Core/Constants.cs#L135
// Client (v2026.2.1): https://github.com/bitwarden/clients/blob/f96380c3138291a028bdd2c7a5fee540d5c98ba5/libs/common/src/enums/feature-flag.enum.ts#L12
// Android (v2026.2.1): https://github.com/bitwarden/android/blob/6902c19c0093fa476bbf74ccaa70c9f14afbb82f/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt#L31
// iOS (v2026.2.1): https://github.com/bitwarden/ios/blob/cdd9ba1770ca2ffc098d02d12cc3208e3a830454/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7
pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
// Architecture
"desktop-ui-migration-milestone-1",
"desktop-ui-migration-milestone-2",
"desktop-ui-migration-milestone-3",
"desktop-ui-migration-milestone-4",
// Auth Team
"pm-5594-safari-account-switching",
// Autofill Team
"ssh-agent",
"ssh-agent-v2",
// Key Management Team
"ssh-key-vault-item",
"pm-25373-windows-biometrics-v2",
// Mobile Team
"anon-addy-self-host-alias",
"simple-login-self-host-alias",
"mutual-tls",
"cxp-import-mobile",
"cxp-export-mobile",
// Platform Team
"pm-30529-webauthn-related-origins",
];
impl Config { impl Config {
pub async fn load() -> Result<Self, Error> { pub async fn load() -> Result<Self, Error> {
// Loading from env and file // Loading from env and file
@ -1484,7 +1416,7 @@ impl Config {
// Fill any missing with defaults // Fill any missing with defaults
let config = builder.build(); let config = builder.build();
if !SKIP_CONFIG_VALIDATION.load(Ordering::Relaxed) { if !SKIP_CONFIG_VALIDATION.load(Ordering::Relaxed) {
validate_config(&config)?; validate_config(&config, false)?;
} }
Ok(Config { Ok(Config {
@ -1520,7 +1452,7 @@ impl Config {
let env = &self.inner.read().unwrap()._env; let env = &self.inner.read().unwrap()._env;
env.merge(&builder, false, &mut overrides).build() env.merge(&builder, false, &mut overrides).build()
}; };
validate_config(&config)?; validate_config(&config, true)?;
// Save both the user and the combined config // Save both the user and the combined config
{ {
@ -1531,7 +1463,7 @@ impl Config {
} }
//Save to file //Save to file
let operator = opendal_operator_for_path(&CONFIG_FILE_PARENT_DIR)?; let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
operator.write(&CONFIG_FILENAME, config_str).await?; operator.write(&CONFIG_FILENAME, config_str).await?;
Ok(()) Ok(())
@ -1596,7 +1528,7 @@ impl Config {
} }
pub async fn delete_user_config(&self) -> Result<(), Error> { pub async fn delete_user_config(&self) -> Result<(), Error> {
let operator = opendal_operator_for_path(&CONFIG_FILE_PARENT_DIR)?; let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
operator.delete(&CONFIG_FILENAME).await?; operator.delete(&CONFIG_FILENAME).await?;
// Empty user config // Empty user config
@ -1620,7 +1552,7 @@ impl Config {
} }
pub fn private_rsa_key(&self) -> String { pub fn private_rsa_key(&self) -> String {
format!("{}.pem", self.rsa_key_filename()) storage::with_extension(&self.rsa_key_filename(), "pem")
} }
pub fn mail_enabled(&self) -> bool { pub fn mail_enabled(&self) -> bool {
let inner = &self.inner.read().unwrap().config; let inner = &self.inner.read().unwrap().config;
@ -1661,15 +1593,11 @@ impl Config {
PathType::IconCache => self.icon_cache_folder(), PathType::IconCache => self.icon_cache_folder(),
PathType::Attachments => self.attachments_folder(), PathType::Attachments => self.attachments_folder(),
PathType::Sends => self.sends_folder(), PathType::Sends => self.sends_folder(),
PathType::RsaKey => std::path::Path::new(&self.rsa_key_filename()) PathType::RsaKey => storage::parent(&self.private_rsa_key())
.parent() .ok_or_else(|| std::io::Error::other("Failed to get directory of RSA key file"))?,
.ok_or_else(|| std::io::Error::other("Failed to get directory of RSA key file"))?
.to_str()
.ok_or_else(|| std::io::Error::other("Failed to convert RSA key file directory to UTF-8 string"))?
.to_string(),
}; };
opendal_operator_for_path(&path) storage::operator_for_path(&path)
} }
pub fn render_template<T: serde::ser::Serialize>(&self, name: &str, data: &T) -> Result<String, Error> { pub fn render_template<T: serde::ser::Serialize>(&self, name: &str, data: &T) -> Result<String, Error> {
@ -1709,7 +1637,7 @@ impl Config {
} }
pub fn sso_master_password_policy_value(&self) -> Option<serde_json::Value> { pub fn sso_master_password_policy_value(&self) -> Option<serde_json::Value> {
validate_sso_master_password_policy(&self.sso_master_password_policy()).ok().flatten() validate_sso_master_password_policy(self.sso_master_password_policy().as_ref()).ok().flatten()
} }
pub fn sso_scopes_vec(&self) -> Vec<String> { pub fn sso_scopes_vec(&self) -> Vec<String> {

7
src/crypto.rs

@ -113,3 +113,10 @@ pub fn ct_eq<T: AsRef<[u8]>, U: AsRef<[u8]>>(a: T, b: U) -> bool {
use subtle::ConstantTimeEq; use subtle::ConstantTimeEq;
a.as_ref().ct_eq(b.as_ref()).into() a.as_ref().ct_eq(b.as_ref()).into()
} }
//
// SHA256
//
pub fn sha256_hex(data: &[u8]) -> String {
HEXLOWER.encode(digest::digest(&digest::SHA256, data).as_ref())
}

52
src/db/mod.rs

@ -272,13 +272,32 @@ impl DbConnType {
#[cfg(not(postgresql))] #[cfg(not(postgresql))]
err!("`DATABASE_URL` is a PostgreSQL URL, but the 'postgresql' feature is not enabled") err!("`DATABASE_URL` is a PostgreSQL URL, but the 'postgresql' feature is not enabled")
//Sqlite // Sqlite (explicit)
} else { } else if url.len() > 7 && &url[..7] == "sqlite:" {
#[cfg(sqlite)] #[cfg(sqlite)]
return Ok(DbConnType::Sqlite); return Ok(DbConnType::Sqlite);
#[cfg(not(sqlite))] #[cfg(not(sqlite))]
err!("`DATABASE_URL` looks like a SQLite URL, but 'sqlite' feature is not enabled") err!("`DATABASE_URL` is a SQLite URL, but the 'sqlite' feature is not enabled")
// No recognized scheme — assume legacy bare-path SQLite, but the database file must already exist.
// This prevents misconfigured URLs (typos, quoted strings) from silently creating a new empty SQLite database.
} else {
#[cfg(sqlite)]
{
if std::path::Path::new(url).exists() {
return Ok(DbConnType::Sqlite);
}
err!(format!(
"`DATABASE_URL` does not match any known database scheme (mysql://, postgresql://, sqlite://) \
and no existing SQLite database was found at '{url}'. \
If you intend to use SQLite, use an explicit `sqlite://` scheme in your `DATABASE_URL`. \
Otherwise, check your DATABASE_URL for typos or quoting issues."
))
}
#[cfg(not(sqlite))]
err!("`DATABASE_URL` does not match any known database scheme (mysql://, postgresql://, sqlite://)")
} }
} }
@ -387,30 +406,27 @@ pub mod models;
#[cfg(sqlite)] #[cfg(sqlite)]
pub fn backup_sqlite() -> Result<String, Error> { pub fn backup_sqlite() -> Result<String, Error> {
use diesel::Connection; use diesel::Connection;
use std::{fs::File, io::Write};
let db_url = CONFIG.database_url(); let db_url = CONFIG.database_url();
if DbConnType::from_url(&CONFIG.database_url()).map(|t| t == DbConnType::Sqlite).unwrap_or(false) { if DbConnType::from_url(&CONFIG.database_url()).map(|t| t == DbConnType::Sqlite).unwrap_or(false) {
// Since we do not allow any schema for sqlite database_url's like `file:` or `sqlite:` to be set, we can assume here it isn't // Strip the sqlite:// prefix if present to get the raw file path
// This way we can set a readonly flag on the opening mode without issues. let file_path = db_url.strip_prefix("sqlite://").unwrap_or(&db_url);
let mut conn = diesel::sqlite::SqliteConnection::establish(&format!("sqlite://{db_url}?mode=ro"))?; // Open a read-only connection for the backup
let mut conn = diesel::sqlite::SqliteConnection::establish(&format!("sqlite://{file_path}?mode=ro"))?;
let db_path = std::path::Path::new(&db_url).parent().unwrap(); let db_path = std::path::Path::new(file_path).parent().unwrap();
let backup_file = db_path let backup_file = db_path
.join(format!("db_{}.sqlite3", chrono::Utc::now().format("%Y%m%d_%H%M%S"))) .join(format!("db_{}.sqlite3", chrono::Utc::now().format("%Y%m%d_%H%M%S")))
.to_string_lossy() .to_string_lossy()
.into_owned(); .into_owned();
match File::create(backup_file.clone()) { diesel::sql_query("VACUUM INTO ?")
Ok(mut f) => { .bind::<diesel::sql_types::Text, _>(&backup_file)
let serialized_db = conn.serialize_database_to_buffer(); .execute(&mut conn)
f.write_all(serialized_db.as_slice()).expect("Error writing SQLite backup"); .map(|_| ())
Ok(backup_file) .map_res("VACUUM INTO failed")?;
}
Err(e) => { Ok(backup_file)
err_silent!(format!("Unable to save SQLite backup: {e:?}"))
}
}
} else { } else {
err_silent!("The database type is not SQLite. Backups only works for SQLite databases") err_silent!("The database type is not SQLite. Backups only works for SQLite databases")
} }

91
src/db/models/archive.rs

@ -0,0 +1,91 @@
use chrono::NaiveDateTime;
use diesel::prelude::*;
use super::{CipherId, User, UserId};
use crate::api::EmptyResult;
use crate::db::schema::archives;
use crate::db::DbConn;
use crate::error::MapResult;
#[derive(Identifiable, Queryable, Insertable)]
#[diesel(table_name = archives)]
#[diesel(primary_key(user_uuid, cipher_uuid))]
pub struct Archive {
pub user_uuid: UserId,
pub cipher_uuid: CipherId,
pub archived_at: NaiveDateTime,
}
impl Archive {
// Returns the date the specified cipher was archived
pub async fn get_archived_at(cipher_uuid: &CipherId, user_uuid: &UserId, conn: &DbConn) -> Option<NaiveDateTime> {
db_run! { conn: {
archives::table
.filter(archives::cipher_uuid.eq(cipher_uuid))
.filter(archives::user_uuid.eq(user_uuid))
.select(archives::archived_at)
.first::<NaiveDateTime>(conn).ok()
}}
}
// Saves (inserts or updates) an archive record with the provided timestamp
pub async fn save(
user_uuid: &UserId,
cipher_uuid: &CipherId,
archived_at: NaiveDateTime,
conn: &DbConn,
) -> EmptyResult {
User::update_uuid_revision(user_uuid, conn).await;
db_run! { conn:
sqlite, mysql {
diesel::replace_into(archives::table)
.values((
archives::user_uuid.eq(user_uuid),
archives::cipher_uuid.eq(cipher_uuid),
archives::archived_at.eq(archived_at),
))
.execute(conn)
.map_res("Error saving archive")
}
postgresql {
diesel::insert_into(archives::table)
.values((
archives::user_uuid.eq(user_uuid),
archives::cipher_uuid.eq(cipher_uuid),
archives::archived_at.eq(archived_at),
))
.on_conflict((archives::user_uuid, archives::cipher_uuid))
.do_update()
.set(archives::archived_at.eq(archived_at))
.execute(conn)
.map_res("Error saving archive")
}
}
}
// Deletes an archive record for a specific cipher
pub async fn delete_by_cipher(user_uuid: &UserId, cipher_uuid: &CipherId, conn: &DbConn) -> EmptyResult {
User::update_uuid_revision(user_uuid, conn).await;
db_run! { conn: {
diesel::delete(
archives::table
.filter(archives::user_uuid.eq(user_uuid))
.filter(archives::cipher_uuid.eq(cipher_uuid))
)
.execute(conn)
.map_res("Error deleting archive")
}}
}
/// Return a vec with (cipher_uuid, archived_at)
/// This is used during a full sync so we only need one query for all archive matches
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<(CipherId, NaiveDateTime)> {
db_run! { conn: {
archives::table
.filter(archives::user_uuid.eq(user_uuid))
.select((archives::cipher_uuid, archives::archived_at))
.load::<(CipherId, NaiveDateTime)>(conn)
.unwrap_or_default()
}}
}
}

4
src/db/models/attachment.rs

@ -46,11 +46,11 @@ impl Attachment {
pub async fn get_url(&self, host: &str) -> Result<String, crate::Error> { pub async fn get_url(&self, host: &str) -> Result<String, crate::Error> {
let operator = CONFIG.opendal_operator_for_path_type(&PathType::Attachments)?; let operator = CONFIG.opendal_operator_for_path_type(&PathType::Attachments)?;
if operator.info().scheme() == <&'static str>::from(opendal::Scheme::Fs) { if crate::storage::is_fs_operator(&operator) {
let token = encode_jwt(&generate_file_download_claims(self.cipher_uuid.clone(), self.id.clone())); let token = encode_jwt(&generate_file_download_claims(self.cipher_uuid.clone(), self.id.clone()));
Ok(format!("{host}/attachments/{}/{}?token={token}", self.cipher_uuid, self.id)) Ok(format!("{host}/attachments/{}/{}?token={token}", self.cipher_uuid, self.id))
} else { } else {
Ok(operator.presign_read(&self.get_file_path(), Duration::from_secs(5 * 60)).await?.uri().to_string()) Ok(operator.presign_read(&self.get_file_path(), Duration::from_mins(5)).await?.uri().to_string())
} }
} }

74
src/db/models/cipher.rs

@ -10,8 +10,8 @@ use diesel::prelude::*;
use serde_json::Value; use serde_json::Value;
use super::{ use super::{
Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership, MembershipStatus, Archive, Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership,
MembershipType, OrganizationId, User, UserId, MembershipStatus, MembershipType, OrganizationId, User, UserId,
}; };
use crate::api::core::{CipherData, CipherSyncData, CipherSyncType}; use crate::api::core::{CipherData, CipherSyncData, CipherSyncType};
use macros::UuidFromParam; use macros::UuidFromParam;
@ -380,6 +380,11 @@ impl Cipher {
} else { } else {
self.is_favorite(user_uuid, conn).await self.is_favorite(user_uuid, conn).await
}); });
json_object["archivedDate"] = json!(if let Some(cipher_sync_data) = cipher_sync_data {
cipher_sync_data.cipher_archives.get(&self.uuid).map_or(Value::Null, |d| Value::String(format_date(d)))
} else {
self.get_archived_at(user_uuid, conn).await.map_or(Value::Null, |d| Value::String(format_date(&d)))
});
// These values are true by default, but can be false if the // These values are true by default, but can be false if the
// cipher belongs to a collection or group where the org owner has enabled // cipher belongs to a collection or group where the org owner has enabled
// the "Read Only" or "Hide Passwords" restrictions for the user. // the "Read Only" or "Hide Passwords" restrictions for the user.
@ -398,7 +403,7 @@ impl Cipher {
3 => "card", 3 => "card",
4 => "identity", 4 => "identity",
5 => "sshKey", 5 => "sshKey",
_ => panic!("Wrong type"), _ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)),
}; };
json_object[key] = type_data_json; json_object[key] = type_data_json;
@ -559,7 +564,7 @@ impl Cipher {
if let Some(cached_member) = cipher_sync_data.members.get(org_uuid) { if let Some(cached_member) = cipher_sync_data.members.get(org_uuid) {
return cached_member.has_full_access(); return cached_member.has_full_access();
} }
} else if let Some(member) = Membership::find_by_user_and_org(user_uuid, org_uuid, conn).await { } else if let Some(member) = Membership::find_confirmed_by_user_and_org(user_uuid, org_uuid, conn).await {
return member.has_full_access(); return member.has_full_access();
} }
} }
@ -668,10 +673,12 @@ impl Cipher {
ciphers::table ciphers::table
.filter(ciphers::uuid.eq(&self.uuid)) .filter(ciphers::uuid.eq(&self.uuid))
.inner_join(ciphers_collections::table.on( .inner_join(ciphers_collections::table.on(
ciphers::uuid.eq(ciphers_collections::cipher_uuid))) ciphers::uuid.eq(ciphers_collections::cipher_uuid)
))
.inner_join(users_collections::table.on( .inner_join(users_collections::table.on(
ciphers_collections::collection_uuid.eq(users_collections::collection_uuid) ciphers_collections::collection_uuid.eq(users_collections::collection_uuid)
.and(users_collections::user_uuid.eq(user_uuid)))) .and(users_collections::user_uuid.eq(user_uuid))
))
.select((users_collections::read_only, users_collections::hide_passwords, users_collections::manage)) .select((users_collections::read_only, users_collections::hide_passwords, users_collections::manage))
.load::<(bool, bool, bool)>(conn) .load::<(bool, bool, bool)>(conn)
.expect("Error getting user access restrictions") .expect("Error getting user access restrictions")
@ -697,6 +704,9 @@ impl Cipher {
.inner_join(users_organizations::table.on( .inner_join(users_organizations::table.on(
users_organizations::uuid.eq(groups_users::users_organizations_uuid) users_organizations::uuid.eq(groups_users::users_organizations_uuid)
)) ))
.inner_join(groups::table.on(groups::uuid.eq(collections_groups::groups_uuid)
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
))
.filter(users_organizations::user_uuid.eq(user_uuid)) .filter(users_organizations::user_uuid.eq(user_uuid))
.select((collections_groups::read_only, collections_groups::hide_passwords, collections_groups::manage)) .select((collections_groups::read_only, collections_groups::hide_passwords, collections_groups::manage))
.load::<(bool, bool, bool)>(conn) .load::<(bool, bool, bool)>(conn)
@ -737,6 +747,18 @@ impl Cipher {
} }
} }
pub async fn get_archived_at(&self, user_uuid: &UserId, conn: &DbConn) -> Option<NaiveDateTime> {
Archive::get_archived_at(&self.uuid, user_uuid, conn).await
}
pub async fn set_archived_at(&self, archived_at: NaiveDateTime, user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
Archive::save(user_uuid, &self.uuid, archived_at, conn).await
}
pub async fn unarchive(&self, user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
Archive::delete_by_cipher(user_uuid, &self.uuid, conn).await
}
pub async fn get_folder_uuid(&self, user_uuid: &UserId, conn: &DbConn) -> Option<FolderId> { pub async fn get_folder_uuid(&self, user_uuid: &UserId, conn: &DbConn) -> Option<FolderId> {
db_run! { conn: { db_run! { conn: {
folders_ciphers::table folders_ciphers::table
@ -795,28 +817,28 @@ impl Cipher {
let mut query = ciphers::table let mut query = ciphers::table
.left_join(ciphers_collections::table.on( .left_join(ciphers_collections::table.on(
ciphers::uuid.eq(ciphers_collections::cipher_uuid) ciphers::uuid.eq(ciphers_collections::cipher_uuid)
)) ))
.left_join(users_organizations::table.on( .left_join(users_organizations::table.on(
ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable()) ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable())
.and(users_organizations::user_uuid.eq(user_uuid)) .and(users_organizations::user_uuid.eq(user_uuid))
.and(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) .and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
)) ))
.left_join(users_collections::table.on( .left_join(users_collections::table.on(
ciphers_collections::collection_uuid.eq(users_collections::collection_uuid) ciphers_collections::collection_uuid.eq(users_collections::collection_uuid)
// Ensure that users_collections::user_uuid is NULL for unconfirmed users. // Ensure that users_collections::user_uuid is NULL for unconfirmed users.
.and(users_organizations::user_uuid.eq(users_collections::user_uuid)) .and(users_organizations::user_uuid.eq(users_collections::user_uuid))
)) ))
.left_join(groups_users::table.on( .left_join(groups_users::table.on(
groups_users::users_organizations_uuid.eq(users_organizations::uuid) groups_users::users_organizations_uuid.eq(users_organizations::uuid)
)) ))
.left_join(groups::table.on( .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
groups::uuid.eq(groups_users::groups_uuid) // Ensure that group and membership belong to the same org
)) .and(groups::organizations_uuid.eq(users_organizations::org_uuid))
))
.left_join(collections_groups::table.on( .left_join(collections_groups::table.on(
collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid).and( collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid)
collections_groups::groups_uuid.eq(groups::uuid) .and(collections_groups::groups_uuid.eq(groups::uuid))
) ))
))
.filter(ciphers::user_uuid.eq(user_uuid)) // Cipher owner .filter(ciphers::user_uuid.eq(user_uuid)) // Cipher owner
.or_filter(users_organizations::access_all.eq(true)) // access_all in org .or_filter(users_organizations::access_all.eq(true)) // access_all in org
.or_filter(users_collections::user_uuid.eq(user_uuid)) // Access to collection .or_filter(users_collections::user_uuid.eq(user_uuid)) // Access to collection
@ -986,7 +1008,9 @@ impl Cipher {
.left_join(groups_users::table.on( .left_join(groups_users::table.on(
groups_users::users_organizations_uuid.eq(users_organizations::uuid) groups_users::users_organizations_uuid.eq(users_organizations::uuid)
)) ))
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid))) .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
))
.left_join(collections_groups::table.on( .left_join(collections_groups::table.on(
collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid) collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid)
.and(collections_groups::groups_uuid.eq(groups::uuid)) .and(collections_groups::groups_uuid.eq(groups::uuid))
@ -1047,7 +1071,9 @@ impl Cipher {
.left_join(groups_users::table.on( .left_join(groups_users::table.on(
groups_users::users_organizations_uuid.eq(users_organizations::uuid) groups_users::users_organizations_uuid.eq(users_organizations::uuid)
)) ))
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid))) .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
))
.left_join(collections_groups::table.on( .left_join(collections_groups::table.on(
collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid) collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid)
.and(collections_groups::groups_uuid.eq(groups::uuid)) .and(collections_groups::groups_uuid.eq(groups::uuid))
@ -1115,8 +1141,8 @@ impl Cipher {
.left_join(groups_users::table.on( .left_join(groups_users::table.on(
groups_users::users_organizations_uuid.eq(users_organizations::uuid) groups_users::users_organizations_uuid.eq(users_organizations::uuid)
)) ))
.left_join(groups::table.on( .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
groups::uuid.eq(groups_users::groups_uuid) .and(groups::organizations_uuid.eq(users_organizations::org_uuid))
)) ))
.left_join(collections_groups::table.on( .left_join(collections_groups::table.on(
collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid).and( collections_groups::collections_uuid.eq(ciphers_collections::collection_uuid).and(

22
src/db/models/collection.rs

@ -191,7 +191,7 @@ impl Collection {
self.update_users_revision(conn).await; self.update_users_revision(conn).await;
CollectionCipher::delete_all_by_collection(&self.uuid, conn).await?; CollectionCipher::delete_all_by_collection(&self.uuid, conn).await?;
CollectionUser::delete_all_by_collection(&self.uuid, conn).await?; CollectionUser::delete_all_by_collection(&self.uuid, conn).await?;
CollectionGroup::delete_all_by_collection(&self.uuid, conn).await?; CollectionGroup::delete_all_by_collection(&self.uuid, &self.org_uuid, conn).await?;
db_run! { conn: { db_run! { conn: {
diesel::delete(collections::table.filter(collections::uuid.eq(self.uuid))) diesel::delete(collections::table.filter(collections::uuid.eq(self.uuid)))
@ -239,8 +239,8 @@ impl Collection {
.left_join(groups_users::table.on( .left_join(groups_users::table.on(
groups_users::users_organizations_uuid.eq(users_organizations::uuid) groups_users::users_organizations_uuid.eq(users_organizations::uuid)
)) ))
.left_join(groups::table.on( .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
groups::uuid.eq(groups_users::groups_uuid) .and(groups::organizations_uuid.eq(users_organizations::org_uuid))
)) ))
.left_join(collections_groups::table.on( .left_join(collections_groups::table.on(
collections_groups::groups_uuid.eq(groups_users::groups_uuid).and( collections_groups::groups_uuid.eq(groups_users::groups_uuid).and(
@ -355,8 +355,8 @@ impl Collection {
.left_join(groups_users::table.on( .left_join(groups_users::table.on(
groups_users::users_organizations_uuid.eq(users_organizations::uuid) groups_users::users_organizations_uuid.eq(users_organizations::uuid)
)) ))
.left_join(groups::table.on( .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
groups::uuid.eq(groups_users::groups_uuid) .and(groups::organizations_uuid.eq(users_organizations::org_uuid))
)) ))
.left_join(collections_groups::table.on( .left_join(collections_groups::table.on(
collections_groups::groups_uuid.eq(groups_users::groups_uuid).and( collections_groups::groups_uuid.eq(groups_users::groups_uuid).and(
@ -422,8 +422,8 @@ impl Collection {
.left_join(groups_users::table.on( .left_join(groups_users::table.on(
groups_users::users_organizations_uuid.eq(users_organizations::uuid) groups_users::users_organizations_uuid.eq(users_organizations::uuid)
)) ))
.left_join(groups::table.on( .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
groups::uuid.eq(groups_users::groups_uuid) .and(groups::organizations_uuid.eq(users_organizations::org_uuid))
)) ))
.left_join(collections_groups::table.on( .left_join(collections_groups::table.on(
collections_groups::groups_uuid.eq(groups_users::groups_uuid) collections_groups::groups_uuid.eq(groups_users::groups_uuid)
@ -484,8 +484,8 @@ impl Collection {
.left_join(groups_users::table.on( .left_join(groups_users::table.on(
groups_users::users_organizations_uuid.eq(users_organizations::uuid) groups_users::users_organizations_uuid.eq(users_organizations::uuid)
)) ))
.left_join(groups::table.on( .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
groups::uuid.eq(groups_users::groups_uuid) .and(groups::organizations_uuid.eq(users_organizations::org_uuid))
)) ))
.left_join(collections_groups::table.on( .left_join(collections_groups::table.on(
collections_groups::groups_uuid.eq(groups_users::groups_uuid).and( collections_groups::groups_uuid.eq(groups_users::groups_uuid).and(
@ -531,8 +531,8 @@ impl Collection {
.left_join(groups_users::table.on( .left_join(groups_users::table.on(
groups_users::users_organizations_uuid.eq(users_organizations::uuid) groups_users::users_organizations_uuid.eq(users_organizations::uuid)
)) ))
.left_join(groups::table.on( .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
groups::uuid.eq(groups_users::groups_uuid) .and(groups::organizations_uuid.eq(users_organizations::org_uuid))
)) ))
.left_join(collections_groups::table.on( .left_join(collections_groups::table.on(
collections_groups::groups_uuid.eq(groups_users::groups_uuid).and( collections_groups::groups_uuid.eq(groups_users::groups_uuid).and(

34
src/db/models/device.rs

@ -1,6 +1,6 @@
use chrono::{NaiveDateTime, Utc}; use chrono::{NaiveDateTime, Utc};
use data_encoding::{BASE64, BASE64URL}; use data_encoding::BASE64URL;
use derive_more::{Display, From}; use derive_more::{Display, From};
use serde_json::Value; use serde_json::Value;
@ -25,7 +25,7 @@ pub struct Device {
pub user_uuid: UserId, pub user_uuid: UserId,
pub name: String, pub name: String,
pub atype: i32, // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Enums/DeviceType.cs pub atype: i32, // https://github.com/bitwarden/server/blob/8d547dcc280babab70dd4a3c94ced6a34b12dfbf/src/Core/Enums/DeviceType.cs
pub push_uuid: Option<PushId>, pub push_uuid: Option<PushId>,
pub push_token: Option<String>, pub push_token: Option<String>,
@ -49,11 +49,16 @@ impl Device {
push_uuid: Some(PushId(get_uuid())), push_uuid: Some(PushId(get_uuid())),
push_token: None, push_token: None,
refresh_token: crypto::encode_random_bytes::<64>(&BASE64URL), refresh_token: Device::generate_refresh_token(),
twofactor_remember: None, twofactor_remember: None,
} }
} }
#[inline(always)]
pub fn generate_refresh_token() -> String {
crypto::encode_random_bytes::<64>(&BASE64URL)
}
pub fn to_json(&self) -> Value { pub fn to_json(&self) -> Value {
json!({ json!({
"id": self.uuid, "id": self.uuid,
@ -67,10 +72,13 @@ impl Device {
} }
pub fn refresh_twofactor_remember(&mut self) -> String { pub fn refresh_twofactor_remember(&mut self) -> String {
let twofactor_remember = crypto::encode_random_bytes::<180>(&BASE64); use crate::auth::{encode_jwt, generate_2fa_remember_claims};
self.twofactor_remember = Some(twofactor_remember.clone());
twofactor_remember let two_factor_remember_claim = generate_2fa_remember_claims(self.uuid.clone(), self.user_uuid.clone());
let two_factor_remember_string = encode_jwt(&two_factor_remember_claim);
self.twofactor_remember = Some(two_factor_remember_string.clone());
two_factor_remember_string
} }
pub fn delete_twofactor_remember(&mut self) { pub fn delete_twofactor_remember(&mut self) {
@ -257,6 +265,17 @@ impl Device {
.unwrap_or(0) != 0 .unwrap_or(0) != 0
}} }}
} }
pub async fn rotate_refresh_tokens_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
// Generate a new token per device.
// We cannot do a single UPDATE with one value because each device needs a unique token.
let devices = Self::find_by_user(user_uuid, conn).await;
for mut device in devices {
device.refresh_token = Device::generate_refresh_token();
device.save(false, conn).await?;
}
Ok(())
}
} }
#[derive(Display)] #[derive(Display)]
@ -313,6 +332,8 @@ pub enum DeviceType {
MacOsCLI = 24, MacOsCLI = 24,
#[display("Linux CLI")] #[display("Linux CLI")]
LinuxCLI = 25, LinuxCLI = 25,
#[display("DuckDuckGo")]
DuckDuckGoBrowser = 26,
} }
impl DeviceType { impl DeviceType {
@ -344,6 +365,7 @@ impl DeviceType {
23 => DeviceType::WindowsCLI, 23 => DeviceType::WindowsCLI,
24 => DeviceType::MacOsCLI, 24 => DeviceType::MacOsCLI,
25 => DeviceType::LinuxCLI, 25 => DeviceType::LinuxCLI,
26 => DeviceType::DuckDuckGoBrowser,
_ => DeviceType::UnknownBrowser, _ => DeviceType::UnknownBrowser,
} }
} }

5
src/db/models/emergency_access.rs

@ -85,7 +85,8 @@ impl EmergencyAccess {
pub async fn to_json_grantee_details(&self, conn: &DbConn) -> Option<Value> { pub async fn to_json_grantee_details(&self, conn: &DbConn) -> Option<Value> {
let grantee_user = if let Some(grantee_uuid) = &self.grantee_uuid { let grantee_user = if let Some(grantee_uuid) = &self.grantee_uuid {
User::find_by_uuid(grantee_uuid, conn).await.expect("Grantee user not found.") User::find_by_uuid(grantee_uuid, conn).await.expect("Grantee user not found.")
} else if let Some(email) = self.email.as_deref() { } else {
let email = self.email.as_deref()?;
match User::find_by_mail(email, conn).await { match User::find_by_mail(email, conn).await {
Some(user) => user, Some(user) => user,
None => { None => {
@ -94,8 +95,6 @@ impl EmergencyAccess {
return None; return None;
} }
} }
} else {
return None;
}; };
Some(json!({ Some(json!({

82
src/db/models/group.rs

@ -1,6 +1,6 @@
use super::{CollectionId, Membership, MembershipId, OrganizationId, User, UserId}; use super::{CollectionId, Membership, MembershipId, OrganizationId, User, UserId};
use crate::api::EmptyResult; use crate::api::EmptyResult;
use crate::db::schema::{collections_groups, groups, groups_users, users_organizations}; use crate::db::schema::{collections, collections_groups, groups, groups_users, users_organizations};
use crate::db::DbConn; use crate::db::DbConn;
use crate::error::MapResult; use crate::error::MapResult;
use chrono::{NaiveDateTime, Utc}; use chrono::{NaiveDateTime, Utc};
@ -81,7 +81,7 @@ impl Group {
// If both read_only and hide_passwords are false, then manage should be true // If both read_only and hide_passwords are false, then manage should be true
// You can't have an entry with read_only and manage, or hide_passwords and manage // You can't have an entry with read_only and manage, or hide_passwords and manage
// Or an entry with everything to false // Or an entry with everything to false
let collections_groups: Vec<Value> = CollectionGroup::find_by_group(&self.uuid, conn) let collections_groups: Vec<Value> = CollectionGroup::find_by_group(&self.uuid, &self.organizations_uuid, conn)
.await .await
.iter() .iter()
.map(|entry| { .map(|entry| {
@ -191,7 +191,7 @@ impl Group {
pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult { pub async fn delete_all_by_organization(org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
for group in Self::find_by_organization(org_uuid, conn).await { for group in Self::find_by_organization(org_uuid, conn).await {
group.delete(conn).await?; group.delete(org_uuid, conn).await?;
} }
Ok(()) Ok(())
} }
@ -246,8 +246,8 @@ impl Group {
.inner_join(users_organizations::table.on( .inner_join(users_organizations::table.on(
users_organizations::uuid.eq(groups_users::users_organizations_uuid) users_organizations::uuid.eq(groups_users::users_organizations_uuid)
)) ))
.inner_join(groups::table.on( .inner_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
groups::uuid.eq(groups_users::groups_uuid) .and(groups::organizations_uuid.eq(users_organizations::org_uuid))
)) ))
.filter(users_organizations::user_uuid.eq(user_uuid)) .filter(users_organizations::user_uuid.eq(user_uuid))
.filter(groups::access_all.eq(true)) .filter(groups::access_all.eq(true))
@ -276,9 +276,9 @@ impl Group {
}} }}
} }
pub async fn delete(&self, conn: &DbConn) -> EmptyResult { pub async fn delete(&self, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
CollectionGroup::delete_all_by_group(&self.uuid, conn).await?; CollectionGroup::delete_all_by_group(&self.uuid, org_uuid, conn).await?;
GroupUser::delete_all_by_group(&self.uuid, conn).await?; GroupUser::delete_all_by_group(&self.uuid, org_uuid, conn).await?;
db_run! { conn: { db_run! { conn: {
diesel::delete(groups::table.filter(groups::uuid.eq(&self.uuid))) diesel::delete(groups::table.filter(groups::uuid.eq(&self.uuid)))
@ -306,8 +306,8 @@ impl Group {
} }
impl CollectionGroup { impl CollectionGroup {
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { pub async fn save(&mut self, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
let group_users = GroupUser::find_by_group(&self.groups_uuid, conn).await; let group_users = GroupUser::find_by_group(&self.groups_uuid, org_uuid, conn).await;
for group_user in group_users { for group_user in group_users {
group_user.update_user_revision(conn).await; group_user.update_user_revision(conn).await;
} }
@ -365,10 +365,19 @@ impl CollectionGroup {
} }
} }
pub async fn find_by_group(group_uuid: &GroupId, conn: &DbConn) -> Vec<Self> { pub async fn find_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
db_run! { conn: { db_run! { conn: {
collections_groups::table collections_groups::table
.inner_join(groups::table.on(
groups::uuid.eq(collections_groups::groups_uuid)
))
.inner_join(collections::table.on(
collections::uuid.eq(collections_groups::collections_uuid)
.and(collections::org_uuid.eq(groups::organizations_uuid))
))
.filter(collections_groups::groups_uuid.eq(group_uuid)) .filter(collections_groups::groups_uuid.eq(group_uuid))
.filter(collections::org_uuid.eq(org_uuid))
.select(collections_groups::all_columns)
.load::<Self>(conn) .load::<Self>(conn)
.expect("Error loading collection groups") .expect("Error loading collection groups")
}} }}
@ -383,6 +392,13 @@ impl CollectionGroup {
.inner_join(users_organizations::table.on( .inner_join(users_organizations::table.on(
users_organizations::uuid.eq(groups_users::users_organizations_uuid) users_organizations::uuid.eq(groups_users::users_organizations_uuid)
)) ))
.inner_join(groups::table.on(groups::uuid.eq(collections_groups::groups_uuid)
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
))
.inner_join(collections::table.on(
collections::uuid.eq(collections_groups::collections_uuid)
.and(collections::org_uuid.eq(groups::organizations_uuid))
))
.filter(users_organizations::user_uuid.eq(user_uuid)) .filter(users_organizations::user_uuid.eq(user_uuid))
.select(collections_groups::all_columns) .select(collections_groups::all_columns)
.load::<Self>(conn) .load::<Self>(conn)
@ -394,14 +410,20 @@ impl CollectionGroup {
db_run! { conn: { db_run! { conn: {
collections_groups::table collections_groups::table
.filter(collections_groups::collections_uuid.eq(collection_uuid)) .filter(collections_groups::collections_uuid.eq(collection_uuid))
.inner_join(collections::table.on(
collections::uuid.eq(collections_groups::collections_uuid)
))
.inner_join(groups::table.on(groups::uuid.eq(collections_groups::groups_uuid)
.and(groups::organizations_uuid.eq(collections::org_uuid))
))
.select(collections_groups::all_columns) .select(collections_groups::all_columns)
.load::<Self>(conn) .load::<Self>(conn)
.expect("Error loading collection groups") .expect("Error loading collection groups")
}} }}
} }
pub async fn delete(&self, conn: &DbConn) -> EmptyResult { pub async fn delete(&self, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
let group_users = GroupUser::find_by_group(&self.groups_uuid, conn).await; let group_users = GroupUser::find_by_group(&self.groups_uuid, org_uuid, conn).await;
for group_user in group_users { for group_user in group_users {
group_user.update_user_revision(conn).await; group_user.update_user_revision(conn).await;
} }
@ -415,8 +437,8 @@ impl CollectionGroup {
}} }}
} }
pub async fn delete_all_by_group(group_uuid: &GroupId, conn: &DbConn) -> EmptyResult { pub async fn delete_all_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
let group_users = GroupUser::find_by_group(group_uuid, conn).await; let group_users = GroupUser::find_by_group(group_uuid, org_uuid, conn).await;
for group_user in group_users { for group_user in group_users {
group_user.update_user_revision(conn).await; group_user.update_user_revision(conn).await;
} }
@ -429,10 +451,14 @@ impl CollectionGroup {
}} }}
} }
pub async fn delete_all_by_collection(collection_uuid: &CollectionId, conn: &DbConn) -> EmptyResult { pub async fn delete_all_by_collection(
collection_uuid: &CollectionId,
org_uuid: &OrganizationId,
conn: &DbConn,
) -> EmptyResult {
let collection_assigned_to_groups = CollectionGroup::find_by_collection(collection_uuid, conn).await; let collection_assigned_to_groups = CollectionGroup::find_by_collection(collection_uuid, conn).await;
for collection_assigned_to_group in collection_assigned_to_groups { for collection_assigned_to_group in collection_assigned_to_groups {
let group_users = GroupUser::find_by_group(&collection_assigned_to_group.groups_uuid, conn).await; let group_users = GroupUser::find_by_group(&collection_assigned_to_group.groups_uuid, org_uuid, conn).await;
for group_user in group_users { for group_user in group_users {
group_user.update_user_revision(conn).await; group_user.update_user_revision(conn).await;
} }
@ -494,10 +520,19 @@ impl GroupUser {
} }
} }
pub async fn find_by_group(group_uuid: &GroupId, conn: &DbConn) -> Vec<Self> { pub async fn find_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> Vec<Self> {
db_run! { conn: { db_run! { conn: {
groups_users::table groups_users::table
.inner_join(groups::table.on(
groups::uuid.eq(groups_users::groups_uuid)
))
.inner_join(users_organizations::table.on(
users_organizations::uuid.eq(groups_users::users_organizations_uuid)
.and(users_organizations::org_uuid.eq(groups::organizations_uuid))
))
.filter(groups_users::groups_uuid.eq(group_uuid)) .filter(groups_users::groups_uuid.eq(group_uuid))
.filter(groups::organizations_uuid.eq(org_uuid))
.select(groups_users::all_columns)
.load::<Self>(conn) .load::<Self>(conn)
.expect("Error loading group users") .expect("Error loading group users")
}} }}
@ -522,6 +557,13 @@ impl GroupUser {
.inner_join(collections_groups::table.on( .inner_join(collections_groups::table.on(
collections_groups::groups_uuid.eq(groups_users::groups_uuid) collections_groups::groups_uuid.eq(groups_users::groups_uuid)
)) ))
.inner_join(groups::table.on(
groups::uuid.eq(groups_users::groups_uuid)
))
.inner_join(collections::table.on(
collections::uuid.eq(collections_groups::collections_uuid)
.and(collections::org_uuid.eq(groups::organizations_uuid))
))
.filter(collections_groups::collections_uuid.eq(collection_uuid)) .filter(collections_groups::collections_uuid.eq(collection_uuid))
.filter(groups_users::users_organizations_uuid.eq(member_uuid)) .filter(groups_users::users_organizations_uuid.eq(member_uuid))
.count() .count()
@ -575,8 +617,8 @@ impl GroupUser {
}} }}
} }
pub async fn delete_all_by_group(group_uuid: &GroupId, conn: &DbConn) -> EmptyResult { pub async fn delete_all_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
let group_users = GroupUser::find_by_group(group_uuid, conn).await; let group_users = GroupUser::find_by_group(group_uuid, org_uuid, conn).await;
for group_user in group_users { for group_user in group_users {
group_user.update_user_revision(conn).await; group_user.update_user_revision(conn).await;
} }

4
src/db/models/mod.rs

@ -1,3 +1,4 @@
mod archive;
mod attachment; mod attachment;
mod auth_request; mod auth_request;
mod cipher; mod cipher;
@ -17,6 +18,7 @@ mod two_factor_duo_context;
mod two_factor_incomplete; mod two_factor_incomplete;
mod user; mod user;
pub use self::archive::Archive;
pub use self::attachment::{Attachment, AttachmentId}; pub use self::attachment::{Attachment, AttachmentId};
pub use self::auth_request::{AuthRequest, AuthRequestId}; pub use self::auth_request::{AuthRequest, AuthRequestId};
pub use self::cipher::{Cipher, CipherId, RepromptType}; pub use self::cipher::{Cipher, CipherId, RepromptType};
@ -36,7 +38,7 @@ pub use self::send::{
id::{SendFileId, SendId}, id::{SendFileId, SendId},
Send, SendType, Send, SendType,
}; };
pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeWrapper, SsoAuth}; pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth};
pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor::{TwoFactor, TwoFactorType};
pub use self::two_factor_duo_context::TwoFactorDuoContext; pub use self::two_factor_duo_context::TwoFactorDuoContext;
pub use self::two_factor_incomplete::TwoFactorIncomplete; pub use self::two_factor_incomplete::TwoFactorIncomplete;

2
src/db/models/org_policy.rs

@ -332,7 +332,7 @@ impl OrgPolicy {
for policy in for policy in
OrgPolicy::find_confirmed_by_user_and_active_policy(user_uuid, OrgPolicyType::SendOptions, conn).await OrgPolicy::find_confirmed_by_user_and_active_policy(user_uuid, OrgPolicyType::SendOptions, conn).await
{ {
if let Some(user) = Membership::find_by_user_and_org(user_uuid, &policy.org_uuid, conn).await { if let Some(user) = Membership::find_confirmed_by_user_and_org(user_uuid, &policy.org_uuid, conn).await {
if user.atype < MembershipType::Admin { if user.atype < MembershipType::Admin {
match serde_json::from_str::<SendOptionsPolicyData>(&policy.data) { match serde_json::from_str::<SendOptionsPolicyData>(&policy.data) {
Ok(opts) => { Ok(opts) => {

7
src/db/models/organization.rs

@ -514,7 +514,8 @@ impl Membership {
"familySponsorshipValidUntil": null, "familySponsorshipValidUntil": null,
"familySponsorshipToDelete": null, "familySponsorshipToDelete": null,
"accessSecretsManager": false, "accessSecretsManager": false,
"limitCollectionCreation": self.atype < MembershipType::Manager, // If less then a manager return true, to limit collection creations // limit collection creation to managers with access_all permission to prevent issues
"limitCollectionCreation": self.atype < MembershipType::Manager || !self.access_all,
"limitCollectionDeletion": true, "limitCollectionDeletion": true,
"limitItemDeletion": false, "limitItemDeletion": false,
"allowAdminAccessToAllCollectionItems": true, "allowAdminAccessToAllCollectionItems": true,
@ -1073,7 +1074,9 @@ impl Membership {
.left_join(collections_groups::table.on( .left_join(collections_groups::table.on(
collections_groups::groups_uuid.eq(groups_users::groups_uuid) collections_groups::groups_uuid.eq(groups_users::groups_uuid)
)) ))
.left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid))) .left_join(groups::table.on(groups::uuid.eq(groups_users::groups_uuid)
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))
))
.left_join(ciphers_collections::table.on( .left_join(ciphers_collections::table.on(
ciphers_collections::collection_uuid.eq(collections_groups::collections_uuid).and(ciphers_collections::cipher_uuid.eq(&cipher_uuid)) ciphers_collections::collection_uuid.eq(collections_groups::collections_uuid).and(ciphers_collections::cipher_uuid.eq(&cipher_uuid))

13
src/db/models/send.rs

@ -46,6 +46,16 @@ pub enum SendType {
File = 1, File = 1,
} }
enum SendAuthType {
#[allow(dead_code)]
// Send requires email OTP verification
Email = 0, // Not yet supported by Vaultwarden
// Send requires a password
Password = 1,
// Send requires no auth
None = 2,
}
impl Send { impl Send {
pub fn new(atype: i32, name: String, data: String, akey: String, deletion_date: NaiveDateTime) -> Self { pub fn new(atype: i32, name: String, data: String, akey: String, deletion_date: NaiveDateTime) -> Self {
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
@ -145,6 +155,7 @@ impl Send {
"maxAccessCount": self.max_access_count, "maxAccessCount": self.max_access_count,
"accessCount": self.access_count, "accessCount": self.access_count,
"password": self.password_hash.as_deref().map(|h| BASE64URL_NOPAD.encode(h)), "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, "disabled": self.disabled,
"hideEmail": self.hide_email, "hideEmail": self.hide_email,
@ -226,7 +237,7 @@ impl Send {
if self.atype == SendType::File as i32 { if self.atype == SendType::File as i32 {
let operator = CONFIG.opendal_operator_for_path_type(&PathType::Sends)?; let operator = CONFIG.opendal_operator_for_path_type(&PathType::Sends)?;
operator.remove_all(&self.uuid).await.ok(); operator.delete_with(&self.uuid).recursive(true).await.ok();
} }
db_run! { conn: { db_run! { conn: {

38
src/db/models/sso_auth.rs

@ -15,17 +15,12 @@ use diesel::sql_types::Text;
#[derive(AsExpression, Clone, Debug, Serialize, Deserialize, FromSqlRow)] #[derive(AsExpression, Clone, Debug, Serialize, Deserialize, FromSqlRow)]
#[diesel(sql_type = Text)] #[diesel(sql_type = Text)]
pub enum OIDCCodeWrapper { pub struct OIDCCodeResponseError {
Ok { pub error: String,
code: OIDCCode, pub error_description: Option<String>,
},
Error {
error: String,
error_description: Option<String>,
},
} }
impl_FromToSqlText!(OIDCCodeWrapper); impl_FromToSqlText!(OIDCCodeResponseError);
#[derive(AsExpression, Clone, Debug, Serialize, Deserialize, FromSqlRow)] #[derive(AsExpression, Clone, Debug, Serialize, Deserialize, FromSqlRow)]
#[diesel(sql_type = Text)] #[diesel(sql_type = Text)]
@ -50,15 +45,23 @@ pub struct SsoAuth {
pub client_challenge: OIDCCodeChallenge, pub client_challenge: OIDCCodeChallenge,
pub nonce: String, pub nonce: String,
pub redirect_uri: String, pub redirect_uri: String,
pub code_response: Option<OIDCCodeWrapper>, pub code_response: Option<OIDCCode>,
pub code_response_error: Option<OIDCCodeResponseError>,
pub auth_response: Option<OIDCAuthenticatedUser>, pub auth_response: Option<OIDCAuthenticatedUser>,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
pub binding_hash: Option<String>,
} }
/// Local methods /// Local methods
impl SsoAuth { impl SsoAuth {
pub fn new(state: OIDCState, client_challenge: OIDCCodeChallenge, nonce: String, redirect_uri: String) -> Self { pub fn new(
state: OIDCState,
client_challenge: OIDCCodeChallenge,
nonce: String,
redirect_uri: String,
binding_hash: Option<String>,
) -> Self {
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
SsoAuth { SsoAuth {
@ -69,7 +72,9 @@ impl SsoAuth {
created_at: now, created_at: now,
updated_at: now, updated_at: now,
code_response: None, code_response: None,
code_response_error: None,
auth_response: None, auth_response: None,
binding_hash,
} }
} }
} }
@ -110,6 +115,17 @@ impl SsoAuth {
}} }}
} }
pub async fn find_by_code(code: &OIDCCode, conn: &DbConn) -> Option<Self> {
let oldest = Utc::now().naive_utc() - *SSO_AUTH_EXPIRATION;
db_run! { conn: {
sso_auth::table
.filter(sso_auth::code_response.eq(code))
.filter(sso_auth::created_at.ge(oldest))
.first::<Self>(conn)
.ok()
}}
}
pub async fn delete(self, conn: &DbConn) -> EmptyResult { pub async fn delete(self, conn: &DbConn) -> EmptyResult {
db_run! {conn: { db_run! {conn: {
diesel::delete(sso_auth::table.filter(sso_auth::state.eq(self.state))) diesel::delete(sso_auth::table.filter(sso_auth::state.eq(self.state)))

1
src/db/models/two_factor.rs

@ -20,7 +20,6 @@ pub struct TwoFactor {
pub last_used: i64, pub last_used: i64,
} }
#[allow(dead_code)]
#[derive(num_derive::FromPrimitive)] #[derive(num_derive::FromPrimitive)]
pub enum TwoFactorType { pub enum TwoFactorType {
Authenticator = 0, Authenticator = 0,

12
src/db/models/user.rs

@ -185,13 +185,14 @@ impl User {
/// These routes are able to use the previous stamp id for the next 2 minutes. /// These routes are able to use the previous stamp id for the next 2 minutes.
/// After these 2 minutes this stamp will expire. /// After these 2 minutes this stamp will expire.
/// ///
pub fn set_password( pub async fn set_password(
&mut self, &mut self,
password: &str, password: &str,
new_key: Option<String>, new_key: Option<String>,
reset_security_stamp: bool, reset_security_stamp: bool,
allow_next_route: Option<Vec<String>>, allow_next_route: Option<Vec<String>>,
) { conn: &DbConn,
) -> EmptyResult {
self.password_hash = crypto::hash_password(password.as_bytes(), &self.salt, self.password_iterations as u32); self.password_hash = crypto::hash_password(password.as_bytes(), &self.salt, self.password_iterations as u32);
if let Some(route) = allow_next_route { if let Some(route) = allow_next_route {
@ -203,12 +204,15 @@ impl User {
} }
if reset_security_stamp { if reset_security_stamp {
self.reset_security_stamp() self.reset_security_stamp(conn).await?;
} }
Ok(())
} }
pub fn reset_security_stamp(&mut self) { pub async fn reset_security_stamp(&mut self, conn: &DbConn) -> EmptyResult {
self.security_stamp = get_uuid(); self.security_stamp = get_uuid();
Device::rotate_refresh_tokens_by_user(&self.uuid, conn).await?;
Ok(())
} }
/// Set the stamp_exception to only allow a subsequent request matching a specific route using the current security-stamp. /// Set the stamp_exception to only allow a subsequent request matching a specific route using the current security-stamp.

13
src/db/schema.rs

@ -262,9 +262,11 @@ table! {
nonce -> Text, nonce -> Text,
redirect_uri -> Text, redirect_uri -> Text,
code_response -> Nullable<Text>, code_response -> Nullable<Text>,
code_response_error -> Nullable<Text>,
auth_response -> Nullable<Text>, auth_response -> Nullable<Text>,
created_at -> Timestamp, created_at -> Timestamp,
updated_at -> Timestamp, updated_at -> Timestamp,
binding_hash -> Nullable<Text>,
} }
} }
@ -341,6 +343,16 @@ table! {
} }
} }
table! {
archives (user_uuid, cipher_uuid) {
user_uuid -> Text,
cipher_uuid -> Text,
archived_at -> Timestamp,
}
}
joinable!(archives -> users (user_uuid));
joinable!(archives -> ciphers (cipher_uuid));
joinable!(attachments -> ciphers (cipher_uuid)); joinable!(attachments -> ciphers (cipher_uuid));
joinable!(ciphers -> organizations (organization_uuid)); joinable!(ciphers -> organizations (organization_uuid));
joinable!(ciphers -> users (user_uuid)); joinable!(ciphers -> users (user_uuid));
@ -372,6 +384,7 @@ joinable!(auth_requests -> users (user_uuid));
joinable!(sso_users -> users (user_uuid)); joinable!(sso_users -> users (user_uuid));
allow_tables_to_appear_in_same_query!( allow_tables_to_appear_in_same_query!(
archives,
attachments, attachments,
ciphers, ciphers,
ciphers_collections, ciphers_collections,

327
src/http_client.rs

@ -1,12 +1,11 @@
use std::{ use std::{
fmt, fmt,
net::{IpAddr, SocketAddr}, net::{IpAddr, SocketAddr},
str::FromStr,
sync::{Arc, LazyLock, Mutex}, sync::{Arc, LazyLock, Mutex},
time::Duration, time::Duration,
}; };
use hickory_resolver::{name_server::TokioConnectionProvider, TokioResolver}; use hickory_resolver::{net::runtime::TokioRuntimeProvider, TokioResolver};
use regex::Regex; use regex::Regex;
use reqwest::{ use reqwest::{
dns::{Name, Resolve, Resolving}, dns::{Name, Resolve, Resolving},
@ -59,16 +58,6 @@ pub fn get_reqwest_client_builder() -> ClientBuilder {
.timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(10))
} }
pub fn should_block_address(domain_or_ip: &str) -> bool {
if let Ok(ip) = IpAddr::from_str(domain_or_ip) {
if should_block_ip(ip) {
return true;
}
}
should_block_address_regex(domain_or_ip)
}
fn should_block_ip(ip: IpAddr) -> bool { fn should_block_ip(ip: IpAddr) -> bool {
if !CONFIG.http_request_block_non_global_ips() { if !CONFIG.http_request_block_non_global_ips() {
return false; return false;
@ -100,11 +89,54 @@ fn should_block_address_regex(domain_or_ip: &str) -> bool {
is_match is_match
} }
fn should_block_host(host: &Host<&str>) -> Result<(), CustomHttpClientError> { pub fn get_valid_host(host: &str) -> Result<Host, CustomHttpClientError> {
let Ok(host) = Host::parse(host) else {
return Err(CustomHttpClientError::Invalid {
domain: host.to_string(),
});
};
// Some extra checks to validate hosts
match host {
Host::Domain(ref domain) => {
// Host::parse() does not verify length or all possible invalid characters
// We do some extra checks here to prevent issues
if domain.len() > 253 {
debug!("Domain validation error: '{domain}' exceeds 253 characters");
return Err(CustomHttpClientError::Invalid {
domain: host.to_string(),
});
}
if !domain.split('.').all(|label| {
!label.is_empty()
// Labels can't be longer than 63 chars
&& label.len() <= 63
// Labels are not allowed to start or end with a hyphen `-`
&& !label.starts_with('-')
&& !label.ends_with('-')
// Only ASCII Alphanumeric characters are allowed
// We already received a punycoded domain back, so no unicode should exists here
&& label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
}) {
debug!(
"Domain validation error: '{domain}' labels contain invalid characters or exceed the maximum length"
);
return Err(CustomHttpClientError::Invalid {
domain: host.to_string(),
});
}
}
Host::Ipv4(_) | Host::Ipv6(_) => {}
}
Ok(host)
}
pub fn should_block_host<S: AsRef<str>>(host: &Host<S>) -> Result<(), CustomHttpClientError> {
let (ip, host_str): (Option<IpAddr>, String) = match host { let (ip, host_str): (Option<IpAddr>, String) = match host {
Host::Ipv4(ip) => (Some(IpAddr::V4(*ip)), ip.to_string()), Host::Ipv4(ip) => (Some(IpAddr::V4(*ip)), ip.to_string()),
Host::Ipv6(ip) => (Some(IpAddr::V6(*ip)), ip.to_string()), Host::Ipv6(ip) => (Some(IpAddr::V6(*ip)), ip.to_string()),
Host::Domain(d) => (None, (*d).to_string()), Host::Domain(d) => (None, d.as_ref().to_string()),
}; };
if let Some(ip) = ip { if let Some(ip) = ip {
@ -134,6 +166,9 @@ pub enum CustomHttpClientError {
domain: Option<String>, domain: Option<String>,
ip: IpAddr, ip: IpAddr,
}, },
Invalid {
domain: String,
},
} }
impl CustomHttpClientError { impl CustomHttpClientError {
@ -155,7 +190,7 @@ impl fmt::Display for CustomHttpClientError {
match self { match self {
Self::Blocked { Self::Blocked {
domain, domain,
} => write!(f, "Blocked domain: {domain} matched HTTP_REQUEST_BLOCK_REGEX"), } => write!(f, "Blocked domain: '{domain}' matched HTTP_REQUEST_BLOCK_REGEX"),
Self::NonGlobalIp { Self::NonGlobalIp {
domain: Some(domain), domain: Some(domain),
ip, ip,
@ -163,7 +198,10 @@ impl fmt::Display for CustomHttpClientError {
Self::NonGlobalIp { Self::NonGlobalIp {
domain: None, domain: None,
ip, ip,
} => write!(f, "IP {ip} is not a global IP!"), } => write!(f, "IP '{ip}' is not a global IP!"),
Self::Invalid {
domain,
} => write!(f, "Invalid host: '{domain}' contains invalid characters or exceeds the maximum length"),
} }
} }
} }
@ -184,40 +222,46 @@ impl CustomDnsResolver {
} }
fn new() -> Arc<Self> { fn new() -> Arc<Self> {
match TokioResolver::builder(TokioConnectionProvider::default()) { TokioResolver::builder(TokioRuntimeProvider::default())
Ok(mut builder) => { .and_then(|mut builder| {
if CONFIG.dns_prefer_ipv6() { // Hickory's default since v0.26 is `Ipv6AndIpv4`, which sorts IPv6 first
builder.options_mut().ip_strategy = hickory_resolver::config::LookupIpStrategy::Ipv6thenIpv4; // This might cause issues on IPv4 only systems or containers
// Unless someone enabled DNS_PREFER_IPV6, use Ipv4AndIpv6, which returns IPv4 first which was our previous default
if !CONFIG.dns_prefer_ipv6() {
builder.options_mut().ip_strategy = hickory_resolver::config::LookupIpStrategy::Ipv4AndIpv6;
} }
let resolver = builder.build(); builder.build()
Arc::new(Self::Hickory(Arc::new(resolver))) })
} .inspect_err(|e| warn!("Error creating Hickory resolver, falling back to default: {e:?}"))
Err(e) => { .map(|resolver| Arc::new(Self::Hickory(Arc::new(resolver))))
warn!("Error creating Hickory resolver, falling back to default: {e:?}"); .unwrap_or_else(|_| Arc::new(Self::Default()))
Arc::new(Self::Default())
}
}
} }
// Note that we get an iterator of addresses, but we only grab the first one for convenience // 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<Option<SocketAddr>, BoxError> { async fn resolve_domain(&self, name: &str) -> Result<Vec<SocketAddr>, BoxError> {
pre_resolve(name)?; pre_resolve(name)?;
let result = match self { let results: Vec<SocketAddr> = match self {
Self::Default() => tokio::net::lookup_host(name).await?.next(), Self::Default() => tokio::net::lookup_host((name, 0)).await?.collect(),
Self::Hickory(r) => r.lookup_ip(name).await?.iter().next().map(|a| SocketAddr::new(a, 0)), Self::Hickory(r) => r.lookup_ip(name).await?.iter().map(|i| SocketAddr::new(i, 0)).collect(),
}; };
if let Some(addr) = &result { for addr in &results {
post_resolve(name, addr.ip())?; post_resolve(name, addr.ip())?;
} }
Ok(result) Ok(results)
} }
} }
fn pre_resolve(name: &str) -> Result<(), CustomHttpClientError> { fn pre_resolve(name: &str) -> Result<(), CustomHttpClientError> {
if should_block_address(name) { let Ok(host) = get_valid_host(name) else {
return Err(CustomHttpClientError::Invalid {
domain: name.to_string(),
});
};
if should_block_host(&host).is_err() {
return Err(CustomHttpClientError::Blocked { return Err(CustomHttpClientError::Blocked {
domain: name.to_string(), domain: name.to_string(),
}); });
@ -242,8 +286,11 @@ impl Resolve for CustomDnsResolver {
let this = self.clone(); let this = self.clone();
Box::pin(async move { Box::pin(async move {
let name = name.as_str(); let name = name.as_str();
let result = this.resolve_domain(name).await?; let results = this.resolve_domain(name).await?;
Ok::<reqwest::dns::Addrs, _>(Box::new(result.into_iter())) if results.is_empty() {
warn!("Unable to resolve {name} to any valid IP address");
}
Ok::<reqwest::dns::Addrs, _>(Box::new(results.into_iter()))
}) })
} }
} }
@ -305,3 +352,209 @@ pub(crate) mod aws {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::util::is_global_hardcoded;
use std::net::Ipv4Addr;
use url::Host;
// ===
// IPv4 numeric-format normalization
fn parse_to_ip(s: &str) -> Option<IpAddr> {
match Host::parse(s).ok()? {
Host::Ipv4(v4) => Some(IpAddr::V4(v4)),
Host::Ipv6(v6) => Some(IpAddr::V6(v6)),
Host::Domain(_) => None,
}
}
#[test]
fn dotted_decimal_loopback_normalizes() {
let ip = parse_to_ip("127.0.0.1").unwrap();
assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
assert!(!is_global_hardcoded(ip));
}
#[test]
fn single_decimal_loopback_normalizes() {
// 127.0.0.1 == 2130706433
let ip = parse_to_ip("2130706433").unwrap();
assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
assert!(!is_global_hardcoded(ip));
}
#[test]
fn hex_loopback_normalizes() {
let ip = parse_to_ip("0x7f000001").unwrap();
assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
assert!(!is_global_hardcoded(ip));
}
#[test]
fn dotted_hex_loopback_normalizes() {
let ip = parse_to_ip("0x7f.0.0.1").unwrap();
assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
assert!(!is_global_hardcoded(ip));
}
#[test]
fn octal_loopback_normalizes() {
// 017700000001 == 127.0.0.1
let ip = parse_to_ip("017700000001").unwrap();
assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
assert!(!is_global_hardcoded(ip));
}
#[test]
fn dotted_octal_loopback_normalizes() {
let ip = parse_to_ip("0177.0.0.01").unwrap();
assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
assert!(!is_global_hardcoded(ip));
}
#[test]
fn aws_metadata_decimal_blocked() {
// 169.254.169.254 == 2852039166 (link-local, AWS IMDS)
let ip = parse_to_ip("2852039166").unwrap();
assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)));
assert!(!is_global_hardcoded(ip));
}
#[test]
fn rfc1918_hex_blocked() {
// 10.0.0.1
let ip = parse_to_ip("0x0a000001").unwrap();
assert!(!is_global_hardcoded(ip));
}
#[test]
fn public_ip_decimal_allowed() {
// 8.8.8.8 == 134744072
let ip = parse_to_ip("134744072").unwrap();
assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)));
assert!(is_global_hardcoded(ip));
}
// ===
// get_valid_host integration: numeric forms become Host::Ipv4
#[test]
fn get_valid_host_normalizes_decimal_int() {
let h = get_valid_host("2130706433").expect("valid");
assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::new(127, 0, 0, 1)));
}
#[test]
fn get_valid_host_normalizes_hex() {
let h = get_valid_host("0x7f000001").expect("valid");
assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::new(127, 0, 0, 1)));
}
#[test]
fn get_valid_host_normalizes_octal() {
let h = get_valid_host("017700000001").expect("valid");
assert!(matches!(h, Host::Ipv4(ip) if ip == Ipv4Addr::new(127, 0, 0, 1)));
}
// ===
// IPv6 formats
#[test]
fn ipv6_loopback_blocked() {
let h = get_valid_host("[::1]").expect("valid");
let Host::Ipv6(ip) = h else {
panic!("expected v6")
};
assert!(!is_global_hardcoded(IpAddr::V6(ip)));
}
#[test]
fn ipv4_mapped_in_ipv6_loopback_blocked() {
// ::ffff:127.0.0.1 — v4-mapped form; is_global_hardcoded blocks via ::ffff:0:0/96
let h = get_valid_host("[::ffff:127.0.0.1]").expect("valid");
let Host::Ipv6(ip) = h else {
panic!("expected v6")
};
assert!(!is_global_hardcoded(IpAddr::V6(ip)));
}
#[test]
fn ipv6_unique_local_blocked() {
let h = get_valid_host("[fc00::1]").expect("valid");
let Host::Ipv6(ip) = h else {
panic!("expected v6")
};
assert!(!is_global_hardcoded(IpAddr::V6(ip)));
}
// ===
// Punycode / IDN
#[test]
fn punycode_passthrough() {
let h = get_valid_host("xn--deadbeafcaf-lbb.test").expect("valid");
match h {
Host::Domain(d) => assert_eq!(d, "xn--deadbeafcaf-lbb.test"),
_ => panic!("expected domain"),
}
}
#[test]
fn idn_unicode_gets_punycoded() {
let h = get_valid_host("deadbeafcafé.test").expect("valid");
match h {
Host::Domain(d) => assert_eq!(d, "xn--deadbeafcaf-lbb.test"),
_ => panic!("expected domain"),
}
}
#[test]
fn idn_unicode_gets_punycoded_tld() {
let h = get_valid_host("deadbeaf.café").expect("valid");
match h {
Host::Domain(d) => assert_eq!(d, "deadbeaf.xn--caf-dma"),
_ => panic!("expected domain"),
}
}
#[test]
fn idn_emoji_gets_punycoded() {
let h = get_valid_host("xn--t88h.test").expect("valid"); // 🛡️.test
match h {
Host::Domain(d) => assert_eq!(d, "xn--t88h.test"),
_ => panic!("expected domain"),
}
}
#[test]
fn idn_unicode_to_punycode_roundtrip() {
let from_unicode = get_valid_host("🛡️.test").expect("valid");
let from_puny = get_valid_host("xn--t88h.test").expect("valid");
match (from_unicode, from_puny) {
(Host::Domain(a), Host::Domain(b)) => assert_eq!(a, b),
_ => panic!("expected domains"),
}
}
#[test]
fn invalid_punycode_rejected() {
// bare invalid punycode
assert!(get_valid_host("xn--").is_err());
}
#[test]
fn underscore_in_label_rejected() {
assert!(get_valid_host("dead_beaf.cafe").is_err());
}
#[test]
fn label_too_long_rejected() {
let label = "a".repeat(64);
assert!(get_valid_host(&format!("{label}.test")).is_err());
}
#[test]
fn domain_too_long_rejected() {
let big = "a.".repeat(130) + "test"; // > 253
assert!(get_valid_host(&big).is_err());
}
}

51
src/main.rs

@ -57,6 +57,7 @@ mod mail;
mod ratelimit; mod ratelimit;
mod sso; mod sso;
mod sso_client; mod sso_client;
mod storage;
mod util; mod util;
use crate::api::core::two_factor::duo_oidc::purge_duo_contexts; use crate::api::core::two_factor::duo_oidc::purge_duo_contexts;
@ -70,6 +71,7 @@ pub use util::is_running_in_container;
#[rocket::main] #[rocket::main]
async fn main() -> Result<(), Error> { async fn main() -> Result<(), Error> {
install_rustls_crypto_provider();
parse_args(); parse_args();
launch_info(); launch_info();
@ -202,6 +204,14 @@ fn parse_args() {
} }
} }
fn install_rustls_crypto_provider() {
if rustls::crypto::CryptoProvider::get_default().is_none() {
rustls::crypto::ring::default_provider()
.install_default()
.expect("failed to install rustls ring crypto provider");
}
}
fn launch_info() { fn launch_info() {
println!( println!(
"\ "\
@ -558,6 +568,12 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error>
let basepath = &CONFIG.domain_path(); let basepath = &CONFIG.domain_path();
let mut config = rocket::Config::from(rocket::Config::figment()); let mut config = rocket::Config::from(rocket::Config::figment());
// We install our own signal handlers below; disable Rocket's built-in handlers
config.shutdown.ctrlc = false;
#[cfg(unix)]
config.shutdown.signals.clear();
config.temp_dir = canonicalize(CONFIG.tmp_folder()).unwrap().into(); config.temp_dir = canonicalize(CONFIG.tmp_folder()).unwrap().into();
config.cli_colors = false; // Make sure Rocket does not color any values for logging. config.cli_colors = false; // Make sure Rocket does not color any values for logging.
config.limits = Limits::new() config.limits = Limits::new()
@ -589,11 +605,7 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error>
CONFIG.set_rocket_shutdown_handle(instance.shutdown()); CONFIG.set_rocket_shutdown_handle(instance.shutdown());
tokio::spawn(async move { spawn_shutdown_signal_handler();
tokio::signal::ctrl_c().await.expect("Error setting Ctrl-C handler");
info!("Exiting Vaultwarden!");
CONFIG.shutdown();
});
#[cfg(all(unix, sqlite))] #[cfg(all(unix, sqlite))]
{ {
@ -621,6 +633,35 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error>
Ok(()) Ok(())
} }
#[cfg(unix)]
fn spawn_shutdown_signal_handler() {
tokio::spawn(async move {
use tokio::signal::unix::signal;
let mut sigint = signal(SignalKind::interrupt()).expect("Error setting SIGINT handler");
let mut sigterm = signal(SignalKind::terminate()).expect("Error setting SIGTERM handler");
let mut sigquit = signal(SignalKind::quit()).expect("Error setting SIGQUIT handler");
let signal_name = tokio::select! {
_ = sigint.recv() => "SIGINT",
_ = sigterm.recv() => "SIGTERM",
_ = sigquit.recv() => "SIGQUIT",
};
info!("Received {signal_name}, initiating graceful shutdown");
CONFIG.shutdown();
});
}
#[cfg(not(unix))]
fn spawn_shutdown_signal_handler() {
tokio::spawn(async move {
tokio::signal::ctrl_c().await.expect("Error setting Ctrl-C handler");
info!("Received Ctrl-C, initiating graceful shutdown");
CONFIG.shutdown();
});
}
fn schedule_jobs(pool: db::DbPool) { fn schedule_jobs(pool: db::DbPool) {
if CONFIG.job_poll_interval_ms() == 0 { if CONFIG.job_poll_interval_ms() == 0 {
info!("Job scheduler disabled."); info!("Job scheduler disabled.");

35
src/sso.rs

@ -10,14 +10,14 @@ use crate::{
auth, auth,
auth::{AuthMethod, AuthTokens, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY}, auth::{AuthMethod, AuthTokens, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY},
db::{ db::{
models::{Device, OIDCAuthenticatedUser, OIDCCodeWrapper, SsoAuth, SsoUser, User}, models::{Device, OIDCAuthenticatedUser, SsoAuth, SsoUser, User},
DbConn, DbConn,
}, },
sso_client::Client, sso_client::Client,
CONFIG, CONFIG,
}; };
pub static FAKE_IDENTIFIER: &str = "VW_DUMMY_IDENTIFIER_FOR_OIDC"; pub static FAKE_SSO_IDENTIFIER: &str = "00000000-01DC-01DC-01DC-000000000000";
static SSO_JWT_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|sso", CONFIG.domain_origin())); static SSO_JWT_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|sso", CONFIG.domain_origin()));
@ -188,6 +188,7 @@ pub async fn authorize_url(
client_challenge: OIDCCodeChallenge, client_challenge: OIDCCodeChallenge,
client_id: &str, client_id: &str,
raw_redirect_uri: &str, raw_redirect_uri: &str,
binding_hash: Option<String>,
conn: DbConn, conn: DbConn,
) -> ApiResult<Url> { ) -> ApiResult<Url> {
let redirect_uri = match client_id { let redirect_uri = match client_id {
@ -203,7 +204,7 @@ pub async fn authorize_url(
_ => err!(format!("Unsupported client {client_id}")), _ => err!(format!("Unsupported client {client_id}")),
}; };
let (auth_url, sso_auth) = Client::authorize_url(state, client_challenge, redirect_uri).await?; let (auth_url, sso_auth) = Client::authorize_url(state, client_challenge, redirect_uri, binding_hash).await?;
sso_auth.save(&conn).await?; sso_auth.save(&conn).await?;
Ok(auth_url) Ok(auth_url)
} }
@ -239,14 +240,14 @@ impl OIDCIdentifier {
// - second time we will rely on `SsoAuth.auth_response` since the `code` has already been exchanged. // - second time we will rely on `SsoAuth.auth_response` since the `code` has already been exchanged.
// The `SsoAuth` will ensure that the user is authorized only once. // The `SsoAuth` will ensure that the user is authorized only once.
pub async fn exchange_code( pub async fn exchange_code(
state: &OIDCState, code: &OIDCCode,
client_verifier: OIDCCodeVerifier, client_verifier: OIDCCodeVerifier,
conn: &DbConn, conn: &DbConn,
) -> ApiResult<(SsoAuth, OIDCAuthenticatedUser)> { ) -> ApiResult<(SsoAuth, OIDCAuthenticatedUser)> {
use openidconnect::OAuth2TokenResponse; use openidconnect::OAuth2TokenResponse;
let mut sso_auth = match SsoAuth::find(state, conn).await { let mut sso_auth = match SsoAuth::find_by_code(code, conn).await {
None => err!(format!("Invalid state cannot retrieve sso auth")), None => err!(format!("Invalid code cannot retrieve sso auth")),
Some(sso_auth) => sso_auth, Some(sso_auth) => sso_auth,
}; };
@ -254,18 +255,18 @@ pub async fn exchange_code(
return Ok((sso_auth, authenticated_user)); return Ok((sso_auth, authenticated_user));
} }
let code = match sso_auth.code_response.clone() { let code = match (sso_auth.code_response.clone(), sso_auth.code_response_error.as_ref()) {
Some(OIDCCodeWrapper::Ok { (Some(code), None) => code,
code, (_, Some(re)) => {
}) => code.clone(), let error_msg = format!(
Some(OIDCCodeWrapper::Error { "SSO authorization failed: {}, {}",
error, re.error,
error_description, re.error_description.as_ref().unwrap_or(&String::new())
}) => { );
sso_auth.delete(conn).await?; sso_auth.delete(conn).await?;
err!(format!("SSO authorization failed: {error}, {}", error_description.as_ref().unwrap_or(&String::new()))) err!(error_msg);
} }
None => { (None, _) => {
sso_auth.delete(conn).await?; sso_auth.delete(conn).await?;
err!("Missing authorization provider return"); err!("Missing authorization provider return");
} }
@ -283,7 +284,7 @@ pub async fn exchange_code(
let email_verified = id_claims.email_verified().or(user_info.email_verified()); let email_verified = id_claims.email_verified().or(user_info.email_verified());
let user_name = id_claims.preferred_username().map(|un| un.to_string()); let user_name = id_claims.preferred_username().or(user_info.preferred_username()).map(|un| un.to_string());
let refresh_token = token_response.refresh_token().map(|t| t.secret()); let refresh_token = token_response.refresh_token().map(|t| t.secret());
if refresh_token.is_none() && CONFIG.sso_scopes_vec().contains(&"offline_access".to_string()) { if refresh_token.is_none() && CONFIG.sso_scopes_vec().contains(&"offline_access".to_string()) {

92
src/sso_client.rs

@ -1,21 +1,26 @@
use std::{borrow::Cow, sync::LazyLock, time::Duration}; use std::{borrow::Cow, future::Future, pin::Pin, sync::LazyLock, time::Duration};
use mini_moka::sync::Cache; use openidconnect::{core::*, *};
use openidconnect::{core::*, reqwest, *};
use regex::Regex; use regex::Regex;
use url::Url; use url::Url;
use crate::{ use crate::{
api::{ApiResult, EmptyResult}, api::{ApiResult, EmptyResult},
db::models::SsoAuth, db::models::SsoAuth,
http_client::get_reqwest_client_builder,
sso::{OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState}, sso::{OIDCCode, OIDCCodeChallenge, OIDCCodeVerifier, OIDCState},
CONFIG, CONFIG,
}; };
static CLIENT_CACHE_KEY: LazyLock<String> = LazyLock::new(|| "sso-client".to_string()); static CLIENT_CACHE_KEY: LazyLock<String> = LazyLock::new(|| "sso-client".to_string());
static CLIENT_CACHE: LazyLock<Cache<String, Client>> = LazyLock::new(|| { static CLIENT_CACHE: LazyLock<moka::sync::Cache<String, Client>> = LazyLock::new(|| {
Cache::builder().max_capacity(1).time_to_live(Duration::from_secs(CONFIG.sso_client_cache_expiration())).build() moka::sync::Cache::builder()
.max_capacity(1)
.time_to_live(Duration::from_secs(CONFIG.sso_client_cache_expiration()))
.build()
}); });
static REFRESH_CACHE: LazyLock<moka::future::Cache<String, Result<RefreshTokenResponse, String>>> =
LazyLock::new(|| moka::future::Cache::builder().max_capacity(1000).time_to_live(Duration::from_secs(30)).build());
/// OpenID Connect Core client. /// OpenID Connect Core client.
pub type CustomClient = openidconnect::Client< pub type CustomClient = openidconnect::Client<
@ -38,12 +43,46 @@ pub type CustomClient = openidconnect::Client<
EndpointSet, EndpointSet,
>; >;
pub type RefreshTokenResponse = (Option<String>, String, Option<Duration>);
#[derive(Clone)] #[derive(Clone)]
pub struct Client { pub struct Client {
pub http_client: reqwest::Client, pub http_client: OidcHttpClient,
pub core_client: CustomClient, pub core_client: CustomClient,
} }
#[derive(Clone)]
pub struct OidcHttpClient {
client: reqwest::Client,
}
impl OidcHttpClient {
fn new() -> Result<Self, reqwest::Error> {
get_reqwest_client_builder().redirect(reqwest::redirect::Policy::none()).build().map(|client| Self {
client,
})
}
}
impl<'c> AsyncHttpClient<'c> for OidcHttpClient {
type Error = HttpClientError<reqwest::Error>;
type Future = Pin<Box<dyn Future<Output = Result<HttpResponse, Self::Error>> + Send + Sync + 'c>>;
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 mut builder = http::Response::builder().status(response.status()).version(response.version());
for (name, value) in response.headers() {
builder = builder.header(name, value);
}
builder.body(response.bytes().await.map_err(Box::new)?.to_vec()).map_err(HttpClientError::Http)
})
}
}
impl Client { impl Client {
// Call the OpenId discovery endpoint to retrieve configuration // Call the OpenId discovery endpoint to retrieve configuration
async fn _get_client() -> ApiResult<Self> { async fn _get_client() -> ApiResult<Self> {
@ -52,7 +91,7 @@ impl Client {
let issuer_url = CONFIG.sso_issuer_url()?; let issuer_url = CONFIG.sso_issuer_url()?;
let http_client = match reqwest::ClientBuilder::new().redirect(reqwest::redirect::Policy::none()).build() { let http_client = match OidcHttpClient::new() {
Err(err) => err!(format!("Failed to build http client: {err}")), Err(err) => err!(format!("Failed to build http client: {err}")),
Ok(client) => client, Ok(client) => client,
}; };
@ -111,6 +150,7 @@ impl Client {
state: OIDCState, state: OIDCState,
client_challenge: OIDCCodeChallenge, client_challenge: OIDCCodeChallenge,
redirect_uri: String, redirect_uri: String,
binding_hash: Option<String>,
) -> ApiResult<(Url, SsoAuth)> { ) -> ApiResult<(Url, SsoAuth)> {
let scopes = CONFIG.sso_scopes_vec().into_iter().map(Scope::new); let scopes = CONFIG.sso_scopes_vec().into_iter().map(Scope::new);
let base64_state = data_encoding::BASE64.encode(state.to_string().as_bytes()); let base64_state = data_encoding::BASE64.encode(state.to_string().as_bytes());
@ -133,7 +173,7 @@ impl Client {
} }
let (auth_url, _, nonce) = auth_req.url(); let (auth_url, _, nonce) = auth_req.url();
Ok((auth_url, SsoAuth::new(state, client_challenge, nonce.secret().clone(), redirect_uri))) Ok((auth_url, SsoAuth::new(state, client_challenge, nonce.secret().clone(), redirect_uri, binding_hash)))
} }
pub async fn exchange_code( pub async fn exchange_code(
@ -231,23 +271,29 @@ impl Client {
verifier verifier
} }
pub async fn exchange_refresh_token( pub async fn exchange_refresh_token(refresh_token: String) -> ApiResult<RefreshTokenResponse> {
refresh_token: String, let client = Client::cached().await?;
) -> ApiResult<(Option<String>, String, Option<Duration>)> {
REFRESH_CACHE
.get_with(refresh_token.clone(), async move { client._exchange_refresh_token(refresh_token).await })
.await
.map_err(Into::into)
}
async fn _exchange_refresh_token(&self, refresh_token: String) -> Result<RefreshTokenResponse, String> {
let rt = RefreshToken::new(refresh_token); let rt = RefreshToken::new(refresh_token);
let client = Client::cached().await?; match self.core_client.exchange_refresh_token(&rt).request_async(&self.http_client).await {
let token_response = Err(err) => {
match client.core_client.exchange_refresh_token(&rt).request_async(&client.http_client).await { error!("Request to exchange_refresh_token endpoint failed: {err}");
Err(err) => err!(format!("Request to exchange_refresh_token endpoint failed: {:?}", err)), Err(format!("Request to exchange_refresh_token endpoint failed: {err}"))
Ok(token_response) => token_response, }
}; Ok(token_response) => Ok((
token_response.refresh_token().map(|token| token.secret().clone()),
Ok(( token_response.access_token().secret().clone(),
token_response.refresh_token().map(|token| token.secret().clone()), token_response.expires_in(),
token_response.access_token().secret().clone(), )),
token_response.expires_in(), }
))
} }
} }

11
src/static/global_domains.json

@ -111,7 +111,8 @@
"microsoftstore.com", "microsoftstore.com",
"xbox.com", "xbox.com",
"azure.com", "azure.com",
"windowsazure.com" "windowsazure.com",
"cloud.microsoft"
], ],
"excluded": false "excluded": false
}, },
@ -971,5 +972,13 @@
"pinterest.se" "pinterest.se"
], ],
"excluded": false "excluded": false
},
{
"type": 91,
"domains": [
"twitter.com",
"x.com"
],
"excluded": false
} }
] ]

15
src/static/scripts/admin.css

@ -1,6 +1,17 @@
body { body {
padding-top: 75px; padding-top: 75px;
} }
/* Some extra width's for the main layout */
@media (min-width: 1600px) {
.container-xxl {
max-width: 1520px;
}
}
@media (min-width: 1800px) {
.container-xxl {
max-width: 1720px;
}
}
img { img {
width: 48px; width: 48px;
height: 48px; height: 48px;
@ -38,8 +49,8 @@ img {
max-width: 130px; max-width: 130px;
} }
#users-table .vw-actions, #orgs-table .vw-actions { #users-table .vw-actions, #orgs-table .vw-actions {
min-width: 155px; min-width: 170px;
max-width: 160px; max-width: 180px;
} }
#users-table .vw-org-cell { #users-table .vw-org-cell {
max-height: 120px; max-height: 120px;

7
src/static/scripts/admin_diagnostics.js

@ -109,6 +109,9 @@ async function generateSupportString(event, dj) {
supportString += "* Websocket Check: disabled\n"; supportString += "* Websocket Check: disabled\n";
} }
supportString += `* HTTP Response Checks: ${httpResponseCheck}\n`; supportString += `* HTTP Response Checks: ${httpResponseCheck}\n`;
if (dj.invalid_feature_flags != "") {
supportString += `* Invalid feature flags: true\n`;
}
const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, { const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, {
"headers": { "Accept": "application/json" } "headers": { "Accept": "application/json" }
@ -128,6 +131,10 @@ async function generateSupportString(event, dj) {
supportString += `\n**Environment settings which are overridden:** ${dj.overrides}\n`; supportString += `\n**Environment settings which are overridden:** ${dj.overrides}\n`;
} }
if (dj.invalid_feature_flags != "") {
supportString += `\n**Invalid feature flags:** ${dj.invalid_feature_flags}\n`;
}
// Add http response check messages if they exists // Add http response check messages if they exists
if (httpResponseCheck === false) { if (httpResponseCheck === false) {
supportString += "\n**Failed HTTP Checks:**\n"; supportString += "\n**Failed HTTP Checks:**\n";

4
src/static/scripts/datatables.css

@ -4,10 +4,10 @@
* *
* To rebuild or modify this file with the latest versions of the included * To rebuild or modify this file with the latest versions of the included
* software please visit: * software please visit:
* https://datatables.net/download/#bs5/dt-2.3.7 * https://datatables.net/download/#bs5/dt-2.3.8
* *
* Included libraries: * Included libraries:
* DataTables 2.3.7 * DataTables 2.3.8
*/ */
:root { :root {

52
src/static/scripts/datatables.js

@ -4,13 +4,13 @@
* *
* To rebuild or modify this file with the latest versions of the included * To rebuild or modify this file with the latest versions of the included
* software please visit: * software please visit:
* https://datatables.net/download/#bs5/dt-2.3.7 * https://datatables.net/download/#bs5/dt-2.3.8
* *
* Included libraries: * Included libraries:
* DataTables 2.3.7 * DataTables 2.3.8
*/ */
/*! DataTables 2.3.7 /*! DataTables 2.3.8
* © SpryMedia Ltd - datatables.net/license * © SpryMedia Ltd - datatables.net/license
*/ */
@ -525,7 +525,7 @@
* *
* @type string * @type string
*/ */
builder: "bs5/dt-2.3.7", builder: "bs5/dt-2.3.8",
/** /**
* Buttons. For use with the Buttons extension for DataTables. This is * Buttons. For use with the Buttons extension for DataTables. This is
@ -3607,6 +3607,11 @@
if ( holdPosition !== true ) { if ( holdPosition !== true ) {
settings._iDisplayStart = 0; settings._iDisplayStart = 0;
} }
else {
// Keep position, but make sure that there is actually data to display,
// otherwise we need to rewind a bit (e.g. if rows were deleted)
_fnLengthOverflow(settings);
}
// Let any modules know about the draw hold position state (used by // Let any modules know about the draw hold position state (used by
// scrolling internally) // scrolling internally)
@ -4920,6 +4925,12 @@
var args = [settings, settings.json]; var args = [settings, settings.json];
// If the footer element is empty after initialisation, then remove it
let tfoot = $(settings.tfoot);
if (tfoot.children().length === 0) {
tfoot.remove();
}
settings._bInitComplete = true; settings._bInitComplete = true;
// Table is fully set up and we have data, so calculate the // Table is fully set up and we have data, so calculate the
@ -5376,12 +5387,12 @@
// the content of the cell so that the width applied to the header and body // the content of the cell so that the width applied to the header and body
// both match, but we want to hide it completely. // both match, but we want to hide it completely.
$('th, td', headerCopy).each(function () { $('th, td', headerCopy).each(function () {
$(this.childNodes).wrapAll('<div class="dt-scroll-sizing">'); $(this.childNodes).wrapAll('<div class="dt-scroll-sizing" />');
}); });
if ( footer ) { if ( footer ) {
$('th, td', footerCopy).each(function () { $('th, td', footerCopy).each(function () {
$(this.childNodes).wrapAll('<div class="dt-scroll-sizing">'); $(this.childNodes).wrapAll('<div class="dt-scroll-sizing" />');
}); });
} }
@ -5409,6 +5420,10 @@
// Correct DOM ordering for colgroup - comes before the thead // Correct DOM ordering for colgroup - comes before the thead
table.children('colgroup').prependTo(table); table.children('colgroup').prependTo(table);
// Remove tabindex from the hidden row elements
table.find('thead, tfoot').find('[tabindex]').removeAttr('tabindex');
table.find('thead, tfoot').find('role').removeAttr('role');
// Adjust the position of the header in case we loose the y-scrollbar // Adjust the position of the header in case we loose the y-scrollbar
divBody.trigger('scroll'); divBody.trigger('scroll');
@ -5732,8 +5747,12 @@
.replace(/id=".*?"/g, '') .replace(/id=".*?"/g, '')
.replace(/name=".*?"/g, ''); .replace(/name=".*?"/g, '');
// Don't want Javascript at all in these calculation cells. // Don't want script, dialog or template tags in the width
cellString = cellString.replace(/<script.*?<\/script>/gi, ' '); // calculations as they are hidden content
cellString = cellString
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<dialog[\s\S]*?<\/dialog>/gi, ' ')
.replace(/<template[\s\S]*?<\/template>/gi, ' ');
var noHtml = _stripHtml(cellString, ' ') var noHtml = _stripHtml(cellString, ' ')
.replace( /&nbsp;/g, ' ' ); .replace( /&nbsp;/g, ' ' );
@ -10304,7 +10323,7 @@
* @type string * @type string
* @default Version number * @default Version number
*/ */
DataTable.version = "2.3.7"; DataTable.version = "2.3.8";
/** /**
* Private data store, containing all of the settings objects that are * Private data store, containing all of the settings objects that are
@ -12586,6 +12605,7 @@
var __mlWarning = false; var __mlWarning = false;
var __luxon; // Can be assigned in DateTable.use() var __luxon; // Can be assigned in DateTable.use()
var __moment; // Can be assigned in DateTable.use() var __moment; // Can be assigned in DateTable.use()
var __reIsoTimezone = /[T\s]\d{2}.*?(Z|[+-]\d{2}(?::?\d{2})?)$/;
/** /**
* *
@ -12606,7 +12626,7 @@
resolveWindowLibs(); resolveWindowLibs();
if (__moment) { if (__moment) {
dt = __moment.utc( d, format, locale, true ); dt = __moment( d, format, locale, true );
if (! dt.isValid()) { if (! dt.isValid()) {
return null; return null;
@ -12716,6 +12736,16 @@
return d; return d;
} }
// Determine if there is a timezone. If there is, we want to reuse
// it for the output, so the timezone doesn't change between the
// input and output.
let options = {};
let tzMatch = typeof d === 'string' ? d.match(__reIsoTimezone) : null;
if (tzMatch) {
options.timeZone = tzMatch[1] === 'Z' ? 'UTC' : tzMatch[1];
}
var dt = __mldObj(d, from, locale); var dt = __mldObj(d, from, locale);
if (dt === null) { if (dt === null) {
@ -12729,7 +12759,7 @@
var formatted = to === null var formatted = to === null
? __mld(dt, 'toDate', 'toJSDate', '')[localeString]( ? __mld(dt, 'toDate', 'toJSDate', '')[localeString](
navigator.language, navigator.language,
{ timeZone: "UTC" } options
) )
: __mld(dt, 'format', 'toFormat', 'toISOString', to); : __mld(dt, 'format', 'toFormat', 'toISOString', to);

2
src/static/templates/admin/base.hbs

@ -27,7 +27,7 @@
</symbol> </symbol>
</svg> </svg>
<nav class="navbar navbar-expand-md navbar-dark bg-dark mb-4 shadow fixed-top"> <nav class="navbar navbar-expand-md navbar-dark bg-dark mb-4 shadow fixed-top">
<div class="container-xl"> <div class="container-xxl">
<a class="navbar-brand" href="{{urlpath}}/admin"><img class="vaultwarden-icon" src="{{urlpath}}/vw_static/vaultwarden-icon.png" alt="V">aultwarden Admin</a> <a class="navbar-brand" href="{{urlpath}}/admin"><img class="vaultwarden-icon" src="{{urlpath}}/vw_static/vaultwarden-icon.png" alt="V">aultwarden Admin</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarCollapse" <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarCollapse"
aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation"> aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation">

10
src/static/templates/admin/diagnostics.hbs

@ -1,4 +1,4 @@
<main class="container-xl"> <main class="container-xxl">
<div id="diagnostics-block" class="my-3 p-3 rounded shadow"> <div id="diagnostics-block" class="my-3 p-3 rounded shadow">
<h6 class="border-bottom pb-2 mb-2">Diagnostics</h6> <h6 class="border-bottom pb-2 mb-2">Diagnostics</h6>
@ -194,6 +194,14 @@
<dd class="col-sm-7"> <dd class="col-sm-7">
<span id="http-response-errors" class="d-block"></span> <span id="http-response-errors" class="d-block"></span>
</dd> </dd>
{{#if page_data.invalid_feature_flags}}
<dt class="col-sm-5">Invalid Feature Flags
<span class="badge bg-warning text-dark abbr-badge" id="feature-flag-warning" title="Some feature flags are invalid or outdated!">Warning</span>
</dt>
<dd class="col-sm-7">
<span id="feature-flags" class="d-block"><b>Flags:</b> <span id="feature-flags-string">{{page_data.invalid_feature_flags}}</span></span>
</dd>
{{/if}}
</dl> </dl>
</div> </div>
</div> </div>

2
src/static/templates/admin/login.hbs

@ -1,4 +1,4 @@
<main class="container-xl"> <main class="container-xxl">
{{#if error}} {{#if error}}
<div class="align-items-center p-3 mb-3 text-opacity-50 text-dark bg-warning rounded shadow"> <div class="align-items-center p-3 mb-3 text-opacity-50 text-dark bg-warning rounded shadow">
<div> <div>

2
src/static/templates/admin/organizations.hbs

@ -1,4 +1,4 @@
<main class="container-xl"> <main class="container-xxl">
<div id="organizations-block" class="my-3 p-3 rounded shadow"> <div id="organizations-block" class="my-3 p-3 rounded shadow">
<h6 class="border-bottom pb-2 mb-3">Organizations</h6> <h6 class="border-bottom pb-2 mb-3">Organizations</h6>
<div class="table-responsive-xl small"> <div class="table-responsive-xl small">

2
src/static/templates/admin/settings.hbs

@ -1,4 +1,4 @@
<main class="container-xl"> <main class="container-xxl">
<div id="admin_token_warning" class="alert alert-warning alert-dismissible fade show d-none"> <div id="admin_token_warning" class="alert alert-warning alert-dismissible fade show d-none">
<button type="button" class="btn-close" data-bs-target="admin_token_warning" data-bs-dismiss="alert" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-target="admin_token_warning" data-bs-dismiss="alert" aria-label="Close"></button>
You are using a plain text `ADMIN_TOKEN` which is insecure.<br> You are using a plain text `ADMIN_TOKEN` which is insecure.<br>

4
src/static/templates/admin/users.hbs

@ -1,4 +1,4 @@
<main class="container-xl"> <main class="container-xxl">
<div id="users-block" class="my-3 p-3 rounded shadow"> <div id="users-block" class="my-3 p-3 rounded shadow">
<h6 class="border-bottom pb-2 mb-3">Registered Users</h6> <h6 class="border-bottom pb-2 mb-3">Registered Users</h6>
<div class="table-responsive-xl small"> <div class="table-responsive-xl small">
@ -43,7 +43,7 @@
</td> </td>
{{#if ../sso_enabled}} {{#if ../sso_enabled}}
<td> <td>
<span class="d-block">{{sso_identifier}}</span> <span class="d-block text-break text-wrap">{{sso_identifier}}</span>
</td> </td>
{{/if}} {{/if}}
<td> <td>

8
src/static/templates/scss/vaultwarden.scss.hbs

@ -137,6 +137,14 @@ bit-nav-logo bit-nav-item .bwi-shield {
app-user-layout app-danger-zone button:nth-child(1) { app-user-layout app-danger-zone button:nth-child(1) {
@extend %vw-hide; @extend %vw-hide;
} }
/* Hide unsupported Forwarding email alias options */
ng-dropdown-panel div.ng-dropdown-panel-items div:has(> [title="Firefox Relay"]) {
@extend %vw-hide;
}
ng-dropdown-panel div.ng-dropdown-panel-items div:has(> [title="DuckDuckGo"]) {
@extend %vw-hide;
}
/**** END Static Vaultwarden Changes ****/ /**** END Static Vaultwarden Changes ****/
/**** START Dynamic Vaultwarden Changes ****/ /**** START Dynamic Vaultwarden Changes ****/
{{#if signup_disabled}} {{#if signup_disabled}}

297
src/storage.rs

@ -0,0 +1,297 @@
use std::sync::LazyLock;
pub(crate) fn join_path(base: &str, child: &str) -> String {
#[cfg(s3)]
if s3::is_uri(base) {
return s3::join_path(base, child);
}
let base = base.trim_end_matches('/');
let child = child.trim_start_matches('/');
if base.is_empty() {
child.to_string()
} else if child.is_empty() {
base.to_string()
} else {
format!("{base}/{child}")
}
}
pub(crate) fn with_extension(path: &str, extension: &str) -> String {
let extension = extension.trim_start_matches('.');
#[cfg(s3)]
if s3::is_uri(path) {
return s3::with_extension(path, extension);
}
format!("{path}.{extension}")
}
pub(crate) fn parent(path: &str) -> Option<String> {
#[cfg(s3)]
if s3::is_uri(path) {
return s3::parent(path);
}
std::path::Path::new(path).parent()?.to_str().map(ToString::to_string)
}
pub(crate) fn file_name(path: &str) -> Option<String> {
#[cfg(s3)]
if s3::is_uri(path) {
return s3::file_name(path);
}
std::path::Path::new(path).file_name()?.to_str().map(ToString::to_string)
}
pub(crate) fn is_fs_operator(operator: &opendal::Operator) -> bool {
operator.info().scheme() == opendal::services::FS_SCHEME
}
pub(crate) fn operator_for_path(path: &str) -> Result<opendal::Operator, crate::Error> {
// Cache of previously built operators by path
static OPERATORS_BY_PATH: LazyLock<dashmap::DashMap<String, opendal::Operator>> =
LazyLock::new(dashmap::DashMap::new);
if let Some(operator) = OPERATORS_BY_PATH.get(path) {
return Ok(operator.clone());
}
let operator = if path.starts_with("s3://") {
#[cfg(not(s3))]
return Err(opendal::Error::new(opendal::ErrorKind::ConfigInvalid, "S3 support is not enabled").into());
#[cfg(s3)]
s3::operator_for_path(path)?
} else {
let builder = opendal::services::Fs::default().root(path);
opendal::Operator::new(builder)?.finish()
};
OPERATORS_BY_PATH.insert(path.to_string(), operator.clone());
Ok(operator)
}
#[cfg(s3)]
mod s3 {
use reqwest::Url;
use crate::error::Error;
pub(super) fn is_uri(path: &str) -> bool {
path.starts_with("s3://")
}
pub(super) fn join_path(base: &str, child: &str) -> String {
if let Ok(mut url) = Url::parse(base) {
let mut segments = path_segments(&url);
segments.extend(child.split('/').filter(|segment| !segment.is_empty()).map(ToString::to_string));
set_path_segments(&mut url, &segments);
return url.to_string();
}
let base = base.trim_end_matches('/');
let child = child.trim_start_matches('/');
if base.is_empty() {
child.to_string()
} else if child.is_empty() {
base.to_string()
} else {
format!("{base}/{child}")
}
}
pub(super) fn with_extension(path: &str, extension: &str) -> String {
if let Ok(mut url) = Url::parse(path) {
let mut segments = path_segments(&url);
if let Some(file_name) = segments.last_mut() {
file_name.push('.');
file_name.push_str(extension);
set_path_segments(&mut url, &segments);
return url.to_string();
}
}
format!("{path}.{extension}")
}
pub(super) fn parent(path: &str) -> Option<String> {
if let Ok(mut url) = Url::parse(path) {
let mut segments = path_segments(&url);
segments.pop()?;
set_path_segments(&mut url, &segments);
return Some(url.to_string());
}
std::path::Path::new(path).parent()?.to_str().map(ToString::to_string)
}
pub(super) fn file_name(path: &str) -> Option<String> {
if let Ok(url) = Url::parse(path) {
return path_segments(&url).pop();
}
std::path::Path::new(path).file_name()?.to_str().map(ToString::to_string)
}
fn path_segments(url: &Url) -> Vec<String> {
url.path_segments()
.map(|segments| segments.filter(|segment| !segment.is_empty()).map(ToString::to_string).collect())
.unwrap_or_default()
}
fn set_path_segments(url: &mut Url, segments: &[String]) {
if segments.is_empty() {
url.set_path("");
} else {
url.set_path(&format!("/{}", segments.join("/")));
}
}
pub(super) fn operator_for_path(path: &str) -> Result<opendal::Operator, Error> {
use crate::http_client::aws::AwsReqwestConnector;
use aws_config::{default_provider::credentials::DefaultCredentialsChain, provider_config::ProviderConfig};
use opendal::Configurator;
use reqsign_aws_v4::Credential;
use reqsign_core::{Context, ProvideCredential, ProvideCredentialChain};
// This is a custom AWS credential loader that uses the official AWS Rust
// SDK config crate to load credentials. This ensures maximum compatibility
// with AWS credential configurations. For example, OpenDAL doesn't support
// AWS SSO temporary credentials yet.
#[derive(Debug)]
struct OpenDALS3CredentialProvider;
impl ProvideCredential for OpenDALS3CredentialProvider {
type Credential = Credential;
async fn provide_credential(&self, _ctx: &Context) -> reqsign_core::Result<Option<Self::Credential>> {
use aws_credential_types::provider::ProvideCredentials as _;
use reqsign_core::time::Timestamp;
use tokio::sync::OnceCell;
static DEFAULT_CREDENTIAL_CHAIN: OnceCell<DefaultCredentialsChain> = OnceCell::const_new();
let chain = DEFAULT_CREDENTIAL_CHAIN
.get_or_init(|| {
let reqwest_client = reqwest::Client::builder().build().unwrap();
let connector = AwsReqwestConnector {
client: reqwest_client,
};
let conf = ProviderConfig::default().with_http_client(connector);
DefaultCredentialsChain::builder().configure(conf).build()
})
.await;
let creds = chain.provide_credentials().await.map_err(|e| {
reqsign_core::Error::unexpected("failed to load AWS credentials via AWS SDK").with_source(e)
})?;
let expires_in = if let Some(expiration) = creds.expiry() {
let duration = expiration.duration_since(std::time::UNIX_EPOCH).map_err(|e| {
reqsign_core::Error::unexpected("AWS credential expiration is before the Unix epoch")
.with_source(e)
})?;
let seconds = i64::try_from(duration.as_secs()).map_err(|e| {
reqsign_core::Error::unexpected("AWS credential expiration is too large").with_source(e)
})?;
Some(Timestamp::from_second(seconds)?)
} else {
None
};
Ok(Some(Credential {
access_key_id: creds.access_key_id().to_string(),
secret_access_key: creds.secret_access_key().to_string(),
session_token: creds.session_token().map(|s| s.to_string()),
expires_in,
}))
}
}
let uri = opendal::OperatorUri::new(path, std::iter::empty::<(String, String)>())?;
let mut config = opendal::services::S3Config::from_uri(&uri)?;
if !uri_has_option(&uri, &["default_storage_class"]) {
config.default_storage_class = Some("INTELLIGENT_TIERING".to_string());
}
if !uri_has_option(
&uri,
&["enable_virtual_host_style", "aws_virtual_hosted_style_request", "virtual_hosted_style_request"],
) {
config.enable_virtual_host_style = true;
}
let use_aws_sdk_credentials = !uri_has_credential_options(&uri, &config);
let mut builder = config.into_builder();
if use_aws_sdk_credentials {
builder =
builder.credential_provider_chain(ProvideCredentialChain::new().push(OpenDALS3CredentialProvider));
}
Ok(opendal::Operator::new(builder)?.finish())
}
fn uri_has_option(uri: &opendal::OperatorUri, names: &[&str]) -> bool {
names.iter().any(|name| uri.options().contains_key(*name))
}
fn uri_has_credential_options(uri: &opendal::OperatorUri, config: &opendal::services::S3Config) -> bool {
config.access_key_id.is_some()
|| config.secret_access_key.is_some()
|| config.session_token.is_some()
|| config.role_arn.is_some()
|| config.external_id.is_some()
|| config.role_session_name.is_some()
|| uri_has_option(uri, &["allow_anonymous", "disable_config_load", "disable_ec2_metadata"])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn handles_local_paths() {
assert_eq!(join_path("data", "attachments"), "data/attachments");
assert_eq!(with_extension("data/rsa_key", "pem"), "data/rsa_key.pem");
assert_eq!(parent("data/rsa_key.pem").as_deref(), Some("data"));
assert_eq!(file_name("data/rsa_key.pem").as_deref(), Some("rsa_key.pem"));
}
}
#[cfg(all(test, s3))]
mod s3_tests {
use super::*;
#[test]
fn joins_s3_path_before_query_string() {
assert_eq!(
join_path("s3://bucket/base?region=us-west-2", "attachments"),
"s3://bucket/base/attachments?region=us-west-2"
);
}
#[test]
fn appends_extension_before_s3_query_string() {
assert_eq!(
with_extension("s3://bucket/base/rsa_key?region=us-west-2", "pem"),
"s3://bucket/base/rsa_key.pem?region=us-west-2"
);
}
#[test]
fn splits_s3_parent_and_file_name_without_query_string() {
let path = "s3://bucket/base/config.json?region=us-west-2";
assert_eq!(parent(path).as_deref(), Some("s3://bucket/base?region=us-west-2"));
assert_eq!(file_name(path).as_deref(), Some("config.json"));
}
}

77
src/util.rs

@ -16,7 +16,10 @@ use tokio::{
time::{sleep, Duration}, time::{sleep, Duration},
}; };
use crate::{config::PathType, CONFIG}; use crate::{
config::{PathType, SUPPORTED_FEATURE_FLAGS},
CONFIG,
};
pub struct AppHeaders(); pub struct AppHeaders();
@ -153,9 +156,11 @@ impl Cors {
fn get_allowed_origin(headers: &HeaderMap<'_>) -> Option<String> { fn get_allowed_origin(headers: &HeaderMap<'_>) -> Option<String> {
let origin = Cors::get_header(headers, "Origin"); let origin = Cors::get_header(headers, "Origin");
let safari_extension_origin = "file://"; let safari_extension_origin = "file://";
let desktop_custom_file_origin = "bw-desktop-file://bundle";
if origin == CONFIG.domain_origin() if origin == CONFIG.domain_origin()
|| origin == safari_extension_origin || origin == safari_extension_origin
|| origin == desktop_custom_file_origin
|| (CONFIG.sso_enabled() && origin == CONFIG.sso_authority()) || (CONFIG.sso_enabled() && origin == CONFIG.sso_authority())
{ {
Some(origin) Some(origin)
@ -629,6 +634,21 @@ fn _process_key(key: &str) -> String {
} }
} }
pub fn deser_opt_nonempty_str<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
where
D: Deserializer<'de>,
T: From<String>,
{
use serde::Deserialize;
Ok(Option::<String>::deserialize(deserializer)?.and_then(|s| {
if s.is_empty() {
None
} else {
Some(T::from(s))
}
}))
}
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
#[serde(untagged)] #[serde(untagged)]
pub enum NumberOrString { pub enum NumberOrString {
@ -714,7 +734,7 @@ where
warn!("Can't connect to database, retrying: {e:?}"); warn!("Can't connect to database, retrying: {e:?}");
sleep(Duration::from_millis(1_000)).await; sleep(Duration::from_secs(1)).await;
} }
} }
} }
@ -763,21 +783,28 @@ pub fn convert_json_key_lcase_first(src_json: Value) -> Value {
} }
} }
pub enum FeatureFlagFilter {
#[allow(dead_code)]
Unfiltered,
ValidOnly,
InvalidOnly,
}
/// Parses the experimental client feature flags string into a HashMap. /// Parses the experimental client feature flags string into a HashMap.
pub fn parse_experimental_client_feature_flags(experimental_client_feature_flags: &str) -> HashMap<String, bool> { pub fn parse_experimental_client_feature_flags(
// These flags could still be configured, but are deprecated and not used anymore experimental_client_feature_flags: &str,
// To prevent old installations from starting filter these out and not error out filter_mode: FeatureFlagFilter,
const DEPRECATED_FLAGS: &[&str] = ) -> HashMap<String, bool> {
&["autofill-overlay", "autofill-v2", "browser-fileless-import", "extension-refresh", "fido2-vault-credentials"];
experimental_client_feature_flags experimental_client_feature_flags
.split(',') .split(',')
.filter_map(|f| { .map(str::trim)
let flag = f.trim(); .filter(|flag| !flag.is_empty())
if !flag.is_empty() && !DEPRECATED_FLAGS.contains(&flag) { .filter(|flag| match filter_mode {
return Some((flag.to_owned(), true)); FeatureFlagFilter::Unfiltered => true,
} FeatureFlagFilter::ValidOnly => SUPPORTED_FEATURE_FLAGS.contains(flag),
None FeatureFlagFilter::InvalidOnly => !SUPPORTED_FEATURE_FLAGS.contains(flag),
}) })
.map(|flag| (flag.to_owned(), true))
.collect() .collect()
} }
@ -791,14 +818,18 @@ pub fn is_global_hardcoded(ip: std::net::IpAddr) -> bool {
std::net::IpAddr::V4(ip) => { std::net::IpAddr::V4(ip) => {
!(ip.octets()[0] == 0 // "This network" !(ip.octets()[0] == 0 // "This network"
|| ip.is_private() || ip.is_private()
|| (ip.octets()[0] == 100 && (ip.octets()[1] & 0b1100_0000 == 0b0100_0000)) //ip.is_shared() || (ip.octets()[0] == 100 && (ip.octets()[1] & 0b1100_0000 == 0b0100_0000)) // ip.is_shared()
|| ip.is_loopback() || ip.is_loopback()
|| ip.is_link_local() || ip.is_link_local()
// addresses reserved for future protocols (`192.0.0.0/24`) // addresses reserved for future protocols (`192.0.0.0/24`)
||(ip.octets()[0] == 192 && ip.octets()[1] == 0 && ip.octets()[2] == 0) // .9 and .10 are documented as globally reachable so they're excluded
|| (
ip.octets()[0] == 192 && ip.octets()[1] == 0 && ip.octets()[2] == 0
&& ip.octets()[3] != 9 && ip.octets()[3] != 10
)
|| ip.is_documentation() || ip.is_documentation()
|| (ip.octets()[0] == 198 && (ip.octets()[1] & 0xfe) == 18) // ip.is_benchmarking() || (ip.octets()[0] == 198 && (ip.octets()[1] & 0xfe) == 18) // ip.is_benchmarking()
|| (ip.octets()[0] & 240 == 240 && !ip.is_broadcast()) //ip.is_reserved() || (ip.octets()[0] & 240 == 240 && !ip.is_broadcast()) // ip.is_reserved()
|| ip.is_broadcast()) || ip.is_broadcast())
} }
std::net::IpAddr::V6(ip) => { std::net::IpAddr::V6(ip) => {
@ -822,11 +853,17 @@ pub fn is_global_hardcoded(ip: std::net::IpAddr) -> bool {
// AS112-v6 (`2001:4:112::/48`) // AS112-v6 (`2001:4:112::/48`)
|| matches!(ip.segments(), [0x2001, 4, 0x112, _, _, _, _, _]) || matches!(ip.segments(), [0x2001, 4, 0x112, _, _, _, _, _])
// ORCHIDv2 (`2001:20::/28`) // ORCHIDv2 (`2001:20::/28`)
|| matches!(ip.segments(), [0x2001, b, _, _, _, _, _, _] if (0x20..=0x2F).contains(&b)) // Drone Remote ID Protocol Entity Tags (DETs) Prefix (`2001:30::/28`)`
|| matches!(ip.segments(), [0x2001, b, _, _, _, _, _, _] if (0x20..=0x3F).contains(&b))
)) ))
|| ((ip.segments()[0] == 0x2001) && (ip.segments()[1] == 0xdb8)) // ip.is_documentation() // 6to4 (`2002::/16`) – it's not explicitly documented as globally reachable,
|| ((ip.segments()[0] & 0xfe00) == 0xfc00) //ip.is_unique_local() // IANA says N/A.
|| ((ip.segments()[0] & 0xffc0) == 0xfe80)) //ip.is_unicast_link_local() || matches!(ip.segments(), [0x2002, _, _, _, _, _, _, _])
|| matches!(ip.segments(), [0x2001, 0xdb8, ..] | [0x3fff, 0..=0x0fff, ..]) // ip.is_documentation()
// Segment Routing (SRv6) SIDs (`5f00::/16`)
|| matches!(ip.segments(), [0x5f00, ..])
|| ip.is_unique_local()
|| ip.is_unicast_link_local())
} }
} }
} }

1
tools/global_domains.py

@ -79,3 +79,4 @@ for name, domain_list in domain_lists.items():
# Write out the global domains JSON file. # Write out the global domains JSON file.
with open(file=OUTPUT_FILE, mode='w', encoding='utf-8') as f: with open(file=OUTPUT_FILE, mode='w', encoding='utf-8') as f:
json.dump(global_domains, f, indent=2) json.dump(global_domains, f, indent=2)
f.write("\n")

Loading…
Cancel
Save