Browse Source

Merge branch 'main' into icon-service-fallback

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

26
.env.template

@ -372,16 +372,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

@ -62,7 +62,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
@ -85,32 +85,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
@ -122,7 +113,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

97
.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
@ -102,14 +106,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: |
@ -117,15 +121,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: |
@ -133,15 +137,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: |
@ -155,7 +159,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
@ -181,7 +185,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 }}"
@ -218,7 +222,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/*
@ -233,12 +237,12 @@ jobs:
# Upload artifacts to Github Actions and Attest the binaries # Upload artifacts to Github Actions and Attest the binaries
- name: Attest binaries - name: Attest binaries
uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0
with: with:
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }} subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
- name: Upload binaries as artifacts - name: Upload binaries as artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with: with:
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }} name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
path: vaultwarden-${{ env.NORMALIZED_ARCH }} path: vaultwarden-${{ env.NORMALIZED_ARCH }}
@ -247,6 +251,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
@ -257,7 +264,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 }}
@ -265,14 +272,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: |
@ -280,15 +287,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: |
@ -296,15 +303,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: |
@ -357,24 +364,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@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
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@7c572958218557a3272c2d6719629443b5cc26fd # v1.45.2

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@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
with: with:
# intentionally not scanning the entire repository, # intentionally not scanning the entire repository,
# since it contains integration tests. # since it contains integration tests.

12
.pre-commit-config.yaml

@ -15,6 +15,13 @@ repos:
- id: detect-private-key - id: detect-private-key
- id: check-symlinks - id: check-symlinks
- id: forbid-submodules - id: forbid-submodules
# 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: 7c572958218557a3272c2d6719629443b5cc26fd # v1.45.2
hooks:
- id: typos
- repo: local - repo: local
hooks: hooks:
- id: fmt - id: fmt
@ -51,8 +58,3 @@ repos:
args: args:
- "-c" - "-c"
- "cd docker && make" - "cd docker && make"
# 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: 57b11c6b7e54c402ccd9cda953f1072ec4f78e33 # v1.43.5
hooks:
- 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--.+"
] ]

1263
Cargo.lock

File diff suppressed because it is too large

62
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,15 +23,17 @@ 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
@ -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.1", 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,25 @@ 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"
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,7 +116,7 @@ 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.3.0", features = ["use_pem", "rust_crypto"], default-features = false }
@ -128,15 +130,15 @@ yubico = { package = "yubico_ng", version = "0.14.1", features = ["online-tokio"
# 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.21", 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"
@ -145,7 +147,7 @@ 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.12.28", features = ["rustls-tls", "rustls-tls-native-roots", "stream", "json", "deflate", "gzip", "brotli", "zstd", "socks", "cookies", "charset", "http2", "system-proxy"], default-features = false}
hickory-resolver = "0.25.2" hickory-resolver = "0.26.0"
# Favicon extraction libraries # Favicon extraction libraries
html5gum = "0.8.3" html5gum = "0.8.3"
@ -155,40 +157,40 @@ 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.78"
# 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", features = ["reqwest", "rustls-tls"] }
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.1"
# 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 }
@ -197,10 +199,10 @@ grass_compiler = { version = "0.13.4", default-features = false }
opendal = { version = "0.55.0", features = ["services-fs"], default-features = false } opendal = { version = "0.55.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 } anyhow = { version = "1.0.102", optional = true }
aws-config = { version = "1.8.14", features = ["behavior-version-latest", "rt-tokio", "credentials-process", "sso"], default-features = false, optional = true } aws-config = { version = "1.8.16", features = ["behavior-version-latest", "rt-tokio", "credentials-process", "sso"], default-features = false, optional = true }
aws-credential-types = { version = "1.2.13", optional = true } aws-credential-types = { version = "1.2.14", optional = true }
aws-smithy-runtime-api = { version = "1.11.5", optional = true } aws-smithy-runtime-api = { version = "1.12.0", optional = true }
http = { version = "1.4.0", optional = true } http = { version = "1.4.0", optional = true }
reqsign = { version = "0.16.5", optional = true } reqsign = { version = "0.16.5", optional = true }
@ -301,6 +303,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 +325,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: trixie # Debian release name to be used debian_version: trixie # 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

20
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

14
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-trixie AS build FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.95.0-slim-trixie AS build
COPY --from=xx / / COPY --from=xx / /
ARG TARGETARCH ARG TARGETARCH
ARG TARGETVARIANT ARG TARGETVARIANT

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/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/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;

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(())

274
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,33 +1666,36 @@ 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( log_event(
EventType::OrganizationPurgedVault as i32, EventType::OrganizationPurgedVault as i32,
&org_data.org_id, &organization.org_id,
&org_data.org_id, &organization.org_id,
&user.uuid, &user.uuid,
headers.device.atype, headers.device.atype,
&headers.ip.ip, &headers.ip.ip,
@ -1677,30 +1704,65 @@ async fn delete_all(
.await; .await;
Ok(()) Ok(())
} else {
err!("You don't have permission to purge the organization vault");
}
} }
_ => err!("You don't have permission to purge the organization vault"),
} }
} }
None => {
// No organization ID in query params, purging user vault #[post("/ciphers/purge", data = "<data>")]
// Delete ciphers and their attachments 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 { for cipher in Cipher::find_owned_by_user(&user.uuid, &conn).await {
cipher.delete(&conn).await?; cipher.delete(&conn).await?;
} }
// Delete folders
for f in Folder::find_by_user(&user.uuid, &conn).await { for f in Folder::find_by_user(&user.uuid, &conn).await {
f.delete(&conn).await?; f.delete(&conn).await?;
} }
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(())
} }
#[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)]
@ -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."),

256
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,
@ -99,6 +101,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 +134,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,11 +254,13 @@ 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;
if membership.atype == MembershipType::Owner
&& Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 && Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1
{ {
err!("The last owner can't leave") err!("The last owner can't leave")
@ -245,7 +268,7 @@ async fn leave_organization(org_id: OrganizationId, headers: Headers, conn: DbCo
log_event( log_event(
EventType::OrganizationUserLeft as i32, EventType::OrganizationUserLeft as i32,
&member.uuid, &membership.uuid,
&org_id, &org_id,
&headers.user.uuid, &headers.user.uuid,
headers.device.atype, headers.device.atype,
@ -254,9 +277,7 @@ async fn leave_organization(org_id: OrganizationId, headers: Headers, conn: DbCo
) )
.await; .await;
member.delete(&conn).await membership.delete(&conn).await
}
}
} }
#[get("/organizations/<org_id>")] #[get("/organizations/<org_id>")]
@ -335,7 +356,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 +366,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 +416,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 +442,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 +501,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 +523,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 +547,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 +597,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 +645,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 +674,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 +907,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 +1007,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 +1036,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 +1296,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 +1448,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 +1543,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 +1706,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 +1861,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 +1874,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 +1893,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 +1963,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 +1990,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 +2184,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 +2199,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",
@ -2422,6 +2471,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 +2531,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 +2569,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 +2602,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 +2695,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 +2754,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 +2782,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 +2929,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 +3022,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 +3059,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 {

2
src/api/core/sends.rs

@ -574,7 +574,7 @@ async fn download_url(host: &Host, send_id: &SendId, file_id: &SendFileId) -> Re
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())
} }
} }

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)
} }

111
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) => {
if dimensions.len() >= 3 {
width = dimensions[1].parse::<u16>().unwrap_or_default(); width = dimensions[1].parse::<u16>().unwrap_or_default();
height = dimensions[2].parse::<u16>().unwrap_or_default(); height = dimensions[2].parse::<u16>().unwrap_or_default();
} }
} _ => {}
} }
} }
@ -569,11 +531,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();
@ -627,13 +601,16 @@ 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,

215
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,16 +12,20 @@ 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, OIDCCodeWrapper, OrganizationApiKey,
@ -39,6 +43,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 +67,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 +133,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 +151,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,7 +179,7 @@ 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())?;
@ -220,7 +230,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 +348,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 +669,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 +696,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 +762,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 +775,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 +812,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 +843,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 +890,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 +907,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 +926,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 +1011,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
@ -1058,7 +1142,7 @@ struct ConnectData {
#[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,13 +1161,16 @@ 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, state,
OIDCCodeWrapper::Ok { OIDCCodeWrapper::Ok {
code, code,
}, },
cookies,
&mut conn, &mut conn,
) )
.await .await
@ -1096,6 +1183,7 @@ 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(
@ -1104,6 +1192,7 @@ async fn oidcsignin_error(
error, error,
error_description, error_description,
}, },
cookies,
&mut conn, &mut conn,
) )
.await .await
@ -1115,6 +1204,7 @@ async fn oidcsignin_error(
async fn _oidcsignin_redirect( async fn _oidcsignin_redirect(
base64_state: String, base64_state: String,
code_response: OIDCCodeWrapper, code_response: OIDCCodeWrapper,
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,6 +1213,17 @@ 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,
}; };
// 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("/identity/connect/").build());
sso_auth.code_response = Some(code_response); sso_auth.code_response = Some(code_response);
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?;
@ -1169,7 +1270,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 +1284,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("/identity/connect/")
.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,

75
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();
@ -160,6 +161,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 +445,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 +704,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,17 +719,10 @@ impl OrgHeaders {
} }
} }
#[rocket::async_trait]
impl<'r> FromRequest<'r> for OrgHeaders {
type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = try_outcome!(Headers::from_request(request).await);
// org_id is usually the second path param ("/organizations/<org_id>"), // org_id is usually the second path param ("/organizations/<org_id>"),
// but there are cases where it is a query value. // 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. // First check the path, if this is not a valid uuid, try the query values.
let url_org_id: Option<OrganizationId> = { fn get_org_id(request: &Request<'_>) -> Option<OrganizationId> {
if let Some(Ok(org_id)) = request.param::<OrganizationId>(1) { if let Some(Ok(org_id)) = request.param::<OrganizationId>(1) {
Some(org_id) Some(org_id)
} else if let Some(Ok(org_id)) = request.query_value::<OrganizationId>("organizationId") { } else if let Some(Ok(org_id)) = request.query_value::<OrganizationId>("organizationId") {
@ -708,7 +730,34 @@ impl<'r> FromRequest<'r> for OrgHeaders {
} else { } else {
None 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]
impl<'r> FromRequest<'r> for OrgHeaders {
type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = try_outcome!(Headers::from_request(request).await);
// Extract the org_id from the request
let url_org_id = get_org_id(request);
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() => {

80
src/config.rs

@ -14,7 +14,10 @@ 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}, 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(|| {
@ -926,7 +929,7 @@ 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)]
{ {
@ -1032,33 +1035,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;
@ -1095,7 +1082,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 {
@ -1311,7 +1298,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));
@ -1498,6 +1485,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
@ -1511,7 +1527,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 {
@ -1547,7 +1563,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
{ {
@ -1736,7 +1752,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())
}

16
src/db/mod.rs

@ -387,7 +387,6 @@ 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) {
@ -401,16 +400,13 @@ pub fn backup_sqlite() -> Result<String, Error> {
.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(|_| ())
.map_res("VACUUM INTO failed")?;
Ok(backup_file) Ok(backup_file)
}
Err(e) => {
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()
}}
}
}

2
src/db/models/attachment.rs

@ -50,7 +50,7 @@ impl Attachment {
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())
} }
} }

56
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
@ -809,13 +831,13 @@ 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) // 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
@ -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;
} }

2
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};

4
src/db/models/org_policy.rs

@ -269,7 +269,7 @@ impl OrgPolicy {
continue; continue;
} }
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 {
return true; return true;
} }
@ -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))

11
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,

10
src/db/models/sso_auth.rs

@ -54,11 +54,18 @@ pub struct SsoAuth {
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 {
@ -70,6 +77,7 @@ impl SsoAuth {
updated_at: now, updated_at: now,
code_response: None, code_response: None,
auth_response: None, auth_response: None,
binding_hash,
} }
} }
} }

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.

12
src/db/schema.rs

@ -265,6 +265,7 @@ table! {
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 +342,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 +383,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,

329
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
let resolver = builder.build(); if !CONFIG.dns_prefer_ipv6() {
Arc::new(Self::Hickory(Arc::new(resolver))) builder.options_mut().ip_strategy = hickory_resolver::config::LookupIpStrategy::Ipv4AndIpv6;
} }
Err(e) => { builder.build()
warn!("Error creating Hickory resolver, falling back to default: {e:?}"); })
Arc::new(Self::Default()) .inspect_err(|e| warn!("Error creating Hickory resolver, falling back to default: {e:?}"))
} .map(|resolver| Arc::new(Self::Hickory(Arc::new(resolver))))
} .unwrap_or_else(|_| 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());
}
}

41
src/main.rs

@ -558,6 +558,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 +595,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 +623,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.");

7
src/sso.rs

@ -17,7 +17,7 @@ use crate::{
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)
} }
@ -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()) {

45
src/sso_client.rs

@ -1,6 +1,5 @@
use std::{borrow::Cow, sync::LazyLock, time::Duration}; use std::{borrow::Cow, sync::LazyLock, time::Duration};
use mini_moka::sync::Cache;
use openidconnect::{core::*, reqwest, *}; use openidconnect::{core::*, reqwest, *};
use regex::Regex; use regex::Regex;
use url::Url; use url::Url;
@ -13,9 +12,14 @@ use crate::{
}; };
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,6 +42,8 @@ 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: reqwest::Client,
@ -111,6 +117,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 +140,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 +238,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,
) -> ApiResult<(Option<String>, String, Option<Duration>)> {
let rt = RefreshToken::new(refresh_token);
let client = Client::cached().await?; let client = Client::cached().await?;
let token_response =
match client.core_client.exchange_refresh_token(&rt).request_async(&client.http_client).await {
Err(err) => err!(format!("Request to exchange_refresh_token endpoint failed: {:?}", err)),
Ok(token_response) => token_response,
};
Ok(( 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);
match self.core_client.exchange_refresh_token(&rt).request_async(&self.http_client).await {
Err(err) => {
error!("Request to exchange_refresh_token endpoint failed: {err}");
Err(format!("Request to exchange_refresh_token endpoint failed: {err}"))
}
Ok(token_response) => Ok((
token_response.refresh_token().map(|token| token.secret().clone()), token_response.refresh_token().map(|token| token.secret().clone()),
token_response.access_token().secret().clone(), token_response.access_token().secret().clone(),
token_response.expires_in(), 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";

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}}

73
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()
} }
@ -795,7 +822,11 @@ pub fn is_global_hardcoded(ip: std::net::IpAddr) -> bool {
|| 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()
@ -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