Browse Source

Merge branch 'main' into feat/sso-cookie-vendor

pull/7127/head
Nathan Moreton 3 days ago
parent
commit
abde332158
  1. 23
      .env.template
  2. 2
      .github/workflows/build.yml
  3. 2
      .github/workflows/check-templates.yml
  4. 6
      .github/workflows/hadolint.yml
  5. 22
      .github/workflows/release.yml
  6. 4
      .github/workflows/trivy.yml
  7. 4
      .github/workflows/typos.yml
  8. 4
      .github/workflows/zizmor.yml
  9. 9
      .pre-commit-config.yaml
  10. 969
      Cargo.lock
  11. 55
      Cargo.toml
  12. 6
      docker/DockerSettings.yaml
  13. 31
      docker/Dockerfile.alpine
  14. 38
      docker/Dockerfile.debian
  15. 19
      docker/Dockerfile.j2
  16. 4
      macros/Cargo.toml
  17. 16
      playwright/.env.template
  18. 30
      playwright/README.md
  19. 4
      playwright/compose/keycloak/setup.sh
  20. 2
      playwright/compose/playwright/Dockerfile
  21. 1
      playwright/compose/warden/Dockerfile
  22. 11
      playwright/compose/warden/build.sh
  23. 15
      playwright/docker-compose.yml
  24. 2
      playwright/global-setup.ts
  25. 15
      playwright/global-utils.ts
  26. 1162
      playwright/package-lock.json
  27. 14
      playwright/package.json
  28. 14
      playwright/playwright.config.ts
  29. 10
      playwright/test.env
  30. 16
      playwright/tests/collection.spec.ts
  31. 56
      playwright/tests/cyphers.spec.ts
  32. 31
      playwright/tests/login.smtp.spec.ts
  33. 4
      playwright/tests/login.spec.ts
  34. 40
      playwright/tests/organization.smtp.spec.ts
  35. 110
      playwright/tests/secrets.spec.ts
  36. 16
      playwright/tests/send.spec.ts
  37. 15
      playwright/tests/setups/2fa.ts
  38. 21
      playwright/tests/setups/admin.ts
  39. 2
      playwright/tests/setups/db-teardown.ts
  40. 25
      playwright/tests/setups/orgs.ts
  41. 2
      playwright/tests/setups/sso-teardown.ts
  42. 25
      playwright/tests/setups/sso.ts
  43. 34
      playwright/tests/setups/user.ts
  44. 55
      playwright/tests/sso_login.smtp.spec.ts
  45. 10
      playwright/tests/sso_login.spec.ts
  46. 16
      playwright/tests/sso_organization.smtp.spec.ts
  47. 25
      playwright/tests/sso_organization.spec.ts
  48. 2
      rust-toolchain.toml
  49. 31
      src/api/admin.rs
  50. 29
      src/api/core/accounts.rs
  51. 19
      src/api/core/ciphers.rs
  52. 8
      src/api/core/events.rs
  53. 11
      src/api/core/mod.rs
  54. 56
      src/api/core/organizations.rs
  55. 11
      src/api/core/public.rs
  56. 47
      src/api/core/sends.rs
  57. 57
      src/api/core/two_factor/yubikey.rs
  58. 16
      src/api/icons.rs
  59. 8
      src/api/identity.rs
  60. 2
      src/api/mod.rs
  61. 68
      src/api/notifications.rs
  62. 43
      src/api/web.rs
  63. 58
      src/auth.rs
  64. 19
      src/auth/send.rs
  65. 34
      src/config.rs
  66. 34
      src/db/models/cipher.rs
  67. 47
      src/db/models/collection.rs
  68. 10
      src/db/models/event.rs
  69. 4
      src/db/models/group.rs
  70. 13
      src/db/models/organization.rs
  71. 47
      src/db/models/send.rs
  72. 17
      src/db/models/user.rs
  73. 2
      src/error.rs
  74. 35
      src/http_client.rs
  75. 15
      src/mail.rs
  76. 2
      src/main.rs
  77. 18
      src/ratelimit.rs
  78. 2
      src/sso_client.rs
  79. 3
      src/static/scripts/admin.js
  80. 36
      src/static/scripts/admin_diagnostics.js
  81. 3
      src/static/scripts/admin_organizations.js
  82. 1
      src/static/scripts/admin_settings.js
  83. 3
      src/static/scripts/admin_users.js
  84. 10
      src/static/templates/admin/diagnostics.hbs
  85. 4
      src/static/templates/scss/vaultwarden.scss.hbs
  86. 4
      src/storage.rs
  87. 38
      src/util.rs

23
.env.template

@ -316,6 +316,14 @@
## unauthenticated access to potentially sensitive data. ## unauthenticated access to potentially sensitive data.
# SHOW_PASSWORD_HINT=false # SHOW_PASSWORD_HINT=false
#########################
### Client settings ###
#########################
## Control whether clients onboarding interstitials are suppressed
## (post-login welcome dialogs, extension install prompts, setup extension redirects, and premium upsell modals)
# CLIENT_SUPPRESS_ONBOARDING=false
######################### #########################
### Advanced settings ### ### Advanced settings ###
######################### #########################
@ -324,6 +332,14 @@
## Set to the string "none" (without quotes), to disable any headers and just use the remote IP ## Set to the string "none" (without quotes), to disable any headers and just use the remote IP
# IP_HEADER=X-Real-IP # IP_HEADER=X-Real-IP
## Which addresses the header above is accepted from, defaults to "local".
## Anyone able to reach Vaultwarden can set the header, and the client IP is used for the login and
## admin rate limits, so it is only trusted when the request comes from a proxy listed here.
## "local" accepts it from any non global address, which covers a reverse proxy running on the same
## host or container network. Use "all" to accept it from anywhere, or list the addresses of your
## proxy as IPs and CIDR ranges if it connects from a public address.
# IP_HEADER_TRUSTED_PROXIES=local
## Icon service ## Icon service
## The predefined icon services are: internal, bitwarden, duckduckgo, google. ## The predefined icon services are: internal, bitwarden, duckduckgo, google.
## To specify a custom icon service, set a URL template with exactly one instance of `{}`, ## To specify a custom icon service, set a URL template with exactly one instance of `{}`,
@ -461,6 +477,13 @@
## Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2. ## Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2.
# LOGIN_RATELIMIT_MAX_BURST=10 # LOGIN_RATELIMIT_MAX_BURST=10
## Number of seconds, on average, between requests from the same IP address to one of the rate limited
## unauthenticated endpoints, like the password hint, the account recovery mails or accessing a Send.
# UNAUTHENTICATED_RATELIMIT_SECONDS=60
## Allow a burst of requests of up to this size, while maintaining the average indicated by `UNAUTHENTICATED_RATELIMIT_SECONDS`.
## This budget is shared between all of those endpoints, so it is more lenient than the login one.
# UNAUTHENTICATED_RATELIMIT_MAX_BURST=50
## BETA FEATURE: Groups ## BETA FEATURE: Groups
## Controls whether group support is enabled for organizations ## Controls whether group support is enabled for organizations
## This setting applies to organizations. ## This setting applies to organizations.

2
.github/workflows/build.yml

@ -62,7 +62,7 @@ jobs:
# Checkout the repo # Checkout the repo
- name: "Checkout" - name: "Checkout"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
fetch-depth: 0 fetch-depth: 0

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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
# End Checkout the repo # End Checkout the repo

6
.github/workflows/hadolint.yml

@ -32,7 +32,7 @@ jobs:
# Checkout the repo # Checkout the repo
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
# End Checkout the repo # End Checkout the repo
@ -41,12 +41,12 @@ jobs:
# Uses the Docker-based action (hadolint pre-bundled in ghcr.io/hadolint/hadolint:v2.14.0-debian) # Uses the Docker-based action (hadolint pre-bundled in ghcr.io/hadolint/hadolint:v2.14.0-debian)
# so no binary is downloaded at runtime. Pinned by commit SHA for supply-chain safety. # so no binary is downloaded at runtime. Pinned by commit SHA for supply-chain safety.
- name: Run hadolint on Dockerfile.debian - name: Run hadolint on Dockerfile.debian
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0 uses: hadolint/hadolint-action@2a66e89f53d0771bb131a7fa31f3136336094aa6 # v3.4.0
with: with:
dockerfile: docker/Dockerfile.debian dockerfile: docker/Dockerfile.debian
- name: Run hadolint on Dockerfile.alpine - name: Run hadolint on Dockerfile.alpine
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0 uses: hadolint/hadolint-action@2a66e89f53d0771bb131a7fa31f3136336094aa6 # v3.4.0
with: with:
dockerfile: docker/Dockerfile.alpine dockerfile: docker/Dockerfile.alpine
# End Test Dockerfiles with hadolint # End Test Dockerfiles with hadolint

22
.github/workflows/release.yml

@ -77,7 +77,7 @@ jobs:
# Checkout the repo # Checkout the repo
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# 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
@ -106,7 +106,7 @@ jobs:
# Login to Docker Hub # Login to Docker Hub
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
@ -121,7 +121,7 @@ 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@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@ -137,7 +137,7 @@ jobs:
# Login to Quay.io # Login to Quay.io
- name: Login to Quay.io - name: Login to Quay.io
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with: with:
registry: quay.io registry: quay.io
username: ${{ secrets.QUAY_USERNAME }} username: ${{ secrets.QUAY_USERNAME }}
@ -237,7 +237,7 @@ 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@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with: with:
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }} subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
@ -272,7 +272,7 @@ jobs:
# Login to Docker Hub # Login to Docker Hub
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
@ -287,7 +287,7 @@ 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@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@ -303,7 +303,7 @@ jobs:
# Login to Quay.io # Login to Quay.io
- name: Login to Quay.io - name: Login to Quay.io
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with: with:
registry: quay.io registry: quay.io
username: ${{ secrets.QUAY_USERNAME }} username: ${{ secrets.QUAY_USERNAME }}
@ -365,7 +365,7 @@ jobs:
# Attest container images # Attest container images
- name: Attest - docker.io - ${{ matrix.base_image }} - name: Attest - docker.io - ${{ matrix.base_image }}
if: ${{ vars.DOCKERHUB_REPO != '' && env.DIGEST_SHA != ''}} if: ${{ vars.DOCKERHUB_REPO != '' && env.DIGEST_SHA != ''}}
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with: with:
subject-name: ${{ vars.DOCKERHUB_REPO }} subject-name: ${{ vars.DOCKERHUB_REPO }}
subject-digest: ${{ env.DIGEST_SHA }} subject-digest: ${{ env.DIGEST_SHA }}
@ -373,7 +373,7 @@ jobs:
- name: Attest - ghcr.io - ${{ matrix.base_image }} - name: Attest - ghcr.io - ${{ matrix.base_image }}
if: ${{ vars.GHCR_REPO != '' && env.DIGEST_SHA != ''}} if: ${{ vars.GHCR_REPO != '' && env.DIGEST_SHA != ''}}
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with: with:
subject-name: ${{ vars.GHCR_REPO }} subject-name: ${{ vars.GHCR_REPO }}
subject-digest: ${{ env.DIGEST_SHA }} subject-digest: ${{ env.DIGEST_SHA }}
@ -381,7 +381,7 @@ jobs:
- name: Attest - quay.io - ${{ matrix.base_image }} - name: Attest - quay.io - ${{ matrix.base_image }}
if: ${{ vars.QUAY_REPO != '' && env.DIGEST_SHA != ''}} if: ${{ vars.QUAY_REPO != '' && env.DIGEST_SHA != ''}}
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with: with:
subject-name: ${{ vars.QUAY_REPO }} subject-name: ${{ vars.QUAY_REPO }}
subject-digest: ${{ env.DIGEST_SHA }} subject-digest: ${{ env.DIGEST_SHA }}

4
.github/workflows/trivy.yml

@ -33,7 +33,7 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
@ -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@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
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@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.48.0 uses: crate-ci/typos@8a48f81b6c64dcfea44b3633223084c4be58ac5f # v1.49.0

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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Run zizmor - name: Run zizmor
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7 uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
with: with:
# intentionally not scanning the entire repository, # intentionally not scanning the entire repository,
# since it contains integration tests. # since it contains integration tests.

9
.pre-commit-config.yaml

@ -18,9 +18,10 @@ repos:
# When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too # When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too
- repo: https://github.com/crate-ci/typos - repo: https://github.com/crate-ci/typos
rev: bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.48.0 rev: 8a48f81b6c64dcfea44b3633223084c4be58ac5f # v1.49.0
hooks: hooks:
- id: typos - id: typos
always_run: true
- repo: local - repo: local
hooks: hooks:
@ -38,8 +39,7 @@ repos:
entry: cargo test entry: cargo test
language: system language: system
args: [ "--features", "sqlite,mysql,postgresql", "--" ] args: [ "--features", "sqlite,mysql,postgresql", "--" ]
types_or: [ rust, file ] types_or: [ rust, toml ] # Cargo.lock matches toml type which is intended
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
pass_filenames: false pass_filenames: false
- id: cargo-clippy - id: cargo-clippy
name: cargo clippy name: cargo clippy
@ -47,8 +47,7 @@ repos:
entry: cargo clippy entry: cargo clippy
language: system language: system
args: [ "--features", "sqlite,mysql,postgresql", "--", "-D", "warnings" ] args: [ "--features", "sqlite,mysql,postgresql", "--", "-D", "warnings" ]
types_or: [ rust, file ] types_or: [ rust, toml ] # Cargo.lock matches toml type which is intended
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
pass_filenames: false pass_filenames: false
- id: check-docker-templates - id: check-docker-templates
name: check docker templates name: check docker templates

969
Cargo.lock

File diff suppressed because it is too large

55
Cargo.toml

@ -1,6 +1,6 @@
[workspace.package] [workspace.package]
edition = "2024" edition = "2024"
rust-version = "1.94.1" rust-version = "1.95.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
@ -75,7 +75,7 @@ dotenvy = { version = "0.15.7", default-features = false }
# Numerical libraries # Numerical libraries
num-traits = "0.2.19" num-traits = "0.2.19"
num-derive = "0.4.2" num-derive = "0.5.1"
bigdecimal = "0.4.10" bigdecimal = "0.4.10"
# Web framework # Web framework
@ -89,8 +89,8 @@ rmpv = "1.3.1" # MessagePack library
dashmap = "6.2.1" dashmap = "6.2.1"
# Async futures # Async futures
futures = "0.3.32" futures = "0.3.33"
tokio = { version = "1.52.3", features = [ tokio = { version = "1.53.1", features = [
"fs", "fs",
"io-util", "io-util",
"net", "net",
@ -99,14 +99,14 @@ tokio = { version = "1.52.3", features = [
"signal", "signal",
"time", "time",
] } ] }
tokio-util = { version = "0.7.18", features = ["compat"] } tokio-util = { version = "0.7.19", features = ["compat"] }
# A generic serialization/deserialization framework # A generic serialization/deserialization framework
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.150" serde_json = "1.0.151"
# A safe, extensible ORM and Query builder # A safe, extensible ORM and Query builder
diesel = { version = "2.3.10", features = ["chrono", "r2d2", "numeric"] } diesel = { version = "2.3.11", features = ["chrono", "r2d2", "numeric"] }
diesel_migrations = "2.3.2" diesel_migrations = "2.3.2"
derive_more = { version = "2.1.1", features = [ derive_more = { version = "2.1.1", features = [
@ -124,31 +124,31 @@ libsqlite3-sys = { version = "0.37.0", optional = true }
# Crypto-related libraries # Crypto-related libraries
rand = "0.10.2" rand = "0.10.2"
ring = "0.17.14" ring = "0.17.14"
rustls = { version = "0.23.41", features = ["ring", "std"], default-features = false } rustls = { version = "0.23.43", features = ["ring", "std"], default-features = false }
subtle = "2.6.1" subtle = "2.6.1"
# UUID generation # UUID generation
uuid = { version = "1.23.4", features = ["v4"] } uuid = { version = "1.24.0", features = ["v4"] }
# Date and time libraries # Date and time libraries
chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] } chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] }
chrono-tz = "0.10.4" chrono-tz = "0.10.4"
time = "0.3.53" time = "0.3.55"
# Job scheduler # Job scheduler
job_scheduler_ng = "2.4.0" job_scheduler_ng = "2.5.0"
# Data encoding library Hex/Base32/Base64 # Data encoding library Hex/Base32/Base64
data-encoding = "2.11.0" data-encoding = "2.11.1"
# JWT library # JWT library
jsonwebtoken = { version = "10.4.0", default-features = false, features = ["rust_crypto", "use_pem"] } jsonwebtoken = { version = "11.0.0", default-features = false, features = ["rust_crypto", "use_pem"] }
# TOTP library # TOTP library
totp-lite = "2.0.1" totp-lite = "2.0.1"
# Yubico Library # Yubico Library
yubico = { package = "yubico_ng", version = "0.15.0", default-features = false, features = ["online-tokio"] } yubico_ng = { version = "1.0.0", default-features = false }
# WebAuthn libraries # WebAuthn libraries
# danger-allow-state-serialisation is needed to save the state in the db # danger-allow-state-serialisation is needed to save the state in the db
@ -161,7 +161,7 @@ webauthn-rs-core = "0.5.5"
url = "2.5.8" url = "2.5.8"
# Email libraries # Email libraries
lettre = { version = "0.11.22", default-features = false, features = [ lettre = { version = "0.11.23", default-features = false, features = [
# Misc # Misc
"tracing", "tracing",
"serde", "serde",
@ -179,7 +179,7 @@ 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"
# HTML Template library # HTML Template library
handlebars = { version = "6.4.2", features = ["dir_source"] } handlebars = { version = "6.4.3", 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.13.4", default-features = false, features = [ reqwest = { version = "0.13.4", default-features = false, features = [
@ -204,7 +204,7 @@ hickory-resolver = "0.26.1"
# Favicon extraction libraries # Favicon extraction libraries
html5gum = "0.8.4" html5gum = "0.8.4"
regex = { version = "1.12.4", default-features = false, features = [ regex = { version = "1.13.1", default-features = false, features = [
"perf", "perf",
"std", "std",
"unicode-perl", "unicode-perl",
@ -230,6 +230,9 @@ pico-args = "0.5.0"
pastey = "0.2.3" pastey = "0.2.3"
governor = "0.10.4" governor = "0.10.4"
# CIDR parsing for the trusted proxies of the client IP header
ipnet = "2.12.1"
# OIDC for SSO # OIDC for SSO
openidconnect = { version = "4.0.1", default-features = false } openidconnect = { version = "4.0.1", default-features = false }
moka = { version = "0.12.15", features = ["future"] } moka = { version = "0.12.15", features = ["future"] }
@ -241,7 +244,7 @@ semver = "1.0.28"
# 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.52", optional = true, default-features = false, features = ["secure"] } mimalloc = { version = "0.1.52", optional = true, default-features = false, features = ["secure"] }
which = "8.0.4" which = "8.0.5"
# Argon2 library with support for the PHC format # Argon2 library with support for the PHC format
argon2 = "0.5.3" argon2 = "0.5.3"
@ -253,20 +256,20 @@ rpassword = "7.5.4"
grass_compiler = { version = "0.13.4", default-features = false } grass_compiler = { version = "0.13.4", default-features = false }
# File are accessed through Apache OpenDAL # File are accessed through Apache OpenDAL
opendal = { version = "0.57.0", default-features = false, features = ["services-fs"] } opendal = { version = "0.58.1", default-features = false, features = ["services-fs"] }
# For retrieving AWS credentials, including temporary SSO credentials # For retrieving AWS credentials, including temporary SSO credentials
aws-config = { version = "1.8.18", optional = true, default-features = false, features = [ aws-config = { version = "1.10.1", optional = true, default-features = false, features = [
"behavior-version-latest", "behavior-version-latest",
"credentials-process", "credentials-process",
"rt-tokio", "rt-tokio",
"sso", "sso",
] } ] }
aws-credential-types = { version = "1.2.14", optional = true } aws-credential-types = { version = "1.3.0", optional = true }
aws-smithy-runtime-api = { version = "1.13.0", optional = true } aws-smithy-runtime-api = { version = "1.14.0", optional = true }
http = { version = "1.4.2", optional = true } http = { version = "1.5.0", optional = true }
reqsign-aws-v4 = { version = "3.0.1", optional = true } reqsign-aws-v4 = { version = "3.1.0", optional = true }
reqsign-core = { version = "3.0.1", optional = true } reqsign-core = { version = "3.2.1", optional = true }
# Strip debuginfo from the release builds # Strip debuginfo from the release builds
# The debug symbols are to provide better panic traces # The debug symbols are to provide better panic traces

6
docker/DockerSettings.yaml

@ -1,11 +1,11 @@
--- ---
vault_version: "v2026.6.2" vault_version: "v2026.7.0"
vault_image_digest: "sha256:f004f72a5d357b87483839500a517da3d1b4ea0a57b9731989d298cccea7d02a" vault_image_digest: "sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c"
# 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.96.1 # Rust version to be used rust_version: 1.97.1 # 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.24" # Alpine version to be used alpine_version: "3.24" # 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

31
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.6.2 # $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.6.2 # $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0
# [docker.io/vaultwarden/web-vault@sha256:f004f72a5d357b87483839500a517da3d1b4ea0a57b9731989d298cccea7d02a] # [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c]
# #
# - 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:f004f72a5d357b87483839500a517da3d1b4ea0a57b9731989d298cccea7d02a # $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c
# [docker.io/vaultwarden/web-vault:v2026.6.2] # [docker.io/vaultwarden/web-vault:v2026.7.0]
# #
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:f004f72a5d357b87483839500a517da3d1b4ea0a57b9731989d298cccea7d02a AS vault FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c 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.96.1 AS build_amd64 FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.97.1 AS build_amd64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.96.1 AS build_arm64 FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.97.1 AS build_arm64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.96.1 AS build_armv7 FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.97.1 AS build_armv7
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.96.1 AS build_armv6 FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.97.1 AS build_armv6
########################## BUILD IMAGE ########################## ########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006 # hadolint ignore=DL3006
@ -66,11 +66,11 @@ RUN USER=root cargo new --bin /app
WORKDIR /app WORKDIR /app
# Environment variables for Cargo on Alpine based builds # Environment variables for Cargo on Alpine based builds
RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \ RUN echo "export CARGO_TARGET=${CARGO_BUILD_TARGET}" >> /env-cargo && \
# Output the current contents of the file # Output the current contents of the file
cat /env-cargo cat /env-cargo
RUN source /env-cargo && \ RUN . /env-cargo && \
rustup target add "${CARGO_TARGET}" rustup target add "${CARGO_TARGET}"
# Copies over *only* your manifests and build files # Copies over *only* your manifests and build files
@ -86,7 +86,7 @@ ARG DB=sqlite,mysql,postgresql,enable_mimalloc
# Builds your dependencies and removes the # Builds your dependencies and removes the
# dummy project, except the target folder # dummy project, except the target folder
# This folder contains the compiled dependencies # This folder contains the compiled dependencies
RUN source /env-cargo && \ RUN . /env-cargo && \
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \ cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
find . -not -path "./target*" -delete find . -not -path "./target*" -delete
@ -97,13 +97,13 @@ COPY . .
ARG VW_VERSION ARG VW_VERSION
# Builds again, this time it will be the actual source files being build # Builds again, this time it will be the actual source files being build
RUN source /env-cargo && \ RUN . /env-cargo && \
# Make sure that we actually build the project by updating the src/main.rs timestamp # Make sure that we actually build the project by updating the src/main.rs timestamp
# Also do this for build.rs to ensure the version is rechecked # Also do this for build.rs to ensure the version is rechecked
touch build.rs src/main.rs && \ touch build.rs src/main.rs && \
# Create a symlink to the binary target folder to easy copy the binary in the final stage # Create a symlink to the binary target folder to easy copy the binary in the final stage
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \ cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \ if [ "${CARGO_PROFILE}" = "dev" ] ; then \
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \ ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
else \ else \
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \ ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
@ -126,6 +126,7 @@ RUN source /env-cargo && \
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*' # To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
# #
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742 # We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
# hadolint ignore=DL3065
FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.24 FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.24
ENV ROCKET_PROFILE="release" \ ENV ROCKET_PROFILE="release" \

38
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.6.2 # $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.6.2 # $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0
# [docker.io/vaultwarden/web-vault@sha256:f004f72a5d357b87483839500a517da3d1b4ea0a57b9731989d298cccea7d02a] # [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c]
# #
# - 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:f004f72a5d357b87483839500a517da3d1b4ea0a57b9731989d298cccea7d02a # $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c
# [docker.io/vaultwarden/web-vault:v2026.6.2] # [docker.io/vaultwarden/web-vault:v2026.7.0]
# #
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:f004f72a5d357b87483839500a517da3d1b4ea0a57b9731989d298cccea7d02a AS vault FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c 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,8 @@ 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.96.1-slim-trixie AS build FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.97.1-slim-trixie AS build
# hadolint ignore=DL3067
COPY --from=xx / / COPY --from=xx / /
ARG TARGETARCH ARG TARGETARCH
ARG TARGETVARIANT ARG TARGETVARIANT
@ -80,7 +81,7 @@ RUN mkdir -pv "${CARGO_HOME}" && \
RUN USER=root cargo new --bin /app RUN USER=root cargo new --bin /app
WORKDIR /app WORKDIR /app
RUN source /env-cargo && \ RUN . /env-cargo && \
rustup target add "${CARGO_TARGET}" rustup target add "${CARGO_TARGET}"
# Copies over *only* your manifests and build files # Copies over *only* your manifests and build files
@ -95,9 +96,14 @@ ARG DB=sqlite,mysql,postgresql
# Builds your dependencies and removes the # Builds your dependencies and removes the
# dummy project, except the target folder # dummy project, except the target folder
# This folder contains the compiled dependencies # This folder contains the compiled dependencies
RUN source /env-cargo && \ RUN . /env-cargo && \
# Workaround for xx related build issues # Configure xx-cargo for target pkg-config and Debian transitive library lookup
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977 # https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
# https://github.com/dani-garcia/vaultwarden/discussions/7522
if xx-info is-cross; then \
XX_RUSTFLAGS="-C link-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
export XX_RUSTFLAGS; \
fi && \
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \ PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
find . -not -path "./target*" -delete find . -not -path "./target*" -delete
@ -108,15 +114,20 @@ COPY . .
ARG VW_VERSION ARG VW_VERSION
# Builds again, this time it will be the actual source files being build # Builds again, this time it will be the actual source files being build
RUN source /env-cargo && \ RUN . /env-cargo && \
# Make sure that we actually build the project by updating the src/main.rs timestamp # Make sure that we actually build the project by updating the src/main.rs timestamp
# Also do this for build.rs to ensure the version is rechecked # Also do this for build.rs to ensure the version is rechecked
touch build.rs src/main.rs && \ touch build.rs src/main.rs && \
# Create a symlink to the binary target folder to easy copy the binary in the final stage # Create a symlink to the binary target folder to easy copy the binary in the final stage
# Workaround for xx related build issues # Configure xx-cargo for target pkg-config and Debian transitive library lookup
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977 # https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
# https://github.com/dani-garcia/vaultwarden/discussions/7522
if xx-info is-cross; then \
XX_RUSTFLAGS="-C link-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
export XX_RUSTFLAGS; \
fi && \
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \ PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \ if [ "${CARGO_PROFILE}" = "dev" ] ; then \
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \ ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
else \ else \
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \ ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
@ -139,6 +150,7 @@ RUN source /env-cargo && \
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*' # To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
# #
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742 # We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
# hadolint ignore=DL3065
FROM --platform=$TARGETPLATFORM docker.io/library/debian:trixie-slim FROM --platform=$TARGETPLATFORM docker.io/library/debian:trixie-slim
ENV ROCKET_PROFILE="release" \ ENV ROCKET_PROFILE="release" \

19
docker/Dockerfile.j2

@ -28,8 +28,13 @@
# [docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}] # [docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}]
# #
{% macro xx_cargo_config() -%} {% macro xx_cargo_config() -%}
# Workaround for xx related build issues # Configure xx-cargo for target pkg-config and Debian transitive library lookup
# https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977 # https://github.com/tonistiigi/xx/pull/108#issuecomment-3700635977
# https://github.com/dani-garcia/vaultwarden/discussions/7522
if xx-info is-cross; then \
XX_RUSTFLAGS="-C link-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
export XX_RUSTFLAGS; \
fi && \
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}"
{%- endmacro %} {%- endmacro %}
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@{{ vault_image_digest }} AS vault FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@{{ vault_image_digest }} AS vault
@ -52,6 +57,7 @@ FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].arch_image[arch] }} AS
# hadolint ignore=DL3006 # hadolint ignore=DL3006
FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].image }} AS build FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].image }} AS build
{% if base == "debian" %} {% if base == "debian" %}
# hadolint ignore=DL3067
COPY --from=xx / / COPY --from=xx / /
{% endif %} {% endif %}
ARG TARGETARCH ARG TARGETARCH
@ -106,12 +112,12 @@ WORKDIR /app
{% if base == "alpine" %} {% if base == "alpine" %}
# Environment variables for Cargo on Alpine based builds # Environment variables for Cargo on Alpine based builds
RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \ RUN echo "export CARGO_TARGET=${CARGO_BUILD_TARGET}" >> /env-cargo && \
# Output the current contents of the file # Output the current contents of the file
cat /env-cargo cat /env-cargo
{% endif %} {% endif %}
RUN source /env-cargo && \ RUN . /env-cargo && \
rustup target add "${CARGO_TARGET}" rustup target add "${CARGO_TARGET}"
# Copies over *only* your manifests and build files # Copies over *only* your manifests and build files
@ -131,7 +137,7 @@ ARG DB=sqlite,mysql,postgresql,enable_mimalloc
# Builds your dependencies and removes the # Builds your dependencies and removes the
# dummy project, except the target folder # dummy project, except the target folder
# This folder contains the compiled dependencies # This folder contains the compiled dependencies
RUN source /env-cargo && \ RUN . /env-cargo && \
{% if base == "debian" %} {% if base == "debian" %}
{{ xx_cargo_config() }} && \ {{ xx_cargo_config() }} && \
{% elif base == "alpine" %} {% elif base == "alpine" %}
@ -146,7 +152,7 @@ COPY . .
ARG VW_VERSION ARG VW_VERSION
# Builds again, this time it will be the actual source files being build # Builds again, this time it will be the actual source files being build
RUN source /env-cargo && \ RUN . /env-cargo && \
# Make sure that we actually build the project by updating the src/main.rs timestamp # Make sure that we actually build the project by updating the src/main.rs timestamp
# Also do this for build.rs to ensure the version is rechecked # Also do this for build.rs to ensure the version is rechecked
touch build.rs src/main.rs && \ touch build.rs src/main.rs && \
@ -156,7 +162,7 @@ RUN source /env-cargo && \
{% elif base == "alpine" %} {% elif base == "alpine" %}
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \ cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
{% endif %} {% endif %}
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \ if [ "${CARGO_PROFILE}" = "dev" ] ; then \
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \ ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
else \ else \
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \ ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
@ -179,6 +185,7 @@ RUN source /env-cargo && \
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*' # To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
# #
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742 # We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
# hadolint ignore=DL3065
FROM --platform=$TARGETPLATFORM {{ runtime_stage_image[base] }} FROM --platform=$TARGETPLATFORM {{ runtime_stage_image[base] }}
ENV ROCKET_PROFILE="release" \ ENV ROCKET_PROFILE="release" \

4
macros/Cargo.toml

@ -13,8 +13,8 @@ path = "src/lib.rs"
proc-macro = true proc-macro = true
[dependencies] [dependencies]
quote = "1.0.46" quote = "1.0.47"
syn = "2.0.118" syn = "3.0.3"
[lints] [lints]
workspace = true workspace = true

16
playwright/.env.template

@ -21,11 +21,19 @@ TEST_USER3=test3
TEST_USER3_PASSWORD=${TEST_USER3} TEST_USER3_PASSWORD=${TEST_USER3}
TEST_USER3_MAIL=${TEST_USER3}@yopmail.com TEST_USER3_MAIL=${TEST_USER3}@yopmail.com
TEST_USER4=test4
TEST_USER4_PASSWORD=${TEST_USER4}
TEST_USER4_MAIL=${TEST_USER4}@yopmail.com
TEST_USER5=test5
TEST_USER5_PASSWORD=${TEST_USER5}
TEST_USER5_MAIL=${TEST_USER5}@yopmail.com
################### ###################
# Keycloak Config # # Keycloak Config #
################### ###################
KEYCLOAK_ADMIN=admin KC_BOOTSTRAP_ADMIN_USERNAME=admin
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN} KC_BOOTSTRAP_ADMIN_PASSWORD=${KC_BOOTSTRAP_ADMIN_USERNAME}
KC_HTTP_HOST=127.0.0.1 KC_HTTP_HOST=127.0.0.1
KC_HTTP_PORT=8080 KC_HTTP_PORT=8080
@ -39,8 +47,10 @@ DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM}
###################### ######################
ROCKET_ADDRESS=0.0.0.0 ROCKET_ADDRESS=0.0.0.0
ROCKET_PORT=8000 ROCKET_PORT=8000
DOMAIN=http://localhost:${ROCKET_PORT} ROCKET_TLS={certs="/data/ssl/localhost.crt",key="/data/ssl/localhost.key"}
DOMAIN=https://127.0.0.1:${ROCKET_PORT}
LOG_LEVEL=info,oidcwarden::sso=debug LOG_LEVEL=info,oidcwarden::sso=debug
SSO_DEBUG_TOKENS=true
I_REALLY_WANT_VOLATILE_STORAGE=true I_REALLY_WANT_VOLATILE_STORAGE=true
SSO_ENABLED=true SSO_ENABLED=true

30
playwright/README.md

@ -1,8 +1,8 @@
# Integration tests # Integration tests
This allows running integration tests using [Playwright](https://playwright.dev/). This allows running integration tests using [Playwright](https://playwright.dev/).
\
It uses its own `test.env` with different ports to not collide with a running dev instance. It usse its own [test.env](/test/scenarios/test.env) with different ports to not collide with a running dev instance.
## Install ## Install
@ -11,11 +11,11 @@ Databases (`Mariadb`, `Mysql` and `Postgres`) and `Playwright` will run in conta
### Running Playwright outside docker ### Running Playwright outside docker
It is possible to run `Playwright` outside of the container, this removes the need to rebuild the image for each change. It's possible to run `Playwright` outside of the container, this remove the need to rebuild the image for each change.
You will additionally need `nodejs` then run: You'll additionally need `nodejs` then run:
```bash ```bash
npm ci --ignore-scripts npm ci --ignore-scripts --allow-git=none --allow-remote=none
npx playwright install-deps npx playwright install-deps
npx playwright install firefox npx playwright install firefox
``` ```
@ -65,7 +65,7 @@ DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Pl
If you want you can keep the DB and Keycloak runnning (states are not impacted by the tests): If you want you can keep the DB and Keycloak runnning (states are not impacted by the tests):
```bash ```bash
PW_KEEP_SERVICE_RUNNNING=true npx playwright test PW_KEEP_SERVICE_RUNNING=true npx playwright test
``` ```
### Running specific tests ### Running specific tests
@ -77,7 +77,7 @@ DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Pl
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite login DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite login
``` ```
To run only a specifc test (It might fail if it has dependency): To run only a specific test (It might fail if it has dependency):
```bash ```bash
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite -g "Account creation" DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Playwright test --project=sqlite -g "Account creation"
@ -92,7 +92,7 @@ This does not start the server, you will need to start it manually.
```bash ```bash
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden
npx playwright codegen "http://127.0.0.1:8003" npx playwright codegen "https://127.0.0.1:8000" --ignore-https-errors
``` ```
## Override web-vault ## Override web-vault
@ -112,12 +112,11 @@ You can check the result running:
DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env up Vaultwarden
``` ```
Then check `http://127.0.0.1:8003/admin/diagnostics` with `admin`. Then check `https://127.0.0.1:8003/admin/diagnostics` with `admin`.
# OpenID Connect test setup # OpenID Connect test setup
Additionally this `docker-compose` template allows to run locally Vaultwarden, Additionally this `docker-compose` template allow to run locally `Vaultwarden`, [Keycloak](https://www.keycloak.org/) and [Maildev](https://github.com/timshel/maildev) to test OIDC.
[Keycloak](https://www.keycloak.org/) and [Maildev](https://github.com/timshel/maildev) to test OIDC.
## Setup ## Setup
@ -131,18 +130,17 @@ Then start the stack (the `profile` is required to run `Vaultwarden`) :
```bash ```bash
> docker compose --profile vaultwarden --env-file .env up > docker compose --profile vaultwarden --env-file .env up
.... ....
keycloakSetup_1 | Logging into http://127.0.0.1:8080 as user admin of realm master keycloakSetup_1 | Logging into https://127.0.0.1:8080 as user admin of realm master
keycloakSetup_1 | Created new realm with id 'test' keycloakSetup_1 | Created new realm with id 'test'
keycloakSetup_1 | 74af4933-e386-4e64-ba15-a7b61212c45e keycloakSetup_1 | 74af4933-e386-4e64-ba15-a7b61212c45e
oidc_keycloakSetup_1 exited with code 0 oidc_keycloakSetup_1 exited with code 0
``` ```
Wait until `oidc_keycloakSetup_1 exited with code 0` which indicates the correct setup of the Keycloak realm, client and user Wait until `oidc_keycloakSetup_1 exited with code 0` which indicate the correct setup of the Keycloak realm, client and user (It's normal for this container to stop once the configuration is done).
(It is normal for this container to stop once the configuration is done).
Then you can access : Then you can access :
- `Vaultwarden` on http://0.0.0.0:8000 with the default user `test@yopmail.com/test`. - `Vaultwarden` on https://0.0.0.0:8000 with the default user `test@yopmail.com/test`.
- `Keycloak` on http://0.0.0.0:8080/admin/master/console/ with the default user `admin/admin` - `Keycloak` on http://0.0.0.0:8080/admin/master/console/ with the default user `admin/admin`
- `Maildev` on http://0.0.0.0:1080 - `Maildev` on http://0.0.0.0:1080
@ -171,7 +169,7 @@ docker compose --profile vaultwarden --env-file .env build VaultwardenPrebuild V
All configuration for `keycloak` / `Vaultwarden` / `keycloak_setup.sh` can be found in [.env](.env.template). All configuration for `keycloak` / `Vaultwarden` / `keycloak_setup.sh` can be found in [.env](.env.template).
The content of the file will be loaded as environment variables in all containers. The content of the file will be loaded as environment variables in all containers.
- `keycloak` [configuration](https://www.keycloak.org/server/all-config) includes `KEYCLOAK_ADMIN` / `KEYCLOAK_ADMIN_PASSWORD` and any variable prefixed `KC_` ([more information](https://www.keycloak.org/server/configuration#_example_configuring_the_db_url_host_parameter)). - `keycloak` [configuration](https://www.keycloak.org/server/all-config) include `KC_BOOTSTRAP_ADMIN_USERNAME` / `KC_BOOTSTRAP_ADMIN_PASSWORD` and any variable prefixed `KC_` ([more information](https://www.keycloak.org/server/configuration#_example_configuring_the_db_url_host_parameter)).
- All `Vaultwarden` configuration can be set (EX: `SMTP_*`) - All `Vaultwarden` configuration can be set (EX: `SMTP_*`)
## Cleanup ## Cleanup

4
playwright/compose/keycloak/setup.sh

@ -17,7 +17,7 @@ done
set -e set -e
kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KEYCLOAK_ADMIN" --password "$KEYCLOAK_ADMIN_PASSWORD" --client admin-cli kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KC_BOOTSTRAP_ADMIN_USERNAME" --password "$KC_BOOTSTRAP_ADMIN_PASSWORD" --client admin-cli
kcadm.sh create realms -s realm="$TEST_REALM" -s enabled=true -s "accessTokenLifespan=600" kcadm.sh create realms -s realm="$TEST_REALM" -s enabled=true -s "accessTokenLifespan=600"
kcadm.sh create clients -r test -s "clientId=$SSO_CLIENT_ID" -s "secret=$SSO_CLIENT_SECRET" -s "redirectUris=[\"$DOMAIN/*\"]" -i kcadm.sh create clients -r test -s "clientId=$SSO_CLIENT_ID" -s "secret=$SSO_CLIENT_SECRET" -s "redirectUris=[\"$DOMAIN/*\"]" -i
@ -39,6 +39,6 @@ kcadm.sh create realms -s realm="$DUMMY_REALM" -s enabled=true -s "accessTokenLi
# THEN in another terminal: # THEN in another terminal:
# docker exec -it keycloakSetup-dev /bin/bash # docker exec -it keycloakSetup-dev /bin/bash
# export PATH=$PATH:/opt/keycloak/bin # export PATH=$PATH:/opt/keycloak/bin
# kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KEYCLOAK_ADMIN" --password "$KEYCLOAK_ADMIN_PASSWORD" --client admin-cli # kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KC_BOOTSTRAP_ADMIN_USERNAME" --password "$KC_BOOTSTRAP_ADMIN_PASSWORD" --client admin-cli
# ENJOY # ENJOY
# Doc: https://wjw465150.gitbooks.io/keycloak-documentation/content/server_admin/topics/admin-cli.html # Doc: https://wjw465150.gitbooks.io/keycloak-documentation/content/server_admin/topics/admin-cli.html

2
playwright/compose/playwright/Dockerfile

@ -28,7 +28,7 @@ RUN mkdir /playwright
WORKDIR /playwright WORKDIR /playwright
COPY package.json package-lock.json . COPY package.json package-lock.json .
RUN npm ci --ignore-scripts && npx playwright install-deps && npx playwright install firefox RUN npm ci --ignore-scripts --allow-git=none --allow-remote=none && npx playwright install-deps && npx playwright install firefox
COPY docker-compose.yml test.env ./ COPY docker-compose.yml test.env ./
COPY compose ./compose COPY compose ./compose

1
playwright/compose/warden/Dockerfile

@ -35,6 +35,7 @@ WORKDIR /
COPY --from=prebuilt /start.sh . COPY --from=prebuilt /start.sh .
COPY --from=prebuilt /vaultwarden . COPY --from=prebuilt /vaultwarden .
COPY --from=build /data ./data
COPY --from=build /web-vault ./web-vault COPY --from=build /web-vault ./web-vault
ENTRYPOINT ["/start.sh"] ENTRYPOINT ["/start.sh"]

11
playwright/compose/warden/build.sh

@ -22,3 +22,14 @@ if [[ ! -z "$REPO_URL" ]] && [[ ! -z "$COMMIT_HASH" ]] ; then
mv build /web-vault mv build /web-vault
fi fi
# Lower the KDF iterations default for faster tests.
sed -i 's/(6e5,2e6,6e5)/(1e5,2e6,1e5)/' /web-vault/app/main.*.js
# Generate a self signed cert
mkdir -p /data/ssl; cd /data/ssl
openssl req -x509 -out localhost.crt -keyout localhost.key \
-newkey rsa:2048 -nodes -sha256 \
-subj '/CN=localhost' -extensions EXT -config <( \
printf "[dn]\nCN=localhost\n[req]\ndistinguished_name = dn\n[EXT]\nsubjectAltName=DNS:localhost\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth")

15
playwright/docker-compose.yml

@ -24,12 +24,15 @@ services:
environment: environment:
- ADMIN_TOKEN - ADMIN_TOKEN
- DATABASE_URL - DATABASE_URL
- CLIENT_SUPPRESS_ONBOARDING
- EMAIL_2FA_AUTO_FALLBACK
- I_REALLY_WANT_VOLATILE_STORAGE - I_REALLY_WANT_VOLATILE_STORAGE
- LOG_LEVEL - LOG_LEVEL
- LOGIN_RATELIMIT_MAX_BURST - LOGIN_RATELIMIT_MAX_BURST
- SMTP_HOST - SMTP_HOST
- SMTP_FROM - SMTP_FROM
- SMTP_DEBUG - SMTP_DEBUG
- SSO_AUTH_ONLY_NOT_SESSION
- SSO_DEBUG_TOKENS - SSO_DEBUG_TOKENS
- SSO_ENABLED - SSO_ENABLED
- SSO_FRONTEND - SSO_FRONTEND
@ -70,7 +73,7 @@ services:
Mysql: Mysql:
profiles: ["playwright"] profiles: ["playwright"]
container_name: playwright_mysql container_name: playwright_mysql
image: mysql:8.4.1 image: mysql:9.7.0
env_file: test.env env_file: test.env
healthcheck: healthcheck:
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"] test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
@ -82,7 +85,7 @@ services:
Postgres: Postgres:
profiles: ["playwright"] profiles: ["playwright"]
container_name: playwright_postgres container_name: playwright_postgres
image: postgres:16.3 image: postgres:18.4
env_file: test.env env_file: test.env
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
@ -94,7 +97,7 @@ services:
Maildev: Maildev:
profiles: ["vaultwarden", "maildev"] profiles: ["vaultwarden", "maildev"]
container_name: maildev container_name: maildev
image: timshel/maildev:3.0.4 image: timshel/maildev:3.2.19
ports: ports:
- ${SMTP_PORT}:1025 - ${SMTP_PORT}:1025
- 1080:1080 - 1080:1080
@ -102,7 +105,7 @@ services:
Keycloak: Keycloak:
profiles: ["keycloak", "vaultwarden"] profiles: ["keycloak", "vaultwarden"]
container_name: keycloak-${ENV:-dev} container_name: keycloak-${ENV:-dev}
image: quay.io/keycloak/keycloak:26.3.4 image: quay.io/keycloak/keycloak:26.6.2
network_mode: "host" network_mode: "host"
command: command:
- start-dev - start-dev
@ -112,12 +115,12 @@ services:
profiles: ["keycloak", "vaultwarden"] profiles: ["keycloak", "vaultwarden"]
container_name: keycloakSetup-${ENV:-dev} container_name: keycloakSetup-${ENV:-dev}
image: keycloak_setup-${ENV:-dev} image: keycloak_setup-${ENV:-dev}
network_mode: "host"
build: build:
context: compose/keycloak context: compose/keycloak
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
KEYCLOAK_VERSION: 26.3.4 KEYCLOAK_VERSION: 26.6.2
network_mode: "host"
depends_on: depends_on:
- Keycloak - Keycloak
restart: "no" restart: "no"

2
playwright/global-setup.ts

@ -1,4 +1,4 @@
import { firefox, type FullConfig } from '@playwright/test'; import { type FullConfig } from '@playwright/test';
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import fs from 'fs'; import fs from 'fs';

15
playwright/global-utils.ts

@ -207,7 +207,7 @@ export async function startVault(browser: Browser, testInfo: TestInfo, env = {},
} }
export async function stopVault(force: boolean = false) { export async function stopVault(force: boolean = false) {
if( force === false && process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) { if( force === false && process.env.PW_KEEP_SERVICE_RUNNING === "true" ) {
console.log(`Keep vaultwarden running on: ${process.env.DOMAIN}`); console.log(`Keep vaultwarden running on: ${process.env.DOMAIN}`);
} else { } else {
console.log(`Vaultwarden stopping`); console.log(`Vaultwarden stopping`);
@ -231,6 +231,7 @@ export async function checkNotification(page: Page, hasText: string) {
} }
export async function cleanLanding(page: Page) { export async function cleanLanding(page: Page) {
await page.context().clearCookies();
await page.goto('/', { waitUntil: 'domcontentloaded' }); await page.goto('/', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('button').nth(0)).toBeVisible(); await expect(page.getByRole('button').nth(0)).toBeVisible();
@ -248,15 +249,3 @@ export async function logout(test: Test, page: Page, user: { name: string }) {
await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible();
}); });
} }
export async function ignoreExtension(page: Page) {
await page.waitForLoadState('domcontentloaded');
try {
await page.getByRole('button', { name: 'Add it later' }).click({timeout: 5_000});
await page.getByRole('link', { name: 'Skip to web app' }).click();
} catch (error) {
console.log('Extension setup not visible. Continuing');
}
}

1162
playwright/package-lock.json

File diff suppressed because it is too large

14
playwright/package.json

@ -8,14 +8,14 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@playwright/test": "1.56.1", "@playwright/test": "1.60.0",
"dotenv": "17.2.3", "dotenv": "17.4.2",
"dotenv-expand": "12.0.3", "dotenv-expand": "13.0.0",
"maildev": "npm:@timshel_npm/maildev@3.2.5" "maildev": "npm:@timshel_npm/maildev@3.2.19"
}, },
"dependencies": { "dependencies": {
"mysql2": "3.15.3", "mysql2": "3.22.3",
"otpauth": "9.4.1", "otpauth": "9.5.1",
"pg": "8.16.3" "pg": "8.21.0"
} }
} }

14
playwright/playwright.config.ts

@ -25,10 +25,12 @@ export default defineConfig({
/* Long global timeout for complex tests /* Long global timeout for complex tests
* But short action/nav/expect timeouts to fail on specific step (raise locally if not enough). * But short action/nav/expect timeouts to fail on specific step (raise locally if not enough).
*/ */
timeout: 120 * 1000, timeout: 240 * 1000,
actionTimeout: 20 * 1000, actionTimeout: 40 * 1000,
navigationTimeout: 20 * 1000, navigationTimeout: 40 * 1000,
expect: { timeout: 20 * 1000 }, expect: { timeout: 40 * 1000 },
"permissions": ["clipboard-read"],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: { use: {
@ -37,6 +39,10 @@ export default defineConfig({
browserName: 'firefox', browserName: 'firefox',
locale: 'en-GB', locale: 'en-GB',
timezoneId: 'Europe/London', timezoneId: 'Europe/London',
ignoreHTTPSErrors: true,
launchOptions: {
args: ['--ignore-certificate-errors']
},
/* Always collect trace (other values add random test failures) See https://playwright.dev/docs/trace-viewer */ /* Always collect trace (other values add random test failures) See https://playwright.dev/docs/trace-viewer */
trace: 'on', trace: 'on',

10
playwright/test.env

@ -10,7 +10,7 @@ DOCKER_BUILDKIT=1
##################### #####################
# Playwright Config # # Playwright Config #
##################### #####################
PW_KEEP_SERVICE_RUNNNING=${PW_KEEP_SERVICE_RUNNNING:-false} PW_KEEP_SERVICE_RUNNING=${PW_KEEP_SERVICE_RUNNING:-false}
PW_SMTP_FROM=vaultwarden@playwright.test PW_SMTP_FROM=vaultwarden@playwright.test
##################### #####################
@ -38,8 +38,8 @@ TEST_USER3_MAIL=${TEST_USER3}@example.com
################### ###################
# Keycloak Config # # Keycloak Config #
################### ###################
KEYCLOAK_ADMIN=admin KC_BOOTSTRAP_ADMIN_USERNAME=admin
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN} KC_BOOTSTRAP_ADMIN_PASSWORD=${KC_BOOTSTRAP_ADMIN_USERNAME}
KC_HTTP_HOST=127.0.0.1 KC_HTTP_HOST=127.0.0.1
KC_HTTP_PORT=8081 KC_HTTP_PORT=8081
@ -52,10 +52,12 @@ DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM}
# Vaultwarden Config # # Vaultwarden Config #
###################### ######################
ROCKET_PORT=8003 ROCKET_PORT=8003
DOMAIN=http://localhost:${ROCKET_PORT} ROCKET_TLS={certs="/data/ssl/localhost.crt",key="/data/ssl/localhost.key"}
DOMAIN=https://127.0.0.1:${ROCKET_PORT}
LOG_LEVEL=info,oidcwarden::sso=debug LOG_LEVEL=info,oidcwarden::sso=debug
LOGIN_RATELIMIT_MAX_BURST=100 LOGIN_RATELIMIT_MAX_BURST=100
ADMIN_TOKEN=admin ADMIN_TOKEN=admin
CLIENT_SUPPRESS_ONBOARDING=true
SMTP_SECURITY=off SMTP_SECURITY=off
SMTP_PORT=${MAILDEV_SMTP_PORT} SMTP_PORT=${MAILDEV_SMTP_PORT}

16
playwright/tests/collection.spec.ts

@ -1,6 +1,8 @@
import { test, expect, type TestInfo } from '@playwright/test'; import { test, expect, type TestInfo } from '@playwright/test';
import * as utils from "../global-utils"; import * as utils from "../global-utils";
import * as orgs from './setups/orgs';
import { createAccount } from './setups/user'; import { createAccount } from './setups/user';
let users = utils.loadEnv(); let users = utils.loadEnv();
@ -16,20 +18,12 @@ test.afterAll('Teardown', async ({}) => {
test('Create', async ({ page }) => { test('Create', async ({ page }) => {
await createAccount(test, page, users.user1); await createAccount(test, page, users.user1);
await test.step('Create Org', async () => { await orgs.create(test, page, 'New organisation');
await page.getByRole('link', { name: 'New organisation' }).click();
await page.getByLabel('Organisation name (required)').fill('Test');
await page.getByRole('button', { name: 'Submit' }).click();
await page.locator('div').filter({ hasText: 'Members' }).nth(2).click();
await utils.checkNotification(page, 'Organisation created');
});
await test.step('Create Collection', async () => { await test.step('Create Collection', async () => {
await page.getByRole('link', { name: 'Collections' }).click(); await page.getByRole('button', { name: 'New', exact: true }).click();
await page.getByRole('button', { name: 'New' }).click();
await page.getByRole('menuitem', { name: 'Collection' }).click(); await page.getByRole('menuitem', { name: 'Collection' }).click();
await page.getByLabel('Name (required)').fill('RandomCollec'); await page.getByRole('textbox', { name: 'Name * (required)', exact: true }).fill('RandomCollec');
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Created collection RandomCollec'); await utils.checkNotification(page, 'Created collection RandomCollec');
await expect(page.getByRole('button', { name: 'RandomCollec' })).toBeVisible(); await expect(page.getByRole('button', { name: 'RandomCollec' })).toBeVisible();

56
playwright/tests/cyphers.spec.ts

@ -0,0 +1,56 @@
import { test, expect, type Page, type TestInfo } from '@playwright/test';
import * as OTPAuth from "otpauth";
import * as utils from "../global-utils";
import { createAccount, logUser } from './setups/user';
import { activateTOTP, disableTOTP } from './setups/2fa';
let users = utils.loadEnv();
let totp;
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo, {});
});
test.afterAll('Teardown', async ({}) => {
utils.stopVault();
});
test('Change Key settings', async ({ page }) => {
await createAccount(test, page, users.user1);
await test.step('Change SHA-256 Iterations', async () => {
await page.getByRole('button', { name: 'Toggle collapse Settings' }).click();
await page.getByRole('link', { name: 'Security' }).click();
await page.getByRole('link', { name: 'Keys' }).click();
await page.getByRole('spinbutton', { name: 'KDF iterations * (required)'}).fill('700000');
await page.getByRole('button', { name: 'Update encryption settings' }).click();
await page.getByRole('textbox', { name: 'Master password * (required)' }).fill(users.user1.password);
await page.getByRole('button', { name: 'Update settings' }).click();
await page.getByRole('heading', { name: 'Log in' }).click();
});
await logUser(test, page, users.user1);
await test.step('Switch to Argon2', async () => {
await page.getByRole('button', { name: 'Toggle collapse Settings' }).click();
await page.getByRole('link', { name: 'Security' }).click();
await page.getByRole('link', { name: 'Keys' }).click();
await page.locator('.ng-arrow-wrapper').click();
await page.getByText('Argon2id').click();
await page.getByRole('spinbutton', { name: 'KDF memory (MB) * (required)'}).fill('16');
await page.getByRole('spinbutton', { name: 'KDF iterations * (required)'}).fill('2');
await page.getByRole('spinbutton', { name: 'KDF parallelism * (required)'}).fill('1');
await page.getByRole('button', { name: 'Update encryption settings' }).click();
await page.getByRole('textbox', { name: 'Master password * (required)' }).fill(users.user1.password);
await page.getByRole('button', { name: 'Update settings' }).click();
await page.getByRole('heading', { name: 'Log in' }).click();
});
await logUser(test, page, users.user1);
});

31
playwright/tests/login.smtp.spec.ts

@ -41,13 +41,10 @@ test('Account creation', async ({ page }) => {
test('Login', async ({ context, page }) => { test('Login', async ({ context, page }) => {
const mailBuffer = mailserver.buffer(users.user1.email); const mailBuffer = mailserver.buffer(users.user1.email);
await logUser(test, page, users.user1, mailBuffer); await logUser(test, page, users.user1, { mailBuffer });
await test.step('verify email', async () => { await test.step('verify email', async () => {
await page.getByText('Verify your account\'s email').click(); await page.getByRole('button', { name: "Send email" }).click();
await expect(page.getByText('Verify your account\'s email')).toBeVisible();
await page.getByRole('button', { name: 'Send email' }).click();
await utils.checkNotification(page, 'Check your email inbox for a verification link'); await utils.checkNotification(page, 'Check your email inbox for a verification link');
const verify = await mailBuffer.expect((m) => m.subject === "Verify Your Email"); const verify = await mailBuffer.expect((m) => m.subject === "Verify Your Email");
@ -78,26 +75,10 @@ test('Activate 2fa', async ({ page }) => {
test('2fa', async ({ page }) => { test('2fa', async ({ page }) => {
const emails = mailserver.buffer(users.user1.email); const emails = mailserver.buffer(users.user1.email);
await test.step('login', async () => { await logUser(test, page, users.user1, {
await page.goto('/'); mailBuffer: emails,
mail2fa: true,
await page.getByLabel(/Email address/).fill(users.user1.email); });
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByLabel('Master password').fill(users.user1.password);
await page.getByRole('button', { name: 'Log in with master password' }).click();
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
const code = await retrieveEmailCode(test, page, emails);
await page.getByLabel(/Verification code/).fill(code);
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Add it later' }).click();
await page.getByRole('link', { name: 'Skip to web app' }).click();
await expect(page).toHaveTitle(/Vaults/);
})
await disableEmail(test, page, users.user1);
emails.close(); emails.close();
}); });

4
playwright/tests/login.spec.ts

@ -37,8 +37,8 @@ test('Authenticator 2fa', async ({ page }) => {
await page.getByLabel(/Email address/).fill(users.user1.email); await page.getByLabel(/Email address/).fill(users.user1.email);
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
await page.getByLabel('Master password').fill(users.user1.password); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password);
await page.getByRole('button', { name: 'Log in with master password' }).click(); await page.getByRole('button', { name: 'Log in', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
await page.getByLabel(/Verification code/).fill(totp.generate({timestamp})); await page.getByLabel(/Verification code/).fill(totp.generate({timestamp}));

40
playwright/tests/organization.smtp.spec.ts

@ -4,6 +4,7 @@ import { MailDev } from 'maildev';
import * as utils from '../global-utils'; import * as utils from '../global-utils';
import * as orgs from './setups/orgs'; import * as orgs from './setups/orgs';
import { createAccount, logUser } from './setups/user'; import { createAccount, logUser } from './setups/user';
import { activateTOTP } from './setups/2fa';
let users = utils.loadEnv(); let users = utils.loadEnv();
@ -20,6 +21,7 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo, { await utils.startVault(browser, testInfo, {
SMTP_HOST: process.env.MAILDEV_HOST, SMTP_HOST: process.env.MAILDEV_HOST,
SMTP_FROM: process.env.PW_SMTP_FROM, SMTP_FROM: process.env.PW_SMTP_FROM,
EMAIL_2FA_AUTO_FALLBACK: "true",
}); });
mail1Buffer = mailServer.buffer(users.user1.email); mail1Buffer = mailServer.buffer(users.user1.email);
@ -45,7 +47,7 @@ test('Invite users', async ({ page }) => {
await orgs.policies(test, page, 'Test'); await orgs.policies(test, page, 'Test');
await page.getByRole('button', { name: 'Account recovery' }).click(); await page.getByRole('button', { name: 'Account recovery' }).click();
await page.getByRole('checkbox', { name: 'Turn on' }).check(); await page.getByRole('checkbox', { name: 'Turn on' }).check();
await page.getByRole('checkbox', { name: 'Require new members' }).check(); await page.getByRole('checkbox', { name: 'Automatically enroll new' }).check();
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Edited policy Account recovery'); await utils.checkNotification(page, 'Edited policy Account recovery');
}); });
@ -67,17 +69,15 @@ test('invited with new account', async ({ page }) => {
await expect(page).toHaveTitle(/Create account | Vaultwarden Web/); await expect(page).toHaveTitle(/Create account | Vaultwarden Web/);
// await page.getByLabel('Name').fill(users.user2.name); // await page.getByLabel('Name').fill(users.user2.name);
await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user2.password);
await page.getByLabel('Confirm master password (').fill(users.user2.password); await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(users.user2.password);
await page.getByRole('button', { name: 'Create account' }).click(); await page.getByRole('button', { name: 'Create account' }).click();
await utils.checkNotification(page, 'Your new account has been created'); await utils.checkNotification(page, 'Your new account has been created');
await utils.checkNotification(page, 'Invitation accepted');
await utils.ignoreExtension(page);
// Redirected to the vault // Redirected to the vault
await expect(page).toHaveTitle('Vaults | Vaultwarden Web'); await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
// await utils.checkNotification(page, 'You have been logged in!'); // await utils.checkNotification(page, 'You have been logged in!');
await utils.checkNotification(page, 'Successfully accepted your invitation');
}); });
await test.step('Check mails', async () => { await test.step('Check mails', async () => {
@ -100,21 +100,19 @@ test('invited with existing account', async ({ page }) => {
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
// Unlock page // Unlock page
await page.getByLabel('Master password').fill(users.user3.password); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user3.password);
await page.getByRole('button', { name: 'Log in with master password' }).click(); await page.getByRole('button', { name: 'Log in', exact: true }).click();
await utils.checkNotification(page, 'Invitation accepted');
await utils.ignoreExtension(page);
// We are now in the default vault page // We are now in the default vault page
await expect(page).toHaveTitle(/Vaultwarden Web/); await expect(page).toHaveTitle(/Vaultwarden Web/);
await utils.checkNotification(page, 'Successfully accepted your invitation');
await mail3Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox'); await mail3Buffer.expect((m) => m.subject === 'New Device Logged In From Firefox');
await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted')); await mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted'));
}); });
test('Confirm invited user', async ({ page }) => { test('Confirm invited user', async ({ page }) => {
await logUser(test, page, users.user1, mail1Buffer); await logUser(test, page, users.user1, { mailBuffer: mail1Buffer });
await orgs.members(test, page, 'Test'); await orgs.members(test, page, 'Test');
await orgs.confirm(test, page, 'Test', users.user2.email); await orgs.confirm(test, page, 'Test', users.user2.email);
@ -123,25 +121,26 @@ test('Confirm invited user', async ({ page }) => {
}); });
test('Organization is visible', async ({ page }) => { test('Organization is visible', async ({ page }) => {
await logUser(test, page, users.user2, mail2Buffer); await logUser(test, page, users.user2, { mailBuffer: mail2Buffer });
await page.getByRole('button', { name: 'vault: Test', exact: true }).click(); await page.getByRole('button', { name: 'vault: Test', exact: true }).click();
await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
}); });
test('Recover user password', async ({ page }) => { test('Recover user password', async ({ page }) => {
await logUser(test, page, users.user1, mail1Buffer); await logUser(test, page, users.user1, { mailBuffer: mail1Buffer });
let newPassword = "TotoNewPassword"; let newPassword = "TotoNewPassword";
await orgs.members(test, page, 'Test'); await orgs.members(test, page, 'Test');
await test.step(`Rrcover ${users.user2.email}`, async () => { await test.step(`Recover ${users.user2.email}`, async () => {
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
await page.getByRole('row').filter({hasText: users.user2.email}).getByLabel('Options').click(); await page.getByRole('row').filter({hasText: users.user2.email}).getByLabel('Options').click();
await page.getByRole('menuitem', { name: 'Recover account' }).click(); await page.getByRole('menuitem', { name: 'Recover account' }).click();
await page.getByRole('textbox', { name: 'New master password (required)', exact: true }).fill(newPassword); await page.getByRole('textbox', { name: 'New master password * (required)', exact: true }).fill(newPassword);
await page.getByRole('textbox', { name: 'Confirm new master password (' }).fill(newPassword); await page.getByRole('textbox', { name: 'Confirm new master password * (' }).fill(newPassword);
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Password reset success'); await utils.checkNotification(page, 'Account recovery success');
await mail2Buffer.expect((m) => m.subject.includes('Master Password Has Been Changed'));
}); });
let user2 = { let user2 = {
@ -149,5 +148,8 @@ test('Recover user password', async ({ page }) => {
name: users.user2.name, name: users.user2.name,
password: newPassword, password: newPassword,
}; };
await logUser(test, page, user2, mail2Buffer); await logUser(test, page, user2, {
mailBuffer: mail2Buffer,
notNewDevice: true,
});
}); });

110
playwright/tests/secrets.spec.ts

@ -0,0 +1,110 @@
import { test, expect, type Page, type TestInfo } from '@playwright/test';
import * as OTPAuth from "otpauth";
import * as utils from "../global-utils";
import { createAccount, logUser } from './setups/user';
let users = utils.loadEnv();
let totp;
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo, {});
const context = await browser.newContext();
const page = await context.newPage();
await createAccount(test, page, users.user1);
await context.close();
});
test.afterAll('Teardown', async ({}) => {
utils.stopVault();
});
test('Password', async ({ context, page }, testInfo: TestInfo) => {
const label = 'Test Password';
await logUser(test, page, users.user1);
await test.step('Create password entry', async () => {
await page.getByRole('button', { name: 'New item' }).click();
await page.getByRole('textbox', { name: 'Item name * (required)' }).fill(label);
await page.getByRole('textbox', { name: 'Username' }).fill(users.user1.name);
await page.getByRole('textbox', { name: 'Password' }).fill(users.user1.password);
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Item added');
await page.getByRole('button', { name: 'Close' }).click();
});
// Log again
await logUser(test, page, users.user1);
await test.step('Check', async () => {
await page.getByRole('row').filter({ hasText: label }).getByRole('button', { name: label }).click();
await page.getByTestId('copy-username').click();
await utils.checkNotification(page, 'Username copied');
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(users.user1.name)
await page.getByTestId('copy-password').click();
await utils.checkNotification(page, 'Password copied');
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(users.user1.password)
await page.getByRole('button', { name: 'Close' }).click();
});
await test.step('Delete', async () => {
await page.getByRole('row').filter({ hasText: label }).getByLabel('Options').click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Yes' }).click();
await utils.checkNotification(page, 'Item sent to bin');
});
// Log again
await logUser(test, page, users.user1);
await test.step('Deleted', async () => {
await expect(page.getByRole('row').filter({ hasText: label })).toHaveCount(0)
});
});
test('SSH Key', async ({ context, page }, testInfo: TestInfo) => {
const label = 'Test SSH key';
await logUser(test, page, users.user1);
const privateKey = await test.step('Create key entry', async () => {
await page.getByRole('button', { name: 'New', exact: true }).click();
await page.getByRole('menuitem', { name: 'SSH key' }).click();
await page.getByRole('textbox', { name: 'Item name * (required)' }).fill('Test SSH key');
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Item added');
await page.getByRole('button', { name: 'Copy private key' }).click();
await utils.checkNotification(page, 'Private key copied');
return await page.evaluate(() => navigator.clipboard.readText());
});
// Log again
await logUser(test, page, users.user1);
await test.step('Check', async () => {
await page.getByRole('row').filter({ hasText: label }).getByRole('button', { name: label }).click();
await page.getByRole('button', { name: 'Copy private key' }).click();
await utils.checkNotification(page, 'Private key copied');
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(privateKey)
await page.getByRole('button', { name: 'Close' }).click();
});
await test.step('Delete', async () => {
await page.getByRole('row').filter({ hasText: label }).getByLabel('Options').click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Yes' }).click();
await utils.checkNotification(page, 'Item sent to bin');
});
// Log again
await logUser(test, page, users.user1);
await test.step('Deleted', async () => {
await expect(page.getByRole('row').filter({ hasText: label })).toHaveCount(0)
})
});

16
playwright/tests/send.spec.ts

@ -21,11 +21,11 @@ test('Send', async ({ browser, page }) => {
await page.getByRole('link', { name: 'Send' }).click(); await page.getByRole('link', { name: 'Send' }).click();
await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible(); await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'New', exact: true }).click(); await page.getByRole('button', { name: 'New Send', exact: true }).click();
await page.getByRole('menuitem', { name: 'Text' }).click(); await page.getByRole('menuitem', { name: 'Text' }).click();
await page.getByRole('textbox', { name: 'Send name (required)' }).fill('Test'); await page.getByRole('textbox', { name: 'Send name * (required)' }).fill('Test');
await page.getByRole('textbox', { name: 'Text to share (required)' }).fill('test'); await page.getByRole('textbox', { name: 'Text to share * (required)' }).fill('test');
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();
await page.locator('footer').getByRole('button', { name: 'Copy link' }).click(); await page.locator('footer').getByRole('button', { name: 'Copy link' }).click();
@ -46,14 +46,14 @@ test('Send', async ({ browser, page }) => {
await page.getByRole('link', { name: 'Send' }).click(); await page.getByRole('link', { name: 'Send' }).click();
await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible(); await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'New', exact: true }).click(); await page.getByRole('button', { name: 'New' }).click();
await page.getByRole('menuitem', { name: 'Text' }).click(); await page.getByRole('menuitem', { name: 'Text' }).click();
await page.getByRole('textbox', { name: 'Send name (required)' }).fill('Password'); await page.getByRole('textbox', { name: 'Send name * (required)' }).fill('Password');
await page.getByRole('textbox', { name: 'Text to share (required)' }).fill('password'); await page.getByRole('textbox', { name: 'Text to share * (required)' }).fill('password');
await page.getByRole('combobox', { name: 'Who can view' }).click(); await page.getByRole('combobox', { name: 'Who can view' }).click();
await page.getByText('Anyone with a password set by you').click(); await page.getByText('Anyone with a password set by you').click();
await page.getByRole('textbox', { name: 'Password (required)' }).fill('password'); await page.getByRole('textbox', { name: 'Password * (required)', exact: true }).fill('password');
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();
await page.locator('footer').getByRole('button', { name: 'Copy link' }).click(); await page.locator('footer').getByRole('button', { name: 'Copy link' }).click();
@ -64,7 +64,7 @@ test('Send', async ({ browser, page }) => {
await test.step('View with password', async () => { await test.step('View with password', async () => {
await page2.goto(pwd_url, { waitUntil: 'domcontentloaded' }); await page2.goto(pwd_url, { waitUntil: 'domcontentloaded' });
await expect(page2.getByRole('heading', { name: 'Enter the password to view' })).toBeVisible(); await expect(page2.getByRole('heading', { name: 'Enter the password to view' })).toBeVisible();
await page2.getByRole('textbox', { name: 'Password (required)' }).fill('password'); await page2.getByRole('textbox', { name: 'Password * (required)' }).fill('password');
await page2.getByRole('button', { name: 'Continue' }).click(); await page2.getByRole('button', { name: 'Continue' }).click();
await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible(); await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible();
await expect(await page2.getByRole('paragraph').filter({ hasText: 'Password' })).toBeVisible(); await expect(await page2.getByRole('paragraph').filter({ hasText: 'Password' })).toBeVisible();

15
playwright/tests/setups/2fa.ts

@ -11,10 +11,11 @@ export async function activateTOTP(test: Test, page: Page, user: { name: string,
await page.getByRole('link', { name: 'Security' }).click(); await page.getByRole('link', { name: 'Security' }).click();
await page.getByRole('link', { name: 'Two-step login' }).click(); await page.getByRole('link', { name: 'Two-step login' }).click();
await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click(); await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click();
await page.getByLabel('Master password (required)').fill(user.password); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
const secret = await page.getByLabel('Key').innerText(); const secret = await page.getByLabel('Key', { exact: true }).innerText();
let totp = new OTPAuth.TOTP({ secret, period: 30 }); let totp = new OTPAuth.TOTP({ secret, period: 30 });
await page.getByLabel(/Verification code/).fill(totp.generate()); await page.getByLabel(/Verification code/).fill(totp.generate());
@ -33,8 +34,8 @@ export async function disableTOTP(test: Test, page: Page, user: { password: stri
await page.getByRole('link', { name: 'Security' }).click(); await page.getByRole('link', { name: 'Security' }).click();
await page.getByRole('link', { name: 'Two-step login' }).click(); await page.getByRole('link', { name: 'Two-step login' }).click();
await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click(); await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click();
await page.getByLabel('Master password (required)').click(); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).click()
await page.getByLabel('Master password (required)').fill(user.password); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Turn off' }).click(); await page.getByRole('button', { name: 'Turn off' }).click();
await page.getByRole('button', { name: 'Yes' }).click(); await page.getByRole('button', { name: 'Yes' }).click();
@ -49,7 +50,7 @@ export async function activateEmail(test: Test, page: Page, user: { name: string
await page.getByRole('link', { name: 'Security' }).click(); await page.getByRole('link', { name: 'Security' }).click();
await page.getByRole('link', { name: 'Two-step login' }).click(); await page.getByRole('link', { name: 'Two-step login' }).click();
await page.locator('bit-item').filter({ hasText: 'Enter a code sent to your email' }).getByRole('button').click(); await page.locator('bit-item').filter({ hasText: 'Enter a code sent to your email' }).getByRole('button').click();
await page.getByLabel('Master password (required)').fill(user.password); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Send email' }).click(); await page.getByRole('button', { name: 'Send email' }).click();
}); });
@ -81,8 +82,8 @@ export async function disableEmail(test: Test, page: Page, user: { password: str
await page.getByRole('link', { name: 'Security' }).click(); await page.getByRole('link', { name: 'Security' }).click();
await page.getByRole('link', { name: 'Two-step login' }).click(); await page.getByRole('link', { name: 'Two-step login' }).click();
await page.locator('bit-item').filter({ hasText: 'Email' }).getByRole('button').click(); await page.locator('bit-item').filter({ hasText: 'Email' }).getByRole('button').click();
await page.getByLabel('Master password (required)').click(); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).click()
await page.getByLabel('Master password (required)').fill(user.password); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Turn off' }).click(); await page.getByRole('button', { name: 'Turn off' }).click();
await page.getByRole('button', { name: 'Yes' }).click(); await page.getByRole('button', { name: 'Yes' }).click();

21
playwright/tests/setups/admin.ts

@ -0,0 +1,21 @@
import { expect, type Browser, Page } from '@playwright/test';
import * as utils from '../../global-utils';
utils.loadEnv();
export async function login(test, page: Page) {
await test.step(`Admin login`, async () => {
await page.goto('/admin');
await page.getByRole('textbox', { name: 'Enter admin token' }).fill(process.env.ADMIN_TOKEN);
await page.getByRole('button', { name: 'Enter' }).click();
});
}
export async function invite(test, page: Page, email: string) {
await test.step(`Invite user with ${email}`, async () => {
await page.getByRole('link', { name: 'Users' }).click();
await page.getByRole('textbox', { name: 'Enter email' }).fill(email);
await page.getByRole('button', { name: 'Invite' }).click();
await expect(page.getByRole('row', { name: email })).toHaveText(/Invited/);
});
}

2
playwright/tests/setups/db-teardown.ts

@ -5,7 +5,7 @@ const utils = require('../../global-utils');
utils.loadEnv(); utils.loadEnv();
test('DB teardown ?', async ({ serviceName }) => { test('DB teardown ?', async ({ serviceName }) => {
if( process.env.PW_KEEP_SERVICE_RUNNNING !== "true" ) { if( process.env.PW_KEEP_SERVICE_RUNNING !== "true" ) {
utils.stopComposeService(serviceName); utils.stopComposeService(serviceName);
} }
}); });

25
playwright/tests/setups/orgs.ts

@ -3,11 +3,14 @@ import { expect, type Browser,Page } from '@playwright/test';
import * as utils from '../../global-utils'; import * as utils from '../../global-utils';
export async function create(test, page: Page, name: string) { export async function create(test, page: Page, name: string) {
await test.step('Create Org', async () => { await test.step(`Create Org ${name}`, async () => {
await page.locator('a').filter({ hasText: 'Password Manager' }).first().click(); let pm_locator = page.locator('a').filter({ hasText: 'Password Manager' });
if( await pm_locator.count() > 0 ){
pm_locator.first().click();
}
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
await page.getByRole('link', { name: 'New organisation' }).click(); await page.getByRole('link', { name: 'New organisation' }).click();
await page.getByLabel('Organisation name (required)').fill(name); await page.getByRole('textbox', { name: 'Organisation name * (required)', exact: true }).fill(name);
await page.getByRole('button', { name: 'Submit' }).click(); await page.getByRole('button', { name: 'Submit' }).click();
await utils.checkNotification(page, 'Organisation created'); await utils.checkNotification(page, 'Organisation created');
@ -18,7 +21,7 @@ export async function policies(test, page: Page, name: string) {
await test.step(`Navigate to ${name} policies`, async () => { await test.step(`Navigate to ${name} policies`, async () => {
await page.locator('a').filter({ hasText: 'Admin Console' }).first().click(); await page.locator('a').filter({ hasText: 'Admin Console' }).first().click();
await page.locator('org-switcher').getByLabel(/Toggle collapse/).click(); await page.locator('org-switcher').getByLabel(/Toggle collapse/).click();
await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click(); await page.locator('org-switcher > bit-nav-group > div > bit-nav-item').filter({ hasText: `${name}` }).first().click();
await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible(); await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible();
await page.getByRole('button', { name: 'Toggle collapse Settings' }).click(); await page.getByRole('button', { name: 'Toggle collapse Settings' }).click();
await page.getByRole('link', { name: 'Policies' }).click(); await page.getByRole('link', { name: 'Policies' }).click();
@ -30,11 +33,11 @@ export async function members(test, page: Page, name: string) {
await test.step(`Navigate to ${name} members`, async () => { await test.step(`Navigate to ${name} members`, async () => {
await page.locator('a').filter({ hasText: 'Admin Console' }).first().click(); await page.locator('a').filter({ hasText: 'Admin Console' }).first().click();
await page.locator('org-switcher').getByLabel(/Toggle collapse/).click(); await page.locator('org-switcher').getByLabel(/Toggle collapse/).click();
await page.locator('org-switcher').getByRole('link', { name: `${name}` }).first().click(); await page.locator('org-switcher > bit-nav-group > div > bit-nav-item').filter({ hasText: `${name}` }).first().click();
await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible(); await expect(page.getByRole('heading', { name: `${name} collections` })).toBeVisible();
await page.locator('div').filter({ hasText: 'Members' }).nth(2).click(); await page.getByRole('link', { name: 'Members' }).click();
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
await expect(page.getByRole('cell', { name: 'All' })).toBeVisible(); await expect(page.getByRole('columnheader', { name: 'Select all' })).toBeVisible();
}); });
} }
@ -42,13 +45,13 @@ export async function invite(test, page: Page, name: string, email: string) {
await test.step(`Invite ${email}`, async () => { await test.step(`Invite ${email}`, async () => {
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
await page.getByRole('button', { name: 'Invite member' }).click(); await page.getByRole('button', { name: 'Invite member' }).click();
await page.getByLabel('Email (required)').fill(email); await page.getByRole('textbox', { name: 'Email * (required)', exact: true }).fill(email);
await page.getByRole('tab', { name: 'Collections' }).click(); await page.getByRole('tab', { name: 'Collections' }).click();
await page.getByRole('combobox', { name: 'Permission' }).click(); await page.getByRole('combobox', { name: 'Permission' }).click();
await page.getByText('Edit items', { exact: true }).click(); await page.getByText('Edit items', { exact: true }).click();
await page.getByLabel('Select collections').click(); await page.getByRole('combobox', { name: 'Select collections' }).click();
await page.getByText('Default collection').click(); await page.getByLabel('Options List').getByText('Default collection').click();
await page.getByRole('cell', { name: 'Collection', exact: true }).click(); await page.getByRole('columnheader', { name: 'Collection', exact: true }).click();
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'User(s) invited'); await utils.checkNotification(page, 'User(s) invited');
}); });

2
playwright/tests/setups/sso-teardown.ts

@ -6,7 +6,7 @@ const utils = require('../../global-utils');
utils.loadEnv(); utils.loadEnv();
test('Keycloak teardown', async () => { test('Keycloak teardown', async () => {
if( process.env.PW_KEEP_SERVICE_RUNNNING === "true" ) { if( process.env.PW_KEEP_SERVICE_RUNNING === "true" ) {
console.log("Keep Keycloak running"); console.log("Keep Keycloak running");
} else { } else {
console.log("Keycloak stopping"); console.log("Keycloak stopping");

25
playwright/tests/setups/sso.ts

@ -15,11 +15,8 @@ export async function logNewUser(
options: { mailBuffer?: MailBuffer } = {} options: { mailBuffer?: MailBuffer } = {}
) { ) {
await test.step(`Create user ${user.name}`, async () => { await test.step(`Create user ${user.name}`, async () => {
await page.context().clearCookies();
await test.step('Landing page', async () => { await test.step('Landing page', async () => {
await utils.cleanLanding(page); await utils.cleanLanding(page);
await page.locator("input[type=email].vw-email-sso").fill(user.email); await page.locator("input[type=email].vw-email-sso").fill(user.email);
await page.getByRole('button', { name: /Use single sign-on/ }).click(); await page.getByRole('button', { name: /Use single sign-on/ }).click();
}); });
@ -33,26 +30,24 @@ export async function logNewUser(
await test.step('Create Vault account', async () => { await test.step('Create Vault account', async () => {
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
await page.getByLabel('Master password (required)', { exact: true }).fill(user.password); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByLabel('Confirm master password (').fill(user.password); await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(user.password);
await page.getByRole('button', { name: 'Create account' }).click(); await page.getByRole('button', { name: 'Create account' }).click();
}); });
await utils.checkNotification(page, 'Account successfully created!');
await utils.checkNotification(page, 'Invitation accepted');
await utils.ignoreExtension(page);
await test.step('Default vault page', async () => { await test.step('Default vault page', async () => {
await expect(page).toHaveTitle(/Vaultwarden Web/); await expect(page).toHaveTitle(/Vaultwarden Web/);
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
}); });
await utils.checkNotification(page, 'Account successfully created!');
await utils.checkNotification(page, 'Invitation accepted');
if( options.mailBuffer ){ if( options.mailBuffer ){
let mailBuffer = options.mailBuffer; let mailBuffer = options.mailBuffer;
await test.step('Check emails', async () => { await test.step('Check emails', async () => {
await mailBuffer.expect((m) => m.subject === "Welcome");
await mailBuffer.expect((m) => m.subject.includes("New Device Logged")); await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
await mailBuffer.expect((m) => m.subject === "Welcome");
}); });
} }
}); });
@ -69,16 +64,14 @@ export async function logUser(
mailBuffer ?: MailBuffer, mailBuffer ?: MailBuffer,
totp?: OTPAuth.TOTP, totp?: OTPAuth.TOTP,
mail2fa?: boolean, mail2fa?: boolean,
notNewDevice?: boolean,
} = {} } = {}
) { ) {
let mailBuffer = options.mailBuffer; let mailBuffer = options.mailBuffer;
await test.step(`Log user ${user.email}`, async () => { await test.step(`Log user ${user.email}`, async () => {
await page.context().clearCookies();
await test.step('Landing page', async () => { await test.step('Landing page', async () => {
await utils.cleanLanding(page); await utils.cleanLanding(page);
await page.locator("input[type=email].vw-email-sso").fill(user.email); await page.locator("input[type=email].vw-email-sso").fill(user.email);
await page.getByRole('button', { name: /Use single sign-on/ }).click(); await page.getByRole('button', { name: /Use single sign-on/ }).click();
}); });
@ -117,14 +110,12 @@ export async function logUser(
await page.getByRole('button', { name: 'Unlock' }).click(); await page.getByRole('button', { name: 'Unlock' }).click();
}); });
await utils.ignoreExtension(page);
await test.step('Default vault page', async () => { await test.step('Default vault page', async () => {
await expect(page).toHaveTitle(/Vaultwarden Web/); await expect(page).toHaveTitle(/Vaultwarden Web/);
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
}); });
if( mailBuffer ){ if( mailBuffer && !options.notNewDevice ){
await test.step('Check email', async () => { await test.step('Check email', async () => {
await mailBuffer.expect((m) => m.subject.includes("New Device Logged")); await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
}); });

34
playwright/tests/setups/user.ts

@ -3,6 +3,7 @@ import { expect, type Browser, Page } from '@playwright/test';
import { type MailBuffer } from 'maildev'; import { type MailBuffer } from 'maildev';
import * as utils from '../../global-utils'; import * as utils from '../../global-utils';
import { retrieveEmailCode } from './2fa';
export async function createAccount(test, page: Page, user: { email: string, name: string, password: string }, mailBuffer?: MailBuffer) { export async function createAccount(test, page: Page, user: { email: string, name: string, password: string }, mailBuffer?: MailBuffer) {
await test.step(`Create user ${user.name}`, async () => { await test.step(`Create user ${user.name}`, async () => {
@ -17,12 +18,11 @@ export async function createAccount(test, page: Page, user: { email: string, nam
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
// Vault finish Creation // Vault finish Creation
await page.getByLabel('Master password (required)', { exact: true }).fill(user.password); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByLabel('Confirm master password (').fill(user.password); await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(user.password);
await page.getByRole('button', { name: 'Create account' }).click(); await page.getByRole('button', { name: 'Create account' }).click();
await utils.checkNotification(page, 'Your new account has been created') await utils.checkNotification(page, 'Your new account has been created')
await utils.ignoreExtension(page);
// We are now in the default vault page // We are now in the default vault page
await expect(page).toHaveTitle('Vaults | Vaultwarden Web'); await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
@ -35,7 +35,16 @@ export async function createAccount(test, page: Page, user: { email: string, nam
}); });
} }
export async function logUser(test, page: Page, user: { email: string, password: string }, mailBuffer?: MailBuffer) { export async function logUser(
test,
page: Page,
user: { email: string, password: string },
options: {
mailBuffer ?: MailBuffer,
mail2fa?: boolean,
notNewDevice?: boolean,
} = {}
) {
await test.step(`Log user ${user.email}`, async () => { await test.step(`Log user ${user.email}`, async () => {
await utils.cleanLanding(page); await utils.cleanLanding(page);
@ -43,16 +52,23 @@ export async function logUser(test, page: Page, user: { email: string, password:
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
// Unlock page // Unlock page
await page.getByLabel('Master password').fill(user.password); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByRole('button', { name: 'Log in with master password' }).click(); await page.getByRole('button', { name: 'Log in', exact: true }).click();
await utils.ignoreExtension(page); if( options.mail2fa ){
await test.step('2FA check', async () => {
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
let code = await retrieveEmailCode(test, page, options.mailBuffer);
await page.getByLabel(/Verification code/).fill(code);
await page.getByRole('button', { name: 'Continue' }).click();
});
}
// We are now in the default vault page // We are now in the default vault page
await expect(page).toHaveTitle(/Vaultwarden Web/); await expect(page).toHaveTitle(/Vaultwarden Web/);
if( mailBuffer ){ if( options.mailBuffer && !options.notNewDevice ){
await mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox"); await options.mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox");
} }
}); });
} }

55
playwright/tests/sso_login.smtp.spec.ts

@ -1,6 +1,7 @@
import { test, expect, type TestInfo } from '@playwright/test'; import { test, expect, type TestInfo } from '@playwright/test';
import { MailDev } from 'maildev'; import { MailDev } from 'maildev';
import * as admin from "./setups/admin";
import { logNewUser, logUser } from './setups/sso'; import { logNewUser, logUser } from './setups/sso';
import { activateEmail, disableEmail } from './setups/2fa'; import { activateEmail, disableEmail } from './setups/2fa';
import * as utils from "../global-utils"; import * as utils from "../global-utils";
@ -19,7 +20,7 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo, { await utils.startVault(browser, testInfo, {
SSO_ENABLED: true, SSO_ENABLED: true,
SSO_ONLY: false, SSO_ONLY: true,
SMTP_HOST: process.env.MAILDEV_HOST, SMTP_HOST: process.env.MAILDEV_HOST,
SMTP_FROM: process.env.PW_SMTP_FROM, SMTP_FROM: process.env.PW_SMTP_FROM,
}); });
@ -32,22 +33,64 @@ test.afterAll('Teardown', async ({}) => {
} }
}); });
test('Create and activate 2FA', async ({ page }) => { test('2FA email', async ({ page }) => {
const mailBuffer = mailserver.buffer(users.user1.email); const mailBuffer = mailserver.buffer(users.user1.email);
await logNewUser(test, page, users.user1, {mailBuffer: mailBuffer}); await logNewUser(test, page, users.user1, {mailBuffer: mailBuffer});
await activateEmail(test, page, users.user1, mailBuffer); await activateEmail(test, page, users.user1, mailBuffer);
await logUser(test, page, users.user1, {mailBuffer: mailBuffer, mail2fa: true, notNewDevice: true});
await disableEmail(test, page, users.user1);
mailBuffer.close(); mailBuffer.close();
}); });
test('Log and disable', async ({ page }) => {
const mailBuffer = mailserver.buffer(users.user1.email);
await logUser(test, page, users.user1, {mailBuffer: mailBuffer, mail2fa: true}); test('Admin invite', async ({ page }) => {
const mailBuffer = mailserver.buffer(users.user2.email);
await disableEmail(test, page, users.user1); await admin.login(test, page);
await admin.invite(test, page, users.user2.email);
const link = await test.step('Extract email link', async () => {
const invited = await mailBuffer.expect((m) => m.subject === "Join Vaultwarden");
await page.setContent(invited.html);
return await page.getByTestId("invite").getAttribute("href");
});
await test.step('Redirect to Keycloak', async () => {
await page.goto(link);
});
await test.step('Keycloak login', async () => {
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible();
await page.getByLabel(/Username/).fill(users.user2.name);
await page.getByLabel('Password', { exact: true }).fill(users.user2.password);
await page.getByRole('button', { name: 'Sign In' }).click();
});
await test.step('Create Vault account', async () => {
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user2.password);
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(users.user2.password);
await page.getByRole('button', { name: 'Create account' }).click();
});
await test.step('Default vault page', async () => {
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
await utils.checkNotification(page, 'Account successfully created!');
await utils.checkNotification(page, 'Invitation accepted');
});
await test.step('Check mails', async () => {
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
await mailBuffer.expect((m) => m.subject === "Welcome");
});
mailBuffer.close(); mailBuffer.close();
}); });

10
playwright/tests/sso_login.spec.ts

@ -33,8 +33,8 @@ test('Non SSO login', async ({ page }) => {
await page.getByRole('button', { name: 'Other' }).click(); await page.getByRole('button', { name: 'Other' }).click();
// Unlock page // Unlock page
await page.getByLabel('Master password').fill(users.user1.password); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password);
await page.getByRole('button', { name: 'Log in with master password' }).click(); await page.getByRole('button', { name: 'Log in', exact: true }).click();
// We are now in the default vault page // We are now in the default vault page
await expect(page).toHaveTitle(/Vaultwarden Web/); await expect(page).toHaveTitle(/Vaultwarden Web/);
@ -58,6 +58,7 @@ test('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) =
// Landing page // Landing page
await page.goto('/'); await page.goto('/');
await page.locator("input[type=email].vw-email-sso").fill(users.user1.email);
// Check that SSO login is available // Check that SSO login is available
await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(1); await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(1);
@ -66,7 +67,6 @@ test('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) =
await expect(page.getByRole('button', { name: 'Other' })).toHaveCount(0); await expect(page.getByRole('button', { name: 'Other' })).toHaveCount(0);
}); });
test('No SSO login', async ({ page }, testInfo: TestInfo) => { test('No SSO login', async ({ page }, testInfo: TestInfo) => {
await utils.restartVault(page, testInfo, { await utils.restartVault(page, testInfo, {
SSO_ENABLED: false SSO_ENABLED: false
@ -74,12 +74,14 @@ test('No SSO login', async ({ page }, testInfo: TestInfo) => {
// Landing page // Landing page
await page.goto('/'); await page.goto('/');
await page.getByLabel(/Email address/).fill(users.user1.email);
// No SSO button (rely on a correct selector checked in previous test) // No SSO button (rely on a correct selector checked in previous test)
await page.getByLabel('Master password');
await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(0); await expect(page.getByRole('button', { name: /Use single sign-on/ })).toHaveCount(0);
// Can continue to Master password // Can continue to Master password
await page.getByLabel(/Email address/).fill(users.user1.email); await page.getByLabel(/Email address/).fill(users.user1.email);
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('button', { name: 'Log in with master password' })).toHaveCount(1); await expect(page.getByRole('button', { name: 'Log in' })).toHaveCount(1);
}); });

16
playwright/tests/sso_organization.smtp.spec.ts

@ -67,17 +67,16 @@ test('invited with new account', async ({ page }) => {
await test.step('Create Vault account', async () => { await test.step('Create Vault account', async () => {
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user2.password);
await page.getByLabel('Confirm master password (').fill(users.user2.password); await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(users.user2.password);
await page.getByRole('button', { name: 'Create account' }).click(); await page.getByRole('button', { name: 'Create account' }).click();
await utils.checkNotification(page, 'Account successfully created!');
await utils.checkNotification(page, 'Invitation accepted');
await utils.ignoreExtension(page);
}); });
await test.step('Default vault page', async () => { await test.step('Default vault page', async () => {
await expect(page).toHaveTitle(/Vaultwarden Web/); await expect(page).toHaveTitle(/Vaultwarden Web/);
await utils.checkNotification(page, 'Account successfully created!');
await utils.checkNotification(page, 'Invitation accepted');
}); });
await test.step('Check mails', async () => { await test.step('Check mails', async () => {
@ -95,6 +94,7 @@ test('invited with existing account', async ({ page }) => {
await test.step('Redirect to Keycloak', async () => { await test.step('Redirect to Keycloak', async () => {
await page.goto(link); await page.goto(link);
await page.getByRole('button', { name: /Use single sign-on/ }).click();
}); });
await test.step('Keycloak login', async () => { await test.step('Keycloak login', async () => {
@ -108,13 +108,11 @@ test('invited with existing account', async ({ page }) => {
await expect(page).toHaveTitle('Vaultwarden Web'); await expect(page).toHaveTitle('Vaultwarden Web');
await page.getByLabel('Master password').fill(users.user3.password); await page.getByLabel('Master password').fill(users.user3.password);
await page.getByRole('button', { name: 'Unlock' }).click(); await page.getByRole('button', { name: 'Unlock' }).click();
await utils.checkNotification(page, 'Invitation accepted');
await utils.ignoreExtension(page);
}); });
await test.step('Default vault page', async () => { await test.step('Default vault page', async () => {
await expect(page).toHaveTitle(/Vaultwarden Web/); await expect(page).toHaveTitle(/Vaultwarden Web/);
await utils.checkNotification(page, 'Successfully accepted your invitation');
}); });
await test.step('Check mails', async () => { await test.step('Check mails', async () => {

25
playwright/tests/sso_organization.spec.ts

@ -49,7 +49,7 @@ test('Organization is visible', async ({ page }) => {
await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
}); });
test('Enforce password policy', async ({ page }) => { test('Activate password policy', async ({ page }) => {
await logUser(test, page, users.user1); await logUser(test, page, users.user1);
await orgs.policies(test, page, '/Test'); await orgs.policies(test, page, '/Test');
@ -61,16 +61,27 @@ test('Enforce password policy', async ({ page }) => {
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Edited policy Master password requirements.'); await utils.checkNotification(page, 'Edited policy Master password requirements.');
}); });
});
test('Unlock trigger policyy', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await utils.logout(test, page, users.user1); await page.locator("input[type=email].vw-email-sso").fill(users.user2.email);
await page.getByRole('button', { name: /Use single sign-on/ }).click();
await test.step(`Unlock trigger policy`, async () => { await test.step('Keycloak login', async () => {
await page.locator("input[type=email].vw-email-sso").fill(users.user1.email); await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible();
await page.getByRole('button', { name: 'Use single sign-on' }).click(); await page.getByLabel(/Username/).fill(users.user2.name);
await page.getByLabel('Password', { exact: true }).fill(users.user2.password);
await page.getByRole('button', { name: 'Sign In' }).click();
});
await page.getByRole('textbox', { name: 'Master password (required)' }).fill(users.user1.password); await test.step('Unlock vault', async () => {
await expect(page).toHaveTitle('Vaultwarden Web');
await expect(page.getByRole('heading', { name: 'Your vault is locked' })).toBeVisible();
await page.getByLabel('Master password').fill(users.user2.password);
await page.getByRole('button', { name: 'Unlock' }).click(); await page.getByRole('button', { name: 'Unlock' }).click();
});
await expect(page.getByRole('heading', { name: 'Update master password' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Update master password' })).toBeVisible();
}); });
});

2
rust-toolchain.toml

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

31
src/api/admin.rs

@ -716,6 +716,36 @@ fn web_vault_compare(active: &str, latest: &str) -> i8 {
} }
} }
fn check_template_overrides() -> Vec<&'static str> {
let template_folder = std::path::PathBuf::from(CONFIG.templates_folder());
let mut overrides = Vec::new();
for folder in ["admin", "email", "scss"] {
if folder_has_hbs_files(&template_folder.join(folder)) {
overrides.push(folder);
}
}
if folder_has_hbs_files(&template_folder) {
overrides.push("other");
}
overrides
}
fn folder_has_hbs_files(dir: &std::path::Path) -> bool {
let Ok(files) = std::fs::read_dir(dir) else {
// No files in this directory at all, so we can return false
return false;
};
files.flatten().any(|f| {
// Validate if it is a file and if it has the `.hbs` extension and starts with a-z or 0-9
f.file_type().is_ok_and(|t| t.is_file())
&& f.path().extension().is_some_and(|e| e.eq_ignore_ascii_case("hbs"))
&& f.file_name().to_str().is_some_and(|n| n.starts_with(|c: char| c.is_ascii_alphanumeric()))
})
}
#[get("/diagnostics")] #[get("/diagnostics")]
async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> ApiResult<Html<String>> { async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> ApiResult<Html<String>> {
use chrono::prelude::*; use chrono::prelude::*;
@ -770,6 +800,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(", "),
"template_overrides": check_template_overrides().join(", "),
"invalid_feature_flags": invalid_feature_flags, "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,

29
src/api/core/accounts.rs

@ -15,7 +15,7 @@ use crate::{
core::{accept_org_invite, log_user_event, two_factor::email}, core::{accept_org_invite, log_user_event, two_factor::email},
master_password_policy, register_push_device, unregister_push_device, master_password_policy, register_push_device, unregister_push_device,
}, },
auth::{ClientHeaders, Headers, decode_delete, decode_invite, decode_verify_email}, auth::{ClientHeaders, ClientIp, Headers, decode_delete, decode_invite, decode_verify_email},
crypto, crypto,
db::{ db::{
DbConn, DbPool, DbConn, DbPool,
@ -693,10 +693,6 @@ struct UnlockData {
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct ChangeKdfData { struct ChangeKdfData {
#[allow(dead_code)]
new_master_password_hash: String,
#[allow(dead_code)]
key: String,
authentication_data: AuthenticationData, authentication_data: AuthenticationData,
unlock_data: UnlockData, unlock_data: UnlockData,
master_password_hash: String, master_password_hash: String,
@ -1197,7 +1193,9 @@ struct DeleteRecoverData {
} }
#[post("/accounts/delete-recover", data = "<data>")] #[post("/accounts/delete-recover", data = "<data>")]
async fn post_delete_recover(data: Json<DeleteRecoverData>, conn: DbConn) -> EmptyResult { async fn post_delete_recover(data: Json<DeleteRecoverData>, ip: ClientIp, conn: DbConn) -> EmptyResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
let data: DeleteRecoverData = data.into_inner(); let data: DeleteRecoverData = data.into_inner();
if CONFIG.mail_enabled() { if CONFIG.mail_enabled() {
@ -1270,9 +1268,11 @@ struct PasswordHintData {
} }
#[post("/accounts/password-hint", data = "<data>")] #[post("/accounts/password-hint", data = "<data>")]
async fn password_hint(data: Json<PasswordHintData>, conn: DbConn) -> EmptyResult { async fn password_hint(data: Json<PasswordHintData>, ip: ClientIp, conn: DbConn) -> EmptyResult {
const NO_HINT: &str = "Sorry, you have no password hint..."; const NO_HINT: &str = "Sorry, you have no password hint...";
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
if !CONFIG.password_hints_allowed() || (!CONFIG.mail_enabled() && !CONFIG.show_password_hint()) { if !CONFIG.password_hints_allowed() || (!CONFIG.mail_enabled() && !CONFIG.show_password_hint()) {
err!("This server is not configured to provide password hints."); err!("This server is not configured to provide password hints.");
} }
@ -1334,6 +1334,13 @@ pub async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
"kdfIterations": kdf_iter, "kdfIterations": kdf_iter,
"kdfMemory": kdf_mem, "kdfMemory": kdf_mem,
"kdfParallelism": kdf_para, "kdfParallelism": kdf_para,
"kdfSettings": {
"iterations": kdf_iter,
"kdfType": kdf_type,
"memory": kdf_mem,
"parallelism": kdf_para
},
"salt": null,
})) }))
} }
@ -1510,7 +1517,9 @@ async fn put_device_token(device_id: DeviceId, data: Json<PushToken>, headers: H
} }
#[put("/devices/identifier/<device_id>/clear-token")] #[put("/devices/identifier/<device_id>/clear-token")]
async fn put_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResult { async fn put_clear_device_token(device_id: DeviceId, ip: ClientIp, conn: DbConn) -> EmptyResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
// This only clears push token // This only clears push token
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Controllers/DevicesController.cs#L215 // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Controllers/DevicesController.cs#L215
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Services/Implementations/DeviceService.cs#L37 // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Services/Implementations/DeviceService.cs#L37
@ -1532,8 +1541,8 @@ async fn put_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResul
// On upstream server, both PUT and POST are declared. Implementing the POST method in case it would be useful somewhere // On upstream server, both PUT and POST are declared. Implementing the POST method in case it would be useful somewhere
#[post("/devices/identifier/<device_id>/clear-token")] #[post("/devices/identifier/<device_id>/clear-token")]
async fn post_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResult { async fn post_clear_device_token(device_id: DeviceId, ip: ClientIp, conn: DbConn) -> EmptyResult {
put_clear_device_token(device_id, conn).await put_clear_device_token(device_id, ip, conn).await
} }
#[get("/tasks")] #[get("/tasks")]

19
src/api/core/ciphers.rs

@ -450,7 +450,9 @@ pub async fn update_cipher_from_data(
match Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &org_id, conn).await { 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() // A non-empty list of collections implies the caller already validated the user's write
// access to them, so we can move the cipher into the organization on that basis.
if shared_to_collections.as_ref().is_some_and(|cols| !cols.is_empty())
|| member.has_full_access() || member.has_full_access()
|| cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await || cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await
{ {
@ -629,7 +631,7 @@ async fn post_ciphers_import(data: Json<ImportData>, headers: Headers, conn: DbC
// Read and create the ciphers // Read and create the ciphers
for (index, mut cipher_data) in data.ciphers.into_iter().enumerate() { for (index, mut cipher_data) in data.ciphers.into_iter().enumerate() {
let folder_id = relations_map.get(&index).map(|i| folders[*i].clone()); let folder_id = relations_map.get(&index).and_then(|i| folders.get(*i).cloned());
cipher_data.folder_id = folder_id; cipher_data.folder_id = folder_id;
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
@ -868,7 +870,7 @@ async fn put_collections_admin(
headers: Headers, headers: Headers,
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> EmptyResult { ) -> JsonResult {
post_collections_admin(cipher_id, data, headers, conn, nt).await post_collections_admin(cipher_id, data, headers, conn, nt).await
} }
@ -879,7 +881,7 @@ async fn post_collections_admin(
headers: Headers, headers: Headers,
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> EmptyResult { ) -> JsonResult {
let data: CollectionsAdminData = data.into_inner(); let data: CollectionsAdminData = data.into_inner();
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else {
@ -938,7 +940,7 @@ async fn post_collections_admin(
) )
.await; .await;
Ok(()) Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::Organization, &conn).await?))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -1043,6 +1045,13 @@ async fn share_cipher_by_uuid(
err!("Cipher doesn't exist") err!("Cipher doesn't exist")
}; };
// `update_cipher_from_data()` rejects this too, but only after the collections below were
// already linked. There are no transactions, so that would leave the cipher linked to a
// collection of another organization.
if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.cipher.organization_id {
err!("Organization mismatch. Please resync the client before updating the cipher")
}
let mut shared_to_collections = vec![]; let mut shared_to_collections = vec![];
if let Some(organization_id) = &data.cipher.organization_id { if let Some(organization_id) = &data.cipher.organization_id {

8
src/api/core/events.rs

@ -182,7 +182,10 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
.await; .await;
} }
1600..=1699 => { 1600..=1699 => {
if let Some(org_id) = &event.organization_id { // Only allow logging events for an organization the user is actually a member of.
if let Some(org_id) = &event.organization_id
&& Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await.is_some()
{
log_event_impl( log_event_impl(
event.r#type, event.r#type,
org_id, org_id,
@ -197,8 +200,11 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
} }
} }
_ => { _ => {
// The cipher determines the organization the event is logged to, so make sure the
// user can actually access it instead of trusting the provided cipher uuid.
if let Some(cipher_uuid) = &event.cipher_id if let Some(cipher_uuid) = &event.cipher_id
&& let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await && let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await
&& cipher.is_accessible_to_user(&headers.user.uuid, &conn).await
&& let Some(org_id) = cipher.organization_uuid && let Some(org_id) = cipher.organization_uuid
{ {
log_event_impl( log_event_impl(

11
src/api/core/mod.rs

@ -246,14 +246,17 @@ fn config() -> Json<Value> {
// Version history: // Version history:
// - Individual cipher key encryption: 2024.2.0 // - Individual cipher key encryption: 2024.2.0
// - Mobile app support for MasterPasswordUnlockData: 2025.8.0 // - Mobile app support for MasterPasswordUnlockData: 2025.8.0
"version": "2025.12.0", "version": "2026.6.0",
"gitHash": option_env!("GIT_REV"), "gitHash": option_env!("GIT_REV"),
"server": { "server": {
"name": "Vaultwarden", "name": "Vaultwarden",
"url": "https://github.com/dani-garcia/vaultwarden" "url": "https://github.com/dani-garcia/vaultwarden"
}, },
"settings": { "settings": {
"disableUserRegistration": CONFIG.is_signup_disabled() "disableUserRegistration": CONFIG.is_signup_disabled(),
// When enabled, this setting signals to clients that onboarding interstitials
// (post-login welcome dialogs, extension install prompts, setup extension redirects, and premium upsell modals) should be suppressed
"suppressOnboardingInterstitials": CONFIG.client_suppress_onboarding(),
}, },
"environment": { "environment": {
"vault": domain, "vault": domain,
@ -270,6 +273,10 @@ fn config() -> Json<Value> {
"vapidPublicKey": null "vapidPublicKey": null
}, },
"featureStates": feature_states, "featureStates": feature_states,
// Not supported right now
// Used for by clients to learn if the server requires extra work to establish a connection.
// See: https://github.com/bitwarden/server/pull/6892 | https://github.com/bitwarden/server/commit/52955d1860b4dfb905f67bbe39d9b10bbd61ded0
"communication": null,
"object": "config", "object": "config",
})) }))
} }

56
src/api/core/organizations.rs

@ -577,6 +577,13 @@ async fn post_bulk_access_collections(
err!("Can't find organization details") err!("Can't find organization details")
} }
// The collections and members are checked below, the groups only here.
let org_groups = Group::find_by_organization(&org_id, &conn).await;
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|g| &g.uuid).collect();
if let Some(g) = data.groups.iter().find(|g| !org_group_ids.contains(&g.id)) {
err!("Invalid group", format!("Group {} does not belong to organization {}!", g.id, org_id))
}
for col_id in data.collection_ids { for col_id in data.collection_ids {
let Some(collection) = Collection::find_by_uuid_and_org(&col_id, &org_id, &conn).await else { let Some(collection) = Collection::find_by_uuid_and_org(&col_id, &org_id, &conn).await else {
err!("Collection not found") err!("Collection not found")
@ -946,6 +953,11 @@ async fn get_members(
if org_id != headers.membership.org_uuid { if org_id != headers.membership.org_uuid {
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
} }
if !headers.membership.has_full_access() {
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code);
}
let mut users_json = Vec::new(); let mut users_json = Vec::new();
for u in Membership::find_by_org(&org_id, &conn).await { for u in Membership::find_by_org(&org_id, &conn).await {
users_json.push( users_json.push(
@ -1167,6 +1179,9 @@ async fn send_invite(
} }
for group_id in &data.groups { for group_id in &data.groups {
if Group::find_by_uuid_and_org(group_id, &org_id, &conn).await.is_none() {
err!("Group not found in Organization")
}
let mut group_entry = GroupUser::new(group_id.clone(), new_member.uuid.clone()); let mut group_entry = GroupUser::new(group_id.clone(), new_member.uuid.clone());
group_entry.save(&conn).await?; group_entry.save(&conn).await?;
} }
@ -1614,6 +1629,9 @@ async fn edit_member(
GroupUser::delete_all_by_member(&member_to_edit.uuid, &conn).await?; GroupUser::delete_all_by_member(&member_to_edit.uuid, &conn).await?;
for group_id in data.groups.iter().flatten() { for group_id in data.groups.iter().flatten() {
if Group::find_by_uuid_and_org(group_id, &org_id, &conn).await.is_none() {
err!("Group not found in Organization")
}
let mut group_entry = GroupUser::new(group_id.clone(), member_to_edit.uuid.clone()); let mut group_entry = GroupUser::new(group_id.clone(), member_to_edit.uuid.clone());
group_entry.save(&conn).await?; group_entry.save(&conn).await?;
} }
@ -1813,19 +1831,19 @@ async fn post_org_import(
// TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks. // TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks.
Cipher::validate_cipher_data(&data.ciphers)?; Cipher::validate_cipher_data(&data.ciphers)?;
let existing_collections: HashSet<Option<CollectionId>> = let existing_collections: HashMap<CollectionId, Collection> =
Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| Some(c.uuid)).collect(); Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| (c.uuid.clone(), c)).collect();
let mut collections: Vec<CollectionId> = Vec::with_capacity(data.collections.len()); let mut collections: Vec<CollectionId> = Vec::with_capacity(data.collections.len());
for col in data.collections { for col in data.collections {
let collection_uuid = if existing_collections.contains(&col.id) { let existing = col.id.as_ref().and_then(|col_id| existing_collections.get(col_id));
let col_id = col.id.unwrap(); let collection_uuid = if let Some(collection) = existing {
// When not an Owner or Admin, check if the member is allowed to access the collection. // When not an Owner or Admin, check if the member is allowed to write to the collection.
if headers.membership.atype < MembershipType::Admin if headers.membership.atype < MembershipType::Admin
&& !Collection::can_access_collection(&headers.membership, &col_id, &conn).await && !collection.is_writable_by_user(&headers.membership.user_uuid, &conn).await
{ {
err!(Compact, "The current user isn't allowed to manage this collection") err!(Compact, "The current user isn't allowed to manage this collection")
} }
col_id collection.uuid.clone()
} else { } else {
// We do not allow users or managers which can not manage all collections to create new collections // We do not allow users or managers which can not manage all collections to create new collections
// If there is any collection other than an existing import collection, abort the import. // If there is any collection other than an existing import collection, abort the import.
@ -1853,6 +1871,8 @@ async fn post_org_import(
for mut cipher_data in data.ciphers { for mut cipher_data in data.ciphers {
// Always clear folder_id's via an organization import // Always clear folder_id's via an organization import
cipher_data.folder_id = None; cipher_data.folder_id = None;
// Replace the client-provided, unvalidated organizationId with the real target org
cipher_data.organization_id = Some(org_id.clone());
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
update_cipher_from_data( update_cipher_from_data(
&mut cipher, &mut cipher,
@ -1870,8 +1890,9 @@ async fn post_org_import(
// Assign the collections // Assign the collections
for (cipher_index, col_index) in relations { for (cipher_index, col_index) in relations {
let cipher_id = &ciphers[cipher_index]; let (Some(cipher_id), Some(col_id)) = (ciphers.get(cipher_index), collections.get(col_index)) else {
let col_id = &collections[col_index]; err!(Compact, "Invalid collection relationship")
};
CollectionCipher::save(cipher_id, col_id, &conn).await?; CollectionCipher::save(cipher_id, col_id, &conn).await?;
} }
@ -2441,6 +2462,23 @@ async fn get_groups_data(
if org_id != headers.membership.org_uuid { if org_id != headers.membership.org_uuid {
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
} }
// The details view (group→collection/user mappings) needs full org access; the plain list only
// needs manage access to a collection, so a manager of a collection (directly or via a group)
// can load it to assign groups.
let has_full_access = headers.membership.has_full_access()
|| (CONFIG.org_groups_enabled()
&& GroupUser::has_full_access_by_member(&org_id, &headers.membership.uuid, &conn).await);
let allowed = if details {
has_full_access
} else {
has_full_access
|| Collection::has_manageable_collection_by_user(&org_id, &headers.membership.user_uuid, &conn).await
};
if !allowed {
err_code!("Resource not found.", "User does not have access", rocket::http::Status::NotFound.code);
}
let groups: Vec<Value> = if CONFIG.org_groups_enabled() { let groups: Vec<Value> = if CONFIG.org_groups_enabled() {
let groups = Group::find_by_organization(&org_id, &conn).await; let groups = Group::find_by_organization(&org_id, &conn).await;
let mut groups_json = Vec::with_capacity(groups.len()); let mut groups_json = Vec::with_capacity(groups.len());

11
src/api/core/public.rs

@ -14,7 +14,7 @@ use crate::{
db::{ db::{
DbConn, DbConn,
models::{ models::{
Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, Organization, Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, OrgPolicy, Organization,
OrganizationApiKey, OrganizationId, User, OrganizationApiKey, OrganizationId, User,
}, },
}, },
@ -84,8 +84,15 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
} }
// If user is part of the organization, restore it // If user is part of the organization, restore it
} else if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await { } else if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await {
let restored = member.restore(); let mut restored = member.restore();
let ext_modified = member.set_external_id(Some(user_data.external_id.clone())); let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
// Enforce org policies as every other restore path does.
// If the user is not allowed, we revoke again and continue so the external_id is still updated.
if restored && let Err(e) = OrgPolicy::check_user_allowed(&member, "restore", &conn).await {
warn!("Not restoring {}: {e:?}", user_data.email);
member.revoke();
restored = false;
}
if restored || ext_modified { if restored || ext_modified {
member.save(&conn).await?; member.save(&conn).await?;
} }

47
src/api/core/sends.rs

@ -453,6 +453,9 @@ async fn post_access(headers: SendHeaders, conn: DbConn, nt: Notify<'_>) -> Json
let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else { let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
}; };
if !send.is_accessible() {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
process_access(send, conn, nt).await process_access(send, conn, nt).await
} }
@ -471,6 +474,8 @@ async fn post_access_legacy(
ip: ClientIp, ip: ClientIp,
nt: Notify<'_>, nt: Notify<'_>,
) -> JsonResult { ) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
let Some(mut send) = Send::find_by_access_id(access_id, &conn).await else { let Some(mut send) = Send::find_by_access_id(access_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
}; };
@ -481,17 +486,7 @@ async fn post_access_legacy(
err_code!(SEND_INACCESSIBLE_MSG, 404); err_code!(SEND_INACCESSIBLE_MSG, 404);
} }
if let Some(expiration) = send.expiration_date if !send.is_accessible() {
&& Utc::now().naive_utc() >= expiration
{
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
if Utc::now().naive_utc() >= send.deletion_date {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
if send.disabled {
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
} }
@ -505,10 +500,12 @@ async fn post_access_legacy(
// Files are incremented during the download // Files are incremented during the download
if send.atype == SendType::Text as i32 { if send.atype == SendType::Text as i32 {
send.access_count += 1; if !send.register_access(&conn).await? {
err_code!(SEND_INACCESSIBLE_MSG, 404)
} }
} else {
send.save(&conn).await?; send.save(&conn).await?;
}
process_access(send, conn, nt).await process_access(send, conn, nt).await
} }
@ -537,6 +534,9 @@ async fn post_access_file(
let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else { let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
}; };
if !send.is_accessible() {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
process_access_file(send, file_id, host, conn, nt).await process_access_file(send, file_id, host, conn, nt).await
} }
@ -548,8 +548,11 @@ async fn post_access_file_legacy(
data: Json<SendAccessData>, data: Json<SendAccessData>,
host: Host, host: Host,
conn: DbConn, conn: DbConn,
ip: ClientIp,
nt: Notify<'_>, nt: Notify<'_>,
) -> JsonResult { ) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
let Some(mut send) = Send::find_by_uuid(&send_id, &conn).await else { let Some(mut send) = Send::find_by_uuid(&send_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
}; };
@ -560,17 +563,7 @@ async fn post_access_file_legacy(
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
} }
if let Some(expiration) = send.expiration_date if !send.is_accessible() {
&& Utc::now().naive_utc() >= expiration
{
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
if Utc::now().naive_utc() >= send.deletion_date {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
if send.disabled {
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
} }
@ -582,9 +575,9 @@ async fn post_access_file_legacy(
} }
} }
send.access_count += 1; if !send.register_access(&conn).await? {
err_code!(SEND_INACCESSIBLE_MSG, 404)
send.save(&conn).await?; }
process_access_file(send, file_id, host, conn, nt).await process_access_file(send, file_id, host, conn, nt).await
} }

57
src/api/core/two_factor/yubikey.rs

@ -1,6 +1,10 @@
use rocket::{Route, serde::json::Json}; use rocket::{Route, serde::json::Json};
use serde_json::Value; use serde_json::Value;
use yubico::{config::Config, verify_async}; use yubico_ng::{
Verifier, YubicoError,
config::Config,
transport::{AsyncTransport, Response},
};
use crate::{ use crate::{
CONFIG, CONFIG,
@ -14,12 +18,39 @@ use crate::{
models::{EventType, TwoFactor, TwoFactorType}, models::{EventType, TwoFactor, TwoFactorType},
}, },
error::{Error, MapResult}, error::{Error, MapResult},
http_client,
}; };
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
routes![generate_yubikey, activate_yubikey, activate_yubikey_put,] routes![generate_yubikey, activate_yubikey, activate_yubikey_put,]
} }
struct HttpClientTransport {
client: reqwest::Client,
}
impl HttpClientTransport {
fn new() -> Result<Self, reqwest::Error> {
http_client::get_reqwest_client_builder(false).redirect(reqwest::redirect::Policy::none()).build().map(
|client| Self {
client,
},
)
}
}
impl AsyncTransport for HttpClientTransport {
type Error = YubicoError;
async fn yubico_get(&self, url: &str) -> Result<Response, Self::Error> {
let response = self.client.get(url).send().await.map_err(YubicoError::transport)?;
Ok(Response {
status: response.status().as_u16(),
body: response.text().await.map_err(YubicoError::transport)?,
})
}
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct EnableYubikeyData { struct EnableYubikeyData {
@ -44,8 +75,7 @@ pub struct YubikeyMetadata {
fn parse_yubikeys(data: &EnableYubikeyData) -> Vec<String> { fn parse_yubikeys(data: &EnableYubikeyData) -> Vec<String> {
let data_keys = [&data.key1, &data.key2, &data.key3, &data.key4, &data.key5]; let data_keys = [&data.key1, &data.key2, &data.key3, &data.key4, &data.key5];
data_keys.into_iter().flatten().filter(|e| !e.is_empty()).cloned().collect()
data_keys.into_iter().flatten().cloned().collect()
} }
fn jsonify_yubikeys(yubikeys: Vec<String>) -> Value { fn jsonify_yubikeys(yubikeys: Vec<String>) -> Value {
@ -73,13 +103,15 @@ fn get_yubico_credentials() -> Result<(String, String), Error> {
async fn verify_yubikey_otp(otp: String) -> EmptyResult { async fn verify_yubikey_otp(otp: String) -> EmptyResult {
let (yubico_id, yubico_secret) = get_yubico_credentials()?; let (yubico_id, yubico_secret) = get_yubico_credentials()?;
let config = Config::default().set_client_id(yubico_id).set_key(yubico_secret); let mut config = Config::default().set_client_id(yubico_id).set_key(yubico_secret)?;
if let Some(yubico_server) = CONFIG.yubico_server() {
match CONFIG.yubico_server() { config = config.set_api_host(yubico_server);
Some(server) => verify_async(otp, config.set_api_hosts(vec![server])).await,
None => verify_async(otp, config).await,
} }
.map_res("Failed to verify OTP")
let client = HttpClientTransport::new()?;
let verifier = Verifier::with_client(config, client)?;
verifier.verify(otp).await.map_res("Failed to verify OTP")
} }
#[post("/two-factor/get-yubikey", data = "<data>")] #[post("/two-factor/get-yubikey", data = "<data>")]
@ -137,10 +169,9 @@ async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn:
let yubikeys = parse_yubikeys(&data); let yubikeys = parse_yubikeys(&data);
if yubikeys.is_empty() { if yubikeys.is_empty() {
return Ok(Json(json!({ // Return an error to prevent saving empty keys which would cause users not being able to login anymore.
"enabled": false, // To remove all keys users should click the `Deactivate all keys` button
"object": "twoFactorU2f", err!("A key is required.");
})));
} }
// Ensure they are valid OTPs // Ensure they are valid OTPs

16
src/api/icons.rs

@ -405,6 +405,22 @@ async fn get_page(url: &str) -> Result<Response, Error> {
} }
async fn get_page_with_referer(url: &str, referer: &str) -> Result<Response, Error> { async fn get_page_with_referer(url: &str, referer: &str) -> Result<Response, Error> {
// The resolver only sees hosts needing name resolution, so IP-literal hrefs from
// attacker-controlled HTML never reach `post_resolve()`. Check them here.
let Ok(parsed_url) = url::Url::parse(url) else {
err_silent!("Invalid URL", url)
};
if !matches!(parsed_url.scheme(), "http" | "https") {
err_silent!("Invalid scheme", url)
}
let Some(host) = parsed_url.host() else {
err_silent!("Invalid host", url)
};
should_block_host(&host)?;
let mut client = CLIENT.get(url); let mut client = CLIENT.get(url);
if !referer.is_empty() { if !referer.is_empty() {
client = client.header("Referer", referer); client = client.header("Referer", referer);

8
src/api/identity.rs

@ -109,6 +109,7 @@ async fn login(
} }
"authorization_code" => err!("SSO sign-in is not available"), "authorization_code" => err!("SSO sign-in is not available"),
"send_access" => { "send_access" => {
crate::ratelimit::check_limit_unauthenticated(&client_header.ip.ip)?;
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
check_is_some(data.send_id.as_ref(), "send_id cannot be blank")?; check_is_some(data.send_id.as_ref(), "send_id cannot be blank")?;
@ -317,7 +318,7 @@ async fn sso_login(
Some((user, _)) if !user.enabled => { Some((user, _)) if !user.enabled => {
err!( err!(
"This user has been disabled", "This user has been disabled",
format!("IP: {}. Username: {}.", ip.ip, user.display_name()), format!("IP: {}. Username: {}.", ip.ip, user.email),
ErrorEvent { ErrorEvent {
event: EventType::UserFailedLogIn event: EventType::UserFailedLogIn
} }
@ -576,7 +577,7 @@ async fn authenticated_response(
result["TwoFactorToken"] = Value::String(token); result["TwoFactorToken"] = Value::String(token);
} }
info!("User {} logged in successfully. IP: {}", user.display_name(), ip.ip); info!("User {} logged in successfully. IP: {}", user.email, ip.ip);
Ok(Json(result)) Ok(Json(result))
} }
@ -1055,8 +1056,11 @@ enum RegisterVerificationResponse {
#[post("/accounts/register/send-verification-email", data = "<data>")] #[post("/accounts/register/send-verification-email", data = "<data>")]
async fn register_verification_email( async fn register_verification_email(
data: Json<RegisterVerificationData>, data: Json<RegisterVerificationData>,
ip: ClientIp,
conn: DbConn, conn: DbConn,
) -> ApiResult<RegisterVerificationResponse> { ) -> ApiResult<RegisterVerificationResponse> {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
let data = data.into_inner(); let data = data.into_inner();
// the registration can only continue if signup is allowed or there exists an invitation // the registration can only continue if signup is allowed or there exists an invitation

2
src/api/mod.rs

@ -30,7 +30,7 @@ pub use crate::api::{
}, },
web::catchers as web_catchers, web::catchers as web_catchers,
web::routes as web_routes, web::routes as web_routes,
web::static_files, web::{invalidate_css_cache, static_files},
}; };
use crate::{ use crate::{
CONFIG, CONFIG,

68
src/api/notifications.rs

@ -33,9 +33,14 @@ pub static WS_USERS: LazyLock<Arc<WebSocketUsers>> = LazyLock::new(|| {
pub static WS_ANONYMOUS_SUBSCRIPTIONS: LazyLock<Arc<AnonymousWebSocketSubscriptions>> = LazyLock::new(|| { pub static WS_ANONYMOUS_SUBSCRIPTIONS: LazyLock<Arc<AnonymousWebSocketSubscriptions>> = LazyLock::new(|| {
Arc::new(AnonymousWebSocketSubscriptions { Arc::new(AnonymousWebSocketSubscriptions {
map: Arc::new(dashmap::DashMap::new()), map: Arc::new(dashmap::DashMap::new()),
connections: Arc::new(dashmap::DashMap::new()),
}) })
}); });
/// The anonymous hub needs no authentication, so bound how much a single client can hold open.
/// One connection is needed per pending login request, several at once are only expected behind NAT.
const MAX_ANONYMOUS_CONNECTIONS_PER_IP: u32 = 25;
static NOTIFICATIONS_DISABLED: LazyLock<bool> = LazyLock::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled()); static NOTIFICATIONS_DISABLED: LazyLock<bool> = LazyLock::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled());
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
@ -82,14 +87,21 @@ impl Drop for WSEntryMapGuard {
struct WSAnonymousEntryMapGuard { struct WSAnonymousEntryMapGuard {
subscriptions: Arc<AnonymousWebSocketSubscriptions>, subscriptions: Arc<AnonymousWebSocketSubscriptions>,
token: String, token: String,
entry_uuid: uuid::Uuid,
addr: IpAddr, addr: IpAddr,
} }
impl WSAnonymousEntryMapGuard { impl WSAnonymousEntryMapGuard {
fn new(subscriptions: Arc<AnonymousWebSocketSubscriptions>, token: String, addr: IpAddr) -> Self { fn new(
subscriptions: Arc<AnonymousWebSocketSubscriptions>,
token: String,
entry_uuid: uuid::Uuid,
addr: IpAddr,
) -> Self {
Self { Self {
subscriptions, subscriptions,
token, token,
entry_uuid,
addr, addr,
} }
} }
@ -98,7 +110,11 @@ impl WSAnonymousEntryMapGuard {
impl Drop for WSAnonymousEntryMapGuard { impl Drop for WSAnonymousEntryMapGuard {
fn drop(&mut self) { fn drop(&mut self) {
info!("Closing WS connection from {}", self.addr); info!("Closing WS connection from {}", self.addr);
self.subscriptions.map.remove(&self.token); if let Some(mut entry) = self.subscriptions.map.get_mut(&self.token) {
entry.retain(|(uuid, _)| uuid != &self.entry_uuid);
}
self.subscriptions.map.remove_if(&self.token, |_, senders| senders.is_empty());
self.subscriptions.release(self.addr);
} }
} }
@ -194,12 +210,19 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R
let (mut rx, guard) = { let (mut rx, guard) = {
let subscriptions = Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS); let subscriptions = Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS);
// Add a channel to send messages to this client to the map if !subscriptions.try_reserve(ip.ip) {
err_code!("Too many connections", 429)
}
// Add a channel to send messages to this client to the map.
// Clients reconnect with the same token while a login request is still pending, so keep
// every subscriber instead of replacing, otherwise the older one takes the newer one down.
let (tx, rx) = tokio::sync::mpsc::channel::<Message>(100); let (tx, rx) = tokio::sync::mpsc::channel::<Message>(100);
subscriptions.map.insert(token.clone(), tx); let entry_uuid = uuid::Uuid::new_v4();
subscriptions.map.entry(token.clone()).or_default().push((entry_uuid, tx));
// Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map // Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map
(rx, WSAnonymousEntryMapGuard::new(subscriptions, token, ip.ip)) (rx, WSAnonymousEntryMapGuard::new(subscriptions, token, entry_uuid, ip.ip))
}; };
Ok({ Ok({
@ -534,17 +557,44 @@ impl WebSocketUsers {
#[derive(Clone)] #[derive(Clone)]
pub struct AnonymousWebSocketSubscriptions { pub struct AnonymousWebSocketSubscriptions {
map: Arc<dashmap::DashMap<String, Sender<Message>>>, map: Arc<dashmap::DashMap<String, Vec<UserSenders>>>,
connections: Arc<dashmap::DashMap<IpAddr, u32>>,
} }
impl AnonymousWebSocketSubscriptions { impl AnonymousWebSocketSubscriptions {
/// Takes a connection slot for this address, returns false when it already reached the limit.
fn try_reserve(&self, addr: IpAddr) -> bool {
let mut count = self.connections.entry(addr).or_insert(0);
if *count >= MAX_ANONYMOUS_CONNECTIONS_PER_IP {
return false;
}
*count += 1;
true
}
/// Releases a slot taken by `try_reserve`.
fn release(&self, addr: IpAddr) {
let empty = if let Some(mut count) = self.connections.get_mut(&addr) {
*count = count.saturating_sub(1);
*count == 0
} else {
false
};
// Only remove once the guard above is dropped, otherwise this deadlocks.
if empty {
self.connections.remove_if(&addr, |_, count| *count == 0);
}
}
async fn send_update(&self, token: &str, data: &[u8]) { async fn send_update(&self, token: &str, data: &[u8]) {
if let Some(sender) = self.map.get(token).map(|v| v.clone()) // Clone the senders so the map isn't kept locked while sending.
&& let Err(e) = sender.send(Message::binary(data)).await let senders = self.map.get(token).map(|v| v.clone()).unwrap_or_default();
{ for (_, sender) in senders {
if let Err(e) = sender.send(Message::binary(data)).await {
error!("Error sending WS update {e}"); error!("Error sending WS update {e}");
} }
} }
}
pub async fn send_auth_response(&self, user_id: &UserId, auth_request_id: &AuthRequestId) { pub async fn send_auth_response(&self, user_id: &UserId, auth_request_id: &AuthRequestId) {
if !CONFIG.enable_websocket() { if !CONFIG.enable_websocket() {

43
src/api/web.rs

@ -1,4 +1,7 @@
use std::path::{Path, PathBuf}; use std::{
path::{Path, PathBuf},
sync::{Arc, RwLock},
};
use rocket::{ use rocket::{
Catcher, Route, Catcher, Route,
@ -13,12 +16,13 @@ use crate::{
CONFIG, CONFIG,
api::{ApiResult, EmptyResult, core::now}, api::{ApiResult, EmptyResult, core::now},
auth::decode_file_download, auth::decode_file_download,
crypto::sha256_hex,
db::{ db::{
DbConn, DbConn,
models::{AttachmentId, CipherId}, models::{AttachmentId, CipherId},
}, },
error::Error, error::Error,
util::Cached, util::{Cached, EtagCached},
}; };
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
@ -63,8 +67,27 @@ fn not_found() -> ApiResult<Html<String>> {
Ok(Html(text)) Ok(Html(text))
} }
struct CssCache {
css: String,
etag: String,
}
static CSS_CACHE: RwLock<Option<Arc<CssCache>>> = RwLock::new(None);
pub fn invalidate_css_cache() {
*CSS_CACHE.write().unwrap() = None;
}
#[get("/css/vaultwarden.css")] #[get("/css/vaultwarden.css")]
fn vaultwarden_css() -> Cached<Css<String>> { fn vaultwarden_css() -> EtagCached<Css<String>> {
// If reload_templates is false, and we already have the CSS Cached, return this
if !CONFIG.reload_templates()
&& let Some(cached) = CSS_CACHE.read().unwrap().as_ref()
{
return EtagCached::new(Css(cached.css.clone()), &cached.etag);
}
// Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS
let css_options = json!({ let css_options = json!({
"emergency_access_allowed": CONFIG.emergency_access_allowed(), "emergency_access_allowed": CONFIG.emergency_access_allowed(),
"load_user_scss": true, "load_user_scss": true,
@ -112,8 +135,18 @@ fn vaultwarden_css() -> Cached<Css<String>> {
} }
}; };
// Cache for one day should be enough and not too much let etag = sha256_hex(css.as_bytes());
Cached::ttl(Css(css), 86_400, false) let cached = Arc::new(CssCache {
css,
etag,
});
if !CONFIG.reload_templates() {
*CSS_CACHE.write().unwrap() = Some(Arc::clone(&cached));
}
// Etag Caching will let the browser send us an etag to verify and send new content if needed
EtagCached::new(Css(cached.css.clone()), &cached.etag)
} }
#[get("/")] #[get("/")]

58
src/auth.rs

@ -10,6 +10,7 @@ use std::{
}; };
use chrono::{DateTime, TimeDelta, Utc}; use chrono::{DateTime, TimeDelta, Utc};
use ipnet::IpNet;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, errors::ErrorKind}; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, errors::ErrorKind};
use num_traits::FromPrimitive; use num_traits::FromPrimitive;
use openssl::rsa::Rsa; use openssl::rsa::Rsa;
@ -1054,12 +1055,44 @@ pub struct ClientIp {
pub ip: IpAddr, pub ip: IpAddr,
} }
/// Parses a single entry of `ip_header_trusted_proxies`, which can be a CIDR range or a plain IP.
pub fn parse_trusted_proxy(entry: &str) -> Option<IpNet> {
let entry = entry.trim();
match entry.parse::<IpNet>() {
Ok(net) => Some(net),
// Without a prefix length it is a single address, which is a valid way to write this.
Err(_) => entry.parse::<IpAddr>().ok().map(IpNet::from),
}
}
/// The client IP header can be set by anyone able to reach us, so only accept it from a proxy we trust.
fn ip_header_is_trusted(remote: Option<IpAddr>) -> bool {
let trusted = CONFIG.ip_header_trusted_proxies();
let trusted = trusted.trim();
if trusted.eq_ignore_ascii_case("all") {
return true;
}
let Some(remote) = remote else {
return false;
};
// A dual stack listener reports IPv4 clients as IPv4-mapped IPv6, which `is_global()` reports as
// non global. That is what we want when blocking outgoing requests, but here it would trust them.
let remote = remote.to_canonical();
if trusted.eq_ignore_ascii_case("local") {
return !crate::util::is_global(remote);
}
trusted.split(',').filter_map(parse_trusted_proxy).any(|net| net.contains(&remote))
}
#[rocket::async_trait] #[rocket::async_trait]
impl<'r> FromRequest<'r> for ClientIp { impl<'r> FromRequest<'r> for ClientIp {
type Error = (); type Error = ();
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> { async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let ip = if CONFIG._ip_header_enabled() { let remote = req.remote().map(|r| r.ip());
let ip = if CONFIG._ip_header_enabled() && ip_header_is_trusted(remote) {
req.headers().get_one(&CONFIG.ip_header()).and_then(|ip| { req.headers().get_one(&CONFIG.ip_header()).and_then(|ip| {
match ip.find(',') { match ip.find(',') {
Some(idx) => &ip[..idx], Some(idx) => &ip[..idx],
@ -1070,10 +1103,15 @@ impl<'r> FromRequest<'r> for ClientIp {
.ok() .ok()
}) })
} else { } else {
if CONFIG._ip_header_enabled() && req.headers().get_one(&CONFIG.ip_header()).is_some() {
// Log the canonical IP, which is what the user filter will need to match against
let remote = remote.map(|ip| ip.to_canonical());
debug!("Ignoring the '{}' header, {remote:?} is not a trusted proxy", CONFIG.ip_header());
}
None None
}; };
let ip = ip.or_else(|| req.remote().map(|r| r.ip())).unwrap_or_else(|| "0.0.0.0".parse().unwrap()); let ip = ip.or(remote).unwrap_or_else(|| "0.0.0.0".parse().unwrap());
Outcome::Success(ClientIp { Outcome::Success(ClientIp {
ip, ip,
@ -1268,20 +1306,8 @@ pub async fn refresh_tokens(
) -> ApiResult<(Device, AuthTokens)> { ) -> ApiResult<(Device, AuthTokens)> {
let refresh_claims = match decode_refresh(refresh_token) { let refresh_claims = match decode_refresh(refresh_token) {
Err(err) => { Err(err) => {
error!("Failed to decode {} refresh_token: {refresh_token}: {err:?}", ip.ip); error!("Failed to decode refresh_token from {}: {err:?}", ip.ip);
//err_silent!(format!("Impossible to read refresh_token: {}", err.message())) err_silent!("Invalid refresh token")
// If the token failed to decode, it was probably one of the old style tokens that was just a Base64 string.
// We can generate a claim for them for backwards compatibility. Note that the password refresh claims don't
// check expiration or issuer, so they're not included here.
RefreshJwtClaims {
nbf: 0,
exp: 0,
iss: String::new(),
sub: AuthMethod::Password,
device_token: refresh_token.into(),
token: None,
}
} }
Ok(claims) => claims, Ok(claims) => claims,
}; };

19
src/auth/send.rs

@ -85,18 +85,8 @@ impl SendTokens {
return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true); return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true);
} }
if let Some(expiration) = send.expiration_date if !send.is_accessible() {
&& Utc::now().naive_utc() >= expiration return Self::invalid_error(&format!("Send {send_id}, not accessible"), "send_id_invalid", true);
{
return Self::invalid_error(&format!("Send {send_id}, expired"), "send_id_invalid", true);
}
if Utc::now().naive_utc() >= send.deletion_date {
return Self::invalid_error(&format!("Send {send_id}, past deletion"), "send_id_invalid", true);
}
if send.disabled {
return Self::invalid_error(&format!("Send {send_id}, disabled"), "send_id_invalid", true);
} }
if send.password_hash.is_some() { if send.password_hash.is_some() {
@ -113,8 +103,9 @@ impl SendTokens {
} }
} }
send.access_count += 1; if !send.register_access(conn).await? {
send.save(conn).await?; return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true);
}
Ok(Self { Ok(Self {
access_claims: generate_send_access_claims(&send_id), access_claims: generate_send_access_claims(&send_id),

34
src/config.rs

@ -659,6 +659,11 @@ make_config! {
events_days_retain: i64, false, option; events_days_retain: i64, false, option;
}, },
client {
/// Control whether clients onboarding interstitials are suppressed |> post-login welcome dialogs, extension install prompts, setup extension redirects, and premium upsell modals
client_suppress_onboarding: bool, true, def, false;
},
/// Advanced settings /// Advanced settings
advanced { advanced {
/// Client IP header |> If not present, the remote IP is used. /// Client IP header |> If not present, the remote IP is used.
@ -666,6 +671,12 @@ make_config! {
ip_header: String, true, def, "X-Real-IP".to_owned(); ip_header: String, true, def, "X-Real-IP".to_owned();
/// Internal IP header property, used to avoid recomputing each time /// Internal IP header property, used to avoid recomputing each time
_ip_header_enabled: bool, false, generated, |c| &c.ip_header.trim().to_lowercase() != "none"; _ip_header_enabled: bool, false, generated, |c| &c.ip_header.trim().to_lowercase() != "none";
/// Trusted proxies |> Which addresses the client IP header is accepted from. Requests from any
/// other address use the remote IP instead, so a client can't spoof the header.
/// Either the string "local" (the default, any non-global address, which covers a reverse proxy
/// running on the same host or container network), the string "all" to accept it from anywhere,
/// or a comma separated list of IPs and CIDR ranges.
ip_header_trusted_proxies: String, true, def, "local".to_owned();
/// Icon service |> The predefined icon services are: internal, bitwarden, duckduckgo, google. /// Icon service |> The predefined icon services are: internal, bitwarden, duckduckgo, google.
/// To specify a custom icon service, set a URL template with exactly one instance of `{}`, /// To specify a custom icon service, set a URL template with exactly one instance of `{}`,
/// which is replaced with the domain. For example: `https://icon.example.com/domain/{}`. /// which is replaced with the domain. For example: `https://icon.example.com/domain/{}`.
@ -768,6 +779,11 @@ make_config! {
/// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2 /// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2
login_ratelimit_max_burst: u32, false, def, 10; login_ratelimit_max_burst: u32, false, def, 10;
/// Seconds between unauthenticated requests |> Number of seconds, on average, between requests from the same IP address to any of the rate limited unauthenticated endpoints
unauthenticated_ratelimit_seconds: u64, false, def, 60;
/// Max burst size for unauthenticated requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `unauthenticated_ratelimit_seconds`. This is shared between several endpoints, so it needs to be more lenient than the login one
unauthenticated_ratelimit_max_burst: u32, false, def, 50;
/// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in /// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in
admin_ratelimit_seconds: u64, false, def, 300; admin_ratelimit_seconds: u64, false, def, 300;
/// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds` /// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds`
@ -954,6 +970,18 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
} }
} }
let trusted_proxies = cfg.ip_header_trusted_proxies.trim();
if !trusted_proxies.eq_ignore_ascii_case("all") && !trusted_proxies.eq_ignore_ascii_case("local") {
for entry in trusted_proxies.split(',').filter(|e| !e.trim().is_empty()) {
if crate::auth::parse_trusted_proxy(entry).is_none() {
err!(format!(
"Invalid IP_HEADER_TRUSTED_PROXIES entry `{}`, expected an IP or CIDR range",
entry.trim()
));
}
}
}
if cfg.password_iterations < 100_000 { if cfg.password_iterations < 100_000 {
err!("PASSWORD_ITERATIONS should be at least 100000 or higher. The default is 600000!"); err!("PASSWORD_ITERATIONS should be at least 100000 or higher. The default is 600000!");
} }
@ -1500,6 +1528,9 @@ impl Config {
let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?; let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
operator.write(&CONFIG_FILENAME, config_str).await?; operator.write(&CONFIG_FILENAME, config_str).await?;
// Invalidate CSS Cache because several config items might have impact on the rendered CSS
crate::api::invalidate_css_cache();
Ok(()) Ok(())
} }
@ -1582,6 +1613,9 @@ impl Config {
writer._overrides = Vec::new(); writer._overrides = Vec::new();
} }
// Invalidate CSS Cache because several config items might have impact on the rendered CSS
crate::api::invalidate_css_cache();
Ok(()) Ok(())
} }

34
src/db/models/cipher.rs

@ -42,13 +42,15 @@ pub struct Cipher {
pub key: Option<String>, pub key: Option<String>,
/* // See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/Vault/Enums/CipherType.cs
Login = 1, // Login = 1,
SecureNote = 2, // SecureNote = 2,
Card = 3, // Card = 3,
Identity = 4, // Identity = 4,
SshKey = 5 // SSHKey = 5
*/ // BankAccount = 6,
// DriversLicense = 7,
// Passport = 8,
pub atype: i32, pub atype: i32,
pub name: String, pub name: String,
pub notes: Option<String>, pub notes: Option<String>,
@ -306,16 +308,6 @@ impl Cipher {
type_data_json = Value::Null; type_data_json = Value::Null;
} }
// Clone the type_data and add some default value.
let mut data_json = type_data_json.clone();
// NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream
// data_json should always contain the following keys with every atype
data_json["fields"] = json!(fields_json);
data_json["name"] = json!(self.name);
data_json["notes"] = json!(self.notes);
data_json["passwordHistory"] = Value::Array(password_history_json.clone());
let collection_ids = if let Some(cipher_sync_data) = cipher_sync_data { let collection_ids = if let Some(cipher_sync_data) = cipher_sync_data {
if let Some(cipher_collections) = cipher_sync_data.cipher_collections.get(&self.uuid) { if let Some(cipher_collections) = cipher_sync_data.cipher_collections.get(&self.uuid) {
Cow::from(cipher_collections) Cow::from(cipher_collections)
@ -355,8 +347,6 @@ impl Cipher {
"notes": self.notes, "notes": self.notes,
"fields": fields_json, "fields": fields_json,
"data": data_json,
"passwordHistory": password_history_json, "passwordHistory": password_history_json,
// All Cipher types are included by default as null, but only the matching one will be populated // All Cipher types are included by default as null, but only the matching one will be populated
@ -365,6 +355,9 @@ impl Cipher {
"card": null, "card": null,
"identity": null, "identity": null,
"sshKey": null, "sshKey": null,
"bankAccount": null,
"driversLicense": null,
"passport": null,
}); });
// These values are only needed for user/default syncs // These values are only needed for user/default syncs
@ -404,6 +397,9 @@ impl Cipher {
3 => "card", 3 => "card",
4 => "identity", 4 => "identity",
5 => "sshKey", 5 => "sshKey",
6 => "bankAccount",
7 => "driversLicense",
8 => "passport",
_ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)), _ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)),
}; };

47
src/db/models/collection.rs

@ -21,6 +21,7 @@ use super::{
User, UserId, User, UserId,
}; };
// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = collections)] #[diesel(table_name = collections)]
#[diesel(treat_none_as_null = true)] #[diesel(treat_none_as_null = true)]
@ -71,6 +72,11 @@ impl Collection {
"id": self.uuid, "id": self.uuid,
"organizationId": self.org_uuid, "organizationId": self.org_uuid,
"name": self.name, "name": self.name,
// Collection types are either 0: SharedCollection or 1: DefaultUserCollection, of which we do not yet support DefaultUserCollection.
// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Enums/CollectionType.cs
"type": 0,
// This is only used together with MyItems/DefaultUserCollection, which we do not yet support.
"defaultUserCollectionEmail": null,
"object": "collection", "object": "collection",
}) })
} }
@ -623,6 +629,47 @@ impl Collection {
pub async fn is_manageable_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { pub async fn is_manageable_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool {
Self::is_coll_manageable_by_user(&self.uuid, user_uuid, conn).await Self::is_coll_manageable_by_user(&self.uuid, user_uuid, conn).await
} }
// Whether the user has manage access to at least one collection in the org, directly or via a
// group. Org-scoped counterpart of is_coll_manageable_by_user.
pub async fn has_manageable_collection_by_user(
org_uuid: &OrganizationId,
user_uuid: &UserId,
conn: &DbConn,
) -> bool {
let org_uuid = org_uuid.to_string();
let user_uuid = user_uuid.to_string();
conn.run(move |conn| {
collections::table
.left_join(
users_collections::table.on(users_collections::collection_uuid
.eq(collections::uuid)
.and(users_collections::user_uuid.eq(user_uuid.clone()))),
)
.left_join(
users_organizations::table.on(collections::org_uuid
.eq(users_organizations::org_uuid)
.and(users_organizations::user_uuid.eq(user_uuid))),
)
.left_join(groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)))
.left_join(
collections_groups::table.on(collections_groups::groups_uuid
.eq(groups_users::groups_uuid)
.and(collections_groups::collections_uuid.eq(collections::uuid))),
)
.filter(collections::org_uuid.eq(&org_uuid))
.filter(
// Manage permission on a collection assigned directly or via a group.
users_collections::manage.eq(true).or(collections_groups::manage.eq(true)),
)
.count()
.first::<i64>(conn)
.ok()
.unwrap_or(0)
!= 0
})
.await
}
} }
/// Database methods /// Database methods

10
src/db/models/event.rs

@ -298,12 +298,16 @@ impl Event {
) -> Vec<Self> { ) -> Vec<Self> {
conn.run(move |conn| { conn.run(move |conn| {
event::table event::table
.inner_join(users_organizations::table.on(users_organizations::uuid.eq(member_uuid))) .inner_join(
users_organizations::table
.on(users_organizations::uuid.eq(member_uuid).and(users_organizations::org_uuid.eq(org_uuid))),
)
.filter(event::org_uuid.eq(org_uuid)) .filter(event::org_uuid.eq(org_uuid))
.filter(event::event_date.between(start, end)) .filter(event::event_date.between(start, end))
.filter( .filter(
event::user_uuid event::org_user_uuid
.eq(users_organizations::user_uuid.nullable()) .eq(member_uuid)
.or(event::user_uuid.eq(users_organizations::user_uuid.nullable()))
.or(event::act_user_uuid.eq(users_organizations::user_uuid.nullable())), .or(event::act_user_uuid.eq(users_organizations::user_uuid.nullable())),
) )
.select(event::all_columns) .select(event::all_columns)

4
src/db/models/group.rs

@ -271,7 +271,9 @@ impl Group {
groups::table groups::table
.inner_join(groups_users::table.on(groups_users::groups_uuid.eq(groups::uuid))) .inner_join(groups_users::table.on(groups_users::groups_uuid.eq(groups::uuid)))
.inner_join( .inner_join(
users_organizations::table.on(users_organizations::uuid.eq(groups_users::users_organizations_uuid)), users_organizations::table.on(users_organizations::uuid
.eq(groups_users::users_organizations_uuid)
.and(users_organizations::org_uuid.eq(groups::organizations_uuid))),
) )
.filter(users_organizations::user_uuid.eq(user_uuid)) .filter(users_organizations::user_uuid.eq(user_uuid))
.filter(groups::organizations_uuid.eq(org_uuid)) .filter(groups::organizations_uuid.eq(org_uuid))

13
src/db/models/organization.rs

@ -217,11 +217,18 @@ impl Organization {
"useSecretsManager": false, // Not supported (Not AGPLv3 Licensed) "useSecretsManager": false, // Not supported (Not AGPLv3 Licensed)
"selfHost": true, "selfHost": true,
"useApi": true, "useApi": true,
"useDisableSMAdsForUsers": true, // Hide Secrets Manager ads
"useInviteLinks": false, // Not (yet) supported
"useMyItems": false, // Not (yet) supported
"useOrganizationDomains": false, // Not supported (Linked to SSO)
"usePam": false, // Not supported
"usePhishingBlocker": false,
"hasPublicAndPrivateKeys": self.private_key.is_some() && self.public_key.is_some(), "hasPublicAndPrivateKeys": self.private_key.is_some() && self.public_key.is_some(),
"useResetPassword": CONFIG.mail_enabled(), "useResetPassword": CONFIG.mail_enabled(),
"allowAdminAccessToAllCollectionItems": true, "allowAdminAccessToAllCollectionItems": true,
"limitCollectionCreation": true, "limitCollectionCreation": true,
"limitCollectionDeletion": true, "limitCollectionDeletion": true,
"limitItemDeletion": false,
"businessName": self.name, "businessName": self.name,
"businessAddress1": null, "businessAddress1": null,
@ -495,6 +502,12 @@ impl Membership {
"useActivateAutofillPolicy": false, "useActivateAutofillPolicy": false,
"useAdminSponsoredFamilies": false, "useAdminSponsoredFamilies": false,
"useRiskInsights": false, // Not supported (Not AGPLv3 Licensed) "useRiskInsights": false, // Not supported (Not AGPLv3 Licensed)
"useDisableSMAdsForUsers": true, // Hide Secrets Manager ads
"useInviteLinks": false, // Not (yet) supported
"useMyItems": false, // Not (yet) supported
"useOrganizationDomains": false, // Not supported (Linked to SSO)
"usePam": false, // Not supported
"usePhishingBlocker": false,
"organizationUserId": self.uuid, "organizationUserId": self.uuid,
"providerId": null, "providerId": null,

47
src/db/models/send.rs

@ -231,6 +231,53 @@ impl Send {
} }
} }
/// Registers an access, incrementing `access_count` only while below `max_access_count`.
/// Returns false when the limit was already reached. The check and the increment are a single
/// statement, otherwise concurrent accesses can both pass the check and exceed the limit.
pub async fn register_access(&mut self, conn: &DbConn) -> Result<bool, crate::Error> {
self.update_users_revision(conn).await;
let revision_date = Utc::now().naive_utc();
let uuid = self.uuid.clone();
let updated = conn
.run(move |conn| {
diesel::update(sends::table)
.filter(sends::uuid.eq(uuid))
.filter(
sends::max_access_count
.is_null()
.or(sends::access_count.nullable().lt(sends::max_access_count)),
)
.set((sends::access_count.eq(sends::access_count + 1), sends::revision_date.eq(revision_date)))
.execute(conn)
})
.await?;
if updated == 0 {
return Ok(false);
}
self.access_count += 1;
self.revision_date = revision_date;
Ok(true)
}
/// Whether the Send is currently within its validity window: not disabled, not past its
/// expiration date, and not past its deletion date. Does not consider `max_access_count`
/// (consumed at token issuance) or the password.
pub fn is_accessible(&self) -> bool {
let now = Utc::now().naive_utc();
if self.disabled {
return false;
}
if let Some(expiration) = self.expiration_date
&& now >= expiration
{
return false;
}
now < self.deletion_date
}
pub async fn delete(&self, conn: &DbConn) -> EmptyResult { pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
self.update_users_revision(conn).await; self.update_users_revision(conn).await;

17
src/db/models/user.rs

@ -268,8 +268,25 @@ impl User {
UserStatus::Enabled UserStatus::Enabled
}; };
let account_keys = if self.private_key.is_some() {
json!({
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": self.private_key,
"publicKey": self.public_key,
"signedPublicKey": null,
"object": "publicKeyEncryptionKeyPair",
},
"securityState": null,
"signatureKeyPair": null,
"object": "privateKeys"
})
} else {
Value::Null
};
json!({ json!({
"_status": status as i32, "_status": status as i32,
"accountKeys": account_keys,
"id": self.uuid, "id": self.uuid,
"name": self.name, "name": self.name,
"email": self.email, "email": self.email,

2
src/error.rs

@ -58,7 +58,7 @@ use serde_json::{Error as SerdeErr, Value};
use std::io::Error as IoErr; use std::io::Error as IoErr;
use std::time::SystemTimeError as TimeErr; use std::time::SystemTimeError as TimeErr;
use webauthn_rs::prelude::WebauthnError as WebauthnErr; use webauthn_rs::prelude::WebauthnError as WebauthnErr;
use yubico::yubicoerror::YubicoError as YubiErr; use yubico_ng::error::YubicoError as YubiErr;
#[derive(Serialize)] #[derive(Serialize)]
pub struct Empty {} pub struct Empty {}

35
src/http_client.rs

@ -174,6 +174,27 @@ pub enum CustomHttpClientError {
} }
impl CustomHttpClientError { impl CustomHttpClientError {
/// Attach the domain that resolved to this address, which `should_block_host()` can't know.
fn with_domain(self, name: &str) -> Self {
match self {
Self::NonGlobalIp {
ip,
..
} => Self::NonGlobalIp {
domain: Some(name.to_owned()),
ip,
},
Self::Blocked {
domain,
} => Self::Blocked {
domain: format!("{name} ({domain})"),
},
other @ Self::Invalid {
..
} => other,
}
}
pub fn downcast_ref(e: &dyn std::error::Error) -> Option<&Self> { pub fn downcast_ref(e: &dyn std::error::Error) -> Option<&Self> {
let mut source = e.source(); let mut source = e.source();
@ -285,14 +306,12 @@ fn pre_resolve(name: &str, enforce_block: bool) -> Result<(), CustomHttpClientEr
} }
fn post_resolve(name: &str, ip: IpAddr) -> Result<(), CustomHttpClientError> { fn post_resolve(name: &str, ip: IpAddr) -> Result<(), CustomHttpClientError> {
if should_block_ip(ip) { let host: Host<&str> = match ip {
Err(CustomHttpClientError::NonGlobalIp { IpAddr::V4(ip) => Host::Ipv4(ip),
domain: Some(name.to_owned()), IpAddr::V6(ip) => Host::Ipv6(ip),
ip, };
})
} else { should_block_host(&host).map_err(|e| e.with_domain(name))
Ok(())
}
} }
impl Resolve for CustomDns { impl Resolve for CustomDns {

15
src/mail.rs

@ -307,9 +307,18 @@ pub async fn send_invite(
if CONFIG.sso_enabled() && CONFIG.sso_only() { if CONFIG.sso_enabled() && CONFIG.sso_only() {
query_params.append_pair("orgSsoIdentifier", &org_id); query_params.append_pair("orgSsoIdentifier", &org_id);
} }
if user.private_key.is_some() {
query_params.append_pair("orgUserHasExistingUser", "true"); // The web vault requires both of these parameters to be present.
} // If either is missing it rejects the invite client-side, before any
// request reaches the server, showing only "Unable to accept invitation".
query_params.append_pair("initOrganization", "false");
let org_user_has_existing_user = if user.private_key.is_some() {
"true"
} else {
"false"
};
query_params.append_pair("orgUserHasExistingUser", org_user_has_existing_user);
} }
let Some(query_string) = query.query() else { let Some(query_string) = query.query() else {

2
src/main.rs

@ -2,7 +2,7 @@
// The recursion_limit is mainly triggered by the json!() macro. // The recursion_limit is mainly triggered by the json!() macro.
// The more key/value pairs there are the more recursion occurs. // The more key/value pairs there are the more recursion occurs.
// We want to keep this as low as possible! // We want to keep this as low as possible!
#![recursion_limit = "165"] #![recursion_limit = "192"]
// When enabled use MiMalloc as malloc instead of the default malloc // When enabled use MiMalloc as malloc instead of the default malloc
#[cfg(feature = "enable_mimalloc")] #[cfg(feature = "enable_mimalloc")]

18
src/ratelimit.rs

@ -18,6 +18,24 @@ static LIMITER_ADMIN: LazyLock<Limiter> = LazyLock::new(|| {
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst)) RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst))
}); });
static LIMITER_UNAUTHENTICATED: LazyLock<Limiter> = LazyLock::new(|| {
let seconds = Duration::from_secs(CONFIG.unauthenticated_ratelimit_seconds());
let burst = NonZeroU32::new(CONFIG.unauthenticated_ratelimit_max_burst())
.expect("Non-zero unauthenticated ratelimit burst");
RateLimiter::keyed(
Quota::with_period(seconds).expect("Non-zero unauthenticated ratelimit seconds").allow_burst(burst),
)
});
pub fn check_limit_unauthenticated(ip: &IpAddr) -> Result<(), Error> {
match LIMITER_UNAUTHENTICATED.check_key(ip) {
Ok(()) => Ok(()),
Err(_e) => {
err_code!("Too many requests", 429);
}
}
}
pub fn check_limit_login(ip: &IpAddr) -> Result<(), Error> { pub fn check_limit_login(ip: &IpAddr) -> Result<(), Error> {
match LIMITER_LOGIN.check_key(ip) { match LIMITER_LOGIN.check_key(ip) {
Ok(()) => Ok(()), Ok(()) => Ok(()),

2
src/sso_client.rs

@ -95,7 +95,9 @@ impl<'c> AsyncHttpClient<'c> for OidcHttpClient {
} }
let body = response.bytes().await.map_err(Box::new)?; let body = response.bytes().await.map_err(Box::new)?;
if CONFIG.sso_debug_tokens() {
debug!("Response body {}", String::from_utf8_lossy(&body)); debug!("Response body {}", String::from_utf8_lossy(&body));
}
builder.body(body.to_vec()).map_err(HttpClientError::Http) builder.body(body.to_vec()).map_err(HttpClientError::Http)
}) })
} }

3
src/static/scripts/admin.js

@ -1,6 +1,5 @@
"use strict"; "use strict";
/* eslint-env es2017, browser */ /* exported BASE_URL, _post, _delete */
/* exported BASE_URL, _post _delete */
function getBaseUrl() { function getBaseUrl() {
// If the base URL is `https://vaultwarden.example.com/base/path/admin/`, // If the base URL is `https://vaultwarden.example.com/base/path/admin/`,

36
src/static/scripts/admin_diagnostics.js

@ -1,5 +1,4 @@
"use strict"; "use strict";
/* eslint-env es2017, browser */
/* global BASE_URL:readable, bootstrap:readable */ /* global BASE_URL:readable, bootstrap:readable */
var dnsCheck = false; var dnsCheck = false;
@ -80,37 +79,44 @@ async function generateSupportString(event, dj) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
// Health check Markdown emoji, if something is a failure or not
const chk = v => v ? "true :white_check_mark:" : "false :x:";
// Yes/No Markdown emoji, if something is not a failure, but just yes or no
const yn = v => v ? "yes :heavy_plus_sign:" : "no :heavy_minus_sign:";
const template_overrides = dj.template_overrides !== "" ? ` (${dj.template_overrides})` : "";
let supportString = "### Your environment (Generated via diagnostics page)\n\n"; let supportString = "### Your environment (Generated via diagnostics page)\n\n";
supportString += `* Vaultwarden version: v${dj.current_release}\n`; supportString += `* Vaultwarden version: v${dj.current_release}\n`;
supportString += `* Web-vault version: v${dj.active_web_release}\n`; supportString += `* Web-vault version: v${dj.active_web_release}\n`;
supportString += `* OS/Arch: ${dj.host_os}/${dj.host_arch}\n`; supportString += `* OS/Arch: ${dj.host_os}/${dj.host_arch}\n`;
supportString += `* Running within a container: ${dj.running_within_container} (Base: ${dj.container_base_image})\n`; supportString += `* Running within a container: ${yn(dj.running_within_container)} (Base: ${dj.container_base_image})\n`;
supportString += `* Database type: ${dj.db_type}\n`; supportString += `* Database type: ${dj.db_type}\n`;
supportString += `* Database version: ${dj.db_version}\n`; supportString += `* Database version: ${dj.db_version}\n`;
supportString += `* Uses config.json: ${dj.overrides !== ""}\n`; supportString += `* Uses config.json: ${yn(dj.overrides !== "")}\n`;
supportString += `* Uses a reverse proxy: ${dj.ip_header_exists}\n`; supportString += `* Uses custom templates: ${yn(dj.template_overrides !== "")}${template_overrides}\n`;
supportString += `* Uses a reverse proxy: ${yn(dj.ip_header_exists)}\n`;
if (dj.ip_header_exists) { if (dj.ip_header_exists) {
supportString += `* IP Header check: ${dj.ip_header_match} (${dj.ip_header_name})\n`; supportString += `* IP Header check: ${chk(dj.ip_header_match)} (${dj.ip_header_name})\n`;
} }
supportString += `* Internet access: ${dj.has_http_access}\n`; supportString += `* Internet access: ${chk(dj.has_http_access)}\n`;
supportString += `* Internet access via a proxy: ${dj.uses_proxy}\n`; supportString += `* Internet access via a proxy: ${yn(dj.uses_proxy)}\n`;
supportString += `* DNS Check: ${dnsCheck}\n`; supportString += `* DNS Check: ${chk(dnsCheck)}\n`;
if (dj.tz_env !== "") { if (dj.tz_env !== "") {
supportString += `* TZ environment: ${dj.tz_env}\n`; supportString += `* TZ environment: ${dj.tz_env}\n`;
} }
supportString += `* Browser/Server Time Check: ${timeCheck}\n`; supportString += `* Browser/Server Time Check: ${chk(timeCheck)}\n`;
supportString += `* Server/NTP Time Check: ${ntpTimeCheck}\n`; supportString += `* Server/NTP Time Check: ${chk(ntpTimeCheck)}\n`;
supportString += `* Domain Configuration Check: ${domainCheck}\n`; supportString += `* Domain Configuration Check: ${chk(domainCheck)}\n`;
supportString += `* HTTPS Check: ${httpsCheck}\n`; supportString += `* HTTPS Check: ${chk(httpsCheck)}\n`;
if (dj.enable_websocket) { if (dj.enable_websocket) {
supportString += `* Websocket Check: ${websocketCheck}\n`; supportString += `* Websocket Check: ${chk(websocketCheck)}\n`;
} else { } else {
supportString += "* Websocket Check: disabled\n"; supportString += "* Websocket Check: disabled\n";
} }
supportString += `* HTTP Response Checks: ${httpResponseCheck}\n`; supportString += `* HTTP Response Checks: ${chk(httpResponseCheck)}\n`;
if (dj.invalid_feature_flags != "") { if (dj.invalid_feature_flags != "") {
supportString += `* Invalid feature flags: true\n`; supportString += "* Invalid feature flags: true\n";
} }
const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, { const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, {

3
src/static/scripts/admin_organizations.js

@ -1,6 +1,5 @@
"use strict"; "use strict";
/* eslint-env es2017, browser, jquery */ /* global jQuery, _post:readable, BASE_URL:readable, reload:readable, jdenticon:readable */
/* global _post:readable, BASE_URL:readable, reload:readable, jdenticon:readable */
function deleteOrganization(event) { function deleteOrganization(event) {
event.preventDefault(); event.preventDefault();

1
src/static/scripts/admin_settings.js

@ -1,5 +1,4 @@
"use strict"; "use strict";
/* eslint-env es2017, browser */
/* global _post:readable, BASE_URL:readable */ /* global _post:readable, BASE_URL:readable */
function smtpTest(event) { function smtpTest(event) {

3
src/static/scripts/admin_users.js

@ -1,6 +1,5 @@
"use strict"; "use strict";
/* eslint-env es2017, browser, jquery */ /* global jQuery, _post:readable, _delete:readable, BASE_URL:readable, reload:readable, jdenticon:readable */
/* global _post:readable, _delete:readable BASE_URL:readable, reload:readable, jdenticon:readable */
function deleteUser(event) { function deleteUser(event) {
event.preventDefault(); event.preventDefault();

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

@ -77,6 +77,16 @@
<span class="d-block"><b>No</b></span> <span class="d-block"><b>No</b></span>
{{/unless}} {{/unless}}
</dd> </dd>
<dt class="col-sm-5">Uses custom templates</dt>
<dd class="col-sm-7">
{{#if page_data.template_overrides}}
<span class="d-inline"><b>Yes</b></span>
<span class="badge bg-info text-dark abbr-badge" title="Custom template files are used.&#013;&#010;{{page_data.template_overrides}}">Details</span>
{{/if}}
{{#unless page_data.template_overrides}}
<span class="d-block"><b>No</b></span>
{{/unless}}
</dd>
<dt class="col-sm-5">Uses a reverse proxy</dt> <dt class="col-sm-5">Uses a reverse proxy</dt>
<dd class="col-sm-7"> <dd class="col-sm-7">
{{#if page_data.ip_header_exists}} {{#if page_data.ip_header_exists}}

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

@ -116,8 +116,8 @@ app-security > app-two-factor-setup > form {
} }
/* Hide unsupported Custom Role options */ /* Hide unsupported Custom Role options */
bit-dialog div.tw-ml-4:has(bit-form-control input), :is(bit-dialog, [bit-dialog]) div.tw-ml-4:has(bit-form-control input),
bit-dialog div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) { :is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) {
@extend %vw-hide; @extend %vw-hide;
} }

4
src/storage.rs

@ -67,7 +67,7 @@ pub(crate) fn operator_for_path(path: &str) -> Result<opendal::Operator, crate::
s3::operator_for_path(path)? s3::operator_for_path(path)?
} else { } else {
let builder = opendal::services::Fs::default().root(path); let builder = opendal::services::Fs::default().root(path);
opendal::Operator::new(builder)?.finish() opendal::Operator::new(builder)?
}; };
OPERATORS_BY_PATH.insert(path.to_owned(), operator.clone()); OPERATORS_BY_PATH.insert(path.to_owned(), operator.clone());
@ -236,7 +236,7 @@ mod s3 {
builder.credential_provider_chain(ProvideCredentialChain::new().push(OpenDALS3CredentialProvider)); builder.credential_provider_chain(ProvideCredentialChain::new().push(OpenDALS3CredentialProvider));
} }
Ok(opendal::Operator::new(builder)?.finish()) Ok(opendal::Operator::new(builder)?)
} }
fn uri_has_option(uri: &opendal::OperatorUri, names: &[&str]) -> bool { fn uri_has_option(uri: &opendal::OperatorUri, names: &[&str]) -> bool {

38
src/util.rs

@ -257,6 +257,44 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for Cache
} }
} }
pub struct EtagCached<R> {
response: R,
etag: String,
}
impl<R> EtagCached<R> {
/// An `etag` response should always be quoted
pub fn new(response: R, etag: &str) -> Self {
Self {
response,
etag: format!("\"{etag}\""),
}
}
}
impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for EtagCached<R> {
fn respond_to(self, request: &'r Request<'_>) -> response::Result<'static> {
// Check and validate a `If-None-Match` ETag header
// Multiple tags could be returned for the same URI if the browser has multiple versions cached
// Also, weak tags are prefixed with `W/`, but ETags are always weak, so just strip it too before comparing
let etag_matches = request
.headers()
.get_one("If-None-Match")
.is_some_and(|v| v.split(',').any(|t| t.trim().trim_start_matches("W/") == self.etag));
let mut res = if etag_matches {
Response::build().status(Status::NotModified).ok()?
} else {
self.response.respond_to(request)?
};
// Both 200 (OK) and 304 (Not Modified) need to return the etag and cache-control
res.set_raw_header("Etag", self.etag);
res.set_raw_header("Cache-Control", "public, no-cache");
Ok(res)
}
}
// Log all the routes from the main paths list, and the attachments endpoint // Log all the routes from the main paths list, and the attachments endpoint
// Effectively ignores, any static file route, and the alive endpoint // Effectively ignores, any static file route, and the alive endpoint
const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"]; const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"];

Loading…
Cancel
Save