Compare commits

...
49 Commits
Author SHA1 Message Date
Daniel García 17cf7574b9 Implement V2 registration support 2026-10-05 22:12:24 +02:00
Daniel García a0a20411f0 Support blob-encrypted ciphers and match upstream's key id checks 2026-10-05 21:56:07 +02:00
Mathijs van Veluw 7c188967e7 Update crates, Rust and other dependencies (#7814)
- Update Rust to v1.99.0
- Updated GitHub Actions
- Updated DataTables

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-10-05 14:39:21 +02:00
Daniel García 895ec2b1e7 Align API behaviour with upstream and return errors on malformed input (#7810)
- Attachments: reject a declared v2 attachment size above the upload limit,
  keep display sizes within the largest unit, and make download links valid
  for 1 minute like upstream.
- Ciphers: moving a personal cipher into an organization needs collection
  write access or full access, like upstream.
- Events: /events/collect only accepts the client event types upstream accepts.
- Client IP: for list headers like X-Forwarded-For, use the rightmost address
  that isn't in IP_HEADER_TRUSTED_PROXIES, reading every occurrence.
- Prelogin: unknown emails get stable KDF settings from upstream's list.
- Login with device: an auth request can be used for one login, like upstream.
- Directory Connector import: never revokes, restores or removes Owners,
  like upstream.
- SSO: check the PKCE verifier again when the code exchange is reused during
  2FA, and accept the same redirect URIs as upstream (desktop localhost
  callback for AppImage and dev builds, CLI on ports 8065-8070).
- Identity: send the merged master password policy in the 2FA response and
  use PascalCase policy keys in identity responses, like upstream.
  Organization API key login no longer needs device fields. Recovery code
  login normalises the code, uses upstream's error message, logs a failed 2FA
  event and sends a "two-step login recovered" email.
- Add GET /api/accounts/keys.
- Return errors instead of panicking on malformed event dates, cipher data,
  key rotation cipher ids, bulk confirm entries and YubiKey OTPs.
2026-10-05 14:38:01 +02:00
Daniel García 1f99d9d276 Remove legacy API endpoints and compatibility code dropped upstream (#7809)
Remove endpoints, response fields, feature flags and compatibility code
that upstream Bitwarden has removed and that no current client uses:

- Legacy POST /identity/accounts/register and POST /api/accounts/prelogin;
  register/finish is rate limited like the other unauthenticated endpoints.
- Legacy organization routes: PUT groups/{id}/users, PUT policies/{type}/vnext,
  PUT users/{id}/reset-password, GET billing/metadata, and the POST aliases of
  the organization, collection, group and member PUT/DELETE routes.
- POST /two-factor/disable (PUT stays).
- ResetMasterPassword, captchaBypassToken, the legacy registration name field,
  userIsManagedByOrganization/managedByOrganization and accessAll.
- The automatic email 2FA code for clients older than 2025.5.0.
- DUO_USE_IFRAME and the Duo Traditional Prompt code.
- Experimental client feature flags that no client reads anymore.
- Web vault CSS selectors for web vaults older than 2025.5.1.

Also accept the PascalCase fields Android 2026.9.0+ sends on registration
and password change, and align a few account, login/2FA and organization
checks with upstream.

Adds tests for the registration body formats.
2026-10-04 22:16:55 +02:00
Daniel García f4f1a8e105 Send API cleanup: remove legacy endpoints and align access with upstream (#7806) 2026-10-03 23:05:56 +02:00
1c89e1772b 2fa email fallback need a verified email (#7770)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
Co-authored-by: Mathijs van Veluw <black.dex@gmail.com>
2026-10-03 14:01:26 +02:00
Tom 3714b504f6 Fix Clippy warnings across all targets (#7782) 2026-10-03 13:50:29 +02:00
Tom f9f011d50c Hide the whole change-email section when EMAIL_CHANGE_ALLOWED is false (#7759) 2026-10-03 13:49:53 +02:00
Julian Speckmann cb89088bc3 Add Windows native credential sync to supported feature flags (#7798) 2026-10-03 13:49:44 +02:00
Pier Carlo Cadoppi 4444442075 Add undetermined-cipher-scenario-logic feature flag (#7802)
Introduced in Browser 2026.2.0 via bitwarden/clients#18395. Rewrites the
post-submit add/update login notification triggering logic, deferring
cipher-type inference until the vault is unlocked. Client-side only.
2026-10-03 13:49:37 +02:00
Mathijs van Veluw 061694d0cb Fix cortex-a53 build issues when using xx-cargo (#7774)
When using xx-cargo it uses a different linker then the default one.
This causes some default fixes Debian/Ubuntu add for the Cortex-A53
architectures to not be included.

This commit fixes that by checking if the target architecture is arm64
and if so, will add the needed fix argument. This should prevent issues
reported here #7754.

We can not say this with 100% certainty since this bug sometimes appears
and sometimes it does not.

Fixes #7754

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-09-25 21:53:06 +02:00
Daniel García 32098ca7d1 Require confirmed membership for user access checks, not for admin views (#7763) 2026-09-23 23:22:43 +02:00
Abhi ShandilyaandDaniel García d2660324e6 Fix revoked org members retaining access to org ciphers (#7554)
The cipher access restriction queries (direct collection access, group
collection access, and full access via groups) never checked the
organization membership status. Since users_collections and groups_users
rows are kept when a membership is revoked, revoked (and not yet
confirmed) members kept read, write, delete, attachment and
collection-move access to org ciphers via the direct-by-UUID endpoints.

Require a confirmed membership in the cipher's organization in all
three queries, matching the behavior of the sync queries.

Co-authored-by: Daniel García <dani-garcia@users.noreply.github.com>
2026-09-23 19:44:05 +02:00
Stefan Melmuk cc67d644f6 set user_created bool for each separate invitation (#7753) 2026-09-18 20:35:11 +02:00
Berk D. Demir 3347698712 Add pm-34171-card-scanner feature flag (#7477)
Introduced in 2026.4.1 Mobile releases (Android and iOS), enables use of
device camera to autofill a card type item.

N.B. This feature relies on Google ML Kit. F-Droid policy disallows
Google ML Kit. Because of this, F-Droid builds do not ship with card
scanner feature. (See https://github.com/bitwarden/android/pull/6890)
2026-09-18 20:34:41 +02:00
Mathijs van Veluw a24683636a Update Crates, GHA and JS (#7751)
Signed-off-by: BlackDex <black.dex@gmail.com>
2026-09-18 18:44:50 +02:00
Berk D. Demir 64c56411f6 Add pm-32009-new-item-types feature flag (#7478)
Introduced in 2026.4.0 for all clients, enables new item types bank
account, driver's license, and passport.
2026-09-18 16:53:27 +02:00
Tom c3f5477525 Add accepted organization data to sync response (#7666) 2026-09-18 16:53:17 +02:00
TimshelandTimshel 9c8aa2359f User key id (#7693)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-09-18 16:53:06 +02:00
Tom 8719f86fcf Add basic auth response client feature flag (#7745) 2026-09-18 16:52:57 +02:00
Tom fddc8e71be Support the vault banner policy (#7748) 2026-09-18 16:52:49 +02:00
Mathijs van Veluw eb212e23fa Fix archiveDate update (#7722)
When `archiveDate` is set to `null` it should unarchive it for that
specific user, which is what Bitwarden does.

This should fix this by checking and validating if it is `null`

Fixes #7581

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-09-09 18:24:33 +02:00
TimshelandTimshel 25dfedafd7 Use insert_into when possible (#6437)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-09-09 16:43:47 +02:00
Tom e992cbb4f5 Fix iOS registration token response (#7714)
* Return registration token as text/plain for Accept: */*

* fix register verification response content negotiation
2026-09-09 14:31:03 +02:00
Chase DouglasandBlackDex 5b51b60f94 Route service clients through shared HTTP setup (#7639)
* storage: route OpenDAL through HTTP client

OpenDAL 0.58 requires applications to provide an HTTP transport. Its
default installer creates a standalone client, bypassing Vaultwarden
DNS, redirect, proxy, timeout, and request configuration.

Build the client through the internal HTTP interface and inject it into
OpenDAL's public reqwest transport.

* http: honor block setting on redirects

Clients can disable host blocking for administrator-configured private
services. DNS resolution honors this setting, but the redirect policy
still performs config-backed host checks.

Capture the setting in the redirect policy and skip those checks when
blocking is disabled. This also avoids re-entering CONFIG when a remote
configuration request is redirected during startup.

* http: make DNS setup bootstrap-safe

Remote configuration can require an HTTP client while CONFIG is still
initializing. Building the DNS resolver currently reads
CONFIG.dns_prefer_ipv6(), so loading an S3-backed config can deadlock.

Build one resolver without consulting CONFIG. Order addresses for each
lookup using the merged setting when available, falling back to the
environment and then IPv4-first during bootstrap.

* aws: use internal HTTP client

The AWS SDK connector builds a raw reqwest client, bypassing
Vaultwarden TLS, DNS, redirect, proxy, timeout, and request setup.

Construct it through the internal HTTP client interface and retain the
standard ten-second request deadline. Permit private AWS metadata and
service endpoints by disabling non-global IP blocking.

Preserve timeout errors when adapting reqwest failures to the AWS SDK so
the runtime receives the correct connector error category.

* Added comment for the prefer IPv function

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

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
Co-authored-by: BlackDex <black.dex@gmail.com>
2026-09-09 14:30:25 +02:00
Mathijs van Veluw de7abaaafa Update Rust and adjust DockerSettings (#7690)
- Update Rust to v1.98.1 which resolves a build issues with strange
  outcomes
- Adjusted the DockerSettings and render_template to extract the
  `rust_version` from the `rust-toolchain.toml` file. This should
  prevent mismatches and forgetting to update DockerSettings.
- Updated typos in GHA and Pre-Commit
- Updated all possible crates including hickory which has several CVE's
  fixed.

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-09-09 11:51:23 +02:00
niniconi b7667e27bf fix: Correct invalid comment syntax in .dockerignore (#7274)
Docker only recognizes `#` as a valid comment indicator in .dockerignore files. 
Using `//` causes the lines to be incorrectly parsed as glob patterns rather than comments. 
While this may not cause fatal errors if no matching files exist, it is syntactically invalid and could lead to unexpected behavior. Corrected the syntax to use `#`.
2026-09-08 13:42:58 +02:00
Bryan f1c36b8c1d fix(security): rate limit prelogin and auth request endpoints (#7681) 2026-09-08 12:14:16 +02:00
Bryan f1ff613008 fix(security): revoke 2FA remember tokens when credentials or 2FA change (#7682) 2026-09-08 12:14:07 +02:00
TimshelandTimshel 57fbed1bed Support admin reset 2fa (#7435)
* Support admin reset 2fa

* Fix recovery email

---------

Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-09-08 12:14:01 +02:00
The CRahn 277e1536eb Log IP/username on two-factor email-login credential failures (#7654)
The three "Username or password is incorrect" errors in
send_email_login() (email.rs) don't log the client IP or submitted
identifier, unlike the equivalent wrong-password error in
password_login() (identity.rs), which logs both via
format!("IP: {}. Username: {username}.", ip.ip).

This makes the two code paths inconsistent for the same underlying
error, and means log-based tooling that keys on the identity.rs
error's "IP: x.x.x.x" pattern can't do the same for this endpoint.
Bring email.rs's three call sites in line with identity.rs's existing
format. The two email-present branches log IP+Username (the email
submitted); the device-identifier-only branch (SSO path, no email in
scope) logs IP+Device instead of fabricating a username.

Verified: cargo build/test/clippy/fmt all pass with the sqlite feature
(matching one leg of this repo's own CI matrix), including the two
existing unit tests in this file.
2026-09-08 12:13:51 +02:00
Tom 2ffad8775d Add pm-32413-multi-client-password-management feature flag (#7677) 2026-09-08 12:13:40 +02:00
Tom 32d85d03bb Fix organization import failing with missing field groups (#7699) 2026-09-08 12:13:32 +02:00
Daniel García a6c3bd6d18 Update rust docker version (#7689) 2026-09-03 21:17:50 +02:00
Mathijs van Veluw 6729e83521 Misc Updates (#7676)
* Misc Updates

- Update Rust to v1.98.0
- Update all the crates and adjusted code where needed
- Updated JavaScript libraries
  Removed jquery as this isn't needed anymore, adjusted code where needed
- Updated all GitHub Actions
- Fixed nightly clippy lint warnings

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

* Adjust email validation as suggested

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

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-09-03 00:07:00 +02:00
TimshelandTimshel fdc156b247 log_event take enum parameter not i32 (#7656)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-08-29 17:02:26 +02:00
TimshelandTimshel 2073c03092 Add SSO_SIGNUPS_ALLOWED (#7272)
* Add SSO_SIGNUPS_ALLOWED

* Fix regression with domain_allowed in SSO onboarding

---------

Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-08-29 17:02:22 +02:00
TimshelandTimshel 923f5d0b5e Fix migration for MariaDB 12.2.2 (#7265)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-08-29 17:02:01 +02:00
xhon-pelushi 83724b301e Ignore reset-password auto-enroll when mail is disabled (#7585)
Account recovery requires SMTP. When mail is off, treat the organization
reset-password auto-enroll policy as inactive so invite/accept flows are
not forced to supply a reset-password key.

Fixes #7459
2026-08-29 17:01:51 +02:00
Matt Van HornandMatt Van Horn 10e044f563 chore: remove duplicate "the" in ciphers.rs comment (#7254)
`src/api/core/ciphers.rs:170` comment said "similar to the the
userDecryptionOptions" -> "similar to the userDecryptionOptions".

Comment-only.

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-08-29 17:01:45 +02:00
Mathijs van Veluw fa2566d14f Fix password change with newer web-vault (#7634) 2026-08-24 19:38:23 +02:00
Stefan Melmuk 46d71107f5 add dummy revisionDate (#7608) 2026-08-20 22:03:56 +02:00
Patrick Bönisch 9e78911a2f Fix sendmail executable permission check (#7483)
* Fix sendmail executable permission check

* Use access check for sendmail command
2026-08-20 17:34:38 +02:00
lmogthbandAlejandro Olmos 0cefa4cca7 Include user email in successful login logs (#7496)
* Include user email in successful login logs

* Modified disable account log to display Email instead of Display Name

---------

Co-authored-by: Alejandro Olmos <aolmos@trevenque.es>
2026-08-07 14:09:43 +02:00
Mathijs van Veluw b30cc08562 Misc fixes and updates (#7558)
* Update GHA and pre-commit

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

* Update admin diagnostics

Added a check if the templates are overridden and return which specific folder, `admin`, `email` or `scss`.
This way we could more quickly point users to possible outdated templates which they are using.

Also updated the Support String to use some emojis so we should be able to quicker see if there is something wrong.
Just checking `true` or `false` could be difficult sometimes, and sometimes what we had as `false` wasn't bad either.

Also adjusted the eslint comments so it will work with the latest version of eslint.

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

* Fix updating collections for a cipher

The newer clients expect a `cipherDetails` response on the `collections-admin` endpoints.
Without it, the client will cause an error and stops handling the update correctly.

This will fix this by returning the cipher json.

Fixes #7545
Fixes #7546

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

* Cache CSS file in a different way

Currently we set a cache ttl of 24 hours, and users need to do a force refresh if there is anything changed to the CSS file.
In the past we have had several issue reported which were related to a still cached CSS file.

This commit will change the caching and also cache the generated CSS file in memory.
Instead of letting the browser cache it for 24 hours we generate an ETag, this is just a hash of the contents.
This ETag is returned by the browser during a request, and we can match this, and if so, just return a `304` `Not Modified`.
If the ETag is not known, we return the new content.

This should make simple refreshes by clients get updated settings or a new version of Vaultwarden which has other CSS entries get updated instantly.
If a user does a hard refresh, we will not receive the ETag and the content will be served.

The same goes if someone has the `reload_templates` feature enabled, since then we should not cache anyway.
If someone adjust settings via the `/admin` interface, the cache will be invalidated and a new CSS will be generated.

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

* Fix showing events for a specific user

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

* Update crates and adjust code.

- Updated opendal and adjusted code where needed.
- Updated yubico_ng and adjusted code where needed.
  This version now supports using an own HttpClient and it pulls in no reqwest dependency anymore.
  Now it will use our own client which uses custom hickory DNS and other features.

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

* Update web-vault to v2026.7.0

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

* Fix hadolint warnings

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

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-08-06 20:22:12 +02:00
TimshelandTimshel 55f883a566 Fix playwright test (#7548)
* Config server setting suppressOnboardingInterstitials

* Backport fix playwright tests

---------

Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-08-05 21:29:41 +02:00
Alex · ASEnough 74ceaf2354 Fix Debian cross-linking with xx-cargo (#7524)
* Fix Debian cross-linking with xx-cargo

* Fix SC2155 in Debian cross builds
2026-08-05 21:29:31 +02:00
Victor J. FoxandClaude Opus 5 2629bcbe13 Always send initOrganization and orgUserHasExistingUser in org invite URL (#7482)
The bundled web vault (2026.6.4) requires seven query parameters in the
accept-organization URL and rejects the invite client-side when any of them is
null, showing only "Unable to accept invitation" without sending a request to
the server.

send_invite() never appended initOrganization, and appended
orgUserHasExistingUser only for users who already had an account, so every
organization invitation e-mail produced a link that could not be accepted.

Web vault 2026.4.1 (shipped with 1.36.0) read these parameters null-safely,
which is why this only appeared in 1.37.0.

Fixes #7481

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 15:13:44 +02:00
130 changed files with 17614 additions and 24587 deletions

No files matched your search

+2 -2
View File
@@ -1,7 +1,7 @@
// Ignore everything
# Ignore everything
*
// Allow what is needed
# Allow what is needed
!.git
!docker/healthcheck.sh
!docker/start.sh
+19 -19
View File
@@ -180,7 +180,7 @@
## Defaults to every minute. Set blank to disable this job.
# AUTH_REQUEST_PURGE_SCHEDULE="30 * * * * *"
##
## Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt.
## Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled.
## Defaults to every minute. Set blank to disable this job.
# DUO_CONTEXT_PURGE_SCHEDULE="30 * * * * *"
#
@@ -316,6 +316,14 @@
## unauthenticated access to potentially sensitive data.
# 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 ###
#########################
@@ -330,6 +338,8 @@
## "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.
## For a list header like X-Forwarded-For, the client IP is the rightmost address that isn't one of
## these proxies, so with several proxies in a row (a CDN in front of nginx, for example) list all of them.
# IP_HEADER_TRUSTED_PROXIES=local
## Icon service
@@ -382,21 +392,13 @@
##
## The following flags are available:
## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0)
## - "ssh-agent": Enable SSH agent support on Desktop. (Desktop >= 2024.12.0)
## - "pm-32413-multi-client-password-management": Enable changing the master password directly in the client. (Desktop/Extension >= 2026.4.0)
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Clients >= 2024.12.0)
## - "pm-25373-windows-biometrics-v2": Enable the new implementation of biometrics on Windows. (Desktop >= 2025.11.0)
## - "pm-26340-linux-biometrics-v2": Enable the new implementation of biometrics on Linux. (Desktop >= 2025.11.0)
## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0)
## - "simple-login-self-host-alias": Enable configuring self-hosted Simple Login alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0)
## - "mutual-tls": Enable the use of mutual TLS on Android (Clients >= 2025.2.0)
## - "cxp-import-mobile": Enable the import via CXP on iOS (Clients >= 2025.9.2)
## - "cxp-export-mobile": Enable the export via CXP on iOS (Clients >= 2025.9.2)
## - "pm-30529-webauthn-related-origins":
## - "desktop-ui-migration-milestone-1": Special feature flag for desktop UI (Desktop >= 2026.2.0)
## - "desktop-ui-migration-milestone-2": Special feature flag for desktop UI (Desktop >= 2026.2.0)
## - "desktop-ui-migration-milestone-3": Special feature flag for desktop UI (Desktop >= 2026.2.0)
## - "desktop-ui-migration-milestone-4": Special feature flag for desktop UI (Desktop >= 2026.2.0)
## - "pm-32009-new-item-types": Enable new item types: Bank Account, Driver's License, and Passport (Clients >= 2026.4.0)
## - "pm-34171-card-scanner": Enable the new card scanner feature on mobile (Android >= 2026.4.1, iOS >= 2026.4.1)
## - "enable-basic-auth-response": Enable HTTP Basic Auth autofill in the browser extension (Browser >= 2026.9.0)
## - "undetermined-cipher-scenario-logic": Enable the rewritten add/update login notification triggering logic in the browser extension (Browser >= 2026.2.0)
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
## Require new device emails. When a user logs in an email is required to be sent.
@@ -510,6 +512,9 @@
## Prevent users from logging in directly without going through SSO
# SSO_ONLY=false
## Allow SSO flow to create account. You probably want to disable it when using a public provider.
# SSO_SIGNUPS_ALLOWED=true
## On SSO Signup if a user with a matching email already exists make the association
# SSO_SIGNUPS_MATCH_EMAIL=true
@@ -572,11 +577,6 @@
# DUO_HOST=<API Hostname>
## After that, you should be able to follow the rest of the guide linked above,
## ignoring the fields that ask for the values that you already configured beforehand.
##
## If you want to attempt to use Duo's 'Traditional Prompt' (deprecated, iframe based) set DUO_USE_IFRAME to 'true'.
## Duo no longer supports this, but it still works for some integrations.
## If you aren't sure, leave this alone.
# DUO_USE_IFRAME=false
## Email 2FA settings
## Email token size
+1 -1
View File
@@ -113,7 +113,7 @@ jobs:
# Enable Rust Caching
- name: Rust Caching
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes.
# Like changing the build host from Ubuntu 20.04 to 22.04 for example.
+3 -3
View File
@@ -20,7 +20,7 @@ jobs:
steps:
# Start Docker Buildx
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
# https://github.com/moby/buildkit/issues/3969
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
with:
@@ -41,12 +41,12 @@ jobs:
# Uses the Docker-based action (hadolint pre-bundled in ghcr.io/hadolint/hadolint:v2.14.0-debian)
# so no binary is downloaded at runtime. Pinned by commit SHA for supply-chain safety.
- name: Run hadolint on Dockerfile.debian
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
with:
dockerfile: docker/Dockerfile.debian
- name: Run hadolint on Dockerfile.alpine
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
with:
dockerfile: docker/Dockerfile.alpine
# End Test Dockerfiles with hadolint
+13 -13
View File
@@ -58,13 +58,13 @@ jobs:
steps:
- name: Initialize QEMU binfmt support
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
with:
platforms: "arm64,arm"
# Start Docker Buildx
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
# https://github.com/moby/buildkit/issues/3969
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
with:
@@ -106,7 +106,7 @@ jobs:
# Login to Docker Hub
- name: Login to Docker Hub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -121,7 +121,7 @@ jobs:
# Login to GitHub Container Registry
- name: Login to GitHub Container Registry
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -137,7 +137,7 @@ jobs:
# Login to Quay.io
- name: Login to Quay.io
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
@@ -185,7 +185,7 @@ jobs:
- name: Bake ${{ matrix.base_image }} containers
id: bake_vw
uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7.3.0
uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0
env:
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
@@ -237,7 +237,7 @@ jobs:
# Upload artifacts to Github Actions and Attest the binaries
- name: Attest binaries
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
@@ -272,7 +272,7 @@ jobs:
# Login to Docker Hub
- name: Login to Docker Hub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -287,7 +287,7 @@ jobs:
# Login to GitHub Container Registry
- name: Login to GitHub Container Registry
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -303,7 +303,7 @@ jobs:
# Login to Quay.io
- name: Login to Quay.io
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
@@ -365,7 +365,7 @@ jobs:
# Attest container images
- name: Attest - docker.io - ${{ matrix.base_image }}
if: ${{ vars.DOCKERHUB_REPO != '' && env.DIGEST_SHA != ''}}
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: ${{ vars.DOCKERHUB_REPO }}
subject-digest: ${{ env.DIGEST_SHA }}
@@ -373,7 +373,7 @@ jobs:
- name: Attest - ghcr.io - ${{ matrix.base_image }}
if: ${{ vars.GHCR_REPO != '' && env.DIGEST_SHA != ''}}
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: ${{ vars.GHCR_REPO }}
subject-digest: ${{ env.DIGEST_SHA }}
@@ -381,7 +381,7 @@ jobs:
- name: Attest - quay.io - ${{ matrix.base_image }}
if: ${{ vars.QUAY_REPO != '' && env.DIGEST_SHA != ''}}
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: ${{ vars.QUAY_REPO }}
subject-digest: ${{ env.DIGEST_SHA }}
+1 -1
View File
@@ -50,6 +50,6 @@ jobs:
severity: CRITICAL,HIGH
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
sarif_file: 'trivy-results.sarif'
+1 -1
View File
@@ -23,4 +23,4 @@ jobs:
# When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too
- name: Spell Check Repo
uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.48.0
uses: crate-ci/typos@00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
with:
# intentionally not scanning the entire repository,
# since it contains integration tests.
+4 -5
View File
@@ -18,9 +18,10 @@ repos:
# When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too
- repo: https://github.com/crate-ci/typos
rev: bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.48.0
rev: 00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3
hooks:
- id: typos
always_run: true
- repo: local
hooks:
@@ -38,8 +39,7 @@ repos:
entry: cargo test
language: system
args: [ "--features", "sqlite,mysql,postgresql", "--" ]
types_or: [ rust, file ]
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
types_or: [ rust, toml ] # Cargo.lock matches toml type which is intended
pass_filenames: false
- id: cargo-clippy
name: cargo clippy
@@ -47,8 +47,7 @@ repos:
entry: cargo clippy
language: system
args: [ "--features", "sqlite,mysql,postgresql", "--", "-D", "warnings" ]
types_or: [ rust, file ]
files: (Cargo.toml|Cargo.lock|rust-toolchain.toml|rustfmt.toml|.*\.rs$)
types_or: [ rust, toml ] # Cargo.lock matches toml type which is intended
pass_filenames: false
- id: check-docker-templates
name: check docker templates
+1
View File
@@ -15,6 +15,7 @@ extend-ignore-re = [
"(?i)helo_name",
"Server name sent during.+HELO",
# COSE Is short for CBOR Object Signing and Encryption, ignore these specific items
"COSE",
"COSEKey",
"COSEAlgorithm",
# Ignore this specific string as it's valid
Generated
+660 -565
View File
File diff suppressed because it is too large. Load diff
+35 -33
View File
@@ -1,6 +1,6 @@
[workspace.package]
edition = "2024"
rust-version = "1.95.0"
rust-version = "1.97.1"
license = "AGPL-3.0-only"
repository = "https://github.com/dani-garcia/vaultwarden"
publish = false
@@ -12,7 +12,6 @@ members = ["macros"]
name = "vaultwarden"
version = "1.0.0"
authors = ["Daniel García <dani-garcia@users.noreply.github.com>"]
readme = "README.md"
build = "build.rs"
repository.workspace = true
edition.workspace = true
@@ -41,6 +40,7 @@ vendored_openssl = ["openssl/vendored"]
enable_mimalloc = ["dep:mimalloc"]
s3 = [
"opendal/services-s3",
"dep:opendal-http-transport-reqwest",
"dep:aws-config",
"dep:aws-credential-types",
"dep:aws-smithy-runtime-api",
@@ -58,6 +58,7 @@ oidc-accept-string-booleans = ["openidconnect/accept-string-booleans"]
unstable = []
[target."cfg(unix)".dependencies]
nix = { version = "0.31.3", features = ["fs"] }
# Logging
syslog = "7.0.0"
@@ -65,7 +66,7 @@ syslog = "7.0.0"
macros = { path = "./macros" }
# Logging
log = "0.4.33"
log = "0.4.34"
fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] }
# We need the `log` feature for `tracing` to enable logging for several crates to work, like lettre or webauthn-rs
tracing = { version = "0.1.44", features = ["log"] }
@@ -76,7 +77,7 @@ dotenvy = { version = "0.15.7", default-features = false }
# Numerical libraries
num-traits = "0.2.19"
num-derive = "0.5.1"
bigdecimal = "0.4.10"
bigdecimal = "0.4.11"
# Web framework
rocket = { version = "0.5.1", default-features = false, features = ["json", "tls"] }
@@ -89,8 +90,8 @@ rmpv = "1.3.1" # MessagePack library
dashmap = "6.2.1"
# Async futures
futures = "0.3.33"
tokio = { version = "1.53.1", features = [
futures = "0.3.34"
tokio = { version = "1.53.2", features = [
"fs",
"io-util",
"net",
@@ -106,7 +107,7 @@ serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
# A safe, extensible ORM and Query builder
diesel = { version = "2.3.11", features = ["chrono", "r2d2", "numeric"] }
diesel = { version = "2.3.13", features = ["chrono", "r2d2", "numeric", "64-column-tables"] }
diesel_migrations = "2.3.2"
derive_more = { version = "2.1.1", features = [
@@ -119,36 +120,36 @@ derive_more = { version = "2.1.1", features = [
diesel-derive-newtype = "2.1.3"
# SQLite, statically bundled unless the `sqlite_system` feature is enabled
libsqlite3-sys = { version = "0.37.0", optional = true }
libsqlite3-sys = { version = "0.38.2", optional = true }
# Crypto-related libraries
rand = "0.10.2"
rand = "0.10.3"
ring = "0.17.14"
rustls = { version = "0.23.42", features = ["ring", "std"], default-features = false }
rustls = { version = "0.23.45", features = ["ring", "std"], default-features = false }
subtle = "2.6.1"
# UUID generation
uuid = { version = "1.24.0", features = ["v4"] }
uuid = { version = "1.27.0", features = ["v4"] }
# Date and time libraries
chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] }
chrono-tz = "0.10.4"
time = "0.3.54"
time = "0.3.55"
# Job scheduler
job_scheduler_ng = "2.4.0"
job_scheduler_ng = "2.5.0"
# Data encoding library Hex/Base32/Base64
data-encoding = "2.11.0"
data-encoding = "2.11.1"
# JWT library
jsonwebtoken = { version = "11.0.0", default-features = false, features = ["rust_crypto", "use_pem"] }
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["rust_crypto", "use_pem"] }
# TOTP library
totp-lite = "2.0.1"
# 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
# danger-allow-state-serialisation is needed to save the state in the db
@@ -161,7 +162,7 @@ webauthn-rs-core = "0.5.5"
url = "2.5.8"
# Email libraries
lettre = { version = "0.11.22", default-features = false, features = [
lettre = { version = "0.11.23", default-features = false, features = [
# Misc
"tracing",
"serde",
@@ -179,10 +180,10 @@ percent-encoding = "2.3.2" # URL encoding library used for URL's in the emails
email_address = "0.2.9"
# HTML Template library
handlebars = { version = "6.4.3", features = ["dir_source"] }
handlebars = { version = "6.4.4", features = ["dir_source"] }
# HTTP client (Used for favicons, version check, DUO and HIBP API)
reqwest = { version = "0.13.4", default-features = false, features = [
reqwest = { version = "0.13.5", default-features = false, features = [
# Misc
"charset",
"cookies",
@@ -200,7 +201,7 @@ reqwest = { version = "0.13.4", default-features = false, features = [
"socks",
"system-proxy",
] }
hickory-resolver = "0.26.1"
hickory-resolver = "0.26.3"
# Favicon extraction libraries
html5gum = "0.8.4"
@@ -211,13 +212,13 @@ regex = { version = "1.13.1", default-features = false, features = [
] }
data-url = "0.3.2"
bytes = "1.12.1"
svg-hush = "0.9.6"
svg-hush = "0.9.7"
# Cache function results (Used for version check and favicon fetching)
cached = { version = "2.0.2", features = ["async"] }
cached = { version = "4.0.1", features = ["async"] }
# Used for custom short lived cookie jar during favicon extraction
cookie = "0.18.1"
cookie = "0.18.2"
cookie_store = "0.22.1"
# Used by U2F, JWT and PostgreSQL
@@ -231,11 +232,11 @@ pastey = "0.2.3"
governor = "0.10.4"
# CIDR parsing for the trusted proxies of the client IP header
ipnet = "2.12.0"
ipnet = "2.12.2"
# OIDC for SSO
openidconnect = { version = "4.0.1", default-features = false }
moka = { version = "0.12.15", features = ["future"] }
moka = { version = "0.12.16", features = ["future"] }
# Check client versions for specific features.
semver = "1.0.28"
@@ -244,10 +245,10 @@ semver = "1.0.28"
# 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"] }
which = "8.0.5"
which = "8.0.6"
# Argon2 library with support for the PHC format
argon2 = "0.5.3"
argon2 = "0.6.0"
# Reading a password from the cli for generating the Argon2id ADMIN_TOKEN
rpassword = "7.5.4"
@@ -256,20 +257,21 @@ rpassword = "7.5.4"
grass_compiler = { version = "0.13.4", default-features = false }
# File are accessed through Apache OpenDAL
opendal = { version = "0.57.0", default-features = false, features = ["services-fs"] }
opendal = { version = "0.59.4", default-features = false, features = ["services-fs"] }
opendal-http-transport-reqwest = { version = "0.59.4", default-features = false, features = ["rustls-no-provider"], optional = true }
# For retrieving AWS credentials, including temporary SSO credentials
aws-config = { version = "1.10.0", optional = true, default-features = false, features = [
aws-config = { version = "1.12.0", optional = true, default-features = false, features = [
"behavior-version-latest",
"credentials-process",
"rt-tokio",
"sso",
] }
aws-credential-types = { version = "1.3.0", optional = true }
aws-smithy-runtime-api = { version = "1.14.0", optional = true }
http = { version = "1.4.2", optional = true }
reqsign-aws-v4 = { version = "3.0.2", optional = true }
reqsign-core = { version = "3.1.0", optional = true }
aws-smithy-runtime-api = { version = "1.19.0", optional = true }
http = { version = "1.5.0", optional = true }
reqsign-aws-v4 = { version = "3.3.1", optional = true }
reqsign-core = { version = "3.3.2", optional = true }
# Strip debuginfo from the release builds
# The debug symbols are to provide better panic traces
+4 -3
View File
@@ -1,11 +1,12 @@
---
vault_version: "v2026.6.4"
vault_image_digest: "sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427"
vault_version: "v2026.7.0"
vault_image_digest: "sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c"
# Cross Compile Docker Helper Scripts v1.9.0
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
rust_version: 1.97.1 # Rust version to be used
# The `rust_version` variable is extracted from `rust-toolchain.toml`
# rust_version: x.yy.z # Rust version to be used
debian_version: trixie # Debian release name to be used
alpine_version: "3.24" # Alpine version to be used
# For which platforms/architectures will we try to build images
+15 -14
View File
@@ -19,23 +19,23 @@
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
# click the tag name to view the digest of the image it currently points to.
# - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2026.6.4
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.6.4
# [docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427]
# $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0
# [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c]
#
# - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427
# [docker.io/vaultwarden/web-vault:v2026.6.4]
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c
# [docker.io/vaultwarden/web-vault:v2026.7.0]
#
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427 AS vault
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault
########################## ALPINE BUILD IMAGES ##########################
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
## And for Alpine we define all build images here, they will only be loaded when actually used
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.97.1 AS build_amd64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.97.1 AS build_arm64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.97.1 AS build_armv7
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.97.1 AS build_armv6
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.99.0 AS build_amd64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.99.0 AS build_arm64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.99.0 AS build_armv7
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.99.0 AS build_armv6
########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006
@@ -70,7 +70,7 @@ RUN echo "export CARGO_TARGET=${CARGO_BUILD_TARGET}" >> /env-cargo && \
# Output the current contents of the file
cat /env-cargo
RUN source /env-cargo && \
RUN . /env-cargo && \
rustup target add "${CARGO_TARGET}"
# 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
# dummy project, except the target folder
# This folder contains the compiled dependencies
RUN source /env-cargo && \
RUN . /env-cargo && \
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
find . -not -path "./target*" -delete
@@ -97,13 +97,13 @@ COPY . .
ARG VW_VERSION
# 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
# Also do this for build.rs to ensure the version is rechecked
touch build.rs src/main.rs && \
# 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}" && \
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \
if [ "${CARGO_PROFILE}" = "dev" ] ; then \
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
else \
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-*'
#
# 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
ENV ROCKET_PROFILE="release" \
+41 -13
View File
@@ -19,15 +19,15 @@
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
# click the tag name to view the digest of the image it currently points to.
# - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2026.6.4
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.6.4
# [docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427]
# $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0
# [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c]
#
# - Conversely, to get the tag name from the digest:
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427
# [docker.io/vaultwarden/web-vault:v2026.6.4]
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c
# [docker.io/vaultwarden/web-vault:v2026.7.0]
#
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427 AS vault
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault
########################## Cross Compile Docker Helper Scripts ##########################
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
@@ -36,7 +36,8 @@ FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f
########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.97.1-slim-trixie AS build
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.99.0-slim-trixie AS build
# hadolint ignore=DL3067
COPY --from=xx / /
ARG TARGETARCH
ARG TARGETVARIANT
@@ -80,7 +81,7 @@ RUN mkdir -pv "${CARGO_HOME}" && \
RUN USER=root cargo new --bin /app
WORKDIR /app
RUN source /env-cargo && \
RUN . /env-cargo && \
rustup target add "${CARGO_TARGET}"
# Copies over *only* your manifests and build files
@@ -95,9 +96,22 @@ ARG DB=sqlite,mysql,postgresql
# Builds your dependencies and removes the
# dummy project, except the target folder
# This folder contains the compiled dependencies
RUN source /env-cargo && \
# Workaround for xx related build issues
RUN . /env-cargo && \
# Configure xx-cargo for target pkg-config and Debian transitive library lookup
# 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="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
fi && \
# Debian's GCC adds a special fix for cortex-a53 arch's which gets losts when using xx-cargo, which uses clang
# Checking if the arch is arm64 and adding this extra link-arg should cause this fix to be applied
# This should fix issues reported when running on a RPi3 which is a Cortex-A35 chip
# See: https://salsa.debian.org/toolchain-team/gcc/-/blob/master/debian/rules2?pow_referer=#L543-548
# See: https://github.com/dani-garcia/vaultwarden/issues/7754
if [ "$(xx-info arch)" = "arm64" ]; then \
XX_RUSTFLAGS="${XX_RUSTFLAGS} -Clink-arg=-Wl,--fix-cortex-a53-843419"; \
fi && \
export XX_RUSTFLAGS && \
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}" && \
find . -not -path "./target*" -delete
@@ -108,15 +122,28 @@ COPY . .
ARG VW_VERSION
# 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
# Also do this for build.rs to ensure the version is rechecked
touch build.rs src/main.rs && \
# 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/dani-garcia/vaultwarden/discussions/7522
if xx-info is-cross; then \
XX_RUSTFLAGS="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
fi && \
# Debian's GCC adds a special fix for cortex-a53 arch's which gets losts when using xx-cargo, which uses clang
# Checking if the arch is arm64 and adding this extra link-arg should cause this fix to be applied
# This should fix issues reported when running on a RPi3 which is a Cortex-A35 chip
# See: https://salsa.debian.org/toolchain-team/gcc/-/blob/master/debian/rules2?pow_referer=#L543-548
# See: https://github.com/dani-garcia/vaultwarden/issues/7754
if [ "$(xx-info arch)" = "arm64" ]; then \
XX_RUSTFLAGS="${XX_RUSTFLAGS} -Clink-arg=-Wl,--fix-cortex-a53-843419"; \
fi && \
export XX_RUSTFLAGS && \
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 ; \
else \
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
@@ -139,6 +166,7 @@ RUN source /env-cargo && \
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
#
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
# hadolint ignore=DL3065
FROM --platform=$TARGETPLATFORM docker.io/library/debian:trixie-slim
ENV ROCKET_PROFILE="release" \
+20 -5
View File
@@ -28,8 +28,21 @@
# [docker.io/vaultwarden/web-vault:{{ vault_version | replace('+', '_') }}]
#
{% 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/dani-garcia/vaultwarden/discussions/7522
if xx-info is-cross; then \
XX_RUSTFLAGS="-Clink-arg=-Wl,-rpath-link,/usr/lib/$(xx-info triple)"; \
fi && \
# Debian's GCC adds a special fix for cortex-a53 arch's which gets losts when using xx-cargo, which uses clang
# Checking if the arch is arm64 and adding this extra link-arg should cause this fix to be applied
# This should fix issues reported when running on a RPi3 which is a Cortex-A35 chip
# See: https://salsa.debian.org/toolchain-team/gcc/-/blob/master/debian/rules2?pow_referer=#L543-548
# See: https://github.com/dani-garcia/vaultwarden/issues/7754
if [ "$(xx-info arch)" = "arm64" ]; then \
XX_RUSTFLAGS="${XX_RUSTFLAGS} -Clink-arg=-Wl,--fix-cortex-a53-843419"; \
fi && \
export XX_RUSTFLAGS && \
PKG_CONFIG="$(command -v "$(xx-info)-pkg-config")" xx-cargo build --features ${DB} --profile "${CARGO_PROFILE}"
{%- endmacro %}
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@{{ vault_image_digest }} AS vault
@@ -52,6 +65,7 @@ FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].arch_image[arch] }} AS
# hadolint ignore=DL3006
FROM --platform=$BUILDPLATFORM {{ build_stage_image[base].image }} AS build
{% if base == "debian" %}
# hadolint ignore=DL3067
COPY --from=xx / /
{% endif %}
ARG TARGETARCH
@@ -111,7 +125,7 @@ RUN echo "export CARGO_TARGET=${CARGO_BUILD_TARGET}" >> /env-cargo && \
cat /env-cargo
{% endif %}
RUN source /env-cargo && \
RUN . /env-cargo && \
rustup target add "${CARGO_TARGET}"
# Copies over *only* your manifests and build files
@@ -131,7 +145,7 @@ ARG DB=sqlite,mysql,postgresql,enable_mimalloc
# Builds your dependencies and removes the
# dummy project, except the target folder
# This folder contains the compiled dependencies
RUN source /env-cargo && \
RUN . /env-cargo && \
{% if base == "debian" %}
{{ xx_cargo_config() }} && \
{% elif base == "alpine" %}
@@ -146,7 +160,7 @@ COPY . .
ARG VW_VERSION
# 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
# Also do this for build.rs to ensure the version is rechecked
touch build.rs src/main.rs && \
@@ -156,7 +170,7 @@ RUN source /env-cargo && \
{% elif base == "alpine" %}
cargo build --features ${DB} --profile "${CARGO_PROFILE}" --target="${CARGO_TARGET}" && \
{% endif %}
if [[ "${CARGO_PROFILE}" == "dev" ]] ; then \
if [ "${CARGO_PROFILE}" = "dev" ] ; then \
ln -vfsr "/app/target/${CARGO_TARGET}/debug" /app/target/final ; \
else \
ln -vfsr "/app/target/${CARGO_TARGET}/${CARGO_PROFILE}" /app/target/final ; \
@@ -179,6 +193,7 @@ RUN source /env-cargo && \
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
#
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
# hadolint ignore=DL3065
FROM --platform=$TARGETPLATFORM {{ runtime_stage_image[base] }}
ENV ROCKET_PROFILE="release" \
+8 -2
View File
@@ -3,17 +3,23 @@
import os
import argparse
import json
import tomllib
import yaml
import jinja2
# Load settings file
with open("DockerSettings.yaml", 'r') as yaml_file:
with open('DockerSettings.yaml', 'r', encoding='utf-8') as yaml_file:
yaml_data = yaml.safe_load(yaml_file)
# Extract the rust_version from the rust-toolchain.toml file
script_dir = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(script_dir, '..', 'rust-toolchain.toml'), 'rb') as toolchain_file:
yaml_data["rust_version"] = tomllib.load(toolchain_file)["toolchain"]["channel"]
settings_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(os.getcwd()),
)
settings_yaml = yaml.safe_load(settings_env.get_template("DockerSettings.yaml").render(yaml_data))
settings_yaml = yaml.safe_load(settings_env.get_template('DockerSettings.yaml').render(yaml_data))
args_parser = argparse.ArgumentParser()
args_parser.add_argument('template_file', help='Jinja2 template file to render.')
+1 -1
View File
@@ -14,7 +14,7 @@ proc-macro = true
[dependencies]
quote = "1.0.47"
syn = "3.0.3"
syn = "3.0.6"
[lints]
workspace = true
Whitespace-only changes.
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN key_id TEXT;
@@ -1,15 +1,31 @@
-- Dynamically create DROP FOREIGN KEY
-- Some versions of MySQL or MariaDB might fail if the key doesn't exists
-- This checks if the key exists, and if so, will drop it.
SET @drop_sso_fk = IF((SELECT true FROM information_schema.TABLE_CONSTRAINTS WHERE
CONSTRAINT_SCHEMA = DATABASE() AND
TABLE_NAME = 'sso_users' AND
CONSTRAINT_NAME = 'sso_users_ibfk_1' AND
CONSTRAINT_TYPE = 'FOREIGN KEY') = true,
'ALTER TABLE sso_users DROP FOREIGN KEY sso_users_ibfk_1',
'SELECT 1');
PREPARE stmt FROM @drop_sso_fk;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT if (
EXISTS(
SELECT CONSTRAINT_NAME FROM information_schema.table_constraints
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sso_users'
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
AND CONSTRAINT_NAME = 'sso_users_ibfk_1'
)
,'ALTER TABLE sso_users DROP FOREIGN KEY `sso_users_ibfk_1`'
,'SELECT "info: FK sso_users_ibfk_1 does not exist."'
) INTO @drop_stmt;
PREPARE drop_stmt FROM @drop_stmt;
EXECUTE drop_stmt;
SELECT if (
EXISTS(
SELECT CONSTRAINT_NAME FROM information_schema.table_constraints
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sso_users'
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
AND CONSTRAINT_NAME = '1'
)
,'ALTER TABLE sso_users DROP FOREIGN KEY `1`'
,'SELECT "info: FK sso_users 1 does not exist."'
) INTO @drop_stmt;
PREPARE drop_stmt FROM @drop_stmt;
EXECUTE drop_stmt;
DEALLOCATE PREPARE drop_stmt;
ALTER TABLE sso_users ADD FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE;
Whitespace-only changes.
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN key_id TEXT;
@@ -0,0 +1,5 @@
DROP TABLE IF EXISTS user_signature_key_pairs;
ALTER TABLE users DROP COLUMN signed_public_key;
ALTER TABLE users DROP COLUMN security_state;
ALTER TABLE users DROP COLUMN security_version;
@@ -0,0 +1,14 @@
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
ALTER TABLE users ADD COLUMN security_state TEXT;
ALTER TABLE users ADD COLUMN security_version INTEGER;
CREATE TABLE user_signature_key_pairs (
uuid CHAR(36) NOT NULL PRIMARY KEY,
user_uuid CHAR(36) NOT NULL UNIQUE,
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
signing_key TEXT NOT NULL,
verifying_key TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (user_uuid) REFERENCES users (uuid) ON DELETE CASCADE
);
Whitespace-only changes.
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN key_id TEXT;
@@ -0,0 +1,5 @@
DROP TABLE IF EXISTS user_signature_key_pairs;
ALTER TABLE users DROP COLUMN signed_public_key;
ALTER TABLE users DROP COLUMN security_state;
ALTER TABLE users DROP COLUMN security_version;
@@ -0,0 +1,13 @@
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
ALTER TABLE users ADD COLUMN security_state TEXT;
ALTER TABLE users ADD COLUMN security_version INTEGER;
CREATE TABLE user_signature_key_pairs (
uuid CHAR(36) NOT NULL PRIMARY KEY,
user_uuid CHAR(36) NOT NULL UNIQUE REFERENCES users (uuid) ON DELETE CASCADE,
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
signing_key TEXT NOT NULL,
verifying_key TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
Whitespace-only changes.
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN key_id TEXT;
@@ -0,0 +1,5 @@
DROP TABLE IF EXISTS user_signature_key_pairs;
ALTER TABLE users DROP COLUMN signed_public_key;
ALTER TABLE users DROP COLUMN security_state;
ALTER TABLE users DROP COLUMN security_version;
@@ -0,0 +1,13 @@
ALTER TABLE users ADD COLUMN signed_public_key TEXT;
ALTER TABLE users ADD COLUMN security_state TEXT;
ALTER TABLE users ADD COLUMN security_version INTEGER;
CREATE TABLE user_signature_key_pairs (
uuid TEXT NOT NULL PRIMARY KEY,
user_uuid TEXT NOT NULL UNIQUE REFERENCES users (uuid) ON DELETE CASCADE,
signature_algorithm INTEGER NOT NULL, -- 0 = ed25519, 1 = mldsa44
signing_key TEXT NOT NULL,
verifying_key TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
+13 -3
View File
@@ -21,11 +21,19 @@ TEST_USER3=test3
TEST_USER3_PASSWORD=${TEST_USER3}
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_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN}
KC_BOOTSTRAP_ADMIN_USERNAME=admin
KC_BOOTSTRAP_ADMIN_PASSWORD=${KC_BOOTSTRAP_ADMIN_USERNAME}
KC_HTTP_HOST=127.0.0.1
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_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
SSO_DEBUG_TOKENS=true
I_REALLY_WANT_VOLATILE_STORAGE=true
SSO_ENABLED=true
+14 -16
View File
@@ -1,8 +1,8 @@
# Integration tests
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
@@ -11,11 +11,11 @@ Databases (`Mariadb`, `Mysql` and `Postgres`) and `Playwright` will run in conta
### 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.
You will additionally need `nodejs` then run:
It's possible to run `Playwright` outside of the container, this remove the need to rebuild the image for each change.
You'll additionally need `nodejs` then run:
```bash
npm ci --ignore-scripts
npm ci --ignore-scripts --allow-git=none --allow-remote=none
npx playwright install-deps
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):
```bash
PW_KEEP_SERVICE_RUNNNING=true npx playwright test
PW_KEEP_SERVICE_RUNNING=true npx playwright test
```
### 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
```
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
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
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
@@ -112,12 +112,11 @@ You can check the result running:
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
Additionally this `docker-compose` template allows to run locally Vaultwarden,
[Keycloak](https://www.keycloak.org/) and [Maildev](https://github.com/timshel/maildev) to test OIDC.
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.
## Setup
@@ -131,18 +130,17 @@ Then start the stack (the `profile` is required to run `Vaultwarden`) :
```bash
> 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 | 74af4933-e386-4e64-ba15-a7b61212c45e
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
(It is normal for this container to stop once the configuration is done).
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).
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`
- `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).
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_*`)
## Cleanup
+2 -2
View File
@@ -17,7 +17,7 @@ done
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 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:
# docker exec -it keycloakSetup-dev /bin/bash
# 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
# Doc: https://wjw465150.gitbooks.io/keycloak-documentation/content/server_admin/topics/admin-cli.html
+1 -1
View File
@@ -28,7 +28,7 @@ RUN mkdir /playwright
WORKDIR /playwright
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 compose ./compose
+1
View File
@@ -35,6 +35,7 @@ WORKDIR /
COPY --from=prebuilt /start.sh .
COPY --from=prebuilt /vaultwarden .
COPY --from=build /data ./data
COPY --from=build /web-vault ./web-vault
ENTRYPOINT ["/start.sh"]
+11
View File
@@ -22,3 +22,14 @@ if [[ ! -z "$REPO_URL" ]] && [[ ! -z "$COMMIT_HASH" ]] ; then
mv build /web-vault
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")
+10 -7
View File
@@ -24,12 +24,15 @@ services:
environment:
- ADMIN_TOKEN
- DATABASE_URL
- CLIENT_SUPPRESS_ONBOARDING
- EMAIL_2FA_AUTO_FALLBACK
- I_REALLY_WANT_VOLATILE_STORAGE
- LOG_LEVEL
- LOGIN_RATELIMIT_MAX_BURST
- SMTP_HOST
- SMTP_FROM
- SMTP_DEBUG
- SSO_AUTH_ONLY_NOT_SESSION
- SSO_DEBUG_TOKENS
- SSO_ENABLED
- SSO_FRONTEND
@@ -58,7 +61,7 @@ services:
Mariadb:
profiles: ["playwright"]
container_name: playwright_mariadb
image: mariadb:11.2.4
image: mariadb:12.2.2
env_file: test.env
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
@@ -70,7 +73,7 @@ services:
Mysql:
profiles: ["playwright"]
container_name: playwright_mysql
image: mysql:8.4.1
image: mysql:9.7.0
env_file: test.env
healthcheck:
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
@@ -82,7 +85,7 @@ services:
Postgres:
profiles: ["playwright"]
container_name: playwright_postgres
image: postgres:16.3
image: postgres:18.4
env_file: test.env
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
@@ -94,7 +97,7 @@ services:
Maildev:
profiles: ["vaultwarden", "maildev"]
container_name: maildev
image: timshel/maildev:3.0.4
image: timshel/maildev:3.2.19
ports:
- ${SMTP_PORT}:1025
- 1080:1080
@@ -102,7 +105,7 @@ services:
Keycloak:
profiles: ["keycloak", "vaultwarden"]
container_name: keycloak-${ENV:-dev}
image: quay.io/keycloak/keycloak:26.3.4
image: quay.io/keycloak/keycloak:26.6.2
network_mode: "host"
command:
- start-dev
@@ -112,12 +115,12 @@ services:
profiles: ["keycloak", "vaultwarden"]
container_name: keycloakSetup-${ENV:-dev}
image: keycloak_setup-${ENV:-dev}
network_mode: "host"
build:
context: compose/keycloak
dockerfile: Dockerfile
args:
KEYCLOAK_VERSION: 26.3.4
network_mode: "host"
KEYCLOAK_VERSION: 26.6.2
depends_on:
- Keycloak
restart: "no"
+1 -1
View File
@@ -1,4 +1,4 @@
import { firefox, type FullConfig } from '@playwright/test';
import { type FullConfig } from '@playwright/test';
import { execSync } from 'node:child_process';
import fs from 'fs';
+2 -13
View File
@@ -207,7 +207,7 @@ export async function startVault(browser: Browser, testInfo: TestInfo, env = {},
}
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}`);
} else {
console.log(`Vaultwarden stopping`);
@@ -231,6 +231,7 @@ export async function checkNotification(page: Page, hasText: string) {
}
export async function cleanLanding(page: Page) {
await page.context().clearCookies();
await page.goto('/', { waitUntil: 'domcontentloaded' });
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();
});
}
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');
}
}
+582 -580
View File
File diff suppressed because it is too large. Load diff
+7 -7
View File
@@ -8,14 +8,14 @@
"author": "",
"license": "ISC",
"devDependencies": {
"@playwright/test": "1.56.1",
"dotenv": "17.2.3",
"dotenv-expand": "12.0.3",
"maildev": "npm:@timshel_npm/maildev@3.2.5"
"@playwright/test": "1.60.0",
"dotenv": "17.4.2",
"dotenv-expand": "13.0.0",
"maildev": "npm:@timshel_npm/maildev@3.2.19"
},
"dependencies": {
"mysql2": "3.15.3",
"otpauth": "9.4.1",
"pg": "8.16.3"
"mysql2": "3.22.3",
"otpauth": "9.5.1",
"pg": "8.21.0"
}
}
+10 -4
View File
@@ -25,10 +25,12 @@ export default defineConfig({
/* Long global timeout for complex tests
* But short action/nav/expect timeouts to fail on specific step (raise locally if not enough).
*/
timeout: 120 * 1000,
actionTimeout: 20 * 1000,
navigationTimeout: 20 * 1000,
expect: { timeout: 20 * 1000 },
timeout: 240 * 1000,
actionTimeout: 40 * 1000,
navigationTimeout: 40 * 1000,
expect: { timeout: 40 * 1000 },
"permissions": ["clipboard-read"],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
@@ -37,6 +39,10 @@ export default defineConfig({
browserName: 'firefox',
locale: 'en-GB',
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 */
trace: 'on',
+6 -4
View File
@@ -10,7 +10,7 @@ DOCKER_BUILDKIT=1
#####################
# 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
#####################
@@ -38,8 +38,8 @@ TEST_USER3_MAIL=${TEST_USER3}@example.com
###################
# Keycloak Config #
###################
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN}
KC_BOOTSTRAP_ADMIN_USERNAME=admin
KC_BOOTSTRAP_ADMIN_PASSWORD=${KC_BOOTSTRAP_ADMIN_USERNAME}
KC_HTTP_HOST=127.0.0.1
KC_HTTP_PORT=8081
@@ -52,10 +52,12 @@ DUMMY_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${DUMMY_REALM}
# Vaultwarden Config #
######################
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
LOGIN_RATELIMIT_MAX_BURST=100
ADMIN_TOKEN=admin
CLIENT_SUPPRESS_ONBOARDING=true
SMTP_SECURITY=off
SMTP_PORT=${MAILDEV_SMTP_PORT}
+5 -11
View File
@@ -1,6 +1,8 @@
import { test, expect, type TestInfo } from '@playwright/test';
import * as utils from "../global-utils";
import * as orgs from './setups/orgs';
import { createAccount } from './setups/user';
let users = utils.loadEnv();
@@ -16,20 +18,12 @@ test.afterAll('Teardown', async ({}) => {
test('Create', async ({ page }) => {
await createAccount(test, page, users.user1);
await test.step('Create Org', async () => {
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 orgs.create(test, page, 'New organisation');
await test.step('Create Collection', async () => {
await page.getByRole('link', { name: 'Collections' }).click();
await page.getByRole('button', { name: 'New' }).click();
await page.getByRole('button', { name: 'New', exact: true }).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 utils.checkNotification(page, 'Created collection RandomCollec');
await expect(page.getByRole('button', { name: 'RandomCollec' })).toBeVisible();
+56
View File
@@ -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);
});
+6 -25
View File
@@ -41,13 +41,10 @@ test('Account creation', async ({ page }) => {
test('Login', async ({ context, page }) => {
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 page.getByText('Verify your account\'s email').click();
await expect(page.getByText('Verify your account\'s email')).toBeVisible();
await page.getByRole('button', { name: 'Send email' }).click();
await page.getByRole('button', { name: "Send email" }).click();
await utils.checkNotification(page, 'Check your email inbox for a verification link');
const verify = await mailBuffer.expect((m) => m.subject === "Verify Your Email");
@@ -78,26 +75,10 @@ test('Activate 2fa', async ({ page }) => {
test('2fa', async ({ page }) => {
const emails = mailserver.buffer(users.user1.email);
await test.step('login', async () => {
await page.goto('/');
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);
await logUser(test, page, users.user1, {
mailBuffer: emails,
mail2fa: true,
});
emails.close();
});
+2 -2
View File
@@ -37,8 +37,8 @@ test('Authenticator 2fa', async ({ page }) => {
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 page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password);
await page.getByRole('button', { name: 'Log in', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible();
await page.getByLabel(/Verification code/).fill(totp.generate({timestamp}));
+28 -21
View File
@@ -4,6 +4,7 @@ import { MailDev } from 'maildev';
import * as utils from '../global-utils';
import * as orgs from './setups/orgs';
import { createAccount, logUser } from './setups/user';
import { activateTOTP } from './setups/2fa';
let users = utils.loadEnv();
@@ -20,6 +21,7 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo, {
SMTP_HOST: process.env.MAILDEV_HOST,
SMTP_FROM: process.env.PW_SMTP_FROM,
EMAIL_2FA_AUTO_FALLBACK: "true",
});
mail1Buffer = mailServer.buffer(users.user1.email);
@@ -45,7 +47,7 @@ test('Invite users', async ({ page }) => {
await orgs.policies(test, page, 'Test');
await page.getByRole('button', { name: 'Account recovery' }).click();
await page.getByRole('checkbox', { name: 'Turn on' }).check();
await page.getByRole('checkbox', { name: 'Require new members' }).check();
await page.getByRole('checkbox', { name: 'Automatically enroll new' }).check();
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Edited policy Account recovery');
});
@@ -66,18 +68,16 @@ test('invited with new account', async ({ page }) => {
await page.goto(link);
await expect(page).toHaveTitle(/Create account | Vaultwarden Web/);
//await page.getByLabel('Name').fill(users.user2.name);
await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password);
await page.getByLabel('Confirm master password (').fill(users.user2.password);
// await page.getByLabel('Name').fill(users.user2.name);
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 utils.checkNotification(page, 'Your new account has been created');
await utils.checkNotification(page, 'Invitation accepted');
await utils.ignoreExtension(page);
// Redirected to the vault
await expect(page).toHaveTitle('Vaults | Vaultwarden Web');
// await utils.checkNotification(page, 'You have been logged in!');
await utils.checkNotification(page, 'Successfully accepted your invitation');
});
await test.step('Check mails', async () => {
@@ -100,21 +100,19 @@ test('invited with existing account', async ({ page }) => {
await page.getByRole('button', { name: 'Continue' }).click();
// Unlock page
await page.getByLabel('Master password').fill(users.user3.password);
await page.getByRole('button', { name: 'Log in with master password' }).click();
await utils.checkNotification(page, 'Invitation accepted');
await utils.ignoreExtension(page);
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user3.password);
await page.getByRole('button', { name: 'Log in', exact: true }).click();
// We are now in the default vault page
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 mail1Buffer.expect((m) => m.subject.includes('Invitation to Test accepted'));
});
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.confirm(test, page, 'Test', users.user2.email);
@@ -123,25 +121,30 @@ test('Confirm invited user', 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 expect(page.getByLabel('Filter: Default collection')).toBeVisible();
});
test('Recover user password', async ({ page }) => {
await logUser(test, page, users.user1, mail1Buffer);
await logUser(test, page, users.user2, { mailBuffer: mail2Buffer });
await activateTOTP(test, page, users.user2);
await logUser(test, page, users.user1, { mailBuffer: mail1Buffer });
let newPassword = "TotoNewPassword";
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 page.getByRole('row').filter({hasText: users.user2.email}).getByLabel('Options').click();
await page.getByRole('menuitem', { name: 'Recover account' }).click();
await page.getByRole('textbox', { name: 'New master password (required)', exact: true }).fill(newPassword);
await page.getByRole('textbox', { name: 'Confirm new master password (' }).fill(newPassword);
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Password reset success');
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('checkbox', { name: 'Reset two-step login' }).check();
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Account recovery success');
await mail2Buffer.expect((m) => m.subject.includes('Admin account recovery from Test organization'));
});
let user2 = {
@@ -149,5 +152,9 @@ test('Recover user password', async ({ page }) => {
name: users.user2.name,
password: newPassword,
};
await logUser(test, page, user2, mail2Buffer);
await logUser(test, page, user2, {
mailBuffer: mail2Buffer,
mail2fa: true,
notNewDevice: true,
});
});
+110
View File
@@ -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)
})
});
+8 -8
View File
@@ -21,11 +21,11 @@ test('Send', async ({ browser, page }) => {
await page.getByRole('link', { name: 'Send' }).click();
await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'New', exact: true }).click();
await page.getByRole('button', { name: 'New Send', exact: true }).click();
await page.getByRole('menuitem', { name: 'Text' }).click();
await page.getByRole('textbox', { name: 'Send name (required)' }).fill('Test');
await page.getByRole('textbox', { name: 'Text to share (required)' }).fill('test');
await page.getByRole('textbox', { name: 'Send name * (required)' }).fill('Test');
await page.getByRole('textbox', { name: 'Text to share * (required)' }).fill('test');
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('footer').getByRole('button', { name: 'Copy link' }).click();
@@ -46,14 +46,14 @@ test('Send', async ({ browser, page }) => {
await page.getByRole('link', { name: 'Send' }).click();
await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'New', exact: true }).click();
await page.getByRole('button', { name: 'New' }).click();
await page.getByRole('menuitem', { name: 'Text' }).click();
await page.getByRole('textbox', { name: 'Send name (required)' }).fill('Password');
await page.getByRole('textbox', { name: 'Text to share (required)' }).fill('password');
await page.getByRole('textbox', { name: 'Send name * (required)' }).fill('Password');
await page.getByRole('textbox', { name: 'Text to share * (required)' }).fill('password');
await page.getByRole('combobox', { name: 'Who can view' }).click();
await page.getByText('Anyone with a password set by you').click();
await page.getByRole('textbox', { name: 'Password (required)' }).fill('password');
await page.getByRole('textbox', { name: 'Password * (required)', exact: true }).fill('password');
await page.getByRole('button', { name: 'Save' }).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 page2.goto(pwd_url, { waitUntil: 'domcontentloaded' });
await expect(page2.getByRole('heading', { name: 'Enter the password to view' })).toBeVisible();
await page2.getByRole('textbox', { name: 'Password (required)' }).fill('password');
await page2.getByRole('textbox', { name: 'Password * (required)' }).fill('password');
await page2.getByRole('button', { name: 'Continue' }).click();
await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible();
await expect(await page2.getByRole('paragraph').filter({ hasText: 'Password' })).toBeVisible();
+8 -7
View File
@@ -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: 'Two-step login' }).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();
const secret = await page.getByLabel('Key').innerText();
const secret = await page.getByLabel('Key', { exact: true }).innerText();
let totp = new OTPAuth.TOTP({ secret, period: 30 });
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: 'Two-step login' }).click();
await page.locator('bit-item').filter({ hasText: /Authenticator app/ }).getByRole('button').click();
await page.getByLabel('Master password (required)').click();
await page.getByLabel('Master password (required)').fill(user.password);
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).click()
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: 'Turn off' }).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: 'Two-step login' }).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: '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: 'Two-step login' }).click();
await page.locator('bit-item').filter({ hasText: 'Email' }).getByRole('button').click();
await page.getByLabel('Master password (required)').click();
await page.getByLabel('Master password (required)').fill(user.password);
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).click()
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: 'Turn off' }).click();
await page.getByRole('button', { name: 'Yes' }).click();
+21
View File
@@ -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/);
});
}
+1 -1
View File
@@ -5,7 +5,7 @@ const utils = require('../../global-utils');
utils.loadEnv();
test('DB teardown ?', async ({ serviceName }) => {
if( process.env.PW_KEEP_SERVICE_RUNNNING !== "true" ) {
if( process.env.PW_KEEP_SERVICE_RUNNING !== "true" ) {
utils.stopComposeService(serviceName);
}
});
+14 -11
View File
@@ -3,11 +3,14 @@ import { expect, type Browser,Page } from '@playwright/test';
import * as utils from '../../global-utils';
export async function create(test, page: Page, name: string) {
await test.step('Create Org', async () => {
await page.locator('a').filter({ hasText: 'Password Manager' }).first().click();
await test.step(`Create Org ${name}`, async () => {
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 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 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 page.locator('a').filter({ hasText: 'Admin Console' }).first().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 page.getByRole('button', { name: 'Toggle collapse Settings' }).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 page.locator('a').filter({ hasText: 'Admin Console' }).first().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 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('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 expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
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('combobox', { name: 'Permission' }).click();
await page.getByText('Edit items', { exact: true }).click();
await page.getByLabel('Select collections').click();
await page.getByText('Default collection').click();
await page.getByRole('cell', { name: 'Collection', exact: true }).click();
await page.getByRole('combobox', { name: 'Select collections' }).click();
await page.getByLabel('Options List').getByText('Default collection').click();
await page.getByRole('columnheader', { name: 'Collection', exact: true }).click();
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'User(s) invited');
});
+1 -1
View File
@@ -6,7 +6,7 @@ const utils = require('../../global-utils');
utils.loadEnv();
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");
} else {
console.log("Keycloak stopping");
+8 -17
View File
@@ -15,11 +15,8 @@ export async function logNewUser(
options: { mailBuffer?: MailBuffer } = {}
) {
await test.step(`Create user ${user.name}`, async () => {
await page.context().clearCookies();
await test.step('Landing page', async () => {
await utils.cleanLanding(page);
await page.locator("input[type=email].vw-email-sso").fill(user.email);
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 expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
await page.getByLabel('Master password (required)', { exact: true }).fill(user.password);
await page.getByLabel('Confirm master password (').fill(user.password);
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(user.password);
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 expect(page).toHaveTitle(/Vaultwarden Web/);
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 ){
let mailBuffer = options.mailBuffer;
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 === "Welcome");
});
}
});
@@ -69,16 +64,14 @@ export async function logUser(
mailBuffer ?: MailBuffer,
totp?: OTPAuth.TOTP,
mail2fa?: boolean,
notNewDevice?: boolean,
} = {}
) {
let mailBuffer = options.mailBuffer;
await test.step(`Log user ${user.email}`, async () => {
await page.context().clearCookies();
await test.step('Landing page', async () => {
await utils.cleanLanding(page);
await page.locator("input[type=email].vw-email-sso").fill(user.email);
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 utils.ignoreExtension(page);
await test.step('Default vault page', async () => {
await expect(page).toHaveTitle(/Vaultwarden Web/);
await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible();
});
if( mailBuffer ){
if( mailBuffer && !options.notNewDevice ){
await test.step('Check email', async () => {
await mailBuffer.expect((m) => m.subject.includes("New Device Logged"));
});
+25 -9
View File
@@ -3,6 +3,7 @@ import { expect, type Browser, Page } from '@playwright/test';
import { type MailBuffer } from 'maildev';
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) {
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();
// Vault finish Creation
await page.getByLabel('Master password (required)', { exact: true }).fill(user.password);
await page.getByLabel('Confirm master password (').fill(user.password);
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
await page.getByRole('textbox', { name: 'Confirm master password * (' }).fill(user.password);
await page.getByRole('button', { name: 'Create account' }).click();
await utils.checkNotification(page, 'Your new account has been created')
await utils.ignoreExtension(page);
// We are now in the default vault page
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 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();
// Unlock page
await page.getByLabel('Master password').fill(user.password);
await page.getByRole('button', { name: 'Log in with master password' }).click();
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password);
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
await expect(page).toHaveTitle(/Vaultwarden Web/);
if( mailBuffer ){
await mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox");
if( options.mailBuffer && !options.notNewDevice ){
await options.mailBuffer.expect((m) => m.subject === "New Device Logged In From Firefox");
}
});
}
+52 -9
View File
@@ -1,6 +1,7 @@
import { test, expect, type TestInfo } from '@playwright/test';
import { MailDev } from 'maildev';
import * as admin from "./setups/admin";
import { logNewUser, logUser } from './setups/sso';
import { activateEmail, disableEmail } from './setups/2fa';
import * as utils from "../global-utils";
@@ -19,7 +20,7 @@ test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo, {
SSO_ENABLED: true,
SSO_ONLY: false,
SSO_ONLY: true,
SMTP_HOST: process.env.MAILDEV_HOST,
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);
await logNewUser(test, page, users.user1, {mailBuffer: mailBuffer});
await activateEmail(test, page, users.user1, mailBuffer);
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});
await logUser(test, page, users.user1, {mailBuffer: mailBuffer, mail2fa: true, notNewDevice: true});
await disableEmail(test, page, users.user1);
mailBuffer.close();
});
test('Admin invite', async ({ page }) => {
const mailBuffer = mailserver.buffer(users.user2.email);
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();
});
+6 -4
View File
@@ -33,8 +33,8 @@ test('Non SSO login', async ({ page }) => {
await page.getByRole('button', { name: 'Other' }).click();
// Unlock page
await page.getByLabel('Master password').fill(users.user1.password);
await page.getByRole('button', { name: 'Log in with master password' }).click();
await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password);
await page.getByRole('button', { name: 'Log in', exact: true }).click();
// We are now in the default vault page
await expect(page).toHaveTitle(/Vaultwarden Web/);
@@ -58,6 +58,7 @@ test('Non SSO login impossible', async ({ page, browser }, testInfo: TestInfo) =
// Landing page
await page.goto('/');
await page.locator("input[type=email].vw-email-sso").fill(users.user1.email);
// Check that SSO login is available
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);
});
test('No SSO login', async ({ page }, testInfo: TestInfo) => {
await utils.restartVault(page, testInfo, {
SSO_ENABLED: false
@@ -74,12 +74,14 @@ test('No SSO login', async ({ page }, testInfo: TestInfo) => {
// Landing page
await page.goto('/');
await page.getByLabel(/Email address/).fill(users.user1.email);
// 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);
// Can continue to Master password
await page.getByLabel(/Email address/).fill(users.user1.email);
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);
});
@@ -67,17 +67,16 @@ test('invited with new account', async ({ page }) => {
await test.step('Create Vault account', async () => {
await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible();
await page.getByLabel('Master password (required)', { exact: true }).fill(users.user2.password);
await page.getByLabel('Confirm master password (').fill(users.user2.password);
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 utils.checkNotification(page, 'Account successfully created!');
await utils.checkNotification(page, 'Invitation accepted');
await utils.ignoreExtension(page);
});
await test.step('Default vault page', async () => {
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 () => {
@@ -95,6 +94,7 @@ test('invited with existing account', async ({ page }) => {
await test.step('Redirect to Keycloak', async () => {
await page.goto(link);
await page.getByRole('button', { name: /Use single sign-on/ }).click();
});
await test.step('Keycloak login', async () => {
@@ -108,13 +108,11 @@ test('invited with existing account', async ({ page }) => {
await expect(page).toHaveTitle('Vaultwarden Web');
await page.getByLabel('Master password').fill(users.user3.password);
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 expect(page).toHaveTitle(/Vaultwarden Web/);
await utils.checkNotification(page, 'Successfully accepted your invitation');
});
await test.step('Check mails', async () => {
+24 -13
View File
@@ -49,7 +49,7 @@ test('Organization is visible', async ({ page }) => {
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 orgs.policies(test, page, '/Test');
@@ -61,16 +61,27 @@ test('Enforce password policy', async ({ page }) => {
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Edited policy Master password requirements.');
});
await utils.logout(test, page, users.user1);
await test.step(`Unlock trigger policy`, async () => {
await page.locator("input[type=email].vw-email-sso").fill(users.user1.email);
await page.getByRole('button', { name: 'Use single sign-on' }).click();
await page.getByRole('textbox', { name: 'Master password (required)' }).fill(users.user1.password);
await page.getByRole('button', { name: 'Unlock' }).click();
await expect(page.getByRole('heading', { name: 'Update master password' })).toBeVisible();
});
});
test('Unlock trigger policyy', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
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('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('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 expect(page.getByRole('heading', { name: 'Update master password' })).toBeVisible();
});
+1 -1
View File
@@ -1,4 +1,4 @@
[toolchain]
channel = "1.97.1"
channel = "1.99.0"
components = [ "rustfmt", "clippy" ]
profile = "minimal"
+50 -18
View File
@@ -231,7 +231,7 @@ fn validate_token(token: &str) -> bool {
None => false,
Some(t) if t.starts_with("$argon2") => {
use argon2::password_hash::PasswordVerifier;
match argon2::password_hash::PasswordHash::new(t) {
match argon2::password_hash::phc::PasswordHash::new(t) {
Ok(h) => {
// NOTE: hash params from `ADMIN_TOKEN` are used instead of what is configured in the `Argon2` instance.
argon2::Argon2::default().verify_password(token.trim().as_ref(), &h).is_ok()
@@ -425,7 +425,7 @@ async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyR
for membership in memberships {
log_event(
EventType::OrganizationUserDeleted as i32,
EventType::OrganizationUserDeleted,
&membership.uuid,
&membership.org_uuid,
&ACTING_ADMIN_USER.into(),
@@ -446,7 +446,7 @@ async fn delete_sso_user(user_id: UserId, token: AdminToken, conn: DbConn) -> Em
for membership in memberships {
log_event(
EventType::OrganizationUserUnlinkedSso as i32,
EventType::OrganizationUserUnlinkedSso,
&membership.uuid,
&membership.org_uuid,
&ACTING_ADMIN_USER.into(),
@@ -508,6 +508,7 @@ async fn enable_user(user_id: UserId, _token: AdminToken, conn: DbConn) -> Empty
async fn remove_2fa(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult {
let mut user = get_user_or_404(&user_id, &conn).await?;
TwoFactor::delete_all_by_user(&user.uuid, &conn).await?;
Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?;
two_factor::enforce_2fa_policy(&user, &ACTING_ADMIN_USER.into(), 14, &token.ip.ip, &conn).await?;
user.totp_recover = None;
user.save(&conn).await
@@ -571,7 +572,7 @@ async fn update_membership_type(data: Json<MembershipTypeData>, token: AdminToke
OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?;
log_event(
EventType::OrganizationUserUpdated as i32,
EventType::OrganizationUserUpdated,
&member_to_edit.uuid,
&data.org_uuid,
&ACTING_ADMIN_USER.into(),
@@ -647,7 +648,7 @@ use cached::macros::cached;
/// Cache this function to prevent API call rate limit. Github only allows 60 requests per hour, and we use 3 here already
/// It will cache this function for 600 seconds (10 minutes) which should prevent the exhaustion of the rate limit
/// Any cache will be lost if Vaultwarden is restarted
#[cached(ttl = 600, sync_writes = "default")]
#[cached(ttl_secs = 600, sync_writes = "default")]
async fn get_release_info(has_http_access: bool) -> (String, String, String) {
// If the HTTP Check failed, do not even attempt to check for new versions since we were not able to connect with github.com anyway.
if has_http_access {
@@ -716,6 +717,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")]
async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> ApiResult<Html<String>> {
use chrono::prelude::*;
@@ -770,6 +801,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
"db_version": get_sql_server_version(&conn).await,
"admin_url": format!("{}/diagnostics", admin_url()),
"overrides": &CONFIG.get_overrides().join(", "),
"template_overrides": check_template_overrides().join(", "),
"invalid_feature_flags": invalid_feature_flags,
"host_arch": env::consts::ARCH,
"host_os": env::consts::OS,
@@ -874,23 +906,23 @@ mod tests {
fn validate_web_vault_compare() {
// web_vault_compare(active, latest)
// Test normal versions
assert!(web_vault_compare("2025.12.0", "2025.12.1") == -1);
assert!(web_vault_compare("2025.12.1", "2025.12.1") == 0);
assert!(web_vault_compare("2025.12.2", "2025.12.1") == 1);
assert_eq!(web_vault_compare("2025.12.0", "2025.12.1"), -1);
assert_eq!(web_vault_compare("2025.12.1", "2025.12.1"), 0);
assert_eq!(web_vault_compare("2025.12.2", "2025.12.1"), 1);
// Test patched/+build.n versions
// Newer latest version
assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1") == -1);
assert!(web_vault_compare("2025.12.1", "2025.12.1+build.1") == -1);
assert!(web_vault_compare("2025.12.0+build.1", "2025.12.1+build.1") == -1);
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.2") == -1);
assert_eq!(web_vault_compare("2025.12.0+build.1", "2025.12.1"), -1);
assert_eq!(web_vault_compare("2025.12.1", "2025.12.1+build.1"), -1);
assert_eq!(web_vault_compare("2025.12.0+build.1", "2025.12.1+build.1"), -1);
assert_eq!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.2"), -1);
// Equal versions
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.1") == 0);
assert!(web_vault_compare("2025.12.2+build.2", "2025.12.2+build.2") == 0);
assert_eq!(web_vault_compare("2025.12.1+build.1", "2025.12.1+build.1"), 0);
assert_eq!(web_vault_compare("2025.12.2+build.2", "2025.12.2+build.2"), 0);
// Newer active version
assert!(web_vault_compare("2025.12.1+build.1", "2025.12.1") == 1);
assert!(web_vault_compare("2025.12.2", "2025.12.1+build.1") == 1);
assert!(web_vault_compare("2025.12.2+build.1", "2025.12.1+build.1") == 1);
assert!(web_vault_compare("2025.12.1+build.3", "2025.12.1+build.2") == 1);
assert_eq!(web_vault_compare("2025.12.1+build.1", "2025.12.1"), 1);
assert_eq!(web_vault_compare("2025.12.2", "2025.12.1+build.1"), 1);
assert_eq!(web_vault_compare("2025.12.2+build.1", "2025.12.1+build.1"), 1);
assert_eq!(web_vault_compare("2025.12.1+build.3", "2025.12.1+build.2"), 1);
}
}
+571 -146
View File
File diff suppressed because it is too large. Load diff
+187 -65
View File
@@ -6,6 +6,7 @@ use rocket::{
Route,
form::{Form, FromForm},
fs::TempFile,
http::Status,
serde::json::Json,
};
use serde_json::Value;
@@ -21,14 +22,15 @@ use crate::{
DbConn, DbPool,
models::{
Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup,
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, Membership,
MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId,
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId,
Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, User, UserId,
is_data_blob_encrypted,
},
},
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file},
};
use super::folders::FolderData;
use super::{folders::FolderData, sends::SIZE_525_MB};
pub fn routes() -> Vec<Route> {
// Note that many routes have an `admin` variant; this seems to be
@@ -161,13 +163,19 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
let policies_json: Vec<Value> =
OrgPolicy::find_confirmed_by_user(&headers.user.uuid, &conn).await.iter().map(OrgPolicy::to_json).collect();
let policies_new_json: Vec<Value> = OrgPolicy::find_accepted_and_confirmed_by_user(&headers.user.uuid, &conn)
.await
.iter()
.map(OrgPolicy::to_json)
.collect();
let domains_json = if data.exclude_domains {
Value::Null
} else {
api::core::get_eq_domains(&headers, true).into_inner()
};
// This is very similar to the the userDecryptionOptions sent in connect/token,
// This is very similar to the userDecryptionOptions sent in connect/token,
// but as of 2025-12-19 they're both using different casing conventions.
let has_master_password = !headers.user.password_hash.is_empty();
let master_password_unlock = if has_master_password {
@@ -182,23 +190,29 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
"masterKeyEncryptedUserKey": headers.user.akey,
"masterKeyWrappedUserKey": headers.user.akey,
"salt": headers.user.email
"salt": headers.user.email,
"containedKeyId": headers.user.key_id,
})
} else {
Value::Null
};
// Upstream omits this when unset rather than sending null.
let mut user_decryption = json!({ "masterPasswordUnlock": master_password_unlock });
if let Some(key_id) = &headers.user.key_id {
user_decryption["userKeyId"] = json!(key_id);
}
Ok(Json(json!({
"profile": user_json,
"folders": folders_json,
"collections": collections_json,
"policies": policies_json,
"policiesNew": policies_new_json,
"ciphers": ciphers_json,
"domains": domains_json,
"sends": sends_json,
"userDecryption": {
"masterPasswordUnlock": master_password_unlock,
},
"userDecryption": user_decryption,
"object": "sync"
})))
}
@@ -260,15 +274,23 @@ pub struct CipherData {
key: Option<String>,
pub encrypted_for: UserId, // Added in web-v2025.6.0
// Added in web-v2025.8.1, Optional for compat
pub encrypted_by_key_id: Option<KeyId>,
/*
Login = 1,
SecureNote = 2,
Card = 3,
Identity = 4,
SshKey = 5
BankAccount = 6
DriversLicense = 7
Passport = 8
*/
pub r#type: i32,
pub name: String,
// Absent on a blob-encrypted cipher, whose name is sealed inside `data`
pub name: Option<String>,
pub notes: Option<String>,
fields: Option<Value>,
@@ -278,6 +300,12 @@ pub struct CipherData {
card: Option<Value>,
identity: Option<Value>,
ssh_key: Option<Value>,
bank_account: Option<Value>,
drivers_license: Option<Value>,
passport: Option<Value>,
// The sealed blob of a v2 account's cipher, which replaces all of the fields above
data: Option<String>,
favorite: Option<bool>,
reprompt: Option<i32>,
@@ -300,6 +328,70 @@ pub struct CipherData {
archived_date: Option<String>,
}
/// A field of a [`CipherData`] that fails upstream's model validation.
#[derive(Debug)]
pub struct CipherValidationError {
pub field: &'static str,
pub message: String,
}
impl From<CipherValidationError> for crate::Error {
fn from(e: CipherValidationError) -> Self {
Self::new_msg(e.message)
}
}
/// A user-owned cipher must be encrypted with the user's current key. Organization ciphers use the
/// organization key, which has no id yet, and either id may be missing: from a client that predates
/// the field, or a user whose key id isn't known yet. There is nothing to compare in those cases.
///
/// Ref: upstream's `CiphersController.ValidateCipherEncryptedByUser`
fn validate_encrypted_by_user_key(data: &CipherData, user: &User, is_org_cipher: bool) -> EmptyResult {
if !is_org_cipher
&& let (Some(cipher_key_id), Some(user_key_id)) = (&data.encrypted_by_key_id, &user.key_id)
&& cipher_key_id != user_key_id
{
err!("Cipher was not encrypted with the current user key. Please try again.")
}
Ok(())
}
/// Upstream's `[StringLength(500000)]` on `CipherRequestModel.Data`
const MAX_CIPHER_DATA_LENGTH: usize = 500_000;
impl CipherData {
/// Whether the content is a single blob rather than the per-type fields. This parses `data`, so
/// callers that need the answer more than once should keep it.
pub fn is_blob(&self) -> bool {
self.data.as_deref().is_some_and(is_data_blob_encrypted)
}
/// Checks the content the way upstream's model validation does, before anything is saved.
/// `is_blob` is [`Self::is_blob`].
///
/// Ref: <https://github.com/bitwarden/server/blob/main/src/Api/Vault/Models/Request/CipherRequestModel.cs>
pub fn validate_content(&self, is_blob: bool) -> Result<(), CipherValidationError> {
if let Some(data) = &self.data
&& data.len() > MAX_CIPHER_DATA_LENGTH
{
return Err(CipherValidationError {
field: "Data",
message: format!("The field Data must be a string with a maximum length of {MAX_CIPHER_DATA_LENGTH}."),
});
}
// A blob carries the name inside it, so only the other formats need one
if !is_blob && self.name.as_deref().is_none_or(|n| n.trim().is_empty()) {
return Err(CipherValidationError {
field: "Name",
message: String::from("The Name field is required."),
});
}
Ok(())
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PartialCipherData {
@@ -333,12 +425,17 @@ async fn post_ciphers_create(
) -> JsonResult {
let mut data: ShareCipherData = data.into_inner();
if data.cipher.encrypted_for != headers.user.uuid {
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
}
validate_encrypted_by_user_key(&data.cipher, &headers.user, data.cipher.organization_id.is_some())?;
// This check is usually only needed in update_cipher_from_data(), but we
// need it here as well to avoid creating an empty cipher in the call to
// cipher.save() below.
enforce_personal_ownership_policy(Some(&data.cipher), &headers, &conn).await?;
let mut cipher = Cipher::new(data.cipher.r#type, data.cipher.name.clone());
let mut cipher = Cipher::new(data.cipher.r#type, String::new());
cipher.user_uuid = Some(headers.user.uuid.clone());
cipher.save(&conn).await?;
@@ -362,13 +459,19 @@ async fn post_ciphers_create(
async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
let mut data: CipherData = data.into_inner();
if data.encrypted_for != headers.user.uuid {
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
}
validate_encrypted_by_user_key(&data, &headers.user, data.organization_id.is_some())?;
// The web/browser clients set this field to null as expected, but the
// mobile clients seem to set the invalid value `0001-01-01T00:00:00`,
// which results in a warning message being logged. This field isn't
// needed when creating a new cipher, so just ignore it unconditionally.
data.last_known_revision_date = None;
let mut cipher = Cipher::new(data.r#type, data.name.clone());
let mut cipher = Cipher::new(data.r#type, String::new());
update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherCreate).await?;
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?))
@@ -407,16 +510,19 @@ pub async fn update_cipher_from_data(
// We do not mind which data is in it, the keep our model more flexible when there are upstream changes.
// But, we at least know we do not need to store and return this specific key.
fn clean_cipher_data(mut json_data: Value) -> Value {
if json_data.is_array() {
json_data.as_array_mut().unwrap().iter_mut().for_each(|ref mut f| {
f.as_object_mut().unwrap().remove("response");
});
if let Some(items) = json_data.as_array_mut() {
for item in items.iter_mut().filter_map(Value::as_object_mut) {
item.remove("response");
}
}
json_data
}
enforce_personal_ownership_policy(Some(&data), headers, conn).await?;
let is_blob = data.is_blob();
data.validate_content(is_blob)?;
// Check that the client isn't updating an existing cipher with stale data.
// And only perform this check when not importing ciphers, else the date/time check will fail.
if ut != UpdateType::None
@@ -452,9 +558,10 @@ pub async fn update_cipher_from_data(
Some(member) => {
// 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.
// Write access to the cipher itself only counts when it already belongs to this organization.
if shared_to_collections.as_ref().is_some_and(|cols| !cols.is_empty())
|| member.has_full_access()
|| cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await
|| (!transfer_cipher && cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await)
{
cipher.organization_uuid = Some(org_id);
// After some discussion in PR #1329 re-added the user_uuid = None again.
@@ -510,38 +617,55 @@ pub async fn update_cipher_from_data(
3 => data.card,
4 => data.identity,
5 => data.ssh_key,
6 => data.bank_account,
7 => data.drivers_license,
8 => data.passport,
_ => err!("Invalid type"),
};
let type_data = if let Some(mut data) = type_data_opt {
// Remove the 'Response' key from the base object.
data.as_object_mut().unwrap().remove("response");
// Remove the 'Response' key from every Uri.
if data["uris"].is_array() {
data["uris"] = clean_cipher_data(data["uris"].clone());
}
data
if let Some(blob) = data.data.filter(|_| is_blob) {
// A blob holds all of the content, the name included, so nothing is kept outside it. The name
// column can't be null, so it's left empty; `to_json` reports it as null, as upstream does.
// TODO: Make `ciphers.name` nullable and store `None` here instead.
cipher.name = String::new();
cipher.notes = None;
cipher.fields = None;
cipher.password_history = None;
cipher.data = blob;
} else {
err!("Data missing")
};
let Some(mut type_data) = type_data_opt else {
err!("Data missing")
};
// Remove the 'Response' key from the base object.
if let Some(data_obj) = type_data.as_object_mut() {
data_obj.remove("response");
}
// Remove the 'Response' key from every Uri.
if type_data["uris"].is_array() {
type_data["uris"] = clean_cipher_data(type_data["uris"].clone());
}
// `validate_content` made sure there is a name
cipher.name = data.name.unwrap_or_default();
cipher.notes = data.notes;
cipher.fields = data.fields.map(|f| clean_cipher_data(f).to_string());
cipher.password_history = data.password_history.map(|f| f.to_string());
cipher.data = type_data.to_string();
}
cipher.key = data.key;
cipher.name = data.name;
cipher.notes = data.notes;
cipher.fields = data.fields.map(|f| clean_cipher_data(f).to_string());
cipher.data = type_data.to_string();
cipher.password_history = data.password_history.map(|f| f.to_string());
cipher.reprompt = data.reprompt.filter(|r| *r == RepromptType::None as i32 || *r == RepromptType::Password as i32);
cipher.save(conn).await?;
cipher.move_to_folder(data.folder_id, &headers.user.uuid, conn).await?;
cipher.set_favorite(data.favorite, &headers.user.uuid, conn).await?;
if let Some(dt_str) = data.archived_date {
match NaiveDateTime::parse_from_str(&dt_str, "%+") {
match data.archived_date {
Some(dt_str) => match NaiveDateTime::parse_from_str(&dt_str, "%+") {
Ok(dt) => cipher.set_archived_at(dt, &headers.user.uuid, conn).await?,
Err(err) => warn!("Error parsing ArchivedDate '{dt_str}': {err}"),
}
},
None => cipher.unarchive(&headers.user.uuid, conn).await?,
}
if ut != UpdateType::None {
@@ -553,16 +677,8 @@ pub async fn update_cipher_from_data(
(_, _) => EventType::CipherUpdated,
};
log_event(
event_type as i32,
&cipher.uuid,
org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
conn,
)
.await;
log_event(event_type, &cipher.uuid, org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn)
.await;
}
nt.send_cipher_update(
ut,
@@ -634,7 +750,7 @@ async fn post_ciphers_import(data: Json<ImportData>, headers: Headers, conn: DbC
let folder_id = relations_map.get(&index).and_then(|i| folders.get(*i).cloned());
cipher_data.folder_id = folder_id;
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
let mut cipher = Cipher::new(cipher_data.r#type, String::new());
update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?;
}
@@ -702,6 +818,8 @@ async fn put_cipher(
err!("Cipher is not write accessible")
}
validate_encrypted_by_user_key(&data, &headers.user, cipher.organization_uuid.is_some())?;
update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherUpdate).await?;
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?))
@@ -850,7 +968,7 @@ async fn post_collections_update(
.await;
log_event(
EventType::CipherUpdatedCollections as i32,
EventType::CipherUpdatedCollections,
&cipher.uuid,
org_uuid,
&headers.user.uuid,
@@ -870,7 +988,7 @@ async fn put_collections_admin(
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
) -> JsonResult {
post_collections_admin(cipher_id, data, headers, conn, nt).await
}
@@ -881,7 +999,7 @@ async fn post_collections_admin(
headers: Headers,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
) -> JsonResult {
let data: CollectionsAdminData = data.into_inner();
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else {
@@ -930,7 +1048,7 @@ async fn post_collections_admin(
.await;
log_event(
EventType::CipherUpdatedCollections as i32,
EventType::CipherUpdatedCollections,
&cipher.uuid,
org_uuid,
&headers.user.uuid,
@@ -940,7 +1058,7 @@ async fn post_collections_admin(
)
.await;
Ok(())
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::Organization, &conn).await?))
}
#[derive(Deserialize)]
@@ -1152,6 +1270,10 @@ async fn post_attachment_v2(
if file_size < 0 {
err!("Attachment size can't be negative")
}
// Matches the "file" upload limit in main.rs, nothing larger can be uploaded
if file_size > SIZE_525_MB {
err!("Max file size is 525 MB.")
}
let attachment_id = crypto::generate_attachment_id();
let attachment =
Attachment::new(attachment_id.clone(), cipher.uuid.clone(), data.file_name, file_size, Some(data.key));
@@ -1335,7 +1457,7 @@ async fn save_attachment(
if let Some(org_id) = &cipher.organization_uuid {
log_event(
EventType::CipherAttachmentCreated as i32,
EventType::CipherAttachmentCreated,
&cipher.uuid,
org_id,
&headers.user.uuid,
@@ -1696,7 +1818,7 @@ async fn purge_org_vault(
nt.send_user_update(UpdateType::SyncVault, &user, headers.device.push_uuid.as_ref(), &conn).await;
log_event(
EventType::OrganizationPurgedVault as i32,
EventType::OrganizationPurgedVault,
&organization.org_id,
&organization.org_id,
&user.uuid,
@@ -1824,9 +1946,9 @@ async fn delete_cipher_by_uuid(
let event_type = if *delete_options == CipherDeleteOptions::SoftSingle
|| *delete_options == CipherDeleteOptions::SoftMulti
{
EventType::CipherSoftDeleted as i32
EventType::CipherSoftDeleted
} else {
EventType::CipherDeleted as i32
EventType::CipherDeleted
};
log_event(event_type, &cipher.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, conn)
@@ -1839,7 +1961,7 @@ async fn delete_cipher_by_uuid(
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CipherIdsData {
ids: Vec<CipherId>,
ids: HashSet<CipherId>,
}
async fn delete_multiple_ciphers(
@@ -1895,7 +2017,7 @@ async fn restore_cipher_by_uuid(
if let Some(org_id) = &cipher.organization_uuid {
log_event(
EventType::CipherRestored as i32,
EventType::CipherRestored,
&cipher.uuid.clone(),
org_id,
&headers.user.uuid,
@@ -1972,7 +2094,7 @@ async fn delete_cipher_attachment_by_id(
if let Some(ref org_id) = cipher.organization_uuid {
log_event(
EventType::CipherAttachmentDeleted as i32,
EventType::CipherAttachmentDeleted,
&cipher.uuid,
org_id,
&headers.user.uuid,
@@ -2150,8 +2272,15 @@ impl CipherSyncData {
}
}
// Generate a HashMap with the Organization UUID as key and the Membership record
let members: HashMap<OrganizationId, Membership> = Membership::find_confirmed_by_user(user_id, conn)
.await
.into_iter()
.map(|m| (m.org_uuid.clone(), m))
.collect();
// Generate a list of Cipher UUID's containing a Vec with one or more Attachment records
let orgs = Membership::get_orgs_by_user(user_id, conn).await;
let orgs: Vec<OrganizationId> = members.keys().cloned().collect();
let attachments = Attachment::find_all_by_user_and_orgs(user_id, &orgs, conn).await;
let mut cipher_attachments: HashMap<CipherId, Vec<Attachment>> = HashMap::with_capacity(attachments.len());
for attachment in attachments {
@@ -2166,13 +2295,6 @@ impl CipherSyncData {
cipher_collections.entry(cipher).or_default().push(collection);
}
// Generate a HashMap with the Organization UUID as key and the Membership record
let members: HashMap<OrganizationId, Membership> = Membership::find_confirmed_by_user(user_id, conn)
.await
.into_iter()
.map(|m| (m.org_uuid.clone(), m))
.collect();
// Generate a HashMap with the User_Collections UUID as key and the CollectionUser record
let user_collections: HashMap<CollectionId, CollectionUser> = CollectionUser::find_by_user(user_id, conn)
.await
+1 -1
View File
@@ -225,7 +225,7 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
let (grantee_user, new_user) = match User::find_by_mail(&email, &conn).await {
None => {
if !CONFIG.invitations_allowed() {
err!(format!("Grantee user does not exist: {email}"))
err!(format!("Grantee user does not exist: {}", email.escape_debug()))
}
if !CONFIG.is_email_domain_allowed(&email) {
+43 -26
View File
@@ -6,12 +6,13 @@ use serde_json::Value;
use crate::{
CONFIG,
api::{EmptyResult, JsonResult},
api::{ApiResult, EmptyResult, JsonResult},
auth::{AdminHeaders, Headers},
db::{
DbConn, DbPool,
models::{Cipher, CipherId, Event, Membership, MembershipId, OrganizationId, UserId},
models::{Cipher, CipherId, Event, EventType, Membership, MembershipId, OrganizationId, UserId},
},
error::MapResult,
util::parse_date,
};
@@ -29,6 +30,14 @@ struct EventRange {
continuation_token: Option<String>,
}
impl EventRange {
fn date_range(&self) -> ApiResult<(NaiveDateTime, NaiveDateTime)> {
let start_date = parse_date(&self.start).map_res("Invalid start date")?;
let end_date = parse_date(self.continuation_token.as_ref().unwrap_or(&self.end)).map_res("Invalid end date")?;
Ok((start_date, end_date))
}
}
// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/EventsController.cs#L87
#[get("/organizations/<org_id>/events?<data..>")]
async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: AdminHeaders, conn: DbConn) -> JsonResult {
@@ -39,12 +48,7 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin
// Return an empty vec when we org events are disabled.
// This prevents client errors
let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
let start_date = parse_date(&data.start);
let end_date = if let Some(before_date) = &data.continuation_token {
parse_date(before_date)
} else {
parse_date(&data.end)
};
let (start_date, end_date) = data.date_range()?;
Event::find_by_organization_uuid(&org_id, &start_date, &end_date, &conn)
.await
@@ -69,12 +73,7 @@ async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Heade
let events_json: Vec<Value> = if CONFIG.org_events_enabled()
&& Membership::user_has_ge_admin_access_to_cipher(&headers.user.uuid, &cipher_id, &conn).await
{
let start_date = parse_date(&data.start);
let end_date = if let Some(before_date) = &data.continuation_token {
parse_date(before_date)
} else {
parse_date(&data.end)
};
let (start_date, end_date) = data.date_range()?;
Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn).await.iter().map(Event::to_json).collect()
} else {
@@ -102,12 +101,7 @@ async fn get_user_events(
// Return an empty vec when we org events are disabled.
// This prevents client errors
let events_json: Vec<Value> = if CONFIG.org_events_enabled() {
let start_date = parse_date(&data.start);
let end_date = if let Some(before_date) = &data.continuation_token {
parse_date(before_date)
} else {
parse_date(&data.end)
};
let (start_date, end_date) = data.date_range()?;
Event::find_by_org_and_member(&org_id, &member_id, &start_date, &end_date, &conn)
.await
@@ -168,9 +162,10 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
}
for event in data.iter() {
let event_date = parse_date(&event.date);
let event_date = parse_date(&event.date).map_res("Invalid event date")?;
// Only the event types upstream accepts from clients, the rest are written server-side.
match event.r#type {
1000..=1099 => {
t if t == EventType::UserClientExportedVault as i32 => {
log_user_event_impl(
event.r#type,
&headers.user.uuid,
@@ -181,7 +176,7 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
)
.await;
}
1600..=1699 => {
t if t == EventType::OrganizationClientExportedVault as i32 => {
// 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()
@@ -199,7 +194,28 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
.await;
}
}
_ => {
// Only the vault notification banner click is accepted from clients. The rest of
// the 1500..=1599 range is written server-side and must not be forgeable by a client.
t if t == EventType::OrganizationUserNotificationBannerActionClicked as i32 => {
if let Some(org_id) = &event.organization_id
&& let Some(membership) =
Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await
{
log_event_impl(
event.r#type,
&membership.uuid,
org_id,
&headers.user.uuid,
headers.device.atype,
Some(event_date),
&headers.ip.ip,
&conn,
)
.await;
}
}
// Client-side cipher events (viewed, copied, toggled, autofilled).
1107..=1114 | 1117 | 1119..=1132 => {
// 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
@@ -220,6 +236,7 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
.await;
}
}
_ => {}
}
}
Ok(())
@@ -267,7 +284,7 @@ async fn log_user_event_impl(
}
pub async fn log_event(
event_type: i32,
event_type: EventType,
source_uuid: &str,
org_id: &OrganizationId,
act_user_id: &UserId,
@@ -278,7 +295,7 @@ pub async fn log_event(
if !CONFIG.org_events_enabled() {
return;
}
log_event_impl(event_type, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await;
log_event_impl(event_type as i32, source_uuid, org_id, act_user_id, device_type, None, ip, conn).await;
}
#[expect(clippy::too_many_arguments)]
+1 -1
View File
@@ -238,7 +238,7 @@ fn config() -> Json<Value> {
"disableUserRegistration": CONFIG.is_signup_disabled(),
// When enabled, this setting signals to clients that onboarding interstitials
// (post-login welcome dialogs, extension install prompts, setup extension redirects, and premium upsell modals) should be suppressed
"suppressOnboardingInterstitials": false
"suppressOnboardingInterstitials": CONFIG.client_suppress_onboarding(),
},
"environment": {
"vault": domain,
+118 -283
View File
@@ -1,7 +1,7 @@
use std::collections::{HashMap, HashSet};
use num_traits::FromPrimitive;
use rocket::{Route, serde::json::Json};
use rocket::{Route, http::Status, serde::json::Json};
use serde_json::Value;
use crate::{
@@ -15,9 +15,10 @@ use crate::{
db::{
DbConn,
models::{
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType,
Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType,
OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, User, UserId,
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, Device,
EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus,
MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor,
TwoFactorType, User, UserId,
},
},
mail,
@@ -30,7 +31,6 @@ pub fn routes() -> Vec<Route> {
get_organization,
create_organization,
delete_organization,
post_delete_organization,
leave_organization,
get_user_collections,
get_org_collections,
@@ -38,13 +38,10 @@ pub fn routes() -> Vec<Route> {
get_org_collection_detail,
get_collection_users,
put_organization,
post_organization,
post_organization_collections,
post_bulk_access_collections,
post_organization_collection_update,
put_organization_collection_update,
delete_organization_collection,
post_organization_collection_delete,
bulk_delete_organization_collections,
post_bulk_collections,
get_org_details,
@@ -59,7 +56,6 @@ pub fn routes() -> Vec<Route> {
get_org_user_mini_details,
get_user,
edit_member,
put_member,
delete_member,
bulk_delete_member,
post_org_import,
@@ -69,7 +65,6 @@ pub fn routes() -> Vec<Route> {
get_master_password_policy,
get_policy,
put_policy,
put_policy_vnext,
get_plans,
post_org_keys,
get_organization_keys,
@@ -85,22 +80,16 @@ pub fn routes() -> Vec<Route> {
post_groups,
get_group,
put_group,
post_group,
get_group_details,
delete_group,
post_delete_group,
bulk_delete_groups,
get_group_members,
put_group_members,
post_delete_group_member,
put_reset_password_enrollment,
get_reset_password_details,
put_reset_password,
put_recover_account,
get_org_export,
post_api_key,
rotate_api_key,
get_billing_metadata,
get_billing_warnings,
get_auto_enroll_status,
get_self_host_billing_metadata,
@@ -132,7 +121,6 @@ struct FullCollectionData {
name: String,
groups: Vec<CollectionGroupData>,
users: Vec<CollectionMembershipData>,
id: Option<CollectionId>,
external_id: Option<String>,
}
@@ -245,16 +233,6 @@ async fn delete_organization(
}
}
#[post("/organizations/<org_id>/delete", data = "<data>")]
async fn post_delete_organization(
org_id: OrganizationId,
data: Json<PasswordOrOtpData>,
headers: OwnerHeaders,
conn: DbConn,
) -> EmptyResult {
delete_organization(org_id, data, headers, conn).await
}
#[post("/organizations/<org_id>/leave")]
async fn leave_organization(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> EmptyResult {
if headers.membership.status != MembershipStatus::Confirmed as i32 {
@@ -269,7 +247,7 @@ async fn leave_organization(org_id: OrganizationId, headers: OrgMemberHeaders, c
}
log_event(
EventType::OrganizationUserLeft as i32,
EventType::OrganizationUserLeft,
&membership.uuid,
&org_id,
&headers.user.uuid,
@@ -300,16 +278,6 @@ async fn put_organization(
headers: OwnerHeaders,
data: Json<OrganizationUpdateData>,
conn: DbConn,
) -> JsonResult {
post_organization(org_id, headers, data, conn).await
}
#[post("/organizations/<org_id>", data = "<data>")]
async fn post_organization(
org_id: OrganizationId,
headers: OwnerHeaders,
data: Json<OrganizationUpdateData>,
conn: DbConn,
) -> JsonResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
@@ -327,7 +295,7 @@ async fn post_organization(
org.save(&conn).await?;
log_event(
EventType::OrganizationUpdated as i32,
EventType::OrganizationUpdated,
org_id.as_ref(),
&org_id,
&headers.user.uuid,
@@ -391,7 +359,7 @@ async fn get_org_collections(org_id: OrganizationId, headers: ManagerHeadersLoos
}
if !headers.membership.has_full_access() {
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code);
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
}
Ok(Json(json!({
@@ -415,8 +383,9 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea
let col_users = CollectionUser::find_by_organization_swap_user_uuid_with_member_uuid(&org_id, &conn).await;
// Generate a HashMap to get the correct MembershipType per user to determine the manage permission
// We use the uuid instead of the user_uuid here, since that is what is used in CollectionUser
// This lists other members for admins, so it must not depend on the membership status
let membership_type: HashMap<MembershipId, i32> =
Membership::find_confirmed_by_org(&org_id, &conn).await.into_iter().map(|m| (m.uuid, m.atype)).collect();
Membership::find_by_org(&org_id, &conn).await.into_iter().map(|m| (m.uuid, m.atype)).collect();
// check if current user has full access to the organization (either directly or via any group)
let has_full_access_to_org = member.has_full_access()
@@ -514,7 +483,7 @@ async fn post_organization_collections(
collection.save(&conn).await?;
log_event(
EventType::CollectionCreated as i32,
EventType::CollectionCreated,
&collection.uuid,
&org_id,
&headers.user.uuid,
@@ -597,7 +566,7 @@ async fn post_bulk_access_collections(
collection.save(&conn).await?;
log_event(
EventType::CollectionUpdated as i32,
EventType::CollectionUpdated,
&collection.uuid,
&org_id,
&headers.user.uuid,
@@ -639,17 +608,6 @@ async fn put_organization_collection_update(
headers: ManagerHeaders,
data: Json<FullCollectionData>,
conn: DbConn,
) -> JsonResult {
post_organization_collection_update(org_id, col_id, headers, data, conn).await
}
#[post("/organizations/<org_id>/collections/<col_id>", data = "<data>", rank = 2)]
async fn post_organization_collection_update(
org_id: OrganizationId,
col_id: CollectionId,
headers: ManagerHeaders,
data: Json<FullCollectionData>,
conn: DbConn,
) -> JsonResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
@@ -674,7 +632,7 @@ async fn post_organization_collection_update(
collection.save(&conn).await?;
log_event(
EventType::CollectionUpdated as i32,
EventType::CollectionUpdated,
&collection.uuid,
&org_id,
&headers.user.uuid,
@@ -723,7 +681,7 @@ async fn delete_organization_collection_impl(
err!("Collection not found", "Collection does not exist or does not belong to this organization")
};
log_event(
EventType::CollectionDeleted as i32,
EventType::CollectionDeleted,
&collection.uuid,
org_id,
&headers.user.uuid,
@@ -745,16 +703,6 @@ async fn delete_organization_collection(
delete_organization_collection_impl(&org_id, &col_id, &headers, &conn).await
}
#[post("/organizations/<org_id>/collections/<col_id>/delete")]
async fn post_organization_collection_delete(
org_id: OrganizationId,
col_id: CollectionId,
headers: ManagerHeaders,
conn: DbConn,
) -> EmptyResult {
delete_organization_collection_impl(&org_id, &col_id, &headers, &conn).await
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct BulkCollectionIds {
@@ -818,11 +766,9 @@ async fn get_org_collection_detail(
// Generate a HashMap to get the correct MembershipType per user to determine the manage permission
// We use the uuid instead of the user_uuid here, since that is what is used in CollectionUser
let membership_type: HashMap<MembershipId, i32> = Membership::find_confirmed_by_org(&org_id, &conn)
.await
.into_iter()
.map(|m| (m.uuid, m.atype))
.collect();
// This lists other members for admins, so it must not depend on the membership status
let membership_type: HashMap<MembershipId, i32> =
Membership::find_by_org(&org_id, &conn).await.into_iter().map(|m| (m.uuid, m.atype)).collect();
let users: Vec<Value> =
CollectionUser::find_by_org_and_coll_swap_user_uuid_with_member_uuid(&org_id, &collection.uuid, &conn)
@@ -887,11 +833,11 @@ struct OrgIdData {
#[get("/ciphers/organization-details?<data..>")]
async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult {
if data.organization_id != headers.membership.org_uuid {
err_code!("Resource not found.", "Organization id's do not match", rocket::http::Status::NotFound.code);
err_code!("Resource not found.", "Organization id's do not match", Status::NotFound.code);
}
if !headers.membership.has_full_access() {
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code);
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
}
Ok(Json(json!({
@@ -955,13 +901,14 @@ async fn get_members(
}
if !headers.membership.has_full_access() {
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code);
err_code!("Resource not found.", "User does not have full access", Status::NotFound.code);
}
let mut users_json = Vec::new();
for u in Membership::find_by_org(&org_id, &conn).await {
// The user can be a manager instead of an admin, but we've checked above that they have full access
users_json.push(
u.to_json_user_details(
u.to_json_details_for_admin(
data.include_collections.unwrap_or(false),
data.include_groups.unwrap_or(false),
&conn,
@@ -1075,13 +1022,13 @@ async fn send_invite(
&& data.permissions.get("deleteAnyCollection") == Some(&json!(true))
&& data.permissions.get("createNewCollections") == Some(&json!(true)));
let mut user_created: bool = false;
let mut user_created: bool;
for email in &data.emails {
let mut member_status = MembershipStatus::Invited as i32;
let user = match User::find_by_mail(email, &conn).await {
None => {
if !CONFIG.invitations_allowed() {
err!(format!("User does not exist: {email}"))
err!(format!("User does not exist: {}", email.escape_debug()))
}
if !CONFIG.is_email_domain_allowed(email) {
@@ -1099,7 +1046,7 @@ async fn send_invite(
}
Some(user) => {
if Membership::find_by_user_and_org(&user.uuid, &org_id, &conn).await.is_some() {
err!(format!("User already in organization: {email}"))
err!(format!("User already in organization: {}", email.escape_debug()))
}
if !CONFIG.mail_enabled() {
@@ -1110,6 +1057,7 @@ async fn send_invite(
member_status = MembershipStatus::Accepted as i32;
}
}
user_created = false;
user
}
};
@@ -1148,7 +1096,7 @@ async fn send_invite(
}
log_event(
EventType::OrganizationUserInvited as i32,
EventType::OrganizationUserInvited,
&new_member.uuid,
&org_id,
&headers.user.uuid,
@@ -1373,7 +1321,9 @@ async fn bulk_confirm_invite(
match data.keys {
Some(keys) => {
for invite in keys {
let member_id = invite.id.unwrap();
let Some(member_id) = invite.id else {
err!("Member id is required")
};
let user_key = invite.key.unwrap_or_default();
let err_msg = match confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await {
Ok(()) => String::new(),
@@ -1447,7 +1397,7 @@ async fn confirm_invite_impl(
OrgPolicy::check_user_allowed(&member_to_confirm, "confirm", conn).await?;
log_event(
EventType::OrganizationUserConfirmed as i32,
EventType::OrganizationUserConfirmed,
&member_to_confirm.uuid,
org_id,
&headers.user.uuid,
@@ -1515,7 +1465,9 @@ async fn get_user(
// In this case, when groups are requested we also need to include collections.
// Else these will not be shown in the interface, and could lead to missing collections when saved.
let include_groups = data.include_groups.unwrap_or(false);
Ok(Json(user.to_json_user_details(data.include_collections.unwrap_or(include_groups), include_groups, &conn).await))
Ok(Json(
user.to_json_details_for_admin(data.include_collections.unwrap_or(include_groups), include_groups, &conn).await,
))
}
#[derive(Deserialize)]
@@ -1529,17 +1481,6 @@ struct EditUserData {
}
#[put("/organizations/<org_id>/users/<member_id>", data = "<data>", rank = 1)]
async fn put_member(
org_id: OrganizationId,
member_id: MembershipId,
data: Json<EditUserData>,
headers: AdminHeaders,
conn: DbConn,
) -> EmptyResult {
edit_member(org_id, member_id, data, headers, conn).await
}
#[post("/organizations/<org_id>/users/<member_id>", data = "<data>", rank = 1)]
async fn edit_member(
org_id: OrganizationId,
member_id: MembershipId,
@@ -1637,7 +1578,7 @@ async fn edit_member(
}
log_event(
EventType::OrganizationUserUpdated as i32,
EventType::OrganizationUserUpdated,
&member_to_edit.uuid,
&org_id,
&headers.user.uuid,
@@ -1724,7 +1665,7 @@ async fn delete_member_impl(
}
log_event(
EventType::OrganizationUserRemoved as i32,
EventType::OrganizationUserRemoved,
&member_to_delete.uuid,
org_id,
&headers.user.uuid,
@@ -1793,11 +1734,22 @@ async fn bulk_public_keys(
use super::ciphers::CipherData;
use super::ciphers::update_cipher_from_data;
// The import endpoint only ever uses the name/id/external_id of a collection.
// Bitwarden's own server ignores `groups`/`users` here too, so do not make them
// mandatory: clients are free to leave them out.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ImportCollectionData {
name: String,
id: Option<CollectionId>,
external_id: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ImportData {
ciphers: Vec<CipherData>,
collections: Vec<FullCollectionData>,
collections: Vec<ImportCollectionData>,
collection_relationships: Vec<RelationsData>,
}
@@ -1823,6 +1775,10 @@ async fn post_org_import(
if org_id != headers.membership.org_uuid {
err!("Organization not found", "Organization id's do not match");
}
// OrgMemberHeaders also allows invited and accepted members, which are not allowed to import
if headers.membership.status != MembershipStatus::Confirmed as i32 {
err!("You need to be a Member of the Organization to call this endpoint")
}
let data: ImportData = data.into_inner();
// Validate the import before continuing
@@ -1873,7 +1829,7 @@ async fn post_org_import(
cipher_data.folder_id = None;
// Replace the client-provided, unvalidated organizationId with the real target org
cipher_data.organization_id = Some(org_id.clone());
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
let mut cipher = Cipher::new(cipher_data.r#type, String::new());
update_cipher_from_data(
&mut cipher,
cipher_data,
@@ -1904,7 +1860,7 @@ async fn post_org_import(
#[serde(rename_all = "camelCase")]
struct BulkCollectionsData {
organization_id: OrganizationId,
cipher_ids: Vec<CipherId>,
cipher_ids: HashSet<CipherId>,
collection_ids: HashSet<CollectionId>,
remove_collections: bool,
}
@@ -2144,7 +2100,7 @@ async fn put_policy(
}
log_event(
EventType::OrganizationUserRemoved as i32,
EventType::OrganizationUserRemoved,
&member.uuid,
&org_id,
&headers.user.uuid,
@@ -2170,7 +2126,7 @@ async fn put_policy(
policy.save(&conn).await?;
log_event(
EventType::PolicyUpdated as i32,
EventType::PolicyUpdated,
policy.uuid.as_ref(),
&org_id,
&headers.user.uuid,
@@ -2183,18 +2139,6 @@ async fn put_policy(
Ok(Json(policy.to_json()))
}
// Deprecated with client v2026.5.0
#[put("/organizations/<org_id>/policies/<pol_type>/vnext", data = "<data>")]
async fn put_policy_vnext(
org_id: OrganizationId,
pol_type: i32,
data: Json<PutPolicy>,
headers: AdminHeaders,
conn: DbConn,
) -> JsonResult {
put_policy(org_id, pol_type, data, headers, conn).await
}
#[get("/plans")]
fn get_plans() -> Json<Value> {
// Respond with a minimal json just enough to allow the creation of an new organization.
@@ -2223,12 +2167,6 @@ fn get_plans() -> Json<Value> {
}))
}
#[get("/organizations/<_org_id>/billing/metadata")]
fn get_billing_metadata(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json<Value> {
// Prevent a 404 error, which also causes Javascript errors.
Json(empty_data_json())
}
#[get("/organizations/<_org_id>/billing/vnext/warnings")]
fn get_billing_warnings(_org_id: OrganizationId, _headers: OrgMemberHeaders) -> Json<Value> {
Json(json!({
@@ -2248,14 +2186,6 @@ fn get_self_host_billing_metadata(_org_id: OrganizationId, _headers: OrgMemberHe
}))
}
fn empty_data_json() -> Value {
json!({
"object": "list",
"data": [],
"continuationToken": null
})
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct BulkRevokeMembershipIds {
@@ -2339,7 +2269,7 @@ async fn revoke_member_impl(
member.save(conn).await?;
log_event(
EventType::OrganizationUserRevoked as i32,
EventType::OrganizationUserRevoked,
&member.uuid,
org_id,
&headers.user.uuid,
@@ -2437,7 +2367,7 @@ async fn restore_member_impl(
member.save(conn).await?;
log_event(
EventType::OrganizationUserRestored as i32,
EventType::OrganizationUserRestored,
&member.uuid,
org_id,
&headers.user.uuid,
@@ -2476,7 +2406,7 @@ async fn get_groups_data(
|| 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);
err_code!("Resource not found.", "User does not have access", Status::NotFound.code);
}
let groups: Vec<Value> = if CONFIG.org_groups_enabled() {
@@ -2574,17 +2504,6 @@ impl CollectionData {
}
}
#[post("/organizations/<org_id>/groups/<group_id>", data = "<data>")]
async fn post_group(
org_id: OrganizationId,
group_id: GroupId,
data: Json<GroupRequest>,
headers: AdminHeaders,
conn: DbConn,
) -> JsonResult {
put_group(org_id, group_id, data, headers, conn).await
}
#[post("/organizations/<org_id>/groups", data = "<data>")]
async fn post_groups(
org_id: OrganizationId,
@@ -2605,7 +2524,7 @@ async fn post_groups(
let group = group_request.to_group(&org_id);
log_event(
EventType::GroupCreated as i32,
EventType::GroupCreated,
&group.uuid,
&org_id,
&headers.user.uuid,
@@ -2646,7 +2565,7 @@ async fn put_group(
GroupUser::delete_all_by_group(&group_id, &org_id, &conn).await?;
log_event(
EventType::GroupUpdated as i32,
EventType::GroupUpdated,
&updated_group.uuid,
&org_id,
&headers.user.uuid,
@@ -2679,7 +2598,7 @@ async fn add_update_group(
user_entry.save(conn).await?;
log_event(
EventType::OrganizationUserUpdatedGroups as i32,
EventType::OrganizationUserUpdatedGroups,
&assigned_member,
&org_id,
&headers.user.uuid,
@@ -2694,7 +2613,6 @@ async fn add_update_group(
"id": group.uuid,
"organizationId": group.organizations_uuid,
"name": group.name,
"accessAll": group.access_all,
"externalId": group.external_id,
"object": "group"
})))
@@ -2721,16 +2639,6 @@ async fn get_group_details(
Ok(Json(group.to_json_details(&conn).await))
}
#[post("/organizations/<org_id>/groups/<group_id>/delete")]
async fn post_delete_group(
org_id: OrganizationId,
group_id: GroupId,
headers: AdminHeaders,
conn: DbConn,
) -> EmptyResult {
delete_group_impl(&org_id, &group_id, &headers, &conn).await
}
#[delete("/organizations/<org_id>/groups/<group_id>")]
async fn delete_group(org_id: OrganizationId, group_id: GroupId, headers: AdminHeaders, conn: DbConn) -> EmptyResult {
delete_group_impl(&org_id, &group_id, &headers, &conn).await
@@ -2754,7 +2662,7 @@ async fn delete_group_impl(
};
log_event(
EventType::GroupDeleted as i32,
EventType::GroupDeleted,
&group.uuid,
org_id,
&headers.user.uuid,
@@ -2832,90 +2740,6 @@ async fn get_group_members(
Ok(Json(json!(group_members)))
}
#[put("/organizations/<org_id>/groups/<group_id>/users", data = "<data>")]
async fn put_group_members(
org_id: OrganizationId,
group_id: GroupId,
headers: AdminHeaders,
data: Json<Vec<MembershipId>>,
conn: DbConn,
) -> EmptyResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
if !CONFIG.org_groups_enabled() {
err!("Group support is disabled");
}
if Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await.is_none() {
err!("Group could not be found!", "Group uuid is invalid or does not belong to the organization")
}
let assigned_members = data.into_inner();
let org_memberships = Membership::find_by_org(&org_id, &conn).await;
let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect();
if let Some(e) = assigned_members.iter().find(|m| !org_membership_ids.contains(m)) {
err!("Invalid member", format!("Member {} does not belong to organization {}!", e, org_id))
}
GroupUser::delete_all_by_group(&group_id, &org_id, &conn).await?;
for assigned_member in assigned_members {
let mut user_entry = GroupUser::new(group_id.clone(), assigned_member.clone());
user_entry.save(&conn).await?;
log_event(
EventType::OrganizationUserUpdatedGroups as i32,
&assigned_member,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
}
Ok(())
}
#[post("/organizations/<org_id>/groups/<group_id>/delete-user/<member_id>")]
async fn post_delete_group_member(
org_id: OrganizationId,
group_id: GroupId,
member_id: MembershipId,
headers: AdminHeaders,
conn: DbConn,
) -> EmptyResult {
if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match");
}
if !CONFIG.org_groups_enabled() {
err!("Group support is disabled");
}
if Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await.is_none() {
err!("User could not be found or does not belong to the organization.");
}
if Group::find_by_uuid_and_org(&group_id, &org_id, &conn).await.is_none() {
err!("Group could not be found or does not belong to the organization.");
}
log_event(
EventType::OrganizationUserUpdatedGroups as i32,
&member_id,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
GroupUser::delete_by_group_and_member(&group_id, &member_id, &conn).await
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct OrganizationUserResetPasswordEnrollmentRequest {
@@ -2927,8 +2751,8 @@ struct OrganizationUserResetPasswordEnrollmentRequest {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct OrganizationUserRecoverAccountRequest {
new_master_password_hash: String,
key: String,
new_master_password_hash: Option<String>,
key: Option<String>,
#[serde(default)]
reset_master_password: bool,
@@ -2971,24 +2795,6 @@ async fn put_recover_account(
data: Json<OrganizationUserRecoverAccountRequest>,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
let req = data.into_inner();
if req.reset_master_password && !req.reset_two_factor {
recover_account(org_id, member_id, headers, req, conn, nt).await
} else {
err!("Unsupported operation")
}
}
// Deprecated since `v2026.4.2`
#[put("/organizations/<org_id>/users/<member_id>/reset-password", data = "<data>")]
async fn put_reset_password(
org_id: OrganizationId,
member_id: MembershipId,
headers: AdminHeaders,
data: Json<OrganizationUserRecoverAccountRequest>,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
recover_account(org_id, member_id, headers, data.into_inner(), conn, nt).await
}
@@ -2997,7 +2803,7 @@ async fn recover_account(
org_id: OrganizationId,
member_id: MembershipId,
headers: AdminHeaders,
reset_request: OrganizationUserRecoverAccountRequest,
req: OrganizationUserRecoverAccountRequest,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
@@ -3012,7 +2818,7 @@ async fn recover_account(
err!("User to reset isn't member of required organization")
};
let Some(user) = User::find_by_uuid(&member.user_uuid, &conn).await else {
let Some(mut user) = User::find_by_uuid(&member.user_uuid, &conn).await else {
err!("User not found")
};
@@ -3025,29 +2831,58 @@ async fn recover_account(
err!("Organization user must be confirmed for password reset functionality");
}
// Sending email before resetting password to ensure working email configuration and the resulting
// user notification. Also this might add some protection against security flaws and misuse
if let Err(e) = mail::send_admin_reset_password(&user.email, user.display_name(), &org.name).await {
let fallback_2fa_email =
if req.reset_two_factor && CONFIG.mail_enabled() && CONFIG.email_2fa_auto_fallback() && user.verified() {
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await.is_none()
} else {
false
};
// Sending email first ensure working email configuration and the resulting user notification.
// Also this might add some protection against security flaws and misuse
if let Err(e) = mail::send_admin_account_recovery(
&user.email,
user.display_name(),
&org.name,
req.reset_master_password,
req.reset_two_factor,
fallback_2fa_email,
)
.await
{
err!(format!("Error sending user reset password email: {e:#?}"));
}
let mut user = user;
user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None, &conn)
.await?;
if req.reset_master_password {
if let Some(key) = req.key
&& let Some(hash) = req.new_master_password_hash
{
user.set_password(hash.as_str(), Some(key), true, None, &conn).await?;
} else {
err_code!("Unprocessable request", "Missing fields to reset password", Status::UnprocessableEntity.code);
}
}
if req.reset_two_factor {
TwoFactor::delete_all_by_user(&user.uuid, &conn).await?;
Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?;
if !fallback_2fa_email || two_factor::email::find_and_activate_email_2fa(&user.uuid, &conn).await.is_err() {
two_factor::enforce_2fa_policy(&user, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await?;
}
}
user.save(&conn).await?;
nt.send_logout(&user, None, &conn).await;
log_event(
EventType::OrganizationUserAdminResetPassword as i32,
&member_id,
&org_id,
&headers.user.uuid,
headers.device.atype,
&headers.ip.ip,
&conn,
)
.await;
if req.reset_master_password {
headers.log_event(EventType::OrganizationUserAdminResetPassword, &member_id, &org_id, &conn).await;
}
if req.reset_two_factor {
headers.log_event(EventType::OrganizationUserAdminResetTwoFactor, &member_id, &org_id, &conn).await;
}
Ok(())
}
@@ -3166,9 +3001,9 @@ async fn put_reset_password_enrollment(
membership.save(&conn).await?;
let event_type = if membership.reset_password_key.is_some() {
EventType::OrganizationUserResetPasswordEnroll as i32
EventType::OrganizationUserResetPasswordEnroll
} else {
EventType::OrganizationUserResetPasswordWithdraw as i32
EventType::OrganizationUserResetPasswordWithdraw
};
log_event(event_type, &membership.uuid, &org_id, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn)
@@ -3199,7 +3034,7 @@ async fn api_key(
org_id: &OrganizationId,
data: Json<PasswordOrOtpData>,
rotate: bool,
headers: AdminHeaders,
headers: OwnerHeaders,
conn: DbConn,
) -> JsonResult {
if org_id != &headers.org_id {
@@ -3208,7 +3043,7 @@ async fn api_key(
let data: PasswordOrOtpData = data.into_inner();
let user = headers.user;
// Validate the admin users password/otp
// Validate the owner users password/otp
data.validate(&user, true, &conn).await?;
let org_api_key = if let Some(mut org_api_key) = OrganizationApiKey::find_by_org_uuid(org_id, &conn).await {
@@ -3236,7 +3071,7 @@ async fn api_key(
async fn post_api_key(
org_id: OrganizationId,
data: Json<PasswordOrOtpData>,
headers: AdminHeaders,
headers: OwnerHeaders,
conn: DbConn,
) -> JsonResult {
api_key(&org_id, data, false, headers, conn).await
@@ -3246,7 +3081,7 @@ async fn post_api_key(
async fn rotate_api_key(
org_id: OrganizationId,
data: Json<PasswordOrOtpData>,
headers: AdminHeaders,
headers: OwnerHeaders,
conn: DbConn,
) -> JsonResult {
api_key(&org_id, data, true, headers, conn).await
+4 -22
View File
@@ -58,25 +58,13 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
let org_id = token.0;
let data = data.into_inner();
// Like upstream, the import never removes Owners, so it doesn't revoke or restore them either.
for user_data in &data.members {
let mut user_created: bool = false;
if user_data.deleted {
// If user is marked for deletion and it exists, revoke it
if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await {
// Only revoke a user if it is not the last confirmed owner
let revoked = if member.atype == MembershipType::Owner
&& member.status == MembershipStatus::Confirmed as i32
{
if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 {
warn!("Can't revoke the last owner");
false
} else {
member.revoke()
}
} else {
member.revoke()
};
let revoked = member.atype != MembershipType::Owner && member.revoke();
let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
if revoked || ext_modified {
member.save(&conn).await?;
@@ -84,7 +72,7 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
}
// If user is part of the organization, restore it
} else if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await {
let mut restored = member.restore();
let mut restored = member.atype != MembershipType::Owner && member.restore();
let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
// Enforce org policies as every other restore path does.
// If the user is not allowed, we revoke again and continue so the external_id is still updated.
@@ -180,14 +168,8 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
for member in Membership::find_by_org(&org_id, &conn).await {
if let Some(ref user_external_id) = member.external_id
&& !sync_members.contains(user_external_id)
&& member.atype != MembershipType::Owner
{
if member.atype == MembershipType::Owner && member.status == MembershipStatus::Confirmed as i32 {
// Removing owner, check that there is at least one other confirmed owner
if Membership::count_confirmed_by_org_and_type(&org_id, MembershipType::Owner, &conn).await <= 1 {
warn!("Can't delete the last owner");
continue;
}
}
member.delete(&conn).await?;
}
}
+22 -182
View File
@@ -12,7 +12,7 @@ use serde_json::Value;
use crate::{
CONFIG,
api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType},
auth::{ClientIp, Headers, Host, SendHeaders},
auth::{Headers, Host, SendHeaders},
config::PathType,
db::{
DbConn, DbPool,
@@ -23,7 +23,7 @@ use crate::{
const SEND_INACCESSIBLE_MSG: &str = "Send does not exist or is no longer available";
static ANON_PUSH_DEVICE: LazyLock<Device> = LazyLock::new(|| {
let dt = crate::util::parse_date("1970-01-01T00:00:00.000000Z");
let dt = DateTime::UNIX_EPOCH.naive_utc();
Device {
uuid: String::from("00000000-0000-0000-0000-000000000000").into(),
created_at: dt,
@@ -39,18 +39,15 @@ static ANON_PUSH_DEVICE: LazyLock<Device> = LazyLock::new(|| {
});
// The max file size allowed by Bitwarden clients and add an extra 5% to avoid issues
const SIZE_525_MB: i64 = 550_502_400;
pub(crate) const SIZE_525_MB: i64 = 550_502_400;
pub fn routes() -> Vec<rocket::Route> {
routes![
get_sends,
get_send,
post_send,
post_send_file,
post_access,
post_access_legacy,
post_access_file,
post_access_file_legacy,
put_send,
delete_send,
put_remove_password,
@@ -73,7 +70,7 @@ pub async fn purge_sends(pool: DbPool) {
#[serde(rename_all = "camelCase")]
pub struct SendData {
r#type: i32,
key: String,
pub key: String,
password: Option<String>,
max_access_count: Option<NumberOrString>,
expiration_date: Option<DateTime<Utc>>,
@@ -201,7 +198,7 @@ async fn post_send(data: Json<SendData>, headers: Headers, conn: DbConn, nt: Not
enforce_disable_hide_email_policy(&data, &headers, &conn).await?;
if data.r#type == SendType::File as i32 {
err!("File sends should use /api/sends/file")
err!("File sends should use /api/sends/file/v2")
}
let mut send = create_send(data, headers.user.uuid)?;
@@ -218,93 +215,11 @@ async fn post_send(data: Json<SendData>, headers: Headers, conn: DbConn, nt: Not
Ok(Json(send.to_json()))
}
#[derive(FromForm)]
struct UploadData<'f> {
model: Json<SendData>,
data: TempFile<'f>,
}
#[derive(FromForm)]
struct UploadDataV2<'f> {
data: TempFile<'f>,
}
// @deprecated Mar 25 2021: This method has been deprecated in favor of direct uploads (v2).
// This method still exists to support older clients, probably need to remove it sometime.
// Upstream: https://github.com/bitwarden/server/blob/d0c793c95181dfb1b447eb450f85ba0bfd7ef643/src/Api/Controllers/SendsController.cs#L164-L167
// 2025: This endpoint doesn't seem to exists anymore in the latest version
// See: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs
#[post("/sends/file", format = "multipart/form-data", data = "<data>")]
async fn post_send_file(data: Form<UploadData<'_>>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
enforce_disable_send_policy(&headers, &conn).await?;
let UploadData {
model,
data,
} = data.into_inner();
let model = model.into_inner();
let Some(size) = data.len().to_i64() else {
err!("Invalid send size");
};
if size < 0 {
err!("Send size can't be negative")
}
enforce_disable_hide_email_policy(&model, &headers, &conn).await?;
let size_limit = match CONFIG.user_send_limit() {
Some(0) => err!("File uploads are disabled"),
Some(limit_kb) => {
let Some(already_used) = Send::size_by_user(&headers.user.uuid, &conn).await else {
err!("Existing sends overflow")
};
let Some(left) = limit_kb.checked_mul(1024).and_then(|l| l.checked_sub(already_used)) else {
err!("Send size overflow");
};
if left <= 0 {
err!("Send storage limit reached! Delete some sends to free up space")
}
i64::clamp(left, 0, SIZE_525_MB)
}
None => SIZE_525_MB,
};
if size > size_limit {
err!("Send storage limit exceeded with this file");
}
let mut send = create_send(model, headers.user.uuid)?;
if send.atype != SendType::File as i32 {
err!("Send content is not a file");
}
let file_id = crate::crypto::generate_send_file_id();
save_temp_file(&PathType::Sends, &format!("{}/{file_id}", send.uuid), data, true).await?;
let mut data_value: Value = serde_json::from_str(&send.data)?;
if let Some(o) = data_value.as_object_mut() {
o.insert(String::from("id"), Value::String(file_id));
o.insert(String::from("size"), Value::Number(size.into()));
o.insert(String::from("sizeName"), Value::String(crate::util::get_display_size(size)));
}
send.data = serde_json::to_string(&data_value)?;
// Save the changes in the database
send.save(&conn).await?;
nt.send_send_update(
UpdateType::SyncSendCreate,
&send,
&send.update_users_revision(&conn).await,
&headers.device,
&conn,
)
.await;
Ok(Json(send.to_json()))
}
// Upstream: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L165
#[post("/sends/file/v2", data = "<data>")]
async fn post_send_file_v2(data: Json<SendData>, headers: Headers, conn: DbConn) -> JsonResult {
@@ -413,8 +328,8 @@ async fn post_send_file_v2_data(
"Send file name does not match.",
format!(
"Expected file name '{}' got '{}'",
send_data.fileName,
raw_file_name.dangerous_unsafe_unsanitized_raw()
send_data.fileName.escape_debug(),
raw_file_name.dangerous_unsafe_unsanitized_raw().as_str().escape_debug()
)
),
_ => err!("Send file name does not match or is not provided."),
@@ -450,63 +365,16 @@ async fn post_send_file_v2_data(
#[post("/sends/access")]
async fn post_access(headers: SendHeaders, conn: DbConn, nt: Notify<'_>) -> JsonResult {
let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
let Some(mut send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404)
};
if !send.is_accessible() {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
process_access(send, conn, nt).await
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SendAccessData {
pub password: Option<String>,
}
// Legacy since web-2026.6.0
#[post("/sends/access/<access_id>", data = "<data>")]
async fn post_access_legacy(
access_id: &str,
data: Json<SendAccessData>,
conn: DbConn,
ip: ClientIp,
nt: Notify<'_>,
) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
let Some(mut send) = Send::find_by_access_id(access_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404)
};
if let Some(max_access_count) = send.max_access_count
&& send.access_count >= max_access_count
{
err_code!(SEND_INACCESSIBLE_MSG, 404);
}
if !send.is_accessible() {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
if send.password_hash.is_some() {
match data.into_inner().password {
Some(ref p) if send.check_password(p) => { /* Nothing to do here */ }
Some(_) => err!("Invalid password", format!("IP: {}.", ip.ip)),
None => err_code!("Password not provided", format!("IP: {}.", ip.ip), 401),
}
}
// Files are incremented during the download
if send.atype == SendType::Text as i32 {
if !send.register_access(&conn).await? {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
} else {
send.save(&conn).await?;
if send.atype == SendType::Text as i32 && !send.register_access(&conn).await? {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
process_access(send, conn, nt).await
}
@@ -531,55 +399,27 @@ async fn post_access_file(
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
let Some(mut send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404)
};
if !send.is_accessible() {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
check_send_file_id(&send, &file_id)?;
if !send.register_access(&conn).await? {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
process_access_file(send, file_id, host, conn, nt).await
}
// Legacy since web-2026.6.0
#[post("/sends/<send_id>/access/file/<file_id>", data = "<data>")]
async fn post_access_file_legacy(
send_id: SendId,
file_id: SendFileId,
data: Json<SendAccessData>,
host: Host,
conn: DbConn,
ip: ClientIp,
nt: Notify<'_>,
) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
let Some(mut send) = Send::find_by_uuid(&send_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404)
};
if let Some(max_access_count) = send.max_access_count
&& send.access_count >= max_access_count
{
err_code!(SEND_INACCESSIBLE_MSG, 404)
fn check_send_file_id(send: &Send, file_id: &SendFileId) -> EmptyResult {
if send.atype != SendType::File as i32 {
err!("Send is not a file type send.");
}
if !send.is_accessible() {
err_code!(SEND_INACCESSIBLE_MSG, 404)
match serde_json::from_str::<SendFileData>(&send.data) {
Ok(data) if &data.id == file_id => Ok(()),
_ => err_code!(SEND_INACCESSIBLE_MSG, 404),
}
if send.password_hash.is_some() {
match data.into_inner().password {
Some(ref p) if send.check_password(p) => { /* Nothing to do here */ }
Some(_) => err!("Invalid password."),
None => err_code!("Password not provided", 401),
}
}
if !send.register_access(&conn).await? {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
process_access_file(send, file_id, host, conn, nt).await
}
async fn process_access_file(send: Send, file_id: SendFileId, host: Host, conn: DbConn, nt: Notify<'_>) -> JsonResult {
@@ -642,7 +482,7 @@ async fn put_send(send_id: SendId, data: Json<SendData>, headers: Headers, conn:
Ok(Json(send.to_json()))
}
pub async fn update_send_from_data(
async fn update_send_from_data(
send: &mut Send,
data: SendData,
headers: &Headers,
+2 -1
View File
@@ -7,7 +7,7 @@ use crate::{
crypto,
db::{
DbConn,
models::{EventType, TwoFactor, TwoFactorType, UserId},
models::{Device, EventType, TwoFactor, TwoFactorType, UserId},
},
util::NumberOrString,
};
@@ -200,6 +200,7 @@ async fn disable_authenticator(data: Json<DisableAuthenticatorData>, headers: He
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
if twofactor.data == data.key {
twofactor.delete(&conn).await?;
Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?;
log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await;
} else {
+3 -116
View File
@@ -1,5 +1,4 @@
use chrono::Utc;
use data_encoding::BASE64;
use rocket::{Route, serde::json::Json};
use crate::{
@@ -222,13 +221,6 @@ async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData)
Ok(())
}
const DUO_EXPIRE: i64 = 300;
const APP_EXPIRE: i64 = 3600;
const AUTH_PREFIX: &str = "AUTH";
const DUO_PREFIX: &str = "TX";
const APP_PREFIX: &str = "APP";
async fn get_user_duo_data(user_id: &UserId, conn: &DbConn) -> DuoStatus {
let type_ = TwoFactorType::Duo as i32;
@@ -251,118 +243,13 @@ async fn get_user_duo_data(user_id: &UserId, conn: &DbConn) -> DuoStatus {
DuoStatus::Disabled(false)
}
// let (ik, sk, ak, host) = get_duo_keys();
pub(crate) async fn get_duo_keys_email(email: &str, conn: &DbConn) -> ApiResult<(String, String, String, String)> {
// let (ik, sk, host) = get_duo_keys_email();
pub(crate) async fn get_duo_keys_email(email: &str, conn: &DbConn) -> ApiResult<(String, String, String)> {
let data = match User::find_by_mail(email, conn).await {
Some(u) => get_user_duo_data(&u.uuid, conn).await.data(),
_ => DuoData::global(),
}
.map_res("Can't fetch Duo Keys")?;
Ok((data.ik, data.sk, CONFIG.get_duo_akey().await, data.host))
}
pub async fn generate_duo_signature(email: &str, conn: &DbConn) -> ApiResult<(String, String)> {
let now = Utc::now().timestamp();
let (ik, sk, ak, host) = get_duo_keys_email(email, conn).await?;
let duo_sign = sign_duo_values(&sk, email, &ik, DUO_PREFIX, now + DUO_EXPIRE);
let app_sign = sign_duo_values(&ak, email, &ik, APP_PREFIX, now + APP_EXPIRE);
Ok((format!("{duo_sign}:{app_sign}"), host))
}
fn sign_duo_values(key: &str, email: &str, ikey: &str, prefix: &str, expire: i64) -> String {
let val = format!("{email}|{ikey}|{expire}");
let cookie = format!("{prefix}|{}", BASE64.encode(val.as_bytes()));
format!("{cookie}|{}", crypto::hmac_sign(key, &cookie))
}
pub async fn validate_duo_login(email: &str, response: &str, conn: &DbConn) -> EmptyResult {
let split: Vec<&str> = response.split(':').collect();
if split.len() != 2 {
err!(
"Invalid response length",
ErrorEvent {
event: EventType::UserFailedLogIn2fa
}
);
}
let auth_sig = split[0];
let app_sig = split[1];
let now = Utc::now().timestamp();
let (ik, sk, ak, _host) = get_duo_keys_email(email, conn).await?;
let auth_user = parse_duo_values(&sk, auth_sig, &ik, AUTH_PREFIX, now)?;
let app_user = parse_duo_values(&ak, app_sig, &ik, APP_PREFIX, now)?;
if !crypto::ct_eq(&auth_user, app_user) || !crypto::ct_eq(&auth_user, email) {
err!(
"Error validating duo authentication",
ErrorEvent {
event: EventType::UserFailedLogIn2fa
}
)
}
Ok(())
}
fn parse_duo_values(key: &str, val: &str, ikey: &str, prefix: &str, time: i64) -> ApiResult<String> {
let split: Vec<&str> = val.split('|').collect();
if split.len() != 3 {
err!("Invalid value length")
}
let u_prefix = split[0];
let u_b64 = split[1];
let u_sig = split[2];
let sig = crypto::hmac_sign(key, &format!("{u_prefix}|{u_b64}"));
if !crypto::ct_eq(crypto::hmac_sign(key, &sig), crypto::hmac_sign(key, u_sig)) {
err!("Duo signatures don't match")
}
if u_prefix != prefix {
err!("Prefixes don't match")
}
let Ok(cookie_vec) = BASE64.decode(u_b64.as_bytes()) else {
err!("Invalid Duo cookie encoding")
};
let Ok(cookie) = String::from_utf8(cookie_vec) else {
err!("Invalid Duo cookie encoding")
};
let cookie_split: Vec<&str> = cookie.split('|').collect();
if cookie_split.len() != 3 {
err!("Invalid cookie length")
}
let username = cookie_split[0];
let u_ikey = cookie_split[1];
let expire = cookie_split[2];
if !crypto::ct_eq(ikey, u_ikey) {
err!("Invalid ikey")
}
let expire: i64 = if let Ok(e) = expire.parse() {
e
} else {
err!("Invalid expire time")
};
if time >= expire {
err!("Expired authorization")
}
Ok(username.into())
Ok((data.ik, data.sk, data.host))
}
+2 -2
View File
@@ -383,7 +383,7 @@ pub async fn get_duo_auth_url(
device_identifier: &DeviceId,
conn: &DbConn,
) -> Result<String, Error> {
let (ik, sk, _, host) = get_duo_keys_email(email, conn).await?;
let (ik, sk, host) = get_duo_keys_email(email, conn).await?;
let callback_url = match make_callback_url(client_id) {
Ok(url) => url,
@@ -435,7 +435,7 @@ pub async fn validate_duo_login(
let code = split[0];
let state = split[1];
let (ik, sk, _, host) = get_duo_keys_email(email, conn).await?;
let (ik, sk, host) = get_duo_keys_email(email, conn).await?;
// Get the context by the state reported by the client. If we don't have one,
// it means the context is either missing or expired.
+17 -7
View File
@@ -63,13 +63,19 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
let user = if let Some(email) = email {
let Some(user) = User::find_by_mail(email, &conn).await else {
err!("Username or password is incorrect. Try again.")
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Username: {}.", client_headers.ip.ip, email.escape_debug())
)
};
if let Some(master_password_hash) = master_password_hash {
// Check password
if !user.check_valid_password(master_password_hash) {
err!("Username or password is incorrect. Try again.")
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Username: {}.", client_headers.ip.ip, email.escape_debug())
)
}
} else if let Some(auth_request_id) = auth_request_id {
let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else {
@@ -96,7 +102,10 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
};
// SSO login only sends device id, so we get the user by the most recently used device
let Some(user) = User::find_by_device_for_email2fa(device_identifier, &conn).await else {
err!("Username or password is incorrect. Try again.")
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Device: {device_identifier}.", client_headers.ip.ip)
)
};
user
@@ -179,7 +188,7 @@ async fn send_email(data: Json<SendEmailData>, headers: Headers, conn: DbConn) -
}
let generated_token = crypto::generate_email_token(CONFIG.email_token_size());
let twofactor_data = EmailTokenData::new(data.email, generated_token);
let twofactor_data = EmailTokenData::new(data.email, Some(generated_token));
// Uses EmailVerificationChallenge as type to show that it's not verified yet.
let twofactor = TwoFactor::new(user.uuid, TwoFactorType::EmailVerificationChallenge, twofactor_data.to_json());
@@ -313,10 +322,10 @@ pub struct EmailTokenData {
}
impl EmailTokenData {
pub fn new(email: String, token: String) -> EmailTokenData {
pub fn new(email: String, token: Option<String>) -> EmailTokenData {
EmailTokenData {
email,
last_token: Some(token),
last_token: token,
token_sent: Utc::now().timestamp(),
attempts: 0,
}
@@ -354,7 +363,8 @@ pub async fn activate_email_2fa(user: &User, conn: &DbConn) -> EmptyResult {
if user.verified_at.is_none() {
err!("Auto-enabling of email 2FA failed because the users email address has not been verified!");
}
let twofactor_data = EmailTokenData::new(user.email.clone(), String::new());
// The token is set when the first code is sent
let twofactor_data = EmailTokenData::new(user.email.clone(), None);
let twofactor = TwoFactor::new(user.uuid.clone(), TwoFactorType::Email, twofactor_data.to_json());
twofactor.save(conn).await
}
+8 -26
View File
@@ -16,8 +16,8 @@ use crate::{
db::{
DbConn, DbPool,
models::{
DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, TwoFactor,
TwoFactorIncomplete, TwoFactorType, User, UserId,
Device, DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId,
TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId,
},
},
mail,
@@ -69,13 +69,7 @@ pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data
}
pub fn routes() -> Vec<Route> {
let mut routes = routes![
get_twofactor,
get_recover,
disable_twofactor,
disable_twofactor_put,
get_device_verification_settings,
];
let mut routes = routes![get_twofactor, get_recover, disable_twofactor, get_device_verification_settings];
routes.append(&mut authenticator::routes());
routes.append(&mut duo::routes());
@@ -134,7 +128,7 @@ struct DisableTwoFactorData {
r#type: NumberOrString,
}
#[post("/two-factor/disable", data = "<data>")]
#[put("/two-factor/disable", data = "<data>")]
async fn disable_twofactor(data: Json<DisableTwoFactorData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: DisableTwoFactorData = data.into_inner();
let user = headers.user;
@@ -151,6 +145,7 @@ async fn disable_twofactor(data: Json<DisableTwoFactorData>, headers: Headers, c
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
twofactor.delete(&conn).await?;
Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?;
log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn)
.await;
}
@@ -166,11 +161,6 @@ async fn disable_twofactor(data: Json<DisableTwoFactorData>, headers: Headers, c
})))
}
#[put("/two-factor/disable", data = "<data>")]
async fn disable_twofactor_put(data: Json<DisableTwoFactorData>, headers: Headers, conn: DbConn) -> JsonResult {
disable_twofactor(data, headers, conn).await
}
pub async fn enforce_2fa_policy(
user: &User,
act_user_id: &UserId,
@@ -190,7 +180,7 @@ pub async fn enforce_2fa_policy(
member.save(conn).await?;
log_event(
EventType::OrganizationUserRevoked as i32,
EventType::OrganizationUserRevoked,
&member.uuid,
&member.org_uuid,
act_user_id,
@@ -224,16 +214,8 @@ pub async fn enforce_2fa_policy_for_org(
member.revoke();
member.save(conn).await?;
log_event(
EventType::OrganizationUserRevoked as i32,
&member.uuid,
org_id,
act_user_id,
device_type,
ip,
conn,
)
.await;
log_event(EventType::OrganizationUserRevoked, &member.uuid, org_id, act_user_id, device_type, ip, conn)
.await;
}
}
+2 -1
View File
@@ -24,7 +24,7 @@ use crate::{
crypto::ct_eq,
db::{
DbConn,
models::{EventType, TwoFactor, TwoFactorType, UserId},
models::{Device, EventType, TwoFactor, TwoFactorType, UserId},
},
error::Error,
util::NumberOrString,
@@ -338,6 +338,7 @@ async fn delete_webauthn(data: Json<DeleteU2FData>, headers: Headers, conn: DbCo
tf.data = serde_json::to_string(&data)?;
tf.save(&conn).await?;
drop(tf);
Device::clear_twofactor_remember_by_user(&headers.user.uuid, &conn).await?;
// If entry is migrated from u2f, delete the u2f entry as well
if let Some(mut u2f) = TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::U2f as i32, &conn).await
+47 -14
View File
@@ -1,6 +1,10 @@
use rocket::{Route, serde::json::Json};
use serde_json::Value;
use yubico::{config::Config, verify_async};
use yubico_ng::{
Verifier, YubicoError,
config::Config,
transport::{AsyncTransport, Response},
};
use crate::{
CONFIG,
@@ -14,12 +18,39 @@ use crate::{
models::{EventType, TwoFactor, TwoFactorType},
},
error::{Error, MapResult},
http_client,
};
pub fn routes() -> Vec<Route> {
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)]
#[serde(rename_all = "camelCase")]
struct EnableYubikeyData {
@@ -44,8 +75,7 @@ pub struct YubikeyMetadata {
fn parse_yubikeys(data: &EnableYubikeyData) -> Vec<String> {
let data_keys = [&data.key1, &data.key2, &data.key3, &data.key4, &data.key5];
data_keys.into_iter().flatten().cloned().collect()
data_keys.into_iter().flatten().filter(|e| !e.is_empty()).cloned().collect()
}
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 {
let (yubico_id, yubico_secret) = get_yubico_credentials()?;
let config = Config::default().set_client_id(yubico_id).set_key(yubico_secret);
match CONFIG.yubico_server() {
Some(server) => verify_async(otp, config.set_api_hosts(vec![server])).await,
None => verify_async(otp, config).await,
let mut config = Config::default().set_client_id(yubico_id).set_key(yubico_secret)?;
if let Some(yubico_server) = CONFIG.yubico_server() {
config = config.set_api_host(yubico_server);
}
.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>")]
@@ -137,10 +169,9 @@ async fn activate_yubikey(data: Json<EnableYubikeyData>, headers: Headers, conn:
let yubikeys = parse_yubikeys(&data);
if yubikeys.is_empty() {
return Ok(Json(json!({
"enabled": false,
"object": "twoFactorU2f",
})));
// Return an error to prevent saving empty keys which would cause users not being able to login anymore.
// To remove all keys users should click the `Deactivate all keys` button
err!("A key is required.");
}
// Ensure they are valid OTPs
@@ -186,7 +217,9 @@ pub async fn validate_yubikey_login(response: &str, twofactor_data: &str) -> Emp
}
let yubikey_metadata: YubikeyMetadata = serde_json::from_str(twofactor_data).expect("Can't parse Yubikey Metadata");
let response_id = &response[..12];
let Some(response_id) = response.get(..12) else {
err!("Invalid Yubikey OTP");
};
if !yubikey_metadata.keys.contains(&response_id.to_owned()) {
err!("Given Yubikey is not registered");
+2 -2
View File
@@ -85,7 +85,7 @@ static ICON_SIZE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?x)(\d+
#[get("/<host>/icon.png")]
fn icon_external(host: &str) -> Cached<Option<Redirect>> {
let Ok(host) = get_valid_host(host) else {
warn!("Invalid host: {host}");
warn!("Invalid host: {}", host.escape_debug());
return Cached::ttl(None, CONFIG.icon_cache_negttl(), true);
};
@@ -113,7 +113,7 @@ async fn icon_internal(host: &str) -> Cached<(ContentType, Vec<u8>)> {
const FALLBACK_ICON: &[u8] = include_bytes!("../static/images/fallback-icon.png");
let Ok(host) = get_valid_host(host) else {
warn!("Invalid host: {host}");
warn!("Invalid host: {}", host.escape_debug());
return Cached::ttl(
(ContentType::new("image", "png"), FALLBACK_ICON.to_vec()),
CONFIG.icon_cache_negttl(),
+151 -172
View File
@@ -3,7 +3,7 @@ use num_traits::FromPrimitive;
use rocket::{
Route,
form::{Form, FromForm},
http::{Cookie, CookieJar, SameSite},
http::{Accept, Cookie, CookieJar, MediaType, SameSite},
response::Redirect,
serde::json::Json,
};
@@ -17,15 +17,14 @@ use crate::{
accounts::{PreloginData, RegisterData, kdf_upgrade, prelogin, register},
log_user_event,
two_factor::{
authenticator, duo, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn,
yubikey,
authenticator, duo_oidc, email, enforce_2fa_policy, is_twofactor_provider_usable, webauthn, yubikey,
},
},
master_password_policy,
identity_master_password_policy,
push::register_push_device,
},
auth,
auth::{AuthMethod, ClientHeaders, ClientIp, ClientVersion, Secure, generate_organization_api_key_login_claims},
auth::{AuthMethod, ClientHeaders, ClientIp, Secure, generate_organization_api_key_login_claims},
crypto,
db::{
DbConn,
@@ -46,7 +45,6 @@ pub fn routes() -> Vec<Route> {
login,
post_prelogin,
prelogin_password,
identity_register,
register_verification_email,
register_finish,
prevalidate,
@@ -57,12 +55,7 @@ pub fn routes() -> Vec<Route> {
}
#[post("/connect/token", data = "<data>")]
async fn login(
data: Form<ConnectData>,
client_header: ClientHeaders,
client_version: Option<ClientVersion>,
conn: DbConn,
) -> JsonResult {
async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbConn) -> JsonResult {
let data: ConnectData = data.into_inner();
let mut user_id: Option<UserId> = None;
@@ -83,17 +76,13 @@ async fn login(
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
password_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await
password_login(data, &mut user_id, &conn, &client_header.ip).await
}
"client_credentials" => {
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
check_is_some(data.client_secret.as_ref(), "client_secret cannot be blank")?;
check_is_some(data.scope.as_ref(), "scope cannot be blank")?;
check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?;
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
api_key_login(data, &mut user_id, &conn, &client_header.ip).await
}
"authorization_code" if CONFIG.sso_enabled() => {
@@ -105,7 +94,7 @@ async fn login(
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
sso_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await
sso_login(data, &mut user_id, &conn, &client_header.ip).await
}
"authorization_code" => err!("SSO sign-in is not available"),
"send_access" => {
@@ -190,13 +179,7 @@ async fn refresh_login(data: ConnectData, conn: &DbConn, ip: &ClientIp) -> JsonR
}
// After exchanging the code we need to check first if 2FA is needed before continuing
async fn sso_login(
data: ConnectData,
user_id: &mut Option<UserId>,
conn: &DbConn,
ip: &ClientIp,
client_version: Option<&ClientVersion>,
) -> JsonResult {
async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
AuthMethod::Sso.check_scope(data.scope.as_ref())?;
// Ratelimit the login
@@ -234,6 +217,24 @@ async fn sso_login(
}
)
}
Some((user, None))
if user.private_key.is_none()
&& !CONFIG.sso_signups_allowed()
&& !CONFIG.is_email_domain_allowed(&user.email)
&& !CONFIG.mail_enabled()
&& Invitation::find_by_mail(&user.email, conn).await.is_none() =>
{
error!(
"Login failure ({}), no invitation with email ({}) was found",
user_infos.identifier, user.email
);
err_silent!(
"Missing invitation",
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
Some((user, None)) if user.private_key.is_some() && !CONFIG.sso_signups_match_email() => {
error!(
"Login failure ({}), existing non SSO user ({}) with same email ({}) and association is disabled",
@@ -281,7 +282,15 @@ async fn sso_login(
// Will trigger 2FA flow if needed
let (user, mut device, twofactor_token, sso_user) = match user_with_sso {
None => {
if !CONFIG.is_email_domain_allowed(&user_infos.email) {
if !CONFIG.is_sso_signup_allowed(&user_infos.email) {
if CONFIG.signups_domains_whitelist().is_empty() {
err!(
"Signups are disabled. You will need an invitation",
ErrorEvent {
event: EventType::UserFailedLogIn
}
);
}
err!(
"Email domain not allowed",
ErrorEvent {
@@ -318,7 +327,7 @@ async fn sso_login(
Some((user, _)) if !user.enabled => {
err!(
"This user has been disabled",
format!("IP: {}. Username: {}.", ip.ip, user.display_name()),
format!("IP: {}. Username: {}.", ip.ip, user.email),
ErrorEvent {
event: EventType::UserFailedLogIn
}
@@ -327,7 +336,7 @@ async fn sso_login(
Some((mut user, sso_user)) => {
let mut device = get_device(&data, conn, &user).await?;
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?;
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).await?;
if user.private_key.is_none() {
// User was invited a stub was created
@@ -359,13 +368,7 @@ async fn sso_login(
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
}
async fn password_login(
data: ConnectData,
user_id: &mut Option<UserId>,
conn: &DbConn,
ip: &ClientIp,
client_version: Option<&ClientVersion>,
) -> JsonResult {
async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
// Validate scope
AuthMethod::Password.check_scope(data.scope.as_ref())?;
@@ -374,24 +377,14 @@ async fn password_login(
// Get the user
let username = data.username.as_ref().unwrap().trim();
let log_username = username.escape_debug();
let Some(mut user) = User::find_by_mail(username, conn).await else {
err!("Username or password is incorrect. Try again", format!("IP: {}. Username: {username}.", ip.ip))
err!("Username or password is incorrect. Try again", format!("IP: {}. Username: {log_username}.", ip.ip))
};
// Set the user_id here to be passed back used for event logging.
*user_id = Some(user.uuid.clone());
// Check if the user is disabled
if !user.enabled {
err!(
"This user has been disabled",
format!("IP: {}. Username: {username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
let password = data.password.as_ref().unwrap();
// If we get an auth request, we don't check the user's password, but the access code of the auth request
@@ -399,7 +392,7 @@ async fn password_login(
let Some(auth_request) = AuthRequest::find_by_uuid_and_user(auth_request_id, &user.uuid, conn).await else {
err!(
"Auth request not found. Try again.",
format!("IP: {}. Username: {username}.", ip.ip),
format!("IP: {}. Username: {log_username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn,
}
@@ -411,13 +404,14 @@ async fn password_login(
if auth_request.user_uuid != user.uuid
|| !auth_request.approved.unwrap_or(false)
|| auth_request.authentication_date.is_some()
|| request_expired
|| ip.ip.to_string() != auth_request.request_ip
|| !auth_request.check_access_code(password)
{
err!(
"Username or access code is incorrect. Try again",
format!("IP: {}. Username: {username}.", ip.ip),
format!("IP: {}. Username: {log_username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn,
}
@@ -426,13 +420,24 @@ async fn password_login(
} else if !user.check_valid_password(password) {
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Username: {username}.", ip.ip),
format!("IP: {}. Username: {log_username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn,
}
)
}
// Check if the user is disabled
if !user.enabled {
err!(
"This user has been disabled",
format!("IP: {}. Username: {log_username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
// Change the KDF Iterations (only when not logging in with an auth request)
if data.auth_request.is_none() {
kdf_upgrade(&mut user, password, conn).await?;
@@ -465,7 +470,7 @@ async fn password_login(
// We still want the login to fail until they actually verified the email address
err!(
"Please verify your email before trying again.",
format!("IP: {}. Username: {username}.", ip.ip),
format!("IP: {}. Username: {log_username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn
}
@@ -474,7 +479,20 @@ async fn password_login(
let mut device = get_device(&data, conn, &user).await?;
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, client_version, conn).await?;
let twofactor_token = twofactor_auth(&mut user, &data, &mut device, ip, conn).await?;
// Like upstream, an auth request can be used for one login only. Marked after 2FA so a 2FA prompt doesn't use it up.
if let Some(ref auth_request_id) = data.auth_request
&& !AuthRequest::set_authentication_date(auth_request_id, conn).await?
{
err!(
"Username or access code is incorrect. Try again",
format!("IP: {}. Username: {log_username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn,
}
)
}
let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id);
@@ -513,7 +531,7 @@ async fn authenticated_response(
// Save to update `device.updated_at` to track usage and toggle new status
device.save(true, conn).await?;
let master_password_policy = master_password_policy(user, conn).await;
let master_password_policy = identity_master_password_policy(user, conn).await;
let has_master_password = !user.password_hash.is_empty();
let master_password_unlock = if has_master_password {
@@ -534,18 +552,7 @@ async fn authenticated_response(
Value::Null
};
let account_keys = if user.private_key.is_some() {
json!({
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": user.private_key,
"publicKey": user.public_key,
"Object": "publicKeyEncryptionKeyPair"
},
"Object": "privateKeys"
})
} else {
Value::Null
};
let account_keys = user.account_keys_json(conn).await;
let mut result = json!({
"access_token": auth_tokens.access_token(),
@@ -557,7 +564,6 @@ async fn authenticated_response(
"KdfIterations": user.client_kdf_iter,
"KdfMemory": user.client_kdf_memory,
"KdfParallelism": user.client_kdf_parallelism,
"ResetMasterPassword": false, // TODO: Same as above
"ForcePasswordReset": false,
"MasterPasswordPolicy": master_password_policy,
"scope": auth_tokens.scope(),
@@ -577,7 +583,7 @@ async fn authenticated_response(
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))
}
@@ -587,7 +593,14 @@ async fn api_key_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &D
// Validate scope
match data.scope.as_ref() {
Some(scope) if scope == &AuthMethod::UserApiKey.scope() => user_api_key_login(data, user_id, conn, ip).await,
Some(scope) if scope == &AuthMethod::UserApiKey.scope() => {
// Like upstream, only the user API key logs in a device
check_is_some(data.device_identifier.as_ref(), "device_identifier cannot be blank")?;
check_is_some(data.device_name.as_ref(), "device_name cannot be blank")?;
check_is_some(data.device_type.as_ref(), "device_type cannot be blank")?;
user_api_key_login(data, user_id, conn, ip).await
}
Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => organization_api_key_login(data, conn, ip).await,
_ => err!("Scope not supported"),
}
@@ -685,18 +698,7 @@ async fn user_api_key_login(
Value::Null
};
let account_keys = if user.private_key.is_some() {
json!({
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": user.private_key,
"publicKey": user.public_key,
"Object": "publicKeyEncryptionKeyPair"
},
"Object": "privateKeys"
})
} else {
Value::Null
};
let account_keys = user.account_keys_json(conn).await;
// Note: No refresh_token is returned. The CLI just repeats the
// client_credentials login flow when the existing token expires.
@@ -711,7 +713,6 @@ async fn user_api_key_login(
"KdfIterations": user.client_kdf_iter,
"KdfMemory": user.client_kdf_memory,
"KdfParallelism": user.client_kdf_parallelism,
"ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing
"ForcePasswordReset": false,
"scope": AuthMethod::UserApiKey.scope(),
"AccountKeys": account_keys,
@@ -777,7 +778,6 @@ async fn twofactor_auth(
data: &ConnectData,
device: &mut Device,
ip: &ClientIp,
client_version: Option<&ClientVersion>,
conn: &DbConn,
) -> ApiResult<Option<String>> {
let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await;
@@ -806,17 +806,12 @@ async fn twofactor_auth(
if ![TwoFactorType::Remember as i32, TwoFactorType::RecoveryCode as i32].contains(&selected_id)
&& !twofactor_ids.contains(&selected_id)
{
err_json!(
json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
"Invalid two factor provider"
)
err_json!(json_err_twofactor(&twofactor_ids, user, data, conn).await?, "Invalid two factor provider")
}
let Some(ref twofactor_code) = data.two_factor_token else {
err_json!(
json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
"2FA token not provided"
)
// Like upstream, a blank token counts as not provided
let Some(twofactor_code) = data.two_factor_token.as_deref().filter(|t| !t.trim().is_empty()) else {
err_json!(json_err_twofactor(&twofactor_ids, user, data, conn).await?, "2FA token not provided")
};
let selected_twofactor = twofactors.into_iter().find(|tf| tf.atype == selected_id && tf.enabled);
@@ -830,20 +825,14 @@ async fn twofactor_auth(
Some(TwoFactorType::Webauthn) => webauthn::validate_webauthn_login(&user.uuid, twofactor_code, conn).await?,
Some(TwoFactorType::YubiKey) => yubikey::validate_yubikey_login(twofactor_code, &selected_data?).await?,
Some(TwoFactorType::Duo) => {
if CONFIG.duo_use_iframe() {
// Legacy iframe prompt flow
duo::validate_duo_login(&user.email, twofactor_code, conn).await?;
} else {
// OIDC based flow
duo_oidc::validate_duo_login(
&user.email,
twofactor_code,
data.client_id.as_ref().unwrap(),
data.device_identifier.as_ref().unwrap(),
conn,
)
.await?;
}
duo_oidc::validate_duo_login(
&user.email,
twofactor_code,
data.client_id.as_ref().unwrap(),
data.device_identifier.as_ref().unwrap(),
conn,
)
.await?;
}
Some(TwoFactorType::Email) => {
email::validate_email_code_str(&user.uuid, twofactor_code, &selected_data?, &ip.ip, conn).await?;
@@ -865,24 +854,43 @@ async fn twofactor_auth(
device.save(true, conn).await?;
}
err_json!(
json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?,
json_err_twofactor(&twofactor_ids, user, data, conn).await?,
"2FA Remember token not provided or expired"
)
}
}
}
Some(TwoFactorType::RecoveryCode) => {
// Check if recovery code is correct
if !user.check_valid_recovery_code(twofactor_code) {
err!("Recovery code is incorrect. Try again.")
// Like upstream, spaces and case don't matter
let recovery_code = twofactor_code.replace(' ', "").trim().to_lowercase();
if !user.check_valid_recovery_code(&recovery_code) {
err!(
"Two-step token is invalid. Try again.",
ErrorEvent {
event: EventType::UserFailedLogIn2fa
}
)
}
// Remove all twofactors from the user
TwoFactor::delete_all_by_user(&user.uuid, conn).await?;
// No device may keep skipping 2FA once every second factor is gone.
// `device` is cleared in memory too, since saving it later would restore its token.
Device::clear_twofactor_remember_by_user(&user.uuid, conn).await?;
device.delete_twofactor_remember();
enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?;
log_user_event(EventType::UserRecovered2fa as i32, &user.uuid, device.atype, &ip.ip, conn).await;
if CONFIG.mail_enabled()
&& let Err(e) =
mail::send_recover_twofactor(&user.email, &ip.ip.to_string(), &Utc::now().naive_utc()).await
{
error!("Error sending two-step login recovered email: {e:#?}");
}
// Remove the recovery code, not needed without twofactors
user.totp_recover = None;
user.save(conn).await?;
@@ -910,21 +918,14 @@ fn selected_data(tf: Option<TwoFactor>) -> ApiResult<String> {
tf.map(|t| t.data).map_res("Two factor doesn't exist")
}
async fn json_err_twofactor(
providers: &[i32],
user_id: &UserId,
data: &ConnectData,
client_version: Option<&ClientVersion>,
conn: &DbConn,
) -> ApiResult<Value> {
async fn json_err_twofactor(providers: &[i32], user: &User, data: &ConnectData, conn: &DbConn) -> ApiResult<Value> {
let user_id = &user.uuid;
let mut result = json!({
"error" : "invalid_grant",
"error_description" : "Two factor required.",
"TwoFactorProviders" : providers.iter().map(ToString::to_string).collect::<Vec<String>>(),
"TwoFactorProviders2" : {}, // { "0" : null }
"MasterPasswordPolicy": {
"Object": "masterPasswordPolicy"
}
"MasterPasswordPolicy": identity_master_password_policy(user, conn).await,
});
for provider in providers {
@@ -937,33 +938,17 @@ async fn json_err_twofactor(
}
Some(TwoFactorType::Duo) => {
let email = if let Some(u) = User::find_by_uuid(user_id, conn).await {
u.email
} else {
err!("User does not exist")
};
let auth_url = duo_oidc::get_duo_auth_url(
&user.email,
data.client_id.as_ref().unwrap(),
data.device_identifier.as_ref().unwrap(),
conn,
)
.await?;
if CONFIG.duo_use_iframe() {
// Legacy iframe prompt flow
let (signature, host) = duo::generate_duo_signature(&email, conn).await?;
result["TwoFactorProviders2"][provider.to_string()] = json!({
"Host": host,
"Signature": signature,
});
} else {
// OIDC based flow
let auth_url = duo_oidc::get_duo_auth_url(
&email,
data.client_id.as_ref().unwrap(),
data.device_identifier.as_ref().unwrap(),
conn,
)
.await?;
result["TwoFactorProviders2"][provider.to_string()] = json!({
"AuthUrl": auth_url,
});
}
result["TwoFactorProviders2"][provider.to_string()] = json!({
"AuthUrl": auth_url,
});
}
Some(tf_type @ TwoFactorType::YubiKey) => {
@@ -983,19 +968,6 @@ async fn json_err_twofactor(
err!("No twofactor email registered")
};
// Starting with version 2025.5.0 the client will call `/api/two-factor/send-email-login`.
let disabled_send = if let Some(cv) = client_version {
let ver_match = semver::VersionReq::parse(">=2025.5.0").unwrap();
ver_match.matches(&cv.0)
} else {
false
};
// Send email immediately if email is the only 2FA option.
if providers.len() == 1 && !disabled_send {
email::send_token(user_id, conn).await?;
}
let email_data = email::EmailTokenData::from_json(&twofactor.data)?;
result["TwoFactorProviders2"][provider.to_string()] = json!({
"Email": email::obscure_email(&email_data.email),
@@ -1024,18 +996,13 @@ async fn json_err_twofactor(
}
#[post("/accounts/prelogin", data = "<data>")]
async fn post_prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
prelogin(data, conn).await
async fn post_prelogin(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
prelogin(data, ip, conn).await
}
#[post("/accounts/prelogin/password", data = "<data>")]
async fn prelogin_password(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
prelogin(data, conn).await
}
#[post("/accounts/register", data = "<data>")]
async fn identity_register(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
register(data, false, conn).await
async fn prelogin_password(data: Json<PreloginData>, ip: ClientIp, conn: DbConn) -> JsonResult {
prelogin(data, ip, conn).await
}
#[derive(Debug, Deserialize)]
@@ -1051,11 +1018,18 @@ enum RegisterVerificationResponse {
#[response(status = 204)]
NoContent(()),
Token(Json<String>),
PlainToken(String),
}
// Return JSON only when the client explicitly requests it, otherwise return plain text.
fn accepts_json(accept: Option<&Accept>) -> bool {
accept.is_some_and(|accept| accept.preferred().media_type() == &MediaType::JSON)
}
#[post("/accounts/register/send-verification-email", data = "<data>")]
async fn register_verification_email(
data: Json<RegisterVerificationData>,
accept: Option<&Accept>,
ip: ClientIp,
conn: DbConn,
) -> ApiResult<RegisterVerificationResponse> {
@@ -1093,13 +1067,18 @@ async fn register_verification_email(
} else {
// If email verification is not required, return the token directly
// the clients will use this token to finish the registration
Ok(RegisterVerificationResponse::Token(Json(token)))
Ok(if accepts_json(accept) {
RegisterVerificationResponse::Token(Json(token))
} else {
RegisterVerificationResponse::PlainToken(token)
})
}
}
#[post("/accounts/register/finish", data = "<data>")]
async fn register_finish(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
register(data, true, conn).await
async fn register_finish(data: Json<RegisterData>, ip: ClientIp, conn: DbConn) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
register(data, conn).await
}
// https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts
@@ -1229,7 +1208,7 @@ async fn oidcsignin_redirect(
let state = sso::decode_state(&base64_state)?;
let Some(mut sso_auth) = SsoAuth::find(&state, conn).await else {
err!(format!("Cannot retrieve sso_auth for {state}"))
err!(format!("Cannot retrieve sso_auth for {}", state.escape_debug()))
};
// Browser-binding check
@@ -1238,7 +1217,7 @@ async fn oidcsignin_redirect(
let provided_hash = cookie_value.as_deref().map(|v| crypto::sha256_hex(v.as_bytes()));
match (sso_auth.binding_hash.as_deref(), provided_hash.as_deref()) {
(Some(expected), Some(actual)) if crypto::ct_eq(expected, actual) => {}
_ => err!(format!("SSO session binding mismatch for {state}")),
_ => err!(format!("SSO session binding mismatch for {}", state.escape_debug())),
}
cookies
.remove(Cookie::build(SSO_BINDING_COOKIE).path(format!("{}/identity/connect/", CONFIG.domain_path())).build());
+19 -1
View File
@@ -30,7 +30,7 @@ pub use crate::api::{
},
web::catchers as web_catchers,
web::routes as web_routes,
web::static_files,
web::{invalidate_css_cache, static_files},
};
use crate::{
CONFIG,
@@ -124,3 +124,21 @@ async fn master_password_policy(user: &User, conn: &DbConn) -> Value {
mpp_json["Object"] = json!("masterPasswordPolicy");
mpp_json
}
// Identity responses use PascalCase keys like upstream, some clients (Android) only read those
async fn identity_master_password_policy(user: &User, conn: &DbConn) -> Value {
match master_password_policy(user, conn).await {
Value::Object(policy) => Value::Object(
policy
.into_iter()
.map(|(mut key, value)| {
if let Some(first) = key.get_mut(..1) {
first.make_ascii_uppercase();
}
(key, value)
})
.collect(),
),
policy => policy,
}
}
+39 -8
View File
@@ -1,4 +1,7 @@
use std::path::{Path, PathBuf};
use std::{
path::{Path, PathBuf},
sync::{Arc, RwLock},
};
use rocket::{
Catcher, Route,
@@ -13,12 +16,13 @@ use crate::{
CONFIG,
api::{ApiResult, EmptyResult, core::now},
auth::decode_file_download,
crypto::sha256_hex,
db::{
DbConn,
models::{AttachmentId, CipherId},
},
error::Error,
util::Cached,
util::{Cached, EtagCached},
};
pub fn routes() -> Vec<Route> {
@@ -63,9 +67,29 @@ fn not_found() -> ApiResult<Html<String>> {
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")]
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!({
"email_change_allowed": CONFIG.email_change_allowed(),
"emergency_access_allowed": CONFIG.emergency_access_allowed(),
"load_user_scss": true,
"mail_2fa_enabled": CONFIG._enable_email_2fa(),
@@ -112,8 +136,18 @@ fn vaultwarden_css() -> Cached<Css<String>> {
}
};
// Cache for one day should be enough and not too much
Cached::ttl(Css(css), 86_400, false)
let etag = sha256_hex(css.as_bytes());
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("/")]
@@ -268,9 +302,6 @@ pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Erro
"jdenticon-3.3.0.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jdenticon-3.3.0.js"))),
"datatables.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/datatables.js"))),
"datatables.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/datatables.css"))),
"jquery-4.0.0.slim.js" => {
Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jquery-4.0.0.slim.js")))
}
_ => err!(format!("Static file not found: {filename}")),
}
}
+102 -15
View File
@@ -14,6 +14,7 @@ use ipnet::IpNet;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, errors::ErrorKind};
use num_traits::FromPrimitive;
use openssl::rsa::Rsa;
use ring::hmac;
use serde::{de::DeserializeOwned, ser::Serialize};
use rocket::{
@@ -23,14 +24,14 @@ use rocket::{
use crate::{
CONFIG,
api::ApiResult,
api::{ApiResult, core::log_event},
config::PathType,
db::{
DbConn,
models::{
AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId,
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId, SendFileId,
SendId, User, UserId, UserStampException,
EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId,
SendFileId, SendId, User, UserId, UserStampException,
},
},
error::Error,
@@ -65,6 +66,7 @@ static JWT_2FA_REMEMBER_ISSUER: LazyLock<String> = LazyLock::new(|| format!("{}|
static PRIVATE_RSA_KEY: OnceLock<EncodingKey> = OnceLock::new();
static PUBLIC_RSA_KEY: OnceLock<DecodingKey> = OnceLock::new();
static PRELOGIN_KDF_KEY: OnceLock<hmac::Key> = OnceLock::new();
pub async fn initialize_keys() -> Result<(), Error> {
use std::io::Error as IoError;
@@ -99,9 +101,22 @@ pub async fn initialize_keys() -> Result<(), Error> {
if PUBLIC_RSA_KEY.set(dec).is_err() {
err!("PUBLIC_RSA_KEY must only be initialized once")
}
// Derived from the RSA key, so it stays the same across restarts without storing another secret
let prelogin_kdf_key = hmac::sign(&hmac::Key::new(hmac::HMAC_SHA256, &priv_key_buffer), b"prelogin-kdf");
if PRELOGIN_KDF_KEY.set(hmac::Key::new(hmac::HMAC_SHA256, prelogin_kdf_key.as_ref())).is_err() {
err!("PRELOGIN_KDF_KEY must only be initialized once")
}
Ok(())
}
/// Returns an index in `0..len` derived from the email, the same for every request
pub fn prelogin_kdf_index(email: &str, len: usize) -> usize {
let tag = hmac::sign(PRELOGIN_KDF_KEY.wait(), email.as_bytes());
let tag = tag.as_ref();
usize::from(u16::from_be_bytes([tag[0], tag[1]])) % len
}
pub fn encode_jwt<T: Serialize>(claims: &T) -> String {
match jsonwebtoken::encode(&JWT_HEADER, claims, PRIVATE_RSA_KEY.wait()) {
Ok(token) => token,
@@ -253,7 +268,7 @@ impl LoginJwtClaims {
premium: true,
name: user.name.clone(),
email: user.email.clone(),
email_verified: !CONFIG.mail_enabled() || user.verified_at.is_some(),
email_verified: user.verified(),
// ---
// Disabled these keys to be added to the JWT since they could cause the JWT to get too large
@@ -422,7 +437,7 @@ pub fn generate_file_download_claims(cipher_id: CipherId, file_id: AttachmentId)
let time_now = Utc::now();
FileDownloadClaims {
nbf: time_now.timestamp(),
exp: (time_now + TimeDelta::try_minutes(5).unwrap()).timestamp(),
exp: (time_now + TimeDelta::try_minutes(1).unwrap()).timestamp(),
iss: JWT_FILE_DOWNLOAD_ISSUER.to_string(),
sub: cipher_id,
file_id,
@@ -822,6 +837,12 @@ pub struct AdminHeaders {
pub org_id: OrganizationId,
}
impl AdminHeaders {
pub async fn log_event(&self, event_type: EventType, source_uuid: &str, org_id: &OrganizationId, conn: &DbConn) {
log_event(event_type, source_uuid, org_id, &self.user.uuid, self.device.atype, &self.ip.ip, conn).await;
}
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for AdminHeaders {
type Error = &'static str;
@@ -1085,6 +1106,31 @@ fn ip_header_is_trusted(remote: Option<IpAddr>) -> bool {
trusted.split(',').filter_map(parse_trusted_proxy).any(|net| net.contains(&remote))
}
/// Each proxy appends the address it received the request from to a list header (`X-Forwarded-For: client, proxy1`).
/// Like ASP.NET's ForwardedHeaders, walk the entries of every occurrence of the header from the right and take the
/// first one that isn't a trusted proxy. If they all are, the leftmost one is the client.
fn client_ip_from_header(req: &Request<'_>, header: &str, is_trusted: impl Fn(IpAddr) -> bool) -> Option<IpAddr> {
let values: Vec<&str> = req.headers().get(header).collect();
client_ip_from_header_values(&values, header, is_trusted)
}
fn client_ip_from_header_values(values: &[&str], header: &str, is_trusted: impl Fn(IpAddr) -> bool) -> Option<IpAddr> {
let entries: Vec<&str> = values.iter().flat_map(|value| value.split(',')).map(str::trim).collect();
let mut client_ip = None;
for entry in entries.into_iter().rev() {
let Ok(ip) = entry.parse() else {
warn!("'{header}' header is malformed: {entry}");
break;
};
client_ip = Some(ip);
if !is_trusted(ip) {
break;
}
}
client_ip
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for ClientIp {
type Error = ();
@@ -1093,15 +1139,7 @@ impl<'r> FromRequest<'r> for ClientIp {
let remote = req.remote().map(|r| r.ip());
let ip = if CONFIG._ip_header_enabled() && ip_header_is_trusted(remote) {
req.headers().get_one(&CONFIG.ip_header()).and_then(|ip| {
match ip.find(',') {
Some(idx) => &ip[..idx],
None => ip,
}
.parse()
.map_err(|_| warn!("'{}' header is malformed: {ip}", CONFIG.ip_header()))
.ok()
})
client_ip_from_header(req, &CONFIG.ip_header(), |ip| ip_header_is_trusted(Some(ip)))
} 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
@@ -1220,7 +1258,7 @@ impl AuthMethod {
match scope {
None => err!("Missing scope"),
Some(scope) if scope == &method_scope => Ok(method_scope),
Some(scope) => err!(format!("Scope ({scope}) not supported")),
Some(scope) => err!(format!("Scope ({}) not supported", scope.escape_debug())),
}
}
}
@@ -1339,3 +1377,52 @@ pub async fn refresh_tokens(
Ok((device, auth_tokens))
}
#[cfg(test)]
mod tests {
use super::*;
fn client_ip(values: &[&str]) -> Option<IpAddr> {
let is_trusted = |ip: IpAddr| match ip {
IpAddr::V4(v4) => v4.is_private() || v4.is_loopback(),
IpAddr::V6(v6) => v6.is_loopback(),
};
client_ip_from_header_values(values, "X-Forwarded-For", is_trusted)
}
fn ip(s: &str) -> Option<IpAddr> {
Some(s.parse().unwrap())
}
#[test]
fn ip_header_single_value() {
assert_eq!(client_ip(&["203.0.113.5"]), ip("203.0.113.5"));
assert_eq!(client_ip(&["10.0.0.7"]), ip("10.0.0.7"));
assert_eq!(client_ip(&["2001:db8::1"]), ip("2001:db8::1"));
}
#[test]
fn ip_header_uses_rightmost_untrusted_entry() {
// Entries already in the request come before the one the proxy appended
assert_eq!(client_ip(&["198.51.100.1, 203.0.113.5"]), ip("203.0.113.5"));
// Trusted proxies on the right are skipped
assert_eq!(client_ip(&["198.51.100.1, 203.0.113.5, 10.0.0.2, 127.0.0.1"]), ip("203.0.113.5"));
// All trusted, so the leftmost entry is the client
assert_eq!(client_ip(&["192.168.1.10, 10.0.0.2"]), ip("192.168.1.10"));
}
#[test]
fn ip_header_reads_every_occurrence() {
assert_eq!(client_ip(&["198.51.100.1", "203.0.113.5"]), ip("203.0.113.5"));
assert_eq!(client_ip(&["198.51.100.1", "203.0.113.5, 10.0.0.2"]), ip("203.0.113.5"));
}
#[test]
fn ip_header_malformed_entries() {
assert_eq!(client_ip(&[]), None);
assert_eq!(client_ip(&[""]), None);
assert_eq!(client_ip(&["203.0.113.5, not-an-ip"]), None);
// Stops at the malformed entry and keeps the closest proxy
assert_eq!(client_ip(&["not-an-ip, 10.0.0.2"]), ip("10.0.0.2"));
}
}
+1 -5
View File
@@ -75,7 +75,7 @@ impl SendTokens {
return Self::invalid_error(&format!("Can't convert {access_id}"), "send_id_invalid", false);
};
let Some(mut send) = Send::find_by_uuid(&send_id, conn).await else {
let Some(send) = Send::find_by_uuid(&send_id, conn).await else {
return Self::invalid_error(&format!("Can't find {send_id}"), "send_id_invalid", false);
};
@@ -103,10 +103,6 @@ impl SendTokens {
}
}
if !send.register_access(conn).await? {
return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true);
}
Ok(Self {
access_claims: generate_send_access_claims(&send_id),
})
+41 -53
View File
@@ -561,7 +561,7 @@ make_config! {
/// Auth Request cleanup schedule |> Cron schedule of the job that cleans old auth requests from the auth request.
/// Defaults to every minute. Set blank to disable this job.
auth_request_purge_schedule: String, false, def, "30 * * * * *".to_owned();
/// Duo Auth context cleanup schedule |> Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled or set to use the legacy iframe prompt.
/// Duo Auth context cleanup schedule |> Cron schedule of the job that cleans expired Duo contexts from the database. Does nothing if Duo MFA is disabled.
/// Defaults to once every minute. Set blank to disable this job.
duo_context_purge_schedule: String, false, def, "30 * * * * *".to_owned();
/// Purge incomplete SSO auth. |> Cron schedule of the job that cleans leftover auth in db due to incomplete SSO login.
@@ -659,6 +659,11 @@ make_config! {
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 {
/// Client IP header |> If not present, the remote IP is used.
@@ -670,7 +675,8 @@ make_config! {
/// 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.
/// or a comma separated list of IPs and CIDR ranges. For a list header like X-Forwarded-For, the
/// client IP is the rightmost address that isn't a trusted proxy, so list every proxy in the chain.
ip_header_trusted_proxies: String, true, def, "local".to_owned();
/// Icon service |> The predefined icon services are: internal, bitwarden, duckduckgo, google.
/// To specify a custom icon service, set a URL template with exactly one instance of `{}`,
@@ -812,6 +818,8 @@ make_config! {
sso_enabled: bool, true, def, false;
/// Only SSO login |> Disable Email+Master Password login
sso_only: bool, true, def, false;
/// Allow SSO flow to create account |> You probably want to disable it when using a public provider
sso_signups_allowed: bool, true, def, true;
/// Allow email association |> Associate existing non-SSO user based on email
sso_signups_match_email: bool, true, def, true;
/// Allow unknown email verification status |> Allowing this with `SSO_SIGNUPS_MATCH_EMAIL=true` open potential account takeover.
@@ -858,16 +866,12 @@ make_config! {
duo: _enable_duo {
/// Enabled
_enable_duo: bool, true, def, true;
/// Attempt to use deprecated iframe-based Traditional Prompt (Duo WebSDK 2)
duo_use_iframe: bool, false, def, false;
/// Client Id
duo_ikey: String, true, option;
/// Client Secret
duo_skey: Pass, true, option;
/// Host
duo_host: String, true, option;
/// Application Key (generated automatically)
_duo_akey: Pass, false, option;
},
/// SMTP Email Settings
@@ -1157,11 +1161,8 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if !metadata.permissions().mode() & 0o111 != 0 {
err!(format!("sendmail command at `{path:?}` isn't executable"));
}
if nix::unistd::access(&path, nix::unistd::AccessFlags::X_OK).is_err() {
err!(format!("sendmail command at `{path:?}` isn't executable"));
}
}
}
@@ -1268,7 +1269,7 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
if !cfg.disable_admin_token {
match cfg.admin_token.as_ref() {
Some(t) if t.starts_with("$argon2") => {
if let Err(e) = argon2::password_hash::PasswordHash::new(t) {
if let Err(e) = argon2::password_hash::phc::PasswordHash::new(t) {
err!(format!("The configured Argon2 PHC in `ADMIN_TOKEN` is invalid: '{e}'"))
}
}
@@ -1414,28 +1415,23 @@ pub enum PathType {
// Android (v2026.2.1): https://github.com/bitwarden/android/blob/6902c19c0093fa476bbf74ccaa70c9f14afbb82f/core/src/main/kotlin/com/bitwarden/core/data/manager/model/FlagKey.kt#L31
// iOS (v2026.2.1): https://github.com/bitwarden/ios/blob/cdd9ba1770ca2ffc098d02d12cc3208e3a830454/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7
pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
// Architecture
"desktop-ui-migration-milestone-1",
"desktop-ui-migration-milestone-2",
"desktop-ui-migration-milestone-3",
"desktop-ui-migration-milestone-4",
// Auth Team
"pm-5594-safari-account-switching",
"pm-32413-multi-client-password-management",
// Autofill Team
"ssh-agent",
"undetermined-cipher-scenario-logic",
"enable-basic-auth-response",
"ssh-agent-v2",
// Key Management Team
"ssh-key-vault-item",
"pm-25373-windows-biometrics-v2",
"pm-26340-linux-biometrics-v2",
"windows-native-credential-sync",
"pm-27278-v2-password-registration",
"enable-account-encryption-v2-jit-password-registration",
// Mobile Team
"anon-addy-self-host-alias",
"simple-login-self-host-alias",
"mutual-tls",
"cxp-import-mobile",
"cxp-export-mobile",
"pm-34171-card-scanner",
// Platform Team
"pm-30529-webauthn-related-origins",
// Vault Team
"pm-32009-new-item-types",
];
impl Config {
@@ -1501,16 +1497,10 @@ impl Config {
let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
operator.write(&CONFIG_FILENAME, config_str).await?;
Ok(())
}
// Invalidate CSS Cache because several config items might have impact on the rendered CSS
crate::api::invalidate_css_cache();
async fn update_config_partial(&self, other: ConfigBuilder) -> Result<(), Error> {
let builder = {
let usr = &self.inner.read().unwrap()._usr;
let mut overrides = Vec::new();
usr.merge(&other, false, &mut overrides)
};
self.update_config(builder, false).await
Ok(())
}
/// Tests whether an email's domain is allowed. A domain is allowed if it
@@ -1539,6 +1529,17 @@ impl Config {
}
}
/// Tests whether SSO signup is allowed for an email address, taking into
/// account the sso_signups_allowed and signups_domains_whitelist settings.
pub fn is_sso_signup_allowed(&self, email: &str) -> bool {
if self.signups_domains_whitelist().is_empty() {
self.sso_signups_allowed()
} else {
// The whitelist setting overrides the signups_allowed setting.
self.is_email_domain_allowed(email)
}
}
// The registration link should be hidden if
// - Signup is not allowed and email whitelist is empty unless mail is disabled and invitations are allowed
// - The SSO is activated and password login is disabled.
@@ -1583,6 +1584,9 @@ impl Config {
writer._overrides = Vec::new();
}
// Invalidate CSS Cache because several config items might have impact on the rendered CSS
crate::api::invalidate_css_cache();
Ok(())
}
@@ -1594,23 +1598,6 @@ impl Config {
inner._enable_smtp && (inner.smtp_host.is_some() || inner.use_sendmail)
}
pub async fn get_duo_akey(&self) -> String {
if let Some(akey) = self._duo_akey() {
akey
} else {
let akey_s = crate::crypto::encode_random_bytes::<64>(&data_encoding::BASE64);
// Save the new value
let builder = ConfigBuilder {
_duo_akey: Some(akey_s.clone()),
..Default::default()
};
self.update_config_partial(builder).await.ok();
akey_s
}
}
pub fn is_webauthn_2fa_supported(&self) -> bool {
Url::parse(&self.domain()).expect("DOMAIN not a valid URL").domain().is_some()
}
@@ -1723,7 +1710,7 @@ where
reg!("email/email_footer");
reg!("email/email_footer_text");
reg!("email/admin_reset_password", ".html");
reg!("email/admin_account_recovery", ".html");
reg!("email/change_email_existing", ".html");
reg!("email/change_email_invited", ".html");
reg!("email/change_email", ".html");
@@ -1742,6 +1729,7 @@ where
reg!("email/protected_action", ".html");
reg!("email/pw_hint_none", ".html");
reg!("email/pw_hint_some", ".html");
reg!("email/recover_twofactor", ".html");
reg!("email/register_verify_email", ".html");
reg!("email/send_2fa_removed_from_org", ".html");
reg!("email/send_emergency_access_invite", ".html");
+6 -3
View File
@@ -41,17 +41,20 @@ impl Archive {
) -> EmptyResult {
User::update_uuid_revision(user_uuid, conn).await;
db_run! { conn:
sqlite, mysql {
diesel::replace_into(archives::table)
mysql {
diesel::insert_into(archives::table)
.values((
archives::user_uuid.eq(user_uuid),
archives::cipher_uuid.eq(cipher_uuid),
archives::archived_at.eq(archived_at),
))
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(archives::archived_at.eq(archived_at))
.execute(conn)
.map_res("Error saving archive")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(archives::table)
.values((
archives::user_uuid.eq(user_uuid),
+8 -16
View File
@@ -61,7 +61,7 @@ impl Attachment {
let token = encode_jwt(&generate_file_download_claims(self.cipher_uuid.clone(), self.id.clone()));
Ok(format!("{host}/attachments/{}/{}?token={token}", self.cipher_uuid, self.id))
} else {
Ok(operator.presign_read(&self.get_file_path(), Duration::from_mins(5)).await?.uri().to_string())
Ok(operator.presign_read(&self.get_file_path(), Duration::from_mins(1)).await?.uri().to_string())
}
}
@@ -82,24 +82,16 @@ impl Attachment {
impl Attachment {
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(attachments::table)
mysql {
diesel::insert_into(attachments::table)
.values(self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(attachments::table)
.filter(attachments::id.eq(&self.id))
.set(self)
.execute(conn)
.map_res("Error saving attachment")
}
Err(e) => Err(e.into()),
}.map_res("Error saving attachment")
.map_res("Error saving attachment")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(attachments::table)
.values(self)
.on_conflict(attachments::id)
+29 -22
View File
@@ -4,7 +4,7 @@ use diesel::prelude::*;
use serde_json::Value;
use crate::{
api::EmptyResult,
api::{ApiResult, EmptyResult},
crypto::ct_eq,
db::{DbConn, schema::auth_requests},
error::MapResult,
@@ -82,31 +82,23 @@ impl AuthRequest {
}
impl AuthRequest {
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(auth_requests::table)
.values(&*self)
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(auth_requests::table)
.filter(auth_requests::uuid.eq(&self.uuid))
.set(&*self)
.execute(conn)
.map_res("Error auth_request")
}
Err(e) => Err(e.into()),
}.map_res("Error auth_request")
}
postgresql {
mysql {
diesel::insert_into(auth_requests::table)
.values(&*self)
.values(self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
.execute(conn)
.map_res("Error saving auth_request")
}
postgresql, sqlite {
diesel::insert_into(auth_requests::table)
.values(self)
.on_conflict(auth_requests::uuid)
.do_update()
.set(&*self)
.set(self)
.execute(conn)
.map_res("Error saving auth_request")
}
@@ -174,6 +166,21 @@ impl AuthRequest {
.await
}
/// Marks the request as used to log in. Returns false when it already was, so it only works once.
pub async fn set_authentication_date(uuid: &AuthRequestId, conn: &DbConn) -> ApiResult<bool> {
let now = Utc::now().naive_utc();
conn.run(move |conn| {
diesel::update(auth_requests::table)
.filter(auth_requests::uuid.eq(uuid))
.filter(auth_requests::authentication_date.is_null())
.set(auth_requests::authentication_date.eq(now))
.execute(conn)
.map(|updated| updated == 1)
.map_res("Error updating auth request")
})
.await
}
pub fn check_access_code(&self, access_code: &str) -> bool {
ct_eq(&self.access_code, access_code)
}
+182 -133
View File
@@ -63,6 +63,22 @@ pub struct Cipher {
pub reprompt: Option<i32>,
}
/// Whether `data` is a sealed cipher blob rather than the legacy per-type JSON.
///
/// Ciphers of v2 accounts are encrypted as a single blob that carries everything, name included,
/// and is opaque to us. It is recognized the same way upstream does, by a top-level
/// `format_version` key that the legacy JSON never has.
///
/// Ref: <https://github.com/bitwarden/server/blob/main/src/Core/Vault/Entities/Cipher.cs>
pub fn is_data_blob_encrypted(data: &str) -> bool {
serde_json::from_str::<Value>(data).is_ok_and(|d| is_blob_value(&d))
}
/// [`is_data_blob_encrypted`] for `data` that was already parsed.
fn is_blob_value(data: &Value) -> bool {
data.get("format_version").is_some()
}
pub enum RepromptType {
None = 0,
Password = 1,
@@ -110,6 +126,11 @@ impl Cipher {
.insert(format!("Ciphers[{index}].Notes"), serde_json::to_value([&max_note_size_msg]).unwrap());
}
if let Err(e) = cipher.validate_content(cipher.is_blob()) {
validation_errors
.insert(format!("Ciphers[{index}].{}", e.field), serde_json::to_value([e.message]).unwrap());
}
// Validate the password history if it contains `null` values and if so, return a warning
if let Some(Value::Array(password_history)) = &cipher.password_history {
for pwh in password_history {
@@ -154,6 +175,11 @@ impl Cipher {
) -> Result<Value, crate::Error> {
use crate::util::{format_date, validate_and_format_date};
// Parsed once here, since `data` can be large and this runs for every cipher in a sync.
// `LowerCase` only lowercases the first letter of each key, so it keeps `format_version`.
let type_data = serde_json::from_str::<LowerCase<Value>>(&self.data).map(|d| d.data);
let is_blob_encrypted = type_data.as_ref().is_ok_and(is_blob_value);
let mut attachments_json: Value = Value::Null;
if let Some(cipher_sync_data) = cipher_sync_data {
if let Some(attachments) = cipher_sync_data.cipher_attachments.get(&self.uuid)
@@ -189,124 +215,62 @@ impl Cipher {
(false, false, false)
};
let fields_json: Vec<_> = self
.fields
.as_ref()
.and_then(|s| {
serde_json::from_str::<Vec<LowerCase<Value>>>(s)
.inspect_err(|e| warn!("Error parsing fields {e:?} for {}", self.uuid))
.ok()
})
.map(|d| {
d.into_iter()
.map(|mut f| {
// Check if the `type` key is a number, strings break some clients
// The fallback type is the hidden type `1`. this should prevent accidental data disclosure
// If not try to convert the string value to a number and fallback to `1`
// If it is both not a number and not a string, fallback to `1`
match f.data.get("type") {
Some(t) if t.is_number() => {}
Some(t) if t.is_string() => {
let type_num = &t.as_str().unwrap_or("1").parse::<u8>().unwrap_or(1);
f.data["type"] = json!(type_num);
// Like upstream, a cipher that was stored without fields or password history reports them as
// null rather than as an empty list; clients keep the two apart.
let fields_json: Option<Vec<_>> = self.fields.as_ref().map(|s| {
serde_json::from_str::<Vec<LowerCase<Value>>>(s)
.inspect_err(|e| warn!("Error parsing fields {e:?} for {}", self.uuid))
.ok()
.map(|d| {
d.into_iter()
.map(|mut f| {
// Check if the `type` key is a number, strings break some clients
// The fallback type is the hidden type `1`. this should prevent accidental data disclosure
// If not try to convert the string value to a number and fallback to `1`
// If it is both not a number and not a string, fallback to `1`
match f.data.get("type") {
Some(t) if t.is_number() => {}
Some(t) if t.is_string() => {
let type_num = &t.as_str().unwrap_or("1").parse::<u8>().unwrap_or(1);
f.data["type"] = json!(type_num);
}
_ => {
f.data["type"] = json!(1);
}
}
_ => {
f.data["type"] = json!(1);
}
}
f.data
})
.collect()
})
.unwrap_or_default();
f.data
})
.collect()
})
.unwrap_or_default()
});
let password_history_json: Vec<_> = self
.password_history
.as_ref()
.and_then(|s| {
serde_json::from_str::<Vec<LowerCase<Value>>>(s)
.inspect_err(|e| warn!("Error parsing password history {e:?} for {}", self.uuid))
.ok()
})
.map(|d| {
// Check every password history item if they are valid and return it.
// If a password field has the type `null` skip it, it breaks newer Bitwarden clients
// A second check is done to verify the lastUsedDate exists and is a valid DateTime string, if not the epoch start time will be used
d.into_iter()
.filter_map(|d| match d.data.get("password") {
Some(p) if p.is_string() => Some(d.data),
_ => None,
})
.map(|mut d| {
let lud = if let Some(l) = d.get("lastUsedDate").and_then(|l| l.as_str()) {
validate_and_format_date(l)
} else {
"1970-01-01T00:00:00.000000Z".to_owned()
};
d["lastUsedDate"] = json!(lud);
d
})
.collect()
})
.unwrap_or_default();
// Get the type_data or a default to an empty json object '{}'.
// If not passing an empty object, mobile clients will crash.
let mut type_data_json = serde_json::from_str::<LowerCase<Value>>(&self.data)
.inspect_err(|_| warn!("Error parsing data field for {}", self.uuid))
.map_or_else(|_| Value::Object(serde_json::Map::new()), |d| d.data);
// NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream
// Set the first element of the Uris array as Uri, this is needed several (mobile) clients.
if self.atype == 1 {
// Upstream always has an `uri` key/value
type_data_json["uri"] = Value::Null;
if let Some(uris) = type_data_json["uris"].as_array_mut()
&& !uris.is_empty()
{
// Fix uri match values first, they are only allowed to be a number or null
// If it is a string, convert it to an int or null if that fails
for uri in &mut *uris {
if uri["match"].is_string() {
let match_value = match uri["match"].as_str().unwrap_or_default().parse::<u8>() {
Ok(n) => json!(n),
_ => Value::Null,
};
uri["match"] = match_value;
}
}
type_data_json["uri"] = uris[0]["uri"].clone();
}
// Check if `passwordRevisionDate` is a valid date, else convert it
if let Some(pw_revision) = type_data_json["passwordRevisionDate"].as_str() {
type_data_json["passwordRevisionDate"] = json!(validate_and_format_date(pw_revision));
}
}
// Fix secure note issues when data is invalid
// This breaks at least the native mobile clients
if self.atype == 2 {
match type_data_json {
Value::Object(ref t) if t.get("type").is_some_and(Value::is_number) => {}
_ => {
type_data_json = json!({"type": 0});
}
}
}
// Fix invalid SSH Entries
// This breaks at least the native mobile client if invalid
// The only way to fix this is by setting type_data_json to `null`
// Opening this ssh-key in the mobile client will probably crash the client, but you can edit, save and afterwards delete it
if self.atype == 5
&& (type_data_json["keyFingerprint"].as_str().is_none_or(str::is_empty)
|| type_data_json["privateKey"].as_str().is_none_or(str::is_empty)
|| type_data_json["publicKey"].as_str().is_none_or(str::is_empty))
{
warn!("Error parsing ssh-key, mandatory fields are invalid for {}", self.uuid);
type_data_json = Value::Null;
}
let password_history_json: Option<Vec<_>> = self.password_history.as_ref().map(|s| {
serde_json::from_str::<Vec<LowerCase<Value>>>(s)
.inspect_err(|e| warn!("Error parsing password history {e:?} for {}", self.uuid))
.ok()
.map(|d| {
// Check every password history item if they are valid and return it.
// If a password field has the type `null` skip it, it breaks newer Bitwarden clients
// A second check is done to verify the lastUsedDate exists and is a valid DateTime string, if not the epoch start time will be used
d.into_iter()
.filter_map(|d| match d.data.get("password") {
Some(p) if p.is_string() => Some(d.data),
_ => None,
})
.map(|mut d| {
let lud = if let Some(l) = d.get("lastUsedDate").and_then(|l| l.as_str()) {
validate_and_format_date(l)
} else {
"1970-01-01T00:00:00.000000Z".to_owned()
};
d["lastUsedDate"] = json!(lud);
d
})
.collect()
})
.unwrap_or_default()
});
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) {
@@ -403,10 +367,83 @@ impl Cipher {
_ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)),
};
json_object[key] = type_data_json;
if is_blob_encrypted {
// The blob holds all of the content, so it is sent back as-is and the structured fields
// stay null, as upstream does. Only the name needs clearing, the others are never stored.
json_object["data"] = json!(self.data);
json_object["name"] = Value::Null;
} else {
json_object[key] = self.legacy_type_data_json(type_data);
}
Ok(json_object)
}
/// The per-type data (`login`, `card`, …) of a legacy cipher, from `type_data` as parsed from
/// `self.data`, with fixups for values that are known to break clients.
fn legacy_type_data_json(&self, type_data: Result<Value, serde_json::Error>) -> Value {
use crate::util::validate_and_format_date;
// Get the type_data or a default to an empty json object '{}'.
// If not passing an empty object, mobile clients will crash.
let mut type_data_json = type_data
.inspect_err(|_| warn!("Error parsing data field for {}", self.uuid))
.unwrap_or_else(|_| Value::Object(serde_json::Map::new()));
// NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream
// Set the first element of the Uris array as Uri, this is needed several (mobile) clients.
if self.atype == 1 {
// Upstream always has an `uri` key/value
type_data_json["uri"] = Value::Null;
if let Some(uris) = type_data_json["uris"].as_array_mut()
&& !uris.is_empty()
{
// Fix uri match values first, they are only allowed to be a number or null
// If it is a string, convert it to an int or null if that fails
for uri in &mut *uris {
if uri["match"].is_string() {
let match_value = match uri["match"].as_str().unwrap_or_default().parse::<u8>() {
Ok(n) => json!(n),
_ => Value::Null,
};
uri["match"] = match_value;
}
}
type_data_json["uri"] = uris[0]["uri"].clone();
}
// Check if `passwordRevisionDate` is a valid date, else convert it
if let Some(pw_revision) = type_data_json["passwordRevisionDate"].as_str() {
type_data_json["passwordRevisionDate"] = json!(validate_and_format_date(pw_revision));
}
}
// Fix secure note issues when data is invalid
// This breaks at least the native mobile clients
if self.atype == 2 {
match type_data_json {
Value::Object(ref t) if t.get("type").is_some_and(Value::is_number) => {}
_ => {
type_data_json = json!({"type": 0});
}
}
}
// Fix invalid SSH Entries
// This breaks at least the native mobile client if invalid
// The only way to fix this is by setting type_data_json to `null`
// Opening this ssh-key in the mobile client will probably crash the client, but you can edit, save and afterwards delete it
if self.atype == 5
&& (type_data_json["keyFingerprint"].as_str().is_none_or(str::is_empty)
|| type_data_json["privateKey"].as_str().is_none_or(str::is_empty)
|| type_data_json["publicKey"].as_str().is_none_or(str::is_empty))
{
warn!("Error parsing ssh-key, mandatory fields are invalid for {}", self.uuid);
type_data_json = Value::Null;
}
type_data_json
}
pub async fn update_users_revision(&self, conn: &DbConn) -> Vec<UserId> {
let mut user_uuids = Vec::new();
match self.user_uuid {
@@ -440,24 +477,16 @@ impl Cipher {
self.updated_at = Utc::now().naive_utc();
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(ciphers::table)
mysql {
diesel::insert_into(ciphers::table)
.values(&*self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(ciphers::table)
.filter(ciphers::uuid.eq(&self.uuid))
.set(&*self)
.execute(conn)
.map_res("Error saving cipher")
}
Err(e) => Err(e.into()),
}.map_res("Error saving cipher")
.map_res("Error saving cipher")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(ciphers::table)
.values(&*self)
.on_conflict(ciphers::uuid)
@@ -665,16 +694,27 @@ impl Cipher {
}
async fn get_user_collections_access_flags(&self, user_uuid: &UserId, conn: &DbConn) -> Vec<(bool, bool, bool)> {
let user_uuid = user_uuid.to_string();
conn.run(move |conn| {
// Check whether this cipher is in any collections accessible to the
// user. If so, retrieve the access flags for each collection.
// The user must have a confirmed membership in the cipher's
// organization, since users_collections rows are kept when a
// membership is revoked or not confirmed yet.
ciphers::table
.filter(ciphers::uuid.eq(&self.uuid))
.inner_join(ciphers_collections::table.on(ciphers::uuid.eq(ciphers_collections::cipher_uuid)))
.inner_join(
users_organizations::table.on(ciphers::organization_uuid
.eq(users_organizations::org_uuid.nullable())
.and(users_organizations::user_uuid.eq(user_uuid))
.and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))),
)
.inner_join(
users_collections::table.on(ciphers_collections::collection_uuid
.eq(users_collections::collection_uuid)
.and(users_collections::user_uuid.eq(user_uuid))),
// Only allow collection access via the confirmed membership.
.and(users_organizations::user_uuid.eq(users_collections::user_uuid))),
)
.select((users_collections::read_only, users_collections::hide_passwords, users_collections::manage))
.load::<(bool, bool, bool)>(conn)
@@ -705,6 +745,10 @@ impl Cipher {
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))),
)
.filter(users_organizations::user_uuid.eq(user_uuid))
// Only allow group access via a confirmed membership, since
// groups_users rows are kept when a membership is revoked.
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable()))
.select((collections_groups::read_only, collections_groups::hide_passwords, collections_groups::manage))
.load::<(bool, bool, bool)>(conn)
.expect("Error getting group access restrictions")
@@ -997,6 +1041,7 @@ impl Cipher {
.eq(ciphers_collections::collection_uuid)
.and(collections_groups::groups_uuid.eq(groups::uuid))),
)
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
users_organizations::access_all
.eq(true) // User has access all
@@ -1028,6 +1073,7 @@ impl Cipher {
.eq(ciphers_collections::collection_uuid)
.and(users_collections::user_uuid.eq(user_uuid.clone()))),
)
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
users_organizations::access_all
.eq(true) // User has access all
@@ -1072,6 +1118,7 @@ impl Cipher {
.eq(ciphers_collections::collection_uuid)
.and(collections_groups::groups_uuid.eq(groups::uuid))),
)
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
users_organizations::access_all
.eq(true) // User has access all
@@ -1104,6 +1151,7 @@ impl Cipher {
.eq(ciphers_collections::collection_uuid)
.and(users_collections::user_uuid.eq(user_uuid.clone()))),
)
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
users_organizations::access_all
.eq(true) // User has access all
@@ -1155,6 +1203,7 @@ impl Cipher {
.or_filter(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner
.or_filter(groups::access_all.eq(true)) //Access via group
.or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.select(ciphers_collections::all_columns)
.distinct()
.load::<(CipherId, CollectionId)>(conn)
+49 -66
View File
@@ -168,24 +168,16 @@ impl Collection {
self.update_users_revision(conn).await;
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(collections::table)
mysql {
diesel::insert_into(collections::table)
.values(self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(collections::table)
.filter(collections::uuid.eq(&self.uuid))
.set(self)
.execute(conn)
.map_res("Error saving collection")
}
Err(e) => Err(e.into()),
}.map_res("Error saving collection")
.map_res("Error saving collection")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(collections::table)
.values(self)
.on_conflict(collections::uuid)
@@ -375,6 +367,7 @@ impl Collection {
.and(collections_groups::collections_uuid.eq(collections::uuid))),
)
.filter(collections::uuid.eq(uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
users_collections::collection_uuid
.eq(uuid)
@@ -414,6 +407,7 @@ impl Collection {
.and(users_organizations::user_uuid.eq(user_uuid))),
)
.filter(collections::uuid.eq(uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(users_collections::collection_uuid.eq(uuid).or(
// Directly accessed collection
users_organizations::access_all.eq(true).or(
@@ -438,7 +432,8 @@ impl Collection {
.inner_join(
users_organizations::table.on(collections::org_uuid
.eq(users_organizations::org_uuid)
.and(users_organizations::user_uuid.eq(user_uuid.clone()))),
.and(users_organizations::user_uuid.eq(user_uuid.clone()))
.and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))),
)
.left_join(
users_collections::table.on(users_collections::collection_uuid
@@ -484,7 +479,8 @@ impl Collection {
.inner_join(
users_organizations::table.on(collections::org_uuid
.eq(users_organizations::org_uuid)
.and(users_organizations::user_uuid.eq(user_uuid.clone()))),
.and(users_organizations::user_uuid.eq(user_uuid.clone()))
.and(users_organizations::status.eq(MembershipStatus::Confirmed as i32))),
)
.left_join(
users_collections::table.on(users_collections::collection_uuid
@@ -535,6 +531,7 @@ impl Collection {
.and(collections_groups::collections_uuid.eq(collections::uuid))),
)
.filter(collections::uuid.eq(&self.uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
users_collections::collection_uuid
.eq(&self.uuid)
@@ -594,6 +591,7 @@ impl Collection {
.and(collections_groups::collections_uuid.eq(collections::uuid))),
)
.filter(collections::uuid.eq(&uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
users_collections::collection_uuid
.eq(&uuid)
@@ -658,6 +656,7 @@ impl Collection {
.and(collections_groups::collections_uuid.eq(collections::uuid))),
)
.filter(collections::org_uuid.eq(&org_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(
// Manage permission on a collection assigned directly or via a group.
users_collections::manage.eq(true).or(collections_groups::manage.eq(true)),
@@ -728,53 +727,30 @@ impl CollectionUser {
) -> EmptyResult {
User::update_uuid_revision(user_uuid, conn).await;
let values = (
users_collections::user_uuid.eq(user_uuid),
users_collections::collection_uuid.eq(collection_uuid),
users_collections::read_only.eq(read_only),
users_collections::hide_passwords.eq(hide_passwords),
users_collections::manage.eq(manage),
);
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(users_collections::table)
.values((
users_collections::user_uuid.eq(user_uuid),
users_collections::collection_uuid.eq(collection_uuid),
users_collections::read_only.eq(read_only),
users_collections::hide_passwords.eq(hide_passwords),
users_collections::manage.eq(manage),
))
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(users_collections::table)
.filter(users_collections::user_uuid.eq(user_uuid))
.filter(users_collections::collection_uuid.eq(collection_uuid))
.set((
users_collections::user_uuid.eq(user_uuid),
users_collections::collection_uuid.eq(collection_uuid),
users_collections::read_only.eq(read_only),
users_collections::hide_passwords.eq(hide_passwords),
users_collections::manage.eq(manage),
))
.execute(conn)
.map_res("Error adding user to collection")
}
Err(e) => Err(e.into()),
}.map_res("Error adding user to collection")
}
postgresql {
mysql {
diesel::insert_into(users_collections::table)
.values((
users_collections::user_uuid.eq(user_uuid),
users_collections::collection_uuid.eq(collection_uuid),
users_collections::read_only.eq(read_only),
users_collections::hide_passwords.eq(hide_passwords),
users_collections::manage.eq(manage),
))
.values(values)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(values)
.execute(conn)
.map_res("Error adding user to collection")
}
postgresql, sqlite {
diesel::insert_into(users_collections::table)
.values(values)
.on_conflict((users_collections::user_uuid, users_collections::collection_uuid))
.do_update()
.set((
users_collections::read_only.eq(read_only),
users_collections::hide_passwords.eq(hide_passwords),
users_collections::manage.eq(manage),
))
.set(values)
.execute(conn)
.map_res("Error adding user to collection")
}
@@ -850,10 +826,18 @@ impl CollectionUser {
.await
}
// Only returns the collections of organizations the user is a confirmed member of
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
conn.run(move |conn| {
users_collections::table
.inner_join(collections::table.on(collections::uuid.eq(users_collections::collection_uuid)))
.inner_join(
users_organizations::table.on(users_organizations::org_uuid
.eq(collections::org_uuid)
.and(users_organizations::user_uuid.eq(users_collections::user_uuid))),
)
.filter(users_collections::user_uuid.eq(user_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.select(users_collections::all_columns)
.load::<Self>(conn)
.expect("Error loading users_collections")
@@ -909,19 +893,18 @@ impl CollectionCipher {
Self::update_users_revision(collection_uuid, conn).await;
db_run! { conn:
sqlite, mysql {
// Not checking for ForeignKey Constraints here.
// Table ciphers_collections does not have ForeignKey Constraints which would cause conflicts.
// This table has no constraints pointing to itself, but only to others.
diesel::replace_into(ciphers_collections::table)
mysql {
diesel::insert_into(ciphers_collections::table)
.values((
ciphers_collections::cipher_uuid.eq(cipher_uuid),
ciphers_collections::collection_uuid.eq(collection_uuid),
))
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_nothing()
.execute(conn)
.map_res("Error adding cipher to collection")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(ciphers_collections::table)
.values((
ciphers_collections::cipher_uuid.eq(cipher_uuid),
+18 -3
View File
@@ -146,15 +146,18 @@ impl Device {
}
db_run! { conn:
sqlite, mysql {
mysql {
crate::util::retry(||
diesel::replace_into(devices::table)
diesel::insert_into(devices::table)
.values(&*self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn),
10,
).map_res("Error saving device")
}
postgresql {
postgresql, sqlite {
crate::util::retry(||
diesel::insert_into(devices::table)
.values(&*self)
@@ -266,10 +269,22 @@ impl Device {
let devices = Self::find_by_user(user_uuid, conn).await;
for mut device in devices {
device.refresh_token = Device::generate_refresh_token();
device.twofactor_remember = None;
device.save(false, conn).await?;
}
Ok(())
}
pub async fn clear_twofactor_remember_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
conn.run(move |conn| {
diesel::update(devices::table)
.filter(devices::user_uuid.eq(user_uuid))
.set(devices::twofactor_remember.eq::<Option<String>>(None))
.execute(conn)
.map_res("Error removing two factor remember tokens")
})
.await
}
}
#[derive(Display)]
+7 -15
View File
@@ -146,24 +146,16 @@ impl EmergencyAccess {
self.updated_at = Utc::now().naive_utc();
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(emergency_access::table)
mysql {
diesel::insert_into(emergency_access::table)
.values(&*self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(emergency_access::table)
.filter(emergency_access::uuid.eq(&self.uuid))
.set(&*self)
.execute(conn)
.map_res("Error updating emergency access")
}
Err(e) => Err(e.into()),
}.map_res("Error saving emergency access")
.map_res("Error saving emergency access")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(emergency_access::table)
.values(&*self)
.on_conflict(emergency_access::uuid)
+30 -17
View File
@@ -43,7 +43,7 @@ pub struct Event {
pub provider_org_uuid: Option<String>,
}
// Upstream enum: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/EventType.cs
// Upstream enum: https://github.com/bitwarden/server/blob/v2026.6.2/src/Core/Dirt/Enums/EventType.cs
#[derive(Debug, Copy, Clone)]
pub enum EventType {
// User
@@ -108,6 +108,12 @@ pub enum EventType {
OrganizationUserRejectedAuthRequest = 1514,
OrganizationUserDeleted = 1515, // Both user and organization user data were deleted
OrganizationUserLeft = 1516, // User voluntarily left the organization
// OrganizationUserAutomaticallyConfirmed = 1517,
// OrganizationUserSelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy
OrganizationUserAdminResetTwoFactor = 1519,
// OrganizationUserRevoked_TwoFactorNonCompliance = 1520,
// OrganizationUserRevoked_SingleOrganizationNonCompliance = 1521,
OrganizationUserNotificationBannerActionClicked = 1522,
// Organization
OrganizationUpdated = 1600,
@@ -202,20 +208,23 @@ impl Event {
/// Basic Queries
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn:
sqlite, mysql {
diesel::replace_into(event::table)
.values(self)
.execute(conn)
.map_res("Error saving event")
}
postgresql {
mysql {
diesel::insert_into(event::table)
.values(self)
.on_conflict(event::uuid)
.do_update()
.set(self)
.execute(conn)
.map_res("Error saving event")
.values(self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(self)
.execute(conn)
.map_res("Error saving event")
}
postgresql, sqlite {
diesel::insert_into(event::table)
.values(self)
.on_conflict(event::uuid)
.do_update()
.set(self)
.execute(conn)
.map_res("Error saving event")
}
}
}
@@ -298,12 +307,16 @@ impl Event {
) -> Vec<Self> {
conn.run(move |conn| {
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::event_date.between(start, end))
.filter(
event::user_uuid
.eq(users_organizations::user_uuid.nullable())
event::org_user_uuid
.eq(member_uuid)
.or(event::user_uuid.eq(users_organizations::user_uuid.nullable()))
.or(event::act_user_uuid.eq(users_organizations::user_uuid.nullable())),
)
.select(event::all_columns)
+12 -21
View File
@@ -77,24 +77,16 @@ impl Folder {
self.updated_at = Utc::now().naive_utc();
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(folders::table)
mysql {
diesel::insert_into(folders::table)
.values(&*self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(folders::table)
.filter(folders::uuid.eq(&self.uuid))
.set(&*self)
.execute(conn)
.map_res("Error saving folder")
}
Err(e) => Err(e.into()),
}.map_res("Error saving folder")
.map_res("Error saving folder")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(folders::table)
.values(&*self)
.on_conflict(folders::uuid)
@@ -147,16 +139,15 @@ impl Folder {
impl FolderCipher {
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn:
sqlite, mysql {
// Not checking for ForeignKey Constraints here.
// Table folders_ciphers does not have ForeignKey Constraints which would cause conflicts.
// This table has no constraints pointing to itself, but only to others.
diesel::replace_into(folders_ciphers::table)
mysql {
diesel::insert_into(folders_ciphers::table)
.values(self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_nothing()
.execute(conn)
.map_res("Error adding cipher to folder")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(folders_ciphers::table)
.values(self)
.on_conflict((folders_ciphers::cipher_uuid, folders_ciphers::folder_uuid))
+49 -114
View File
@@ -13,7 +13,7 @@ use crate::{
};
use macros::UuidFromParam;
use super::{CollectionId, Membership, MembershipId, OrganizationId, User, UserId};
use super::{CollectionId, Membership, MembershipId, MembershipStatus, OrganizationId, User, UserId};
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = groups)]
@@ -104,7 +104,6 @@ impl Group {
"id": self.uuid,
"organizationId": self.organizations_uuid,
"name": self.name,
"accessAll": self.access_all,
"externalId": self.external_id,
"collections": collections_groups,
"object": "groupDetails"
@@ -166,24 +165,16 @@ impl Group {
self.revision_date = Utc::now().naive_utc();
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(groups::table)
mysql {
diesel::insert_into(groups::table)
.values(&*self)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(&*self)
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(groups::table)
.filter(groups::uuid.eq(&self.uuid))
.set(&*self)
.execute(conn)
.map_res("Error saving group")
}
Err(e) => Err(e.into()),
}.map_res("Error saving group")
.map_res("Error saving group")
}
postgresql {
postgresql, sqlite {
diesel::insert_into(groups::table)
.values(&*self)
.on_conflict(groups::uuid)
@@ -257,6 +248,7 @@ impl Group {
.and(groups::organizations_uuid.eq(users_organizations::org_uuid))),
)
.filter(users_organizations::user_uuid.eq(user_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(groups::access_all.eq(true))
.select(groups::organizations_uuid)
.distinct()
@@ -276,6 +268,9 @@ impl Group {
.and(users_organizations::org_uuid.eq(groups::organizations_uuid))),
)
.filter(users_organizations::user_uuid.eq(user_uuid))
// Only allow full access via a confirmed membership, since
// groups_users rows are kept when a membership is revoked.
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.filter(groups::organizations_uuid.eq(org_uuid))
.filter(groups::access_all.eq(true))
.select(groups::access_all)
@@ -326,53 +321,30 @@ impl CollectionGroup {
group_user.update_user_revision(conn).await;
}
let values = (
collections_groups::collections_uuid.eq(&self.collections_uuid),
collections_groups::groups_uuid.eq(&self.groups_uuid),
collections_groups::read_only.eq(&self.read_only),
collections_groups::hide_passwords.eq(&self.hide_passwords),
collections_groups::manage.eq(&self.manage),
);
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(collections_groups::table)
.values((
collections_groups::collections_uuid.eq(&self.collections_uuid),
collections_groups::groups_uuid.eq(&self.groups_uuid),
collections_groups::read_only.eq(&self.read_only),
collections_groups::hide_passwords.eq(&self.hide_passwords),
collections_groups::manage.eq(&self.manage),
))
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(collections_groups::table)
.filter(collections_groups::collections_uuid.eq(&self.collections_uuid))
.filter(collections_groups::groups_uuid.eq(&self.groups_uuid))
.set((
collections_groups::collections_uuid.eq(&self.collections_uuid),
collections_groups::groups_uuid.eq(&self.groups_uuid),
collections_groups::read_only.eq(&self.read_only),
collections_groups::hide_passwords.eq(&self.hide_passwords),
collections_groups::manage.eq(&self.manage),
))
.execute(conn)
.map_res("Error adding group to collection")
}
Err(e) => Err(e.into()),
}.map_res("Error adding group to collection")
}
postgresql {
mysql {
diesel::insert_into(collections_groups::table)
.values((
collections_groups::collections_uuid.eq(&self.collections_uuid),
collections_groups::groups_uuid.eq(&self.groups_uuid),
collections_groups::read_only.eq(self.read_only),
collections_groups::hide_passwords.eq(self.hide_passwords),
collections_groups::manage.eq(self.manage),
))
.values(values)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_update()
.set(values)
.execute(conn)
.map_res("Error adding group to collection")
}
postgresql, sqlite {
diesel::insert_into(collections_groups::table)
.values(values)
.on_conflict((collections_groups::collections_uuid, collections_groups::groups_uuid))
.do_update()
.set((
collections_groups::read_only.eq(self.read_only),
collections_groups::hide_passwords.eq(self.hide_passwords),
collections_groups::manage.eq(self.manage),
))
.set(values)
.execute(conn)
.map_res("Error adding group to collection")
}
@@ -415,6 +387,7 @@ impl CollectionGroup {
.and(collections::org_uuid.eq(groups::organizations_uuid))),
)
.filter(users_organizations::user_uuid.eq(user_uuid))
.filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
.select(collections_groups::all_columns)
.load::<Self>(conn)
.expect("Error loading user collection groups")
@@ -497,43 +470,25 @@ impl GroupUser {
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
self.update_user_revision(conn).await;
let values = (
groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid),
groups_users::groups_uuid.eq(&self.groups_uuid),
);
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(groups_users::table)
.values((
groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid),
groups_users::groups_uuid.eq(&self.groups_uuid),
))
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(groups_users::table)
.filter(groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid))
.filter(groups_users::groups_uuid.eq(&self.groups_uuid))
.set((
groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid),
groups_users::groups_uuid.eq(&self.groups_uuid),
))
.execute(conn)
.map_res("Error adding user to group")
}
Err(e) => Err(e.into()),
}.map_res("Error adding user to group")
}
postgresql {
mysql {
diesel::insert_into(groups_users::table)
.values((
groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid),
groups_users::groups_uuid.eq(&self.groups_uuid),
))
.values(values)
.on_conflict(diesel::dsl::DuplicatedKeys)
.do_nothing()
.execute(conn)
.map_res("Error adding user to group")
}
postgresql, sqlite {
diesel::insert_into(groups_users::table)
.values(values)
.on_conflict((groups_users::users_organizations_uuid, groups_users::groups_uuid))
.do_update()
.set((
groups_users::users_organizations_uuid.eq(&self.users_organizations_uuid),
groups_users::groups_uuid.eq(&self.groups_uuid),
))
.do_nothing()
.execute(conn)
.map_res("Error adding user to group")
}
@@ -618,26 +573,6 @@ impl GroupUser {
}
}
pub async fn delete_by_group_and_member(
group_uuid: &GroupId,
member_uuid: &MembershipId,
conn: &DbConn,
) -> EmptyResult {
match Membership::find_by_uuid(member_uuid, conn).await {
Some(member) => User::update_uuid_revision(&member.user_uuid, conn).await,
None => warn!("Member could not be found!"),
}
conn.run(move |conn| {
diesel::delete(groups_users::table)
.filter(groups_users::groups_uuid.eq(group_uuid))
.filter(groups_users::users_organizations_uuid.eq(member_uuid))
.execute(conn)
.map_res("Error deleting group users")
})
.await
}
pub async fn delete_all_by_group(group_uuid: &GroupId, org_uuid: &OrganizationId, conn: &DbConn) -> EmptyResult {
let group_users = GroupUser::find_by_group(group_uuid, org_uuid, conn).await;
for group_user in group_users {
+4 -2
View File
@@ -17,11 +17,12 @@ mod two_factor;
mod two_factor_duo_context;
mod two_factor_incomplete;
mod user;
mod user_signature_key_pair;
pub use self::archive::Archive;
pub use self::attachment::{Attachment, AttachmentId};
pub use self::auth_request::{AuthRequest, AuthRequestId};
pub use self::cipher::{Cipher, CipherId, RepromptType};
pub use self::cipher::{Cipher, CipherId, RepromptType, is_data_blob_encrypted};
pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser};
pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId};
pub use self::emergency_access::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType};
@@ -39,4 +40,5 @@ pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth};
pub use self::two_factor::{TwoFactor, TwoFactorType};
pub use self::two_factor_duo_context::TwoFactorDuoContext;
pub use self::two_factor_incomplete::TwoFactorIncomplete;
pub use self::user::{Invitation, SsoUser, User, UserId, UserKdfType, UserStampException};
pub use self::user::{Invitation, KeyId, SsoUser, User, UserId, UserKdfType, UserStampException};
pub use self::user_signature_key_pair::{SignatureAlgorithm, UserSignatureKeyPair};
+29
View File
@@ -49,6 +49,7 @@ pub enum OrgPolicyType {
// AutotypeDefaultSetting = 17, // Not supported yet
// AutoConfirm = 18, // Not supported (not implemented yet)
// BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed)
OrganizationUserNotification = 20,
}
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendOptionsPolicyData.cs#L5
@@ -91,6 +92,7 @@ impl OrgPolicy {
"type": self.atype,
"data": data_json,
"enabled": self.enabled,
"revisionDate": null,
"object": "policy",
});
@@ -185,6 +187,26 @@ impl OrgPolicy {
.await
}
pub async fn find_accepted_and_confirmed_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
conn.run(move |conn| {
org_policies::table
.inner_join(
users_organizations::table.on(users_organizations::org_uuid
.eq(org_policies::org_uuid)
.and(users_organizations::user_uuid.eq(user_uuid))),
)
.filter(
users_organizations::status
.eq(MembershipStatus::Accepted as i32)
.or(users_organizations::status.eq(MembershipStatus::Confirmed as i32)),
)
.select(org_policies::all_columns)
.load::<Self>(conn)
.expect("Error loading org_policy")
})
.await
}
pub async fn find_by_org_and_type(
org_uuid: &OrganizationId,
policy_type: OrgPolicyType,
@@ -317,6 +339,13 @@ impl OrgPolicy {
}
pub async fn org_is_reset_password_auto_enroll(org_uuid: &OrganizationId, conn: &DbConn) -> bool {
// Account recovery depends on outbound mail. When SMTP is disabled, treat the
// auto-enroll policy as inactive so invites/registration are not forced to
// supply a reset-password key (see check_reset_password_applicable).
if !CONFIG.mail_enabled() {
return false;
}
match OrgPolicy::find_by_org_and_type(org_uuid, OrgPolicyType::ResetPassword, conn).await {
Some(policy) => match serde_json::from_str::<ResetPasswordDataModel>(&policy.data) {
Ok(opts) => {
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff. Show more