mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-11-03 17:06:48 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2e285248e | ||
|
|
274ee69db3 | ||
|
|
46f4ec83df |
No files matched your search
+6
-17
@@ -78,7 +78,7 @@
|
|||||||
# DB_CONNECTION_RETRIES=15
|
# DB_CONNECTION_RETRIES=15
|
||||||
|
|
||||||
## Database timeout
|
## Database timeout
|
||||||
## Timeout in seconds when acquiring database connection
|
## Timeout when acquiring database connection
|
||||||
# DATABASE_TIMEOUT=30
|
# DATABASE_TIMEOUT=30
|
||||||
|
|
||||||
## Database idle timeout
|
## Database idle timeout
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
## Defaults to daily. Set blank to disable this job. Also without EVENTS_DAYS_RETAIN set, this job will not start.
|
## Defaults to daily. Set blank to disable this job. Also without EVENTS_DAYS_RETAIN set, this job will not start.
|
||||||
# EVENT_CLEANUP_SCHEDULE="0 10 0 * * *"
|
# EVENT_CLEANUP_SCHEDULE="0 10 0 * * *"
|
||||||
## Number of days to retain events stored in the database.
|
## Number of days to retain events stored in the database.
|
||||||
## If unset (the default) or 0, events are kept indefinitely and the scheduled job is disabled!
|
## If unset (the default), events are kept indefinitely and the scheduled job is disabled!
|
||||||
# EVENTS_DAYS_RETAIN=
|
# EVENTS_DAYS_RETAIN=
|
||||||
##
|
##
|
||||||
## Cron schedule of the job that cleans old auth requests from the auth request.
|
## Cron schedule of the job that cleans old auth requests from the auth request.
|
||||||
@@ -233,7 +233,7 @@
|
|||||||
# USER_SEND_LIMIT=
|
# USER_SEND_LIMIT=
|
||||||
|
|
||||||
## Number of days to wait before auto-deleting a trashed item.
|
## Number of days to wait before auto-deleting a trashed item.
|
||||||
## If unset (the default) or 0, trashed items are not auto-deleted.
|
## If unset (the default), trashed items are not auto-deleted.
|
||||||
## This setting applies globally, so make sure to inform all users of any changes to this setting.
|
## This setting applies globally, so make sure to inform all users of any changes to this setting.
|
||||||
# TRASH_AUTO_DELETE_DAYS=
|
# TRASH_AUTO_DELETE_DAYS=
|
||||||
|
|
||||||
@@ -386,31 +386,23 @@
|
|||||||
# HTTP_REQUEST_BLOCK_NON_GLOBAL_IPS=true
|
# HTTP_REQUEST_BLOCK_NON_GLOBAL_IPS=true
|
||||||
|
|
||||||
## Client Settings
|
## Client Settings
|
||||||
## Server-wide Fill Assist rules feed base directory URL, not a JSON file.
|
|
||||||
## Defaults to https://fillassist.bitwarden.com.
|
|
||||||
## Must be a valid HTTPS URL without credentials, query parameters, fragments, or surrounding whitespace.
|
|
||||||
# FILL_ASSIST_RULES_URL=https://fillassist.bitwarden.com
|
|
||||||
|
|
||||||
## Enable experimental feature flags for clients.
|
## Enable experimental feature flags for clients.
|
||||||
## This is a comma-separated list of flags, e.g. "flag1,flag2,flag3".
|
## This is a comma-separated list of flags, e.g. "flag1,flag2,flag3".
|
||||||
## Note that clients cache the /api/config endpoint for about 1 hour and it could take some time before they are enabled or disabled!
|
## Note that clients cache the /api/config endpoint for about 1 hour and it could take some time before they are enabled or disabled!
|
||||||
##
|
##
|
||||||
## The following flags are available:
|
## The following flags are available:
|
||||||
## - "fill-assist-targeting-rules": Enable Fill Assist targeting rules in the browser extension. (Browser >= 2026.6.0)
|
|
||||||
## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0)
|
## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0)
|
||||||
## - "pm-32413-multi-client-password-management": Enable changing the master password directly in the client. (Desktop/Extension >= 2026.4.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-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
|
||||||
## - "windows-desktop-autotype": Enable the autotype feature preview on Windows. (Desktop >= 2025.8.0)
|
## - "windows-desktop-autotype": Enable the autotype feature preview on Windows. (Desktop >= 2025.8.0)
|
||||||
## - "windows-desktop-autotype-ga": Enable the new, still in development, autotype implementation on Windows. (Desktop >= 2026.9.1)
|
## - "windows-desktop-autotype-ga": Enable the new, still in development, autotype implementation on Windows. (Desktop >= 2026.9.1)
|
||||||
## Only enable one of the two autotype flags, the clients disable autotype completely when both are enabled.
|
## Only enable one of the two autotype flags, the clients disable autotype completely when both are enabled.
|
||||||
## - "pm-30529-webauthn-related-origins": Allow passkeys stored in the vault to be used on related origins listed in a site's /.well-known/webauthn file (Browser >= 2026.2.0)
|
## - "pm-30529-webauthn-related-origins":
|
||||||
## - "pm-32009-new-item-types": Enable new item types: Bank Account, Driver's License, and Passport (Clients >= 2026.4.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)
|
## - "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)
|
## - "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)
|
## - "undetermined-cipher-scenario-logic": Enable the rewritten add/update login notification triggering logic in the browser extension (Browser >= 2026.2.0)
|
||||||
## - "biometrics-sdk-ipc": Enable biometric unlock over the SDK IPC framework (Desktop >= 2026.9.0, Browser >= 2026.9.0)
|
## - "biometrics-sdk-ipc": Enable biometric unlock over the SDK IPC framework (Desktop >= 2026.9.0, Browser >= 2026.9.0)
|
||||||
## - "windows-native-credential-sync": Register the desktop app as a passkey provider in Windows, so passkeys in the vault can be used by other apps and browsers.
|
|
||||||
## Requires the Microsoft Store (appx) build of the desktop app. Stable releases up to and including 2026.9.1 lack the required plugin registration, so it has no effect there yet.
|
|
||||||
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
|
# EXPERIMENTAL_CLIENT_FEATURE_FLAGS=
|
||||||
|
|
||||||
## Require new device emails. When a user logs in an email is required to be sent.
|
## Require new device emails. When a user logs in an email is required to be sent.
|
||||||
@@ -471,7 +463,7 @@
|
|||||||
|
|
||||||
## Allowed connect-src (Know the risks!)
|
## Allowed connect-src (Know the risks!)
|
||||||
## https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/connect-src
|
## https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/connect-src
|
||||||
## Allows other domains for URLs which can be loaded using script interfaces like the Forwarded email alias feature
|
## Allows other domains to URLs which can be loaded using script interfaces like the Forwarded email alias feature
|
||||||
## This adds the configured value to the 'Content-Security-Policy' headers 'connect-src' value.
|
## This adds the configured value to the 'Content-Security-Policy' headers 'connect-src' value.
|
||||||
## Multiple values must be separated with a whitespace. And only HTTPS values are allowed.
|
## Multiple values must be separated with a whitespace. And only HTTPS values are allowed.
|
||||||
## Example: "https://my-addy-io.domain.tld https://my-simplelogin.domain.tld"
|
## Example: "https://my-addy-io.domain.tld https://my-simplelogin.domain.tld"
|
||||||
@@ -614,9 +606,6 @@
|
|||||||
## Note that the checkbox would still be present, but ignored.
|
## Note that the checkbox would still be present, but ignored.
|
||||||
# DISABLE_2FA_REMEMBER=false
|
# DISABLE_2FA_REMEMBER=false
|
||||||
##
|
##
|
||||||
## Number of days before a remembered 2FA login expires (min: 1, max: 90).
|
|
||||||
# TWO_FACTOR_REMEMBER_DAYS=30
|
|
||||||
##
|
|
||||||
## Authenticator Settings
|
## Authenticator Settings
|
||||||
## Disable authenticator time drifted codes to be valid.
|
## Disable authenticator time drifted codes to be valid.
|
||||||
## TOTP codes of the previous and next 30 seconds will be invalid
|
## TOTP codes of the previous and next 30 seconds will be invalid
|
||||||
@@ -625,7 +614,7 @@
|
|||||||
## we allow by default the TOTP code which was valid one step back and one in the future.
|
## we allow by default the TOTP code which was valid one step back and one in the future.
|
||||||
## This can however allow attackers to be a bit more lucky with their attempts because there are 3 valid codes.
|
## This can however allow attackers to be a bit more lucky with their attempts because there are 3 valid codes.
|
||||||
## You can disable this, so that only the current TOTP Code is allowed.
|
## You can disable this, so that only the current TOTP Code is allowed.
|
||||||
## Keep in mind that when a server drifts out of time, valid codes could be marked as invalid.
|
## Keep in mind that when a sever drifts out of time, valid codes could be marked as invalid.
|
||||||
## In any case, if a code has been used it can not be used again, also codes which predates it will be invalid.
|
## In any case, if a code has been used it can not be used again, also codes which predates it will be invalid.
|
||||||
# AUTHENTICATOR_DISABLE_TIME_DRIFT=false
|
# AUTHENTICATOR_DISABLE_TIME_DRIFT=false
|
||||||
|
|
||||||
|
|||||||
@@ -4,4 +4,3 @@
|
|||||||
/.github/ISSUE_TEMPLATE/** @dani-garcia @BlackDex
|
/.github/ISSUE_TEMPLATE/** @dani-garcia @BlackDex
|
||||||
/.github/workflows/** @dani-garcia @BlackDex
|
/.github/workflows/** @dani-garcia @BlackDex
|
||||||
/SECURITY.md @dani-garcia @BlackDex
|
/SECURITY.md @dani-garcia @BlackDex
|
||||||
/THREAT_MODEL.md @dani-garcia @BlackDex
|
|
||||||
@@ -113,7 +113,7 @@ jobs:
|
|||||||
|
|
||||||
# Enable Rust Caching
|
# Enable Rust Caching
|
||||||
- name: Rust Caching
|
- name: Rust Caching
|
||||||
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||||
with:
|
with:
|
||||||
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes.
|
# Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes.
|
||||||
# Like changing the build host from Ubuntu 20.04 to 22.04 for example.
|
# Like changing the build host from Ubuntu 20.04 to 22.04 for example.
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to Docker Hub
|
# Login to Docker Hub
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -121,7 +121,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to GitHub Container Registry
|
# Login to GitHub Container Registry
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
@@ -137,7 +137,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to Quay.io
|
# Login to Quay.io
|
||||||
- name: Login to Quay.io
|
- name: Login to Quay.io
|
||||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: quay.io
|
registry: quay.io
|
||||||
username: ${{ secrets.QUAY_USERNAME }}
|
username: ${{ secrets.QUAY_USERNAME }}
|
||||||
@@ -185,7 +185,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Bake ${{ matrix.base_image }} containers
|
- name: Bake ${{ matrix.base_image }} containers
|
||||||
id: bake_vw
|
id: bake_vw
|
||||||
uses: docker/bake-action@59cb1203cd42bcefd40a1b6d73b589e48c86040e # v7.5.0
|
uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0
|
||||||
env:
|
env:
|
||||||
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
|
BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}"
|
||||||
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
|
SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}"
|
||||||
@@ -222,7 +222,7 @@ jobs:
|
|||||||
touch "${RUNNER_TEMP}/digests/${digest#sha256:}"
|
touch "${RUNNER_TEMP}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: digests-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
|
name: digests-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
|
||||||
path: ${{ runner.temp }}/digests/*
|
path: ${{ runner.temp }}/digests/*
|
||||||
@@ -242,7 +242,7 @@ jobs:
|
|||||||
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
|
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
|
||||||
|
|
||||||
- name: Upload binaries as artifacts
|
- name: Upload binaries as artifacts
|
||||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
with:
|
with:
|
||||||
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
|
name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }}
|
||||||
path: vaultwarden-${{ env.NORMALIZED_ARCH }}
|
path: vaultwarden-${{ env.NORMALIZED_ARCH }}
|
||||||
@@ -264,7 +264,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download digests
|
- name: Download digests
|
||||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
with:
|
with:
|
||||||
path: ${{ runner.temp }}/digests
|
path: ${{ runner.temp }}/digests
|
||||||
pattern: digests-*-${{ matrix.base_image }}
|
pattern: digests-*-${{ matrix.base_image }}
|
||||||
@@ -272,7 +272,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to Docker Hub
|
# Login to Docker Hub
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -287,7 +287,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to GitHub Container Registry
|
# Login to GitHub Container Registry
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
@@ -303,7 +303,7 @@ jobs:
|
|||||||
|
|
||||||
# Login to Quay.io
|
# Login to Quay.io
|
||||||
- name: Login to Quay.io
|
- name: Login to Quay.io
|
||||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: quay.io
|
registry: quay.io
|
||||||
username: ${{ secrets.QUAY_USERNAME }}
|
username: ${{ secrets.QUAY_USERNAME }}
|
||||||
|
|||||||
@@ -50,6 +50,6 @@ jobs:
|
|||||||
severity: CRITICAL,HIGH
|
severity: CRITICAL,HIGH
|
||||||
|
|
||||||
- name: Upload Trivy scan results to GitHub Security tab
|
- name: Upload Trivy scan results to GitHub Security tab
|
||||||
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
|
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||||
with:
|
with:
|
||||||
sarif_file: 'trivy-results.sarif'
|
sarif_file: 'trivy-results.sarif'
|
||||||
@@ -23,4 +23,4 @@ jobs:
|
|||||||
|
|
||||||
# When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too
|
# When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too
|
||||||
- name: Spell Check Repo
|
- name: Spell Check Repo
|
||||||
uses: crate-ci/typos@7f9d0819ca1c008ade73f4b41fa9d258d48f605b # v1.51.1
|
uses: crate-ci/typos@00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3
|
||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Run zizmor
|
- name: Run zizmor
|
||||||
uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0
|
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
||||||
with:
|
with:
|
||||||
# intentionally not scanning the entire repository,
|
# intentionally not scanning the entire repository,
|
||||||
# since it contains integration tests.
|
# since it contains integration tests.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ repos:
|
|||||||
|
|
||||||
# When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too
|
# When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too
|
||||||
- repo: https://github.com/crate-ci/typos
|
- repo: https://github.com/crate-ci/typos
|
||||||
rev: 7f9d0819ca1c008ade73f4b41fa9d258d48f605b # v1.51.1
|
rev: 00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3
|
||||||
hooks:
|
hooks:
|
||||||
- id: typos
|
- id: typos
|
||||||
always_run: true
|
always_run: true
|
||||||
|
|||||||
+2
-3
@@ -14,9 +14,8 @@ extend-ignore-re = [
|
|||||||
# In SMTP it's called HELO, so ignore it
|
# In SMTP it's called HELO, so ignore it
|
||||||
"(?i)helo_name",
|
"(?i)helo_name",
|
||||||
"Server name sent during.+HELO",
|
"Server name sent during.+HELO",
|
||||||
# COSE Is short for CBOR Object Signing and Encryption, ignore these specific items
|
# COSE Is short for CBOR Object Signing and Encryption
|
||||||
"COSEKey",
|
"(?i)cose",
|
||||||
"COSEAlgorithm",
|
|
||||||
# Ignore this specific string as it's valid
|
# Ignore this specific string as it's valid
|
||||||
"Ensure they are valid OTPs",
|
"Ensure they are valid OTPs",
|
||||||
# This word is misspelled upstream
|
# This word is misspelled upstream
|
||||||
|
|||||||
Generated
+499
-790
File diff suppressed because it is too large.
Load diff
+16
-16
@@ -66,7 +66,7 @@ syslog = "7.0.0"
|
|||||||
macros = { path = "./macros" }
|
macros = { path = "./macros" }
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
log = "0.4.35"
|
log = "0.4.34"
|
||||||
fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] }
|
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
|
# 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"] }
|
tracing = { version = "0.1.44", features = ["log"] }
|
||||||
@@ -80,7 +80,7 @@ num-derive = "0.5.1"
|
|||||||
bigdecimal = "0.4.11"
|
bigdecimal = "0.4.11"
|
||||||
|
|
||||||
# Web framework
|
# Web framework
|
||||||
rocket = { version = "0.5.2", default-features = false, features = ["json", "tls"] }
|
rocket = { version = "0.5.1", default-features = false, features = ["json", "tls"] }
|
||||||
rocket_ws = { version = "0.1.1" }
|
rocket_ws = { version = "0.1.1" }
|
||||||
|
|
||||||
# WebSockets libraries
|
# WebSockets libraries
|
||||||
@@ -91,7 +91,7 @@ dashmap = "6.2.1"
|
|||||||
|
|
||||||
# Async futures
|
# Async futures
|
||||||
futures = "0.3.34"
|
futures = "0.3.34"
|
||||||
tokio = { version = "1.54.1", features = [
|
tokio = { version = "1.53.2", features = [
|
||||||
"fs",
|
"fs",
|
||||||
"io-util",
|
"io-util",
|
||||||
"net",
|
"net",
|
||||||
@@ -100,14 +100,14 @@ tokio = { version = "1.54.1", features = [
|
|||||||
"signal",
|
"signal",
|
||||||
"time",
|
"time",
|
||||||
] }
|
] }
|
||||||
tokio-util = { version = "0.7.20", features = ["compat"] }
|
tokio-util = { version = "0.7.19", features = ["compat"] }
|
||||||
|
|
||||||
# A generic serialization/deserialization framework
|
# A generic serialization/deserialization framework
|
||||||
serde = { version = "1.0.229", features = ["derive"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
serde_json = "1.0.154"
|
serde_json = "1.0.151"
|
||||||
|
|
||||||
# A safe, extensible ORM and Query builder
|
# A safe, extensible ORM and Query builder
|
||||||
diesel = { version = "2.3.14", features = ["chrono", "r2d2", "numeric"] }
|
diesel = { version = "2.3.13", features = ["chrono", "r2d2", "numeric", "64-column-tables"] }
|
||||||
diesel_migrations = "2.3.2"
|
diesel_migrations = "2.3.2"
|
||||||
|
|
||||||
derive_more = { version = "2.1.1", features = [
|
derive_more = { version = "2.1.1", features = [
|
||||||
@@ -125,11 +125,11 @@ libsqlite3-sys = { version = "0.38.2", optional = true }
|
|||||||
# Crypto-related libraries
|
# Crypto-related libraries
|
||||||
rand = "0.10.3"
|
rand = "0.10.3"
|
||||||
ring = "0.17.14"
|
ring = "0.17.14"
|
||||||
rustls = { version = "0.23.46", features = ["ring", "std"], default-features = false }
|
rustls = { version = "0.23.45", features = ["ring", "std"], default-features = false }
|
||||||
subtle = "2.6.1"
|
subtle = "2.6.1"
|
||||||
|
|
||||||
# UUID generation
|
# UUID generation
|
||||||
uuid = { version = "1.29.0", features = ["v4"] }
|
uuid = { version = "1.27.0", features = ["v4"] }
|
||||||
|
|
||||||
# Date and time libraries
|
# Date and time libraries
|
||||||
chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] }
|
chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] }
|
||||||
@@ -204,7 +204,7 @@ reqwest = { version = "0.13.5", default-features = false, features = [
|
|||||||
hickory-resolver = "0.26.3"
|
hickory-resolver = "0.26.3"
|
||||||
|
|
||||||
# Favicon extraction libraries
|
# Favicon extraction libraries
|
||||||
html5gum = "0.9.0"
|
html5gum = "0.8.4"
|
||||||
regex = { version = "1.13.1", default-features = false, features = [
|
regex = { version = "1.13.1", default-features = false, features = [
|
||||||
"perf",
|
"perf",
|
||||||
"std",
|
"std",
|
||||||
@@ -215,7 +215,7 @@ bytes = "1.12.1"
|
|||||||
svg-hush = "0.9.7"
|
svg-hush = "0.9.7"
|
||||||
|
|
||||||
# Cache function results (Used for version check and favicon fetching)
|
# Cache function results (Used for version check and favicon fetching)
|
||||||
cached = { version = "4.1.0", features = ["async"] }
|
cached = { version = "4.0.1", features = ["async"] }
|
||||||
|
|
||||||
# Used for custom short lived cookie jar during favicon extraction
|
# Used for custom short lived cookie jar during favicon extraction
|
||||||
cookie = "0.18.2"
|
cookie = "0.18.2"
|
||||||
@@ -235,7 +235,7 @@ governor = "0.10.4"
|
|||||||
ipnet = "2.12.2"
|
ipnet = "2.12.2"
|
||||||
|
|
||||||
# OIDC for SSO
|
# OIDC for SSO
|
||||||
openidconnect = { version = "4.1.0", default-features = false }
|
openidconnect = { version = "4.0.1", default-features = false }
|
||||||
moka = { version = "0.12.16", features = ["future"] }
|
moka = { version = "0.12.16", features = ["future"] }
|
||||||
|
|
||||||
# Check client versions for specific features.
|
# Check client versions for specific features.
|
||||||
@@ -257,18 +257,18 @@ rpassword = "7.5.4"
|
|||||||
grass_compiler = { version = "0.13.4", default-features = false }
|
grass_compiler = { version = "0.13.4", default-features = false }
|
||||||
|
|
||||||
# File are accessed through Apache OpenDAL
|
# File are accessed through Apache OpenDAL
|
||||||
opendal = { version = "0.59.5", default-features = false, features = ["services-fs"] }
|
opendal = { version = "0.59.4", default-features = false, features = ["services-fs"] }
|
||||||
opendal-http-transport-reqwest = { version = "0.59.5", default-features = false, features = ["rustls-no-provider"], optional = true }
|
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
|
# For retrieving AWS credentials, including temporary SSO credentials
|
||||||
aws-config = { version = "1.13.0", optional = true, default-features = false, features = [
|
aws-config = { version = "1.12.0", optional = true, default-features = false, features = [
|
||||||
"behavior-version-latest",
|
"behavior-version-latest",
|
||||||
"credentials-process",
|
"credentials-process",
|
||||||
"rt-tokio",
|
"rt-tokio",
|
||||||
"sso",
|
"sso",
|
||||||
] }
|
] }
|
||||||
aws-credential-types = { version = "1.3.1", optional = true }
|
aws-credential-types = { version = "1.3.0", optional = true }
|
||||||
aws-smithy-runtime-api = { version = "1.19.2", optional = true }
|
aws-smithy-runtime-api = { version = "1.19.0", optional = true }
|
||||||
http = { version = "1.5.0", optional = true }
|
http = { version = "1.5.0", optional = true }
|
||||||
reqsign-aws-v4 = { version = "3.3.1", optional = true }
|
reqsign-aws-v4 = { version = "3.3.1", optional = true }
|
||||||
reqsign-core = { version = "3.3.2", optional = true }
|
reqsign-core = { version = "3.3.2", optional = true }
|
||||||
|
|||||||
+9
-25
@@ -8,43 +8,30 @@ notify us. We welcome working with you to resolve the issue promptly. Thanks in
|
|||||||
effort to quickly resolve the issue.
|
effort to quickly resolve the issue.
|
||||||
- Provide us a reasonable amount of time to resolve the issue before any disclosure to the public or a
|
- Provide us a reasonable amount of time to resolve the issue before any disclosure to the public or a
|
||||||
third-party. We may publicly disclose the issue before resolving it, if appropriate.
|
third-party. We may publicly disclose the issue before resolving it, if appropriate.
|
||||||
- Vaultwarden has no hosted service. Test only on instances you run yourself, and make a good faith effort
|
- Make a good faith effort to avoid privacy violations, destruction of data, and interruption or
|
||||||
to avoid privacy violations, destruction of data, and interruption or degradation of other people's
|
degradation of our service. Only interact with accounts you own or with explicit permission of the
|
||||||
servers. Only interact with accounts you own or with explicit permission of the account holder.
|
account holder.
|
||||||
|
|
||||||
# What happens after a report
|
|
||||||
|
|
||||||
This is how we usually handle reports. We decide each one on its own.
|
|
||||||
|
|
||||||
- **It affects a tagged release:** we publish an advisory, request a CVE and list it in the release notes. Advisories are published about 14 days after the release containing the fix, so users have time to update.
|
|
||||||
- **It only ever existed on `main` / `testing`:** we publish an advisory and credit the reporter, but there is no CVE or release notes entry, because no released version was affected.
|
|
||||||
- **Low-impact issues, including low-impact differences from upstream Bitwarden, and most denial of service or resource exhaustion:** we usually fix these as ordinary hardening in a public pull request, with no advisory.
|
|
||||||
- **A bug in the web vault we bundle:** if it's in Bitwarden's code, the advisory and CVE belong to Bitwarden, so report it to them. Tell us as well, and we'll ship a security release that references theirs. If our own patches added it, report it to us.
|
|
||||||
- **Credit** goes on the advisory and needs a GitHub account or email address. If several people report the same issue, we close the later reports and link them to the first advisory, and credit their reporters as finders on it as long as the fix isn't public yet. A fix is public from the moment its pull request is opened, so reports of the same issue after that point are normally not credited.
|
|
||||||
- **Rewards:** Vaultwarden is an open source project and does not offer rewards.
|
|
||||||
|
|
||||||
# In-scope
|
# In-scope
|
||||||
|
|
||||||
- Security issues in any current release of Vaultwarden. Source code is available at https://github.com/dani-garcia/vaultwarden. This includes the current `latest` release and `main / testing` release.
|
- Security issues in any current release of Vaultwarden. Source code is available at https://github.com/dani-garcia/vaultwarden. This includes the current `latest` release and `main / testing` release.
|
||||||
|
|
||||||
Our [threat model](THREAT_MODEL.md) describes who is trusted with what, how we rate severity, and behaviour we have already reviewed and consider intended. Please read it before reporting.
|
|
||||||
|
|
||||||
# Exclusions
|
# Exclusions
|
||||||
|
|
||||||
The following bug classes are out-of scope:
|
The following bug classes are out-of scope:
|
||||||
|
|
||||||
- Bugs that are already reported on Vaultwarden's issue tracker (https://github.com/dani-garcia/vaultwarden/issues)
|
- Bugs that are already reported on Vaultwarden's issue tracker (https://github.com/dani-garcia/vaultwarden/issues)
|
||||||
- Bugs that are not part of Vaultwarden, like in Bitwarden's web-vault code or the mobile and desktop clients. These issues need to be reported to Bitwarden through their [HackerOne program](https://hackerone.com/bitwarden/). We are not associated with Bitwarden. Bugs that our own web vault patches add are in scope
|
- Bugs that are not part of Vaultwarden, like on the web-vault or mobile and desktop clients. These issues need to be reported in the respective project issue tracker at https://github.com/bitwarden to which we are not associated
|
||||||
- Issues in an upstream software dependency (ex: Rust, or External Libraries) or base image that are not shown to be reachable through Vaultwarden
|
- Issues in an upstream software dependency (ex: Rust, or External Libraries) which are already reported to the upstream maintainer
|
||||||
- Attacks requiring physical access to a user's device
|
- Attacks requiring physical access to a user's device
|
||||||
- Issues related to software or protocols not under Vaultwarden's control, including things attached to it like log parsers (Fail2Ban and similar) and misconfigured reverse proxies, and attacks that need another component to violate its own specification
|
- Issues related to software or protocols not under Vaultwarden's control
|
||||||
- Vulnerabilities in outdated versions of Vaultwarden
|
- Vulnerabilities in outdated versions of Vaultwarden
|
||||||
- Missing security best practices that do not directly lead to a vulnerability (You may still report them as a normal issue)
|
- Missing security best practices that do not directly lead to a vulnerability (You may still report them as a normal issue)
|
||||||
- Actions of someone who already holds the admin token or has access to the host, which are trusted by design
|
- Issues that do not have any impact on the general public
|
||||||
|
|
||||||
While researching, we'd like to ask you to refrain from:
|
While researching, we'd like to ask you to refrain from:
|
||||||
|
|
||||||
- Denial of service against servers you do not run. Availability issues that still apply behind a reverse proxy can be reported, so reproduce them on your own instance
|
- Denial of service
|
||||||
- Spamming
|
- Spamming
|
||||||
- Social engineering (including phishing) of Vaultwarden developers, contributors or users
|
- Social engineering (including phishing) of Vaultwarden developers, contributors or users
|
||||||
|
|
||||||
@@ -52,7 +39,7 @@ Thank you for helping keep Vaultwarden and our users safe!
|
|||||||
|
|
||||||
# How to contact us
|
# How to contact us
|
||||||
|
|
||||||
- You can open a [private vulnerability report](https://github.com/dani-garcia/vaultwarden/security/advisories/new) on GitHub.
|
- You can contact us on Matrix https://matrix.to/#/#vaultwarden:matrix.org (users: `@danig:matrix.org` and/or `@blackdex:matrix.org`)
|
||||||
- You can send an  to report a security issue.<br>
|
- You can send an  to report a security issue.<br>
|
||||||
If you want to send an encrypted email you can use the following GPG key: 13BB3A34C9E380258CE43D595CB150B31F6426BC<br>
|
If you want to send an encrypted email you can use the following GPG key: 13BB3A34C9E380258CE43D595CB150B31F6426BC<br>
|
||||||
It can be found on several public GPG key servers.<br>
|
It can be found on several public GPG key servers.<br>
|
||||||
@@ -60,6 +47,3 @@ Thank you for helping keep Vaultwarden and our users safe!
|
|||||||
* https://keys.mailvelope.com/pks/lookup?op=get&search=security%40vaultwarden.org
|
* https://keys.mailvelope.com/pks/lookup?op=get&search=security%40vaultwarden.org
|
||||||
* https://pgpkeys.eu/pks/lookup?search=security%40vaultwarden.org&fingerprint=on&op=index
|
* https://pgpkeys.eu/pks/lookup?search=security%40vaultwarden.org&fingerprint=on&op=index
|
||||||
* https://keyserver.ubuntu.com/pks/lookup?search=security%40vaultwarden.org&fingerprint=on&op=index
|
* https://keyserver.ubuntu.com/pks/lookup?search=security%40vaultwarden.org&fingerprint=on&op=index
|
||||||
- You can contact us on Matrix for questions, without vulnerability details: https://matrix.to/#/#vaultwarden:matrix.org (users: `@danig:matrix.org` and/or `@blackdex:matrix.org`)
|
|
||||||
|
|
||||||
A good report states the version or commit you tested, the lowest privileges needed, any non-default settings, and the steps to reproduce it.
|
|
||||||
-166
@@ -1,166 +0,0 @@
|
|||||||
# Vaultwarden threat model
|
|
||||||
|
|
||||||
To report a security issue, see [SECURITY.md](SECURITY.md).
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Vaultwarden is an unofficial, self-hosted server for the Bitwarden clients, written in Rust. It stores encrypted vaults and handles login, two-factor authentication, organizations, file and text sharing through Send, emergency access, and an admin panel for the operator. It is not affiliated with Bitwarden. The browser, desktop, mobile and CLI clients come from Bitwarden. Our container images include a web vault built from Bitwarden's web client with our own patches.
|
|
||||||
|
|
||||||
## Security expectations
|
|
||||||
|
|
||||||
### Deployment
|
|
||||||
|
|
||||||
We expect a TLS-terminating reverse proxy in front of the server, `DOMAIN` set to its public origin, and an operator who keeps the host secure. This model covers Vaultwarden itself. Third-party software that consumes its output or sits in front of it, such as log parsers and reverse proxies, is the operator's to configure and secure.
|
|
||||||
|
|
||||||
### Encryption
|
|
||||||
|
|
||||||
Clients encrypt vault contents end to end. The server stores and serves ciphertext it cannot decrypt, and it never sees the master password. The server enforces access to data and actions itself, without relying on a client to behave. A few settings rely on the client for part of their effect, such as `hide_passwords`, where the client still receives the password, and some organization policies.
|
|
||||||
|
|
||||||
A member receives the organization key when they are confirmed. Invited and accepted members do not have it. A revoked member who was once confirmed may still have it. Every confirmed member holds the same key for the whole organization, so only the server separates collections.
|
|
||||||
|
|
||||||
The server can read everything that is not end-to-end encrypted, such as email addresses, names, organization names, who owns what, item types and timestamps, attachment sizes, password hints, the stored master password hash and KDF settings, the TOTP and other 2FA secrets that protect login to the account, API keys, refresh tokens, security stamps, the JWT signing key, and SMTP and push credentials. TOTP secrets that users store in vault items are end-to-end encrypted with the rest of the item.
|
|
||||||
|
|
||||||
### Trust
|
|
||||||
|
|
||||||
Registration is open by default (`SIGNUPS_ALLOWED=true`), but many personal and small-team instances turn it off, so holding an account usually implies a minimal level of trust. Account holders are still never trusted with anyone else's data. Members of an organization have some trust in each other.
|
|
||||||
|
|
||||||
| Actor | Can reach | Must not reach |
|
|
||||||
|---|---|---|
|
|
||||||
| Anonymous internet | Login, prelogin, registration, public Send access, icons, the anonymous notifications hub, static files | Any vault, Send, organization or member data; the admin panel; the internal network through outbound requests |
|
|
||||||
| Registered user | Their own vault, Sends, devices and settings; organizations they belong to | Another user's data; any other organization; raising their own role or status |
|
|
||||||
| Invited or accepted member | Accepting the invitation, enrolling in account recovery, and the organization's name, public key and policies | Items, collections, the member list, the organization key, any other privileged action before confirmation |
|
|
||||||
| Revoked member | Nothing in that organization | Any organization data from the server, even data they could once read |
|
|
||||||
| Organization User | Items in assigned collections, with `read_only` and `manage` enforced by the server | Other collections, write access to read-only collections, Admin-level metadata |
|
|
||||||
| Organization Manager (shown as Custom in clients) | The above, plus managing collections where they have `manage`. With "manage all collections" (`access_all`), every collection and full member and group details | Without `access_all`: managing other collections, full member and group details. Always: changing policies, role changes |
|
|
||||||
| Organization Admin | All data in that organization, members below Owner (some actions on Managers and Admins are limited to Owners), policies, events, account recovery for enrolled members below Owner | Owners, other organizations, the personal vault of a member who is not enrolled in recovery, the organization API key |
|
|
||||||
| Organization Owner | The whole organization, including account recovery for enrolled members | Other organizations; the personal vault of a member who is not enrolled in recovery |
|
|
||||||
| Organization API key holder (Owner-issued) | Directory import into its own organization | Other organizations, item content, Owners |
|
|
||||||
| Send recipient | One Send, within its limits | Other Sends, access beyond the configured limits, the owner's vault |
|
|
||||||
| Emergency access grantee | After approval or the wait time: the access the grantor configured, which can be full takeover by design | Anything before that |
|
|
||||||
| Admin panel (`ADMIN_TOKEN`) | All users, organizations and settings. We expect the holder to also have admin access to the machine | No limit once authenticated |
|
|
||||||
| Host operator, database and data folder access | Everything | No limit |
|
|
||||||
| Configured SSO provider, SMTP server, push relay | What they need for their function | Vault contents |
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
Optional features that operators turn on carry the same expectations as the defaults: SSO, groups, organization events, anything needing SMTP (email 2FA, invitations), push, S3 storage, the admin panel, and the MySQL and PostgreSQL backends.
|
|
||||||
|
|
||||||
Some settings remove a protection on purpose, and using them is the operator's choice: `DISABLE_ADMIN_TOKEN`, `IP_HEADER_TRUSTED_PROXIES=all` on a directly exposed instance, turning off `HTTP_REQUEST_BLOCK_NON_GLOBAL_IPS`, and `SSO_DEBUG_TOKENS`.
|
|
||||||
|
|
||||||
### Upstream Bitwarden
|
|
||||||
|
|
||||||
Where this document does not cover a behaviour, we follow upstream Bitwarden by default.
|
|
||||||
|
|
||||||
## Security issues
|
|
||||||
|
|
||||||
A security issue is a bug in Vaultwarden that breaks these expectations. Examples:
|
|
||||||
|
|
||||||
- someone reading, adding, changing or deleting data that the table above says they must not reach;
|
|
||||||
- an organization member reaching collections they are not assigned to, or a revoked or unconfirmed member reaching organization items or collections;
|
|
||||||
- someone gaining a role or permission they should not have;
|
|
||||||
- getting past an organization policy the server enforces;
|
|
||||||
- a login session or refresh token surviving a password change or security stamp rotation, or a remembered device still skipping 2FA after a 2FA method is removed;
|
|
||||||
- getting past authentication, 2FA or login brute-force protection;
|
|
||||||
- logging in as someone else through SSO;
|
|
||||||
- reading a Send that is disabled or expired, or exceeding its limits;
|
|
||||||
- reaching an emergency access outcome early or out of order;
|
|
||||||
- reaching the admin panel without the token, or acting through an admin's session with script or a cross-site request;
|
|
||||||
- outbound requests reaching the internal network;
|
|
||||||
- attacker-controlled script or HTML served from the vault origin, where the web vault runs and holds decrypted keys;
|
|
||||||
- code execution on the server, or exposure of keys or other server-held secrets, including in logs;
|
|
||||||
- denial of service or resource exhaustion that still applies with a reverse proxy in front.
|
|
||||||
|
|
||||||
## Not security issues
|
|
||||||
|
|
||||||
These are intended behaviour or outside this model. Several match upstream Bitwarden.
|
|
||||||
|
|
||||||
- What the admin panel or host operator can do once authenticated, including an admin token holder running code on the server. We expect them to have admin access to the machine.
|
|
||||||
- Organization Owners and Admins reading all organization data, and account recovery of enrolled members. Both are by design.
|
|
||||||
- Organization Managers listing basic member details, such as names and emails. Upstream allows this for all members.
|
|
||||||
- `hide_passwords` ("except passwords"). It helps avoid accidental leaks and copy-pastes, but the client still holds the password.
|
|
||||||
- Bugs in Bitwarden's client code, including that code in the web vault we bundle, such as CSV formula injection (exports are built in the browser) or a client failing on a malformed stored value. The advisory and CVE for those belong to Bitwarden. When the bundled web vault is affected, we ship a security release that references theirs. Bugs that our own web vault patches add are ours.
|
|
||||||
- The server not decrypting or authenticating ciphertext. It holds no key.
|
|
||||||
- `/identity/accounts/prelogin` returning KDF settings for any email, with deterministic fake settings for unknown ones.
|
|
||||||
- Login timing not being equalized.
|
|
||||||
- Download link replay: an attachment token or Send access token being reusable by its holder until it expires (1 and 2 minutes), and object storage presigned URLs that we cannot count or revoke. The holder already has the content.
|
|
||||||
- Organization API tokens staying valid until they expire (1 hour) after the key is rotated.
|
|
||||||
- The personal API key working after a password change until the user rotates it. It is a separate credential.
|
|
||||||
- A revoked or removed member's login session staying valid. It belongs to their own account, and the server checks membership on every request.
|
|
||||||
- A revoked or removed member keeping what their client already synced, and the organization key not being rotated when a member leaves.
|
|
||||||
- The notifications WebSocket accepting a login token until it expires, and keeping open connections, after a password change. It carries change notifications and login requests, not vault data.
|
|
||||||
- Anonymous `clear-token` on `/devices/identifier/<id>/clear-token`. The impact is delayed push delivery.
|
|
||||||
- No server-side cleanup of attachments that were reserved but never uploaded.
|
|
||||||
- The number of items a user can store. There is no limit, by design.
|
|
||||||
- Log injection. Changing log lines has no effect on Vaultwarden and only matters to third-party software parsing them, such as Fail2Ban, including filters from our community wiki.
|
|
||||||
- Reverse proxy misconfiguration, such as a proxy not sending `X-Forwarded-Proto`, which decides the admin cookie's `Secure` flag.
|
|
||||||
- Attacks that need another component to break its own specification.
|
|
||||||
- Outbound requests to the SSO provider, Yubico server or S3 endpoint the operator configures, which skip the outbound filter and can be on the internal network.
|
|
||||||
- Weak KDF settings a user chooses for their own account.
|
|
||||||
- File permissions in the data folder. Host security belongs to the operator.
|
|
||||||
- The WebSocket `access_token` query parameter. Clients send it that way, and our hardening guide covers log redaction.
|
|
||||||
- Dependency and base image CVEs without a path reachable through Vaultwarden.
|
|
||||||
- Missing best practices with no demonstrated impact, such as headers, `style-src 'unsafe-inline'` (the web vault needs it) and version disclosure.
|
|
||||||
- Outdated versions.
|
|
||||||
|
|
||||||
## Severity
|
|
||||||
|
|
||||||
We rate issues by what the attacker holds at the end (plaintext, ciphertext they have the key for, ciphertext they have no key for, or the power to write or delete, which needs no key) and by the access they needed first. An attack that needs no account usually rates higher than one that does. This applies to new issues. Older advisories may be rated differently.
|
|
||||||
|
|
||||||
Denial of service and resource exhaustion only count if they still apply with a reverse proxy in front. We do not consider them a serious problem in practice, so they are never High and Medium should be rare.
|
|
||||||
|
|
||||||
Secrets written to logs are rated by what the secret gives someone who reads the logs and how long it stays valid. Reading logs does not assume host access.
|
|
||||||
|
|
||||||
### Critical
|
|
||||||
|
|
||||||
Anything that lets someone read another account's data decrypted is Critical by default, whether or not they have an account of their own. That covers:
|
|
||||||
|
|
||||||
- attacker-controlled script running on the vault origin for other users during normal use, caused by how Vaultwarden stores or serves content;
|
|
||||||
- code execution on the server by anyone other than an admin token holder, because it can change the web vault the server serves;
|
|
||||||
- exposing key material that lets someone else decrypt an account's data;
|
|
||||||
- anyone other than the named grantee reaching an emergency access outcome.
|
|
||||||
|
|
||||||
Forging login or admin tokens, reading the JWT signing key, reaching the admin panel without the token, and taking over arbitrary accounts with no action by the victim are also Critical when no account is needed.
|
|
||||||
|
|
||||||
### High
|
|
||||||
|
|
||||||
- Decrypted reads within the same organization, including a member reaching collections they are not assigned to, or a revoked member who kept the key. Members share one key and have some trust in each other, and the scope is limited to that organization.
|
|
||||||
- Changing or deleting another user's or organization's existing data.
|
|
||||||
- Reading another user's server-held secrets, such as their account's 2FA secrets or API keys.
|
|
||||||
- Reading another user's stored master password hash or encrypted user key, which allow offline guessing of the master password.
|
|
||||||
- SSRF that returns the response to the attacker.
|
|
||||||
- Escalating within an organization with real effect on data or members.
|
|
||||||
- Account takeover through SSO, unless it meets the Critical conditions above.
|
|
||||||
- The named emergency access grantee reaching the outcome early. The grantor chose them and the scope is one account.
|
|
||||||
- Script or a cross-site request acting through an admin's session without them noticing. This includes script that only runs in the admin panel. It is Critical if it reaches code execution or the files the server serves.
|
|
||||||
- Attacker-controlled script on the vault origin that needs unusual victim interaction.
|
|
||||||
|
|
||||||
### Medium
|
|
||||||
|
|
||||||
- Ciphertext read by someone with no key.
|
|
||||||
- Defeating login brute-force protection. The attacker still has to guess the password, and get past 2FA if the victim has it.
|
|
||||||
- 2FA bypass that needs the password, and a login session or refresh token surviving a password change or security stamp rotation.
|
|
||||||
- Getting past an organization policy the server enforces, such as required 2FA or single organization.
|
|
||||||
- Adding data to another user's or organization's space without permission, such as a read-only member adding items.
|
|
||||||
- Data loss for another user that the attacker cannot reliably trigger or aim.
|
|
||||||
- SSRF where the response is filtered or blind.
|
|
||||||
- Reading a Send that is disabled or expired.
|
|
||||||
- Denial of service or resource exhaustion from one request with no account needed, in the default configuration, where the denial stays after the attacker stops until someone has to act, such as by restarting the server or repairing data.
|
|
||||||
|
|
||||||
### Low
|
|
||||||
|
|
||||||
These may be handled as ordinary hardening in some cases.
|
|
||||||
|
|
||||||
- Behaviour that differs from upstream Bitwarden with low impact.
|
|
||||||
- Bypassing a non-default limit (`USER_ATTACHMENT_LIMIT`, `ORG_ATTACHMENT_LIMIT`, `USER_SEND_LIMIT`).
|
|
||||||
- Vaultwarden mishandling the headers of a correctly configured proxy (`IP_HEADER`, `IP_HEADER_TRUSTED_PROXIES`), even if that weakens rate limits.
|
|
||||||
- Metadata across a boundary, such as who is assigned to which collection.
|
|
||||||
- Exceeding a Send's access limits when the attacker already holds the link.
|
|
||||||
- A missing rate limit outside login, with no other effect.
|
|
||||||
- Forging organization audit events, as long as existing events cannot be modified.
|
|
||||||
- Other ways to tell whether an email has an account, apart from the prelogin and login timing cases under "Not security issues".
|
|
||||||
- Denial of service or resource exhaustion from one operation that needs an account or a non-default setting, where the denial stays after the attacker stops until someone has to act.
|
|
||||||
- Denial of service or resource exhaustion that lasts only while the attacker keeps going, or that recovers by itself, such as a crash the container restarts from or a panic that yields a 500 with no lasting effect. We handle these as hardening.
|
|
||||||
|
|
||||||
### CVSS
|
|
||||||
|
|
||||||
Our ratings can differ from CVSS 3.1, because its scoring parameters don't translate well to an end-to-end encrypted system.
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
vault_version: "v2026.10.1"
|
vault_version: "v2026.8.0"
|
||||||
vault_image_digest: "sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52"
|
vault_image_digest: "sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a"
|
||||||
# Cross Compile Docker Helper Scripts v1.10.0
|
# Cross Compile Docker Helper Scripts v1.9.0
|
||||||
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
|
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
|
||||||
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
|
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
|
||||||
xx_image_digest: "sha256:40fe21f44bb8cddf1eb1226db190d0a984118f62ee02a91b77d99448d28264a6"
|
xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
|
||||||
# The `rust_version` variable is extracted from `rust-toolchain.toml`
|
# The `rust_version` variable is extracted from `rust-toolchain.toml`
|
||||||
# rust_version: x.yy.z # Rust version to be used
|
# rust_version: x.yy.z # Rust version to be used
|
||||||
debian_version: trixie # Debian release name to be used
|
debian_version: trixie # Debian release name to be used
|
||||||
|
|||||||
+10
-10
@@ -19,23 +19,23 @@
|
|||||||
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
||||||
# click the tag name to view the digest of the image it currently points to.
|
# click the tag name to view the digest of the image it currently points to.
|
||||||
# - From the command line:
|
# - From the command line:
|
||||||
# $ docker pull docker.io/vaultwarden/web-vault:v2026.10.1
|
# $ docker pull docker.io/vaultwarden/web-vault:v2026.8.0
|
||||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.10.1
|
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.8.0
|
||||||
# [docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52]
|
# [docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a]
|
||||||
#
|
#
|
||||||
# - Conversely, to get the tag name from the digest:
|
# - Conversely, to get the tag name from the digest:
|
||||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52
|
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a
|
||||||
# [docker.io/vaultwarden/web-vault:v2026.10.1]
|
# [docker.io/vaultwarden/web-vault:v2026.8.0]
|
||||||
#
|
#
|
||||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52 AS vault
|
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a AS vault
|
||||||
|
|
||||||
########################## ALPINE BUILD IMAGES ##########################
|
########################## ALPINE BUILD IMAGES ##########################
|
||||||
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
|
## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64
|
||||||
## And for Alpine we define all build images here, they will only be loaded when actually used
|
## And for Alpine we define all build images here, they will only be loaded when actually used
|
||||||
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.99.1 AS build_amd64
|
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.1 AS build_arm64
|
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.1 AS build_armv7
|
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.1 AS build_armv6
|
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.99.0 AS build_armv6
|
||||||
|
|
||||||
########################## BUILD IMAGE ##########################
|
########################## BUILD IMAGE ##########################
|
||||||
# hadolint ignore=DL3006
|
# hadolint ignore=DL3006
|
||||||
|
|||||||
@@ -19,24 +19,24 @@
|
|||||||
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
|
||||||
# click the tag name to view the digest of the image it currently points to.
|
# click the tag name to view the digest of the image it currently points to.
|
||||||
# - From the command line:
|
# - From the command line:
|
||||||
# $ docker pull docker.io/vaultwarden/web-vault:v2026.10.1
|
# $ docker pull docker.io/vaultwarden/web-vault:v2026.8.0
|
||||||
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.10.1
|
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.8.0
|
||||||
# [docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52]
|
# [docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a]
|
||||||
#
|
#
|
||||||
# - Conversely, to get the tag name from the digest:
|
# - Conversely, to get the tag name from the digest:
|
||||||
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52
|
# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a
|
||||||
# [docker.io/vaultwarden/web-vault:v2026.10.1]
|
# [docker.io/vaultwarden/web-vault:v2026.8.0]
|
||||||
#
|
#
|
||||||
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52 AS vault
|
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a AS vault
|
||||||
|
|
||||||
########################## Cross Compile Docker Helper Scripts ##########################
|
########################## Cross Compile Docker Helper Scripts ##########################
|
||||||
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
|
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
|
||||||
## And these bash scripts do not have any significant difference if at all
|
## And these bash scripts do not have any significant difference if at all
|
||||||
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:40fe21f44bb8cddf1eb1226db190d0a984118f62ee02a91b77d99448d28264a6 AS xx
|
FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707 AS xx
|
||||||
|
|
||||||
########################## BUILD IMAGE ##########################
|
########################## BUILD IMAGE ##########################
|
||||||
# hadolint ignore=DL3006
|
# hadolint ignore=DL3006
|
||||||
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.99.1-slim-trixie AS build
|
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.99.0-slim-trixie AS build
|
||||||
# hadolint ignore=DL3067
|
# hadolint ignore=DL3067
|
||||||
COPY --from=xx / /
|
COPY --from=xx / /
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ Some unsupported platforms for Debian based images. These are not built and test
|
|||||||
- 386 (linux/386)
|
- 386 (linux/386)
|
||||||
- ppc64le (linux/ppc64le)
|
- ppc64le (linux/ppc64le)
|
||||||
- s390x (linux/s390x)
|
- s390x (linux/s390x)
|
||||||
- riscv64 (linux/riscv64)
|
|
||||||
|
|
||||||
To build these containers you need to enable QEMU binfmt support to be able to run/emulate architectures which are different then your host.<br>
|
To build these containers you need to enable QEMU binfmt support to be able to run/emulate architectures which are different then your host.<br>
|
||||||
This ensures the container build process can run binaries from other architectures.<br>
|
This ensures the container build process can run binaries from other architectures.<br>
|
||||||
|
|||||||
@@ -148,12 +148,6 @@ target "debian-s390x" {
|
|||||||
platforms = ["linux/s390x"]
|
platforms = ["linux/s390x"]
|
||||||
tags = generate_tags("", "-s390x")
|
tags = generate_tags("", "-s390x")
|
||||||
}
|
}
|
||||||
|
|
||||||
target "debian-riscv64" {
|
|
||||||
inherits = ["debian"]
|
|
||||||
platforms = ["linux/riscv64"]
|
|
||||||
tags = generate_tags("", "-riscv64")
|
|
||||||
}
|
|
||||||
// ==== End of unsupported Debian architecture targets ===
|
// ==== End of unsupported Debian architecture targets ===
|
||||||
|
|
||||||
// A Group to build all platforms individually for local testing
|
// A Group to build all platforms individually for local testing
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ proc-macro = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
quote = "1.0.47"
|
quote = "1.0.47"
|
||||||
syn = "3.0.9"
|
syn = "3.0.6"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
Whitespace-only changes.
@@ -0,0 +1,3 @@
|
|||||||
|
-- A blob-encrypted cipher keeps all of its content in `data`, up to 500,000 characters, so the
|
||||||
|
-- 64 KiB of TEXT is too small. Like upstream, which uses LONGTEXT.
|
||||||
|
ALTER TABLE ciphers MODIFY data LONGTEXT NOT NULL;
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users DROP COLUMN v2_upgrade_token;
|
||||||
|
ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
|
||||||
|
ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT;
|
||||||
Whitespace-only changes.
@@ -0,0 +1 @@
|
|||||||
|
-- TEXT has no size limit here, only MySQL needed the change
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users DROP COLUMN v2_upgrade_token;
|
||||||
|
ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
|
||||||
|
ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT;
|
||||||
Whitespace-only changes.
@@ -0,0 +1 @@
|
|||||||
|
-- TEXT has no size limit here, only MySQL needed the change
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users DROP COLUMN v2_upgrade_token;
|
||||||
|
ALTER TABLE users_organizations DROP COLUMN v2_upgrade_token;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN v2_upgrade_token TEXT;
|
||||||
|
ALTER TABLE users_organizations ADD COLUMN v2_upgrade_token TEXT;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
[toolchain]
|
[toolchain]
|
||||||
channel = "1.99.1"
|
channel = "1.99.0"
|
||||||
components = [ "rustfmt", "clippy" ]
|
components = [ "rustfmt", "clippy" ]
|
||||||
profile = "minimal"
|
profile = "minimal"
|
||||||
+5
-20
@@ -17,7 +17,7 @@ use crate::{
|
|||||||
api::{
|
api::{
|
||||||
ApiResult, EmptyResult, JsonResult, Notify,
|
ApiResult, EmptyResult, JsonResult, Notify,
|
||||||
core::{log_event, two_factor},
|
core::{log_event, two_factor},
|
||||||
push_logout_to_relay, unregister_push_device,
|
unregister_push_device,
|
||||||
},
|
},
|
||||||
auth::{ClientIp, Secure, decode_admin, encode_jwt, generate_admin_claims},
|
auth::{ClientIp, Secure, decode_admin, encode_jwt, generate_admin_claims},
|
||||||
config::ConfigBuilder,
|
config::ConfigBuilder,
|
||||||
@@ -201,15 +201,12 @@ fn post_admin_login(
|
|||||||
// If the token is invalid, redirect to login page
|
// If the token is invalid, redirect to login page
|
||||||
if validate_token(&data.token) {
|
if validate_token(&data.token) {
|
||||||
// If the token received is valid, generate JWT and save it as a cookie
|
// If the token received is valid, generate JWT and save it as a cookie
|
||||||
let claims = match generate_admin_claims() {
|
let claims = generate_admin_claims();
|
||||||
Ok(claims) => claims,
|
|
||||||
Err(e) => return Err(AdminResponse::Ok(render_admin_login(Some(e.message()), redirect.as_deref()))),
|
|
||||||
};
|
|
||||||
let jwt = encode_jwt(&claims);
|
let jwt = encode_jwt(&claims);
|
||||||
|
|
||||||
let cookie = Cookie::build((COOKIE_NAME, jwt))
|
let cookie = Cookie::build((COOKIE_NAME, jwt))
|
||||||
.path(admin_path())
|
.path(admin_path())
|
||||||
.max_age(time::Duration::minutes(i64::from(CONFIG.admin_session_lifetime())))
|
.max_age(time::Duration::minutes(CONFIG.admin_session_lifetime()))
|
||||||
.same_site(SameSite::Strict)
|
.same_site(SameSite::Strict)
|
||||||
.http_only(true)
|
.http_only(true)
|
||||||
.secure(secure.https);
|
.secure(secure.https);
|
||||||
@@ -419,21 +416,13 @@ async fn get_user_json(user_id: UserId, _token: AdminToken, conn: DbConn) -> Jso
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/users/<user_id>/delete", format = "application/json")]
|
#[post("/users/<user_id>/delete", format = "application/json")]
|
||||||
async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
|
async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult {
|
||||||
let user = get_user_or_404(&user_id, &conn).await?;
|
let user = get_user_or_404(&user_id, &conn).await?;
|
||||||
|
|
||||||
// Get the membership records and push devices before deleting the actual user
|
// Get the membership records before deleting the actual user
|
||||||
let memberships = Membership::find_any_state_by_user(&user_id, &conn).await;
|
let memberships = Membership::find_any_state_by_user(&user_id, &conn).await;
|
||||||
let has_push_device = CONFIG.push_enabled() && Device::check_user_has_push_device(&user_id, &conn).await;
|
|
||||||
let res = user.delete(&conn).await;
|
let res = user.delete(&conn).await;
|
||||||
|
|
||||||
if res.is_ok() {
|
|
||||||
nt.send_logout(&user, None, &conn).await;
|
|
||||||
if has_push_device {
|
|
||||||
push_logout_to_relay(&user, None);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for membership in memberships {
|
for membership in memberships {
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserDeleted,
|
EventType::OrganizationUserDeleted,
|
||||||
@@ -790,8 +779,6 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
|
|||||||
.into_keys()
|
.into_keys()
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let invalid_env_vars = crate::util::INVALID_ENV_VARS.lock().unwrap().join(", ");
|
|
||||||
|
|
||||||
let diagnostics_json = json!({
|
let diagnostics_json = json!({
|
||||||
"dns_resolved": dns_resolved,
|
"dns_resolved": dns_resolved,
|
||||||
"current_release": VERSION,
|
"current_release": VERSION,
|
||||||
@@ -814,9 +801,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A
|
|||||||
"db_version": get_sql_server_version(&conn).await,
|
"db_version": get_sql_server_version(&conn).await,
|
||||||
"admin_url": format!("{}/diagnostics", admin_url()),
|
"admin_url": format!("{}/diagnostics", admin_url()),
|
||||||
"overrides": &CONFIG.get_overrides().join(", "),
|
"overrides": &CONFIG.get_overrides().join(", "),
|
||||||
"config_file_status": &CONFIG.get_config_file_status(),
|
|
||||||
"template_overrides": check_template_overrides().join(", "),
|
"template_overrides": check_template_overrides().join(", "),
|
||||||
"invalid_env_vars": invalid_env_vars,
|
|
||||||
"invalid_feature_flags": invalid_feature_flags,
|
"invalid_feature_flags": invalid_feature_flags,
|
||||||
"host_arch": env::consts::ARCH,
|
"host_arch": env::consts::ARCH,
|
||||||
"host_os": env::consts::OS,
|
"host_os": env::consts::OS,
|
||||||
|
|||||||
+952
-205
File diff suppressed because it is too large.
Load diff
+329
-229
@@ -13,7 +13,7 @@ use serde_json::Value;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
CONFIG,
|
CONFIG,
|
||||||
api::{self, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::log_event},
|
api::{self, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::log_event},
|
||||||
auth::ClientVersion,
|
auth::ClientVersion,
|
||||||
auth::{Headers, OrgIdGuard, OwnerHeaders},
|
auth::{Headers, OrgIdGuard, OwnerHeaders},
|
||||||
config::PathType,
|
config::PathType,
|
||||||
@@ -24,6 +24,7 @@ use crate::{
|
|||||||
Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup,
|
Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup,
|
||||||
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId,
|
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId,
|
||||||
Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, User, UserId,
|
Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, User, UserId,
|
||||||
|
is_data_blob_encrypted,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file},
|
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file},
|
||||||
@@ -189,12 +190,22 @@ 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
|
// 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,
|
"masterKeyEncryptedUserKey": headers.user.akey,
|
||||||
"masterKeyWrappedUserKey": headers.user.akey,
|
"masterKeyWrappedUserKey": headers.user.akey,
|
||||||
"salt": headers.user.email
|
"salt": headers.user.master_password_salt(),
|
||||||
|
"containedKeyId": headers.user.key_id,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
Value::Null
|
Value::Null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Upstream omits these 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);
|
||||||
|
}
|
||||||
|
if let Some(v2_upgrade_token) = headers.user.v2_upgrade_token_json() {
|
||||||
|
user_decryption["v2UpgradeToken"] = v2_upgrade_token;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"profile": user_json,
|
"profile": user_json,
|
||||||
"folders": folders_json,
|
"folders": folders_json,
|
||||||
@@ -204,10 +215,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
|
|||||||
"ciphers": ciphers_json,
|
"ciphers": ciphers_json,
|
||||||
"domains": domains_json,
|
"domains": domains_json,
|
||||||
"sends": sends_json,
|
"sends": sends_json,
|
||||||
"userDecryption": {
|
"userDecryption": user_decryption,
|
||||||
"masterPasswordUnlock": master_password_unlock,
|
|
||||||
"userKeyId": headers.user.key_id,
|
|
||||||
},
|
|
||||||
"object": "sync"
|
"object": "sync"
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
@@ -269,7 +277,8 @@ pub struct CipherData {
|
|||||||
|
|
||||||
key: Option<String>,
|
key: Option<String>,
|
||||||
|
|
||||||
pub encrypted_for: UserId, // Added in web-v2025.6.0
|
// Added in web-v2025.6.0. Deprecated upstream for `encrypted_by_key_id`, but still checked when sent
|
||||||
|
pub encrypted_for: Option<UserId>,
|
||||||
// Added in web-v2025.8.1, Optional for compat
|
// Added in web-v2025.8.1, Optional for compat
|
||||||
pub encrypted_by_key_id: Option<KeyId>,
|
pub encrypted_by_key_id: Option<KeyId>,
|
||||||
|
|
||||||
@@ -284,7 +293,8 @@ pub struct CipherData {
|
|||||||
Passport = 8
|
Passport = 8
|
||||||
*/
|
*/
|
||||||
pub r#type: i32,
|
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>,
|
pub notes: Option<String>,
|
||||||
fields: Option<Value>,
|
fields: Option<Value>,
|
||||||
|
|
||||||
@@ -298,6 +308,9 @@ pub struct CipherData {
|
|||||||
drivers_license: Option<Value>,
|
drivers_license: Option<Value>,
|
||||||
passport: 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>,
|
favorite: Option<bool>,
|
||||||
reprompt: Option<i32>,
|
reprompt: Option<i32>,
|
||||||
|
|
||||||
@@ -319,6 +332,98 @@ pub struct CipherData {
|
|||||||
archived_date: Option<String>,
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cipher must have been encrypted for the acting user. Only checked when the client sends the field.
|
||||||
|
///
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Controllers/CiphersController.cs#L1857-L1870>
|
||||||
|
fn validate_encrypted_for_user(data: &CipherData, user_id: &UserId) -> EmptyResult {
|
||||||
|
if data.encrypted_for.as_ref().is_some_and(|encrypted_for| encrypted_for != user_id) {
|
||||||
|
err!("Cipher was not encrypted for the current user. Please try again.")
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`validate_encrypted_for_user`], plus the key id of a user-owned cipher, when both ids are known.
|
||||||
|
///
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Controllers/CiphersController.cs#L1886-L1911>
|
||||||
|
fn validate_encrypted_by_user(data: &CipherData, user: &User, is_org_cipher: bool) -> EmptyResult {
|
||||||
|
validate_encrypted_for_user(data, &user.uuid)?;
|
||||||
|
|
||||||
|
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 `data` is a blob rather than the per-type fields. Parses `data`, so keep the result.
|
||||||
|
pub fn is_blob(&self) -> bool {
|
||||||
|
self.data.as_deref().is_some_and(is_data_blob_encrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saving's last checks: a known type, and its data unless it's a blob.
|
||||||
|
pub fn validate_type_data(&self, is_blob: bool) -> EmptyResult {
|
||||||
|
let type_data = match self.r#type {
|
||||||
|
1 => &self.login,
|
||||||
|
2 => &self.secure_note,
|
||||||
|
3 => &self.card,
|
||||||
|
4 => &self.identity,
|
||||||
|
5 => &self.ssh_key,
|
||||||
|
6 => &self.bank_account,
|
||||||
|
7 => &self.drivers_license,
|
||||||
|
8 => &self.passport,
|
||||||
|
_ => err!("Invalid type"),
|
||||||
|
};
|
||||||
|
if !is_blob && type_data.is_none() {
|
||||||
|
err!("Data missing")
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upstream's model checks that depend on the format: the size of `data`, and a name unless it's a blob.
|
||||||
|
///
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Models/Request/CipherRequestModel.cs#L76-L77>
|
||||||
|
/// and <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Models/Request/CipherRequestModel.cs#L91-L99>
|
||||||
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PartialCipherData {
|
pub struct PartialCipherData {
|
||||||
@@ -352,16 +457,14 @@ async fn post_ciphers_create(
|
|||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
let mut data: ShareCipherData = data.into_inner();
|
let mut data: ShareCipherData = data.into_inner();
|
||||||
|
|
||||||
if data.cipher.encrypted_for != headers.user.uuid {
|
validate_encrypted_by_user(&data.cipher, &headers.user, data.cipher.organization_id.is_some())?;
|
||||||
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
|
|
||||||
}
|
|
||||||
|
|
||||||
// This check is usually only needed in update_cipher_from_data(), but we
|
// 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
|
// need it here as well to avoid creating an empty cipher in the call to
|
||||||
// cipher.save() below.
|
// cipher.save() below.
|
||||||
enforce_personal_ownership_policy(Some(&data.cipher), &headers, &conn).await?;
|
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);
|
||||||
cipher.user_uuid = Some(headers.user.uuid.clone());
|
cipher.user_uuid = Some(headers.user.uuid.clone());
|
||||||
cipher.save(&conn).await?;
|
cipher.save(&conn).await?;
|
||||||
|
|
||||||
@@ -385,16 +488,7 @@ async fn post_ciphers_create(
|
|||||||
async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
|
||||||
let mut data: CipherData = data.into_inner();
|
let mut data: CipherData = data.into_inner();
|
||||||
|
|
||||||
if data.encrypted_for != headers.user.uuid {
|
validate_encrypted_by_user(&data, &headers.user, data.organization_id.is_some())?;
|
||||||
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(cipher_key_id) = &data.encrypted_by_key_id
|
|
||||||
&& let Some(user_key_id) = &headers.user.key_id
|
|
||||||
&& cipher_key_id != user_key_id
|
|
||||||
{
|
|
||||||
err_code!("Invalid key cipher", Status::UnprocessableEntity.code);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The web/browser clients set this field to null as expected, but the
|
// 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`,
|
// mobile clients seem to set the invalid value `0001-01-01T00:00:00`,
|
||||||
@@ -402,7 +496,7 @@ async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt
|
|||||||
// needed when creating a new cipher, so just ignore it unconditionally.
|
// needed when creating a new cipher, so just ignore it unconditionally.
|
||||||
data.last_known_revision_date = None;
|
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);
|
||||||
update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherCreate).await?;
|
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?))
|
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?))
|
||||||
@@ -426,6 +520,44 @@ async fn enforce_personal_ownership_policy(data: Option<&CipherData>, headers: &
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The checks of saving a cipher, for callers that run them before writing anything else
|
||||||
|
async fn validate_cipher_update(
|
||||||
|
cipher: &Cipher,
|
||||||
|
data: &CipherData,
|
||||||
|
headers: &Headers,
|
||||||
|
conn: &DbConn,
|
||||||
|
ut: UpdateType,
|
||||||
|
) -> EmptyResult {
|
||||||
|
// 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
|
||||||
|
&& let Some(dt) = &data.last_known_revision_date
|
||||||
|
{
|
||||||
|
match NaiveDateTime::parse_from_str(dt, "%+") {
|
||||||
|
// ISO 8601 format
|
||||||
|
Err(err) => warn!("Error parsing LastKnownRevisionDate '{dt}': {err}"),
|
||||||
|
Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => {
|
||||||
|
err!("The client copy of this cipher is out of date. Resync the client and try again.")
|
||||||
|
}
|
||||||
|
Ok(_) => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(note) = &data.notes {
|
||||||
|
let max_note_size = CONFIG._max_note_size();
|
||||||
|
if note.len() > max_note_size {
|
||||||
|
err!(format!("The field Notes exceeds the maximum encrypted value length of {max_note_size} characters."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(folder_id) = &data.folder_id
|
||||||
|
&& Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, conn).await.is_none()
|
||||||
|
{
|
||||||
|
err!("Invalid folder", "Folder does not exist or belongs to another user");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn update_cipher_from_data(
|
pub async fn update_cipher_from_data(
|
||||||
cipher: &mut Cipher,
|
cipher: &mut Cipher,
|
||||||
data: CipherData,
|
data: CipherData,
|
||||||
@@ -434,6 +566,40 @@ pub async fn update_cipher_from_data(
|
|||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
nt: &Notify<'_>,
|
nt: &Notify<'_>,
|
||||||
ut: UpdateType,
|
ut: UpdateType,
|
||||||
|
) -> EmptyResult {
|
||||||
|
enforce_personal_ownership_policy(Some(&data), headers, conn).await?;
|
||||||
|
save_cipher_data(cipher, data, headers, shared_to_collections, conn, nt, ut).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`update_cipher_from_data`] for a rotation: like upstream, the personal ownership policy doesn't apply.
|
||||||
|
pub async fn rotate_cipher_data(
|
||||||
|
cipher: &mut Cipher,
|
||||||
|
data: CipherData,
|
||||||
|
headers: &Headers,
|
||||||
|
conn: &DbConn,
|
||||||
|
nt: &Notify<'_>,
|
||||||
|
) -> EmptyResult {
|
||||||
|
save_cipher_data(cipher, data, headers, None, conn, nt, UpdateType::None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The checks of [`rotate_cipher_data`] that need the database, to run on every cipher before the first is saved
|
||||||
|
pub async fn validate_rotated_cipher(
|
||||||
|
cipher: &Cipher,
|
||||||
|
data: &CipherData,
|
||||||
|
headers: &Headers,
|
||||||
|
conn: &DbConn,
|
||||||
|
) -> EmptyResult {
|
||||||
|
validate_cipher_update(cipher, data, headers, conn, UpdateType::None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_cipher_data(
|
||||||
|
cipher: &mut Cipher,
|
||||||
|
data: CipherData,
|
||||||
|
headers: &Headers,
|
||||||
|
shared_to_collections: Option<Vec<CollectionId>>,
|
||||||
|
conn: &DbConn,
|
||||||
|
nt: &Notify<'_>,
|
||||||
|
ut: UpdateType,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
// Cleanup cipher data, like removing the 'Response' key.
|
// Cleanup cipher data, like removing the 'Response' key.
|
||||||
// This key is somewhere generated during Javascript so no way for us this fix this.
|
// This key is somewhere generated during Javascript so no way for us this fix this.
|
||||||
@@ -449,34 +615,14 @@ pub async fn update_cipher_from_data(
|
|||||||
json_data
|
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.
|
validate_cipher_update(cipher, &data, headers, conn, ut).await?;
|
||||||
// And only perform this check when not importing ciphers, else the date/time check will fail.
|
|
||||||
if ut != UpdateType::None
|
|
||||||
&& let Some(dt) = data.last_known_revision_date
|
|
||||||
{
|
|
||||||
match NaiveDateTime::parse_from_str(&dt, "%+") {
|
|
||||||
// ISO 8601 format
|
|
||||||
Err(err) => warn!("Error parsing LastKnownRevisionDate '{}': {err}", dt.escape_debug()),
|
|
||||||
Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => {
|
|
||||||
err!("The client copy of this cipher is out of date. Resync the client and try again.")
|
|
||||||
}
|
|
||||||
Ok(_) => (),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.organization_id {
|
if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.organization_id {
|
||||||
err!("Organization mismatch. Please resync the client before updating the cipher")
|
err!("Organization mismatch. Please resync the client before updating the cipher")
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(note) = &data.notes {
|
|
||||||
let max_note_size = CONFIG._max_note_size();
|
|
||||||
if note.len() > max_note_size {
|
|
||||||
err!(format!("The field Notes exceeds the maximum encrypted value length of {max_note_size} characters."))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this cipher is being transferred from a personal to an organization vault
|
// Check if this cipher is being transferred from a personal to an organization vault
|
||||||
let transfer_cipher = cipher.organization_uuid.is_none() && data.organization_id.is_some();
|
let transfer_cipher = cipher.organization_uuid.is_none() && data.organization_id.is_some();
|
||||||
|
|
||||||
@@ -507,12 +653,6 @@ pub async fn update_cipher_from_data(
|
|||||||
cipher.user_uuid = Some(headers.user.uuid.clone());
|
cipher.user_uuid = Some(headers.user.uuid.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref folder_id) = data.folder_id
|
|
||||||
&& Folder::find_by_uuid_and_user(folder_id, &headers.user.uuid, conn).await.is_none()
|
|
||||||
{
|
|
||||||
err!("Invalid folder", "Folder does not exist or belongs to another user");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modify attachments name and keys when rotating
|
// Modify attachments name and keys when rotating
|
||||||
if let Some(attachments) = data.attachments2 {
|
if let Some(attachments) = data.attachments2 {
|
||||||
for (id, attachment) in attachments {
|
for (id, attachment) in attachments {
|
||||||
@@ -521,7 +661,7 @@ pub async fn update_cipher_from_data(
|
|||||||
// A missing attachment means it was removed via an other client.
|
// A missing attachment means it was removed via an other client.
|
||||||
// Also the Desktop Client supports removing attachments and save an update afterwards.
|
// Also the Desktop Client supports removing attachments and save an update afterwards.
|
||||||
// Bitwarden it self ignores these mismatches server side.
|
// Bitwarden it self ignores these mismatches server side.
|
||||||
warn!("Attachment {} doesn't exist", id.escape_debug());
|
warn!("Attachment {id} doesn't exist");
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -551,26 +691,36 @@ pub async fn update_cipher_from_data(
|
|||||||
_ => err!("Invalid type"),
|
_ => err!("Invalid type"),
|
||||||
};
|
};
|
||||||
|
|
||||||
let type_data = if let Some(mut data) = type_data_opt {
|
if let Some(blob) = data.data.filter(|_| is_blob) {
|
||||||
|
// A blob holds everything, the name included; the column can't be null, so it's left empty
|
||||||
|
// 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 {
|
||||||
|
let Some(mut type_data) = type_data_opt else {
|
||||||
|
err!("Data missing")
|
||||||
|
};
|
||||||
// Remove the 'Response' key from the base object.
|
// Remove the 'Response' key from the base object.
|
||||||
if let Some(data_obj) = data.as_object_mut() {
|
if let Some(data_obj) = type_data.as_object_mut() {
|
||||||
data_obj.remove("response");
|
data_obj.remove("response");
|
||||||
}
|
}
|
||||||
// Remove the 'Response' key from every Uri.
|
// Remove the 'Response' key from every Uri.
|
||||||
if data["uris"].is_array() {
|
if type_data["uris"].is_array() {
|
||||||
data["uris"] = clean_cipher_data(data["uris"].clone());
|
type_data["uris"] = clean_cipher_data(type_data["uris"].clone());
|
||||||
}
|
}
|
||||||
data
|
|
||||||
} else {
|
// `validate_content` made sure there is a name
|
||||||
err!("Data missing")
|
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.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.reprompt = data.reprompt.filter(|r| *r == RepromptType::None as i32 || *r == RepromptType::Password as i32);
|
||||||
|
|
||||||
cipher.save(conn).await?;
|
cipher.save(conn).await?;
|
||||||
@@ -580,7 +730,7 @@ pub async fn update_cipher_from_data(
|
|||||||
match data.archived_date {
|
match data.archived_date {
|
||||||
Some(dt_str) => match NaiveDateTime::parse_from_str(&dt_str, "%+") {
|
Some(dt_str) => match NaiveDateTime::parse_from_str(&dt_str, "%+") {
|
||||||
Ok(dt) => cipher.set_archived_at(dt, &headers.user.uuid, conn).await?,
|
Ok(dt) => cipher.set_archived_at(dt, &headers.user.uuid, conn).await?,
|
||||||
Err(err) => warn!("Error parsing ArchivedDate '{}': {err}", dt_str.escape_debug()),
|
Err(err) => warn!("Error parsing ArchivedDate '{dt_str}': {err}"),
|
||||||
},
|
},
|
||||||
None => cipher.unarchive(&headers.user.uuid, conn).await?,
|
None => cipher.unarchive(&headers.user.uuid, conn).await?,
|
||||||
}
|
}
|
||||||
@@ -666,11 +816,8 @@ async fn post_ciphers_import(data: Json<ImportData>, headers: Headers, conn: DbC
|
|||||||
for (index, mut cipher_data) in data.ciphers.into_iter().enumerate() {
|
for (index, mut cipher_data) in data.ciphers.into_iter().enumerate() {
|
||||||
let folder_id = relations_map.get(&index).and_then(|i| folders.get(*i).cloned());
|
let folder_id = relations_map.get(&index).and_then(|i| folders.get(*i).cloned());
|
||||||
cipher_data.folder_id = folder_id;
|
cipher_data.folder_id = folder_id;
|
||||||
// A personal import always creates personal ciphers, the organization ID is ignored instead of rejected
|
|
||||||
// Upstream: https://github.com/bitwarden/server/blob/b06b81c1fb33ce9c9872b96397ba2b4d879b66af/src/Api/Tools/Controllers/ImportCiphersController.cs#L58
|
|
||||||
cipher_data.organization_id = None;
|
|
||||||
|
|
||||||
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
|
let mut cipher = Cipher::new(cipher_data.r#type);
|
||||||
update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?;
|
update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -690,11 +837,6 @@ async fn put_cipher_admin(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
// Only organization ciphers can be edited here, put_cipher() rejects an organization ID that isn't the cipher's
|
|
||||||
if data.organization_id.is_none() {
|
|
||||||
err_code!("Resource not found.", "Cipher is not an organization cipher", Status::NotFound.code);
|
|
||||||
}
|
|
||||||
|
|
||||||
put_cipher(cipher_id, data, headers, conn, nt).await
|
put_cipher(cipher_id, data, headers, conn, nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,7 +848,7 @@ async fn post_cipher_admin(
|
|||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
put_cipher_admin(cipher_id, data, headers, conn, nt).await
|
post_cipher(cipher_id, data, headers, conn, nt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/ciphers/<cipher_id>", data = "<data>")]
|
#[post("/ciphers/<cipher_id>", data = "<data>")]
|
||||||
@@ -743,10 +885,8 @@ async fn put_cipher(
|
|||||||
err!("Cipher is not write accessible")
|
err!("Cipher is not write accessible")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Moving a cipher into an organization is only possible through the share endpoints
|
// Against the cipher we hold rather than the organization the client claims, like upstream
|
||||||
if cipher.organization_uuid != data.organization_id {
|
validate_encrypted_by_user(&data, &headers.user, cipher.organization_uuid.is_some())?;
|
||||||
err!("Organization mismatch. Please resync the client before updating the cipher")
|
|
||||||
}
|
|
||||||
|
|
||||||
update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherUpdate).await?;
|
update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherUpdate).await?;
|
||||||
|
|
||||||
@@ -1019,13 +1159,20 @@ async fn put_cipher_share(
|
|||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
let data: ShareCipherData = data.into_inner();
|
let data: ShareCipherData = data.into_inner();
|
||||||
|
|
||||||
|
// Upstream's `PutShare` checks, before anything is written.
|
||||||
|
// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Controllers/CiphersController.cs#L899-L912>
|
||||||
|
// and <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Models/Request/CipherRequestModel.cs#L498-L519>
|
||||||
if data.cipher.organization_id.is_none() {
|
if data.cipher.organization_id.is_none() {
|
||||||
err!("Cipher OrganizationId is required.")
|
err!("Cipher OrganizationId is required.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if data.collection_ids.is_empty() {
|
if data.collection_ids.is_empty() {
|
||||||
err!("You must select at least one collection.")
|
err!("You must select at least one collection.")
|
||||||
}
|
}
|
||||||
|
if !Cipher::find_by_uuid(&cipher_id, &conn).await.is_some_and(|c| c.user_uuid.as_ref() == Some(&headers.user.uuid))
|
||||||
|
{
|
||||||
|
err_code!("Cipher doesn't exist", Status::NotFound.code)
|
||||||
|
}
|
||||||
|
validate_encrypted_for_user(&data.cipher, &headers.user.uuid)?;
|
||||||
|
|
||||||
share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await
|
share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await
|
||||||
}
|
}
|
||||||
@@ -1037,103 +1184,63 @@ struct ShareSelectedCipherData {
|
|||||||
collection_ids: Vec<CollectionId>,
|
collection_ids: Vec<CollectionId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// A bulk share returns cipherMini models, not the cipherDetails models used by
|
|
||||||
// the single-share route. Keep the encrypted legacy fields and a future v2
|
|
||||||
// `data` blob, but do not expose details-only fields in this response.
|
|
||||||
fn cipher_mini_from_json(mut cipher: Value) -> Value {
|
|
||||||
const FIELDS: &[&str] = &[
|
|
||||||
"id",
|
|
||||||
"organizationId",
|
|
||||||
"type",
|
|
||||||
"data",
|
|
||||||
"partialData",
|
|
||||||
"name",
|
|
||||||
"notes",
|
|
||||||
"login",
|
|
||||||
"card",
|
|
||||||
"identity",
|
|
||||||
"secureNote",
|
|
||||||
"sshKey",
|
|
||||||
"bankAccount",
|
|
||||||
"driversLicense",
|
|
||||||
"passport",
|
|
||||||
"fields",
|
|
||||||
"passwordHistory",
|
|
||||||
"attachments",
|
|
||||||
"organizationUseTotp",
|
|
||||||
"revisionDate",
|
|
||||||
"creationDate",
|
|
||||||
"deletedDate",
|
|
||||||
"reprompt",
|
|
||||||
"key",
|
|
||||||
];
|
|
||||||
|
|
||||||
let Some(object) = cipher.as_object_mut() else {
|
|
||||||
return json!({ "object": "cipherMini" });
|
|
||||||
};
|
|
||||||
object.retain(|field, _| FIELDS.contains(&field.as_str()));
|
|
||||||
object.insert("object".into(), "cipherMini".into());
|
|
||||||
cipher
|
|
||||||
}
|
|
||||||
|
|
||||||
#[put("/ciphers/share", data = "<data>")]
|
#[put("/ciphers/share", data = "<data>")]
|
||||||
async fn put_cipher_share_selected(
|
async fn put_cipher_share_selected(
|
||||||
data: Json<ShareSelectedCipherData>,
|
data: Json<ShareSelectedCipherData>,
|
||||||
headers: Headers,
|
headers: Headers,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
nt: Notify<'_>,
|
||||||
) -> JsonResult {
|
) -> EmptyResult {
|
||||||
let data: ShareSelectedCipherData = data.into_inner();
|
let mut data: ShareSelectedCipherData = data.into_inner();
|
||||||
|
|
||||||
|
// Upstream's `PutShareMany` checks, before anything is written.
|
||||||
|
// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Controllers/CiphersController.cs#L1395-L1421>
|
||||||
|
// and <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Api/Vault/Models/Request/CipherRequestModel.cs#L560-L604>
|
||||||
if data.ciphers.is_empty() {
|
if data.ciphers.is_empty() {
|
||||||
err!("You must select at least one cipher.")
|
err!("You must select at least one cipher.")
|
||||||
}
|
}
|
||||||
|
if data.ciphers.iter().any(|c| c.id.is_none() || c.organization_id.is_none()) {
|
||||||
|
err!("All Ciphers must have an Id and OrganizationId.")
|
||||||
|
}
|
||||||
|
if data.ciphers.iter().map(|c| &c.organization_id).collect::<HashSet<_>>().len() != 1 {
|
||||||
|
err!("All ciphers must be for the same organization.")
|
||||||
|
}
|
||||||
if data.collection_ids.is_empty() {
|
if data.collection_ids.is_empty() {
|
||||||
err!("You must select at least one collection.")
|
err!("You must select at least one collection.")
|
||||||
}
|
}
|
||||||
|
|
||||||
for cipher in &data.ciphers {
|
for cipher in &data.ciphers {
|
||||||
if cipher.id.is_none() {
|
let is_blob = cipher.is_blob();
|
||||||
err!("Request missing ids field")
|
cipher.validate_content(is_blob)?;
|
||||||
}
|
cipher.validate_type_data(is_blob)?;
|
||||||
if cipher.organization_id.is_none() {
|
}
|
||||||
err!("Cipher OrganizationId is required.")
|
for cipher in &data.ciphers {
|
||||||
}
|
validate_encrypted_for_user(cipher, &headers.user.uuid)?;
|
||||||
|
}
|
||||||
|
let owned_ciphers = Cipher::find_owned_by_user(&headers.user.uuid, &conn).await;
|
||||||
|
for cipher_data in &data.ciphers {
|
||||||
|
let Some(cipher) = owned_ciphers.iter().find(|c| cipher_data.id.as_ref() == Some(&c.uuid)) else {
|
||||||
|
err!("Trying to share ciphers that you do not own.")
|
||||||
|
};
|
||||||
|
validate_cipher_update(cipher, cipher_data, &headers, &conn, UpdateType::None).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut responses = Vec::with_capacity(data.ciphers.len());
|
while let Some(cipher) = data.ciphers.pop() {
|
||||||
for cipher in data.ciphers {
|
|
||||||
let mut shared_cipher_data = ShareCipherData {
|
let mut shared_cipher_data = ShareCipherData {
|
||||||
cipher,
|
cipher,
|
||||||
collection_ids: data.collection_ids.clone(),
|
collection_ids: data.collection_ids.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(id) = shared_cipher_data.cipher.id.take() {
|
if let Some(id) = shared_cipher_data.cipher.id.take() {
|
||||||
let response =
|
share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await?
|
||||||
share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await?;
|
|
||||||
|
|
||||||
// Like upstream's VisibleToClient, withhold a cipher if sharing it
|
|
||||||
// removed the user's access to it. Only then include its encrypted
|
|
||||||
// mini response; the full single-share response stays internal.
|
|
||||||
if let Some(shared_cipher) = Cipher::find_by_uuid(&id, &conn).await
|
|
||||||
&& shared_cipher.is_accessible_to_user(&headers.user.uuid, &conn).await
|
|
||||||
{
|
|
||||||
responses.push(cipher_mini_from_json(response.into_inner()));
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
err!("Request missing ids field")
|
err!("Request missing ids field")
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multi share actions do not send out a push for each cipher, we need to send a general sync here
|
// Multi share actions do not send out a push for each cipher, we need to send a general sync here
|
||||||
nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), &conn).await;
|
nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), &conn).await;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(())
|
||||||
"data": responses,
|
|
||||||
"object": "list",
|
|
||||||
"continuationToken": null,
|
|
||||||
})))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn share_cipher_by_uuid(
|
async fn share_cipher_by_uuid(
|
||||||
@@ -1161,6 +1268,22 @@ async fn share_cipher_by_uuid(
|
|||||||
err!("Organization mismatch. Please resync the client before updating the cipher")
|
err!("Organization mismatch. Please resync the client before updating the cipher")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When LastKnownRevisionDate is None, it is a new cipher, so send CipherCreate.
|
||||||
|
// If there is an override, like when handling multiple items, we want to prevent a push notification for every single item
|
||||||
|
let ut = if let Some(ut) = override_ut {
|
||||||
|
ut
|
||||||
|
} else if data.cipher.last_known_revision_date.is_some() {
|
||||||
|
UpdateType::SyncCipherUpdate
|
||||||
|
} else {
|
||||||
|
UpdateType::SyncCipherCreate
|
||||||
|
};
|
||||||
|
|
||||||
|
// For the same reason, the other checks of saving
|
||||||
|
let is_blob = data.cipher.is_blob();
|
||||||
|
data.cipher.validate_content(is_blob)?;
|
||||||
|
data.cipher.validate_type_data(is_blob)?;
|
||||||
|
validate_cipher_update(&cipher, &data.cipher, headers, conn, ut).await?;
|
||||||
|
|
||||||
let mut shared_to_collections = vec![];
|
let mut shared_to_collections = vec![];
|
||||||
|
|
||||||
if let Some(organization_id) = &data.cipher.organization_id {
|
if let Some(organization_id) = &data.cipher.organization_id {
|
||||||
@@ -1179,16 +1302,6 @@ async fn share_cipher_by_uuid(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// When LastKnownRevisionDate is None, it is a new cipher, so send CipherCreate.
|
|
||||||
// If there is an override, like when handling multiple items, we want to prevent a push notification for every single item
|
|
||||||
let ut = if let Some(ut) = override_ut {
|
|
||||||
ut
|
|
||||||
} else if data.cipher.last_known_revision_date.is_some() {
|
|
||||||
UpdateType::SyncCipherUpdate
|
|
||||||
} else {
|
|
||||||
UpdateType::SyncCipherCreate
|
|
||||||
};
|
|
||||||
|
|
||||||
update_cipher_from_data(&mut cipher, data.cipher, headers, Some(shared_to_collections), conn, nt, ut).await?;
|
update_cipher_from_data(&mut cipher, data.cipher, headers, Some(shared_to_collections), conn, nt, ut).await?;
|
||||||
|
|
||||||
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?))
|
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, conn).await?))
|
||||||
@@ -1236,40 +1349,6 @@ enum FileUploadType {
|
|||||||
// Azure = 1, // only used upstream
|
// Azure = 1, // only used upstream
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remaining attachment storage in bytes for the cipher's owner, `None` when unlimited.
|
|
||||||
/// `size_adjust` credits a v2 attachment whose record already counts as used.
|
|
||||||
async fn attachment_size_limit(cipher: &Cipher, size_adjust: i64, conn: &DbConn) -> ApiResult<Option<i64>> {
|
|
||||||
let (limit_kb, already_used) = if let Some(ref user_id) = cipher.user_uuid {
|
|
||||||
let Some(limit_kb) = CONFIG.user_attachment_limit() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
(limit_kb, Attachment::size_by_user(user_id, conn).await)
|
|
||||||
} else if let Some(ref org_id) = cipher.organization_uuid {
|
|
||||||
let Some(limit_kb) = CONFIG.org_attachment_limit() else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
(limit_kb, Attachment::size_by_org(org_id, conn).await)
|
|
||||||
} else {
|
|
||||||
err!("Cipher is neither owned by a user nor an organization");
|
|
||||||
};
|
|
||||||
|
|
||||||
if limit_kb == 0 {
|
|
||||||
err!("Attachments are disabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(left) =
|
|
||||||
limit_kb.checked_mul(1024).and_then(|l| l.checked_sub(already_used)).and_then(|l| l.checked_add(size_adjust))
|
|
||||||
else {
|
|
||||||
err!("Attachment size overflow");
|
|
||||||
};
|
|
||||||
|
|
||||||
if left <= 0 {
|
|
||||||
err!("Attachment storage limit reached! Delete some attachments to free up space")
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Some(left))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// v2 API for creating an attachment associated with a cipher.
|
/// v2 API for creating an attachment associated with a cipher.
|
||||||
/// This redirects the client to the API it should use to upload the attachment.
|
/// This redirects the client to the API it should use to upload the attachment.
|
||||||
/// For upstream's cloud-hosted service, it's an Azure object storage API.
|
/// For upstream's cloud-hosted service, it's an Azure object storage API.
|
||||||
@@ -1280,7 +1359,6 @@ async fn post_attachment_v2(
|
|||||||
data: Json<AttachmentRequestData>,
|
data: Json<AttachmentRequestData>,
|
||||||
headers: Headers,
|
headers: Headers,
|
||||||
conn: DbConn,
|
conn: DbConn,
|
||||||
nt: Notify<'_>,
|
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else {
|
let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else {
|
||||||
err!("Cipher doesn't exist")
|
err!("Cipher doesn't exist")
|
||||||
@@ -1300,18 +1378,10 @@ async fn post_attachment_v2(
|
|||||||
if file_size > SIZE_525_MB {
|
if file_size > SIZE_525_MB {
|
||||||
err!("Max file size is 525 MB.")
|
err!("Max file size is 525 MB.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(size_limit) = attachment_size_limit(&cipher, 0, &conn).await?
|
|
||||||
&& file_size > size_limit
|
|
||||||
{
|
|
||||||
err!("Attachment storage limit exceeded with this file");
|
|
||||||
}
|
|
||||||
|
|
||||||
let attachment_id = crypto::generate_attachment_id();
|
let attachment_id = crypto::generate_attachment_id();
|
||||||
let attachment =
|
let attachment =
|
||||||
Attachment::new(attachment_id.clone(), cipher.uuid.clone(), data.file_name, file_size, Some(data.key));
|
Attachment::new(attachment_id.clone(), cipher.uuid.clone(), data.file_name, file_size, Some(data.key));
|
||||||
attachment.save(&conn).await.expect("Error saving attachment");
|
attachment.save(&conn).await.expect("Error saving attachment");
|
||||||
notify_attachment_created(&cipher, &headers, &conn, &nt).await;
|
|
||||||
|
|
||||||
let url = format!("/ciphers/{}/attachment/{attachment_id}", cipher.uuid);
|
let url = format!("/ciphers/{}/attachment/{attachment_id}", cipher.uuid);
|
||||||
let response_key = match data.admin_request {
|
let response_key = match data.admin_request {
|
||||||
@@ -1374,7 +1444,55 @@ async fn save_attachment(
|
|||||||
Some(a) => a.file_size, // v2 API
|
Some(a) => a.file_size, // v2 API
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(size_limit) = attachment_size_limit(&cipher, size_adjust, &conn).await?
|
let size_limit = if let Some(ref user_id) = cipher.user_uuid {
|
||||||
|
match CONFIG.user_attachment_limit() {
|
||||||
|
Some(0) => err!("Attachments are disabled"),
|
||||||
|
Some(limit_kb) => {
|
||||||
|
let already_used = Attachment::size_by_user(user_id, &conn).await;
|
||||||
|
let left = limit_kb
|
||||||
|
.checked_mul(1024)
|
||||||
|
.and_then(|l| l.checked_sub(already_used))
|
||||||
|
.and_then(|l| l.checked_add(size_adjust));
|
||||||
|
|
||||||
|
let Some(left) = left else {
|
||||||
|
err!("Attachment size overflow");
|
||||||
|
};
|
||||||
|
|
||||||
|
if left <= 0 {
|
||||||
|
err!("Attachment storage limit reached! Delete some attachments to free up space")
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(left)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
} else if let Some(ref org_id) = cipher.organization_uuid {
|
||||||
|
match CONFIG.org_attachment_limit() {
|
||||||
|
Some(0) => err!("Attachments are disabled"),
|
||||||
|
Some(limit_kb) => {
|
||||||
|
let already_used = Attachment::size_by_org(org_id, &conn).await;
|
||||||
|
let left = limit_kb
|
||||||
|
.checked_mul(1024)
|
||||||
|
.and_then(|l| l.checked_sub(already_used))
|
||||||
|
.and_then(|l| l.checked_add(size_adjust));
|
||||||
|
|
||||||
|
let Some(left) = left else {
|
||||||
|
err!("Attachment size overflow");
|
||||||
|
};
|
||||||
|
|
||||||
|
if left <= 0 {
|
||||||
|
err!("Attachment storage limit reached! Delete some attachments to free up space")
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(left)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
err!("Cipher is neither owned by a user nor an organization");
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(size_limit) = size_limit
|
||||||
&& size > size_limit
|
&& size > size_limit
|
||||||
{
|
{
|
||||||
err!("Attachment storage limit exceeded with this file");
|
err!("Attachment storage limit exceeded with this file");
|
||||||
@@ -1431,23 +1549,13 @@ async fn save_attachment(
|
|||||||
|
|
||||||
save_temp_file(&PathType::Attachments, &format!("{cipher_id}/{file_id}"), data.data, true).await?;
|
save_temp_file(&PathType::Attachments, &format!("{cipher_id}/{file_id}"), data.data, true).await?;
|
||||||
|
|
||||||
// With the v2 API, post_attachment_v2() already did this when it created the attachment record
|
|
||||||
if attachment.is_none() {
|
|
||||||
notify_attachment_created(&cipher, headers, &conn, &nt).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((cipher, conn))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sends the cipher update to the clients and logs the attachment event
|
|
||||||
async fn notify_attachment_created(cipher: &Cipher, headers: &Headers, conn: &DbConn, nt: &Notify<'_>) {
|
|
||||||
nt.send_cipher_update(
|
nt.send_cipher_update(
|
||||||
UpdateType::SyncCipherUpdate,
|
UpdateType::SyncCipherUpdate,
|
||||||
cipher,
|
&cipher,
|
||||||
&cipher.update_users_revision(conn).await,
|
&cipher.update_users_revision(&conn).await,
|
||||||
&headers.device,
|
&headers.device,
|
||||||
None,
|
None,
|
||||||
conn,
|
&conn,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -1459,10 +1567,12 @@ async fn notify_attachment_created(cipher: &Cipher, headers: &Headers, conn: &Db
|
|||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
headers.device.atype,
|
headers.device.atype,
|
||||||
&headers.ip.ip,
|
&headers.ip.ip,
|
||||||
conn,
|
&conn,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok((cipher, conn))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// v2 API for uploading the actual data content of an attachment.
|
/// v2 API for uploading the actual data content of an attachment.
|
||||||
@@ -1738,22 +1848,12 @@ async fn move_cipher_selected(
|
|||||||
// Find all ciphers a user has access to, all others will be ignored
|
// Find all ciphers a user has access to, all others will be ignored
|
||||||
let accessible_ciphers = Cipher::find_by_user_and_ciphers(user_id, &data.ids, &conn).await;
|
let accessible_ciphers = Cipher::find_by_user_and_ciphers(user_id, &data.ids, &conn).await;
|
||||||
let accessible_ciphers_count = accessible_ciphers.len();
|
let accessible_ciphers_count = accessible_ciphers.len();
|
||||||
let mut move_result = Ok(());
|
|
||||||
for cipher in accessible_ciphers {
|
for cipher in accessible_ciphers {
|
||||||
move_result = cipher.move_to_folder_without_revision_update(data.folder_id.clone(), user_id, &conn).await;
|
cipher.move_to_folder(data.folder_id.clone(), user_id, &conn).await?;
|
||||||
if move_result.is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if cipher_count == 1 {
|
if cipher_count == 1 {
|
||||||
single_cipher = Some(cipher);
|
single_cipher = Some(cipher);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Update the revision once for the whole batch. Also when a move failed halfway,
|
|
||||||
// since the ciphers moved before it are already changed.
|
|
||||||
if accessible_ciphers_count > 0 {
|
|
||||||
User::update_uuid_revision(user_id, &conn).await;
|
|
||||||
}
|
|
||||||
move_result?;
|
|
||||||
|
|
||||||
if let Some(cipher) = single_cipher {
|
if let Some(cipher) = single_cipher {
|
||||||
nt.send_cipher_update(
|
nt.send_cipher_update(
|
||||||
|
|||||||
+101
-173
@@ -1,4 +1,4 @@
|
|||||||
use chrono::Utc;
|
use chrono::{TimeDelta, Utc};
|
||||||
use rocket::{Route, serde::json::Json};
|
use rocket::{Route, serde::json::Json};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@@ -15,8 +15,8 @@ use crate::{
|
|||||||
db::{
|
db::{
|
||||||
DbConn, DbPool,
|
DbConn, DbPool,
|
||||||
models::{
|
models::{
|
||||||
Cipher, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus,
|
Cipher, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation,
|
||||||
EmergencyAccessType, Invitation, Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
|
Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mail,
|
mail,
|
||||||
@@ -136,21 +136,19 @@ async fn post_emergency_access(
|
|||||||
|
|
||||||
let data: EmergencyAccessUpdateData = data.into_inner();
|
let data: EmergencyAccessUpdateData = data.into_inner();
|
||||||
|
|
||||||
let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) else {
|
|
||||||
err!("Invalid emergency access type.")
|
|
||||||
};
|
|
||||||
|
|
||||||
if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&data.wait_time_days) {
|
|
||||||
err!("Emergency access wait time is out-of-bounds!")
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(mut emergency_access) =
|
let Some(mut emergency_access) =
|
||||||
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
|
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
|
||||||
else {
|
else {
|
||||||
err!("Emergency access not valid.")
|
err!("Emergency access not valid.")
|
||||||
};
|
};
|
||||||
|
|
||||||
emergency_access.atype = new_type as i32;
|
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
||||||
|
new_type as i32
|
||||||
|
} else {
|
||||||
|
err!("Invalid emergency access type.")
|
||||||
|
};
|
||||||
|
|
||||||
|
emergency_access.atype = new_type;
|
||||||
emergency_access.wait_time_days = data.wait_time_days;
|
emergency_access.wait_time_days = data.wait_time_days;
|
||||||
if data.key_encrypted.is_some() {
|
if data.key_encrypted.is_some() {
|
||||||
emergency_access.key_encrypted = data.key_encrypted;
|
emergency_access.key_encrypted = data.key_encrypted;
|
||||||
@@ -212,11 +210,11 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
|
|||||||
let email = data.email.to_lowercase();
|
let email = data.email.to_lowercase();
|
||||||
let wait_time_days = data.wait_time_days;
|
let wait_time_days = data.wait_time_days;
|
||||||
|
|
||||||
if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&wait_time_days) {
|
let emergency_access_status = EmergencyAccessStatus::Invited as i32;
|
||||||
err!("Emergency access wait time is out-of-bounds!")
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) else {
|
let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) {
|
||||||
|
new_type as i32
|
||||||
|
} else {
|
||||||
err!("Invalid emergency access type.")
|
err!("Invalid emergency access type.")
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -262,8 +260,6 @@ async fn send_invite(data: Json<EmergencyAccessInviteData>, headers: Headers, co
|
|||||||
err!(format!("Grantee user already invited: {}", &grantee_user.email))
|
err!(format!("Grantee user already invited: {}", &grantee_user.email))
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_type = new_type as i32;
|
|
||||||
let emergency_access_status = EmergencyAccessStatus::Invited as i32;
|
|
||||||
let mut new_emergency_access =
|
let mut new_emergency_access =
|
||||||
EmergencyAccess::new(grantor_user.uuid, grantee_user.email, emergency_access_status, new_type, wait_time_days);
|
EmergencyAccess::new(grantor_user.uuid, grantee_user.email, emergency_access_status, new_type, wait_time_days);
|
||||||
new_emergency_access.save(&conn).await?;
|
new_emergency_access.save(&conn).await?;
|
||||||
@@ -296,7 +292,7 @@ async fn resend_invite(emer_id: EmergencyAccessId, headers: Headers, conn: DbCon
|
|||||||
};
|
};
|
||||||
|
|
||||||
if emergency_access.status != EmergencyAccessStatus::Invited as i32 {
|
if emergency_access.status != EmergencyAccessStatus::Invited as i32 {
|
||||||
err!("The grantee user is already accepted or confirmed for emergency access");
|
err!("The grantee user is already accepted or confirmed to the organization");
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(email) = emergency_access.email.clone() else {
|
let Some(email) = emergency_access.email.clone() else {
|
||||||
@@ -457,24 +453,11 @@ async fn initiate_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
|
|||||||
let Some(mut emergency_access) =
|
let Some(mut emergency_access) =
|
||||||
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &initiating_user.uuid, &conn).await
|
EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &initiating_user.uuid, &conn).await
|
||||||
else {
|
else {
|
||||||
err!(
|
err!("Emergency access not valid.")
|
||||||
"Emergency access not valid.",
|
|
||||||
format!("Emergency access '{emer_id}', not found for user '{}'.", initiating_user.email)
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if emergency_access.status != EmergencyAccessStatus::Confirmed as i32 {
|
if emergency_access.status != EmergencyAccessStatus::Confirmed as i32 {
|
||||||
err!(
|
err!("Emergency access not valid.")
|
||||||
"Emergency access not valid.",
|
|
||||||
format!("Emergency access '{emer_id}', not confirmed for user '{}'.", initiating_user.email)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !EMERGENCY_ACCESS_TIMEOUT_RANGE.contains(&emergency_access.wait_time_days) {
|
|
||||||
err!(
|
|
||||||
"Emergency access not valid.",
|
|
||||||
format!("Emergency access '{emer_id}' timeout range '{}' is invalid", emergency_access.wait_time_days)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else {
|
let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else {
|
||||||
@@ -507,17 +490,11 @@ async fn approve_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
|
|||||||
let Some(mut emergency_access) =
|
let Some(mut emergency_access) =
|
||||||
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
|
EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await
|
||||||
else {
|
else {
|
||||||
err!(
|
err!("Emergency access not valid.")
|
||||||
"Emergency access not valid.",
|
|
||||||
format!("Emergency access '{emer_id}', not found for user '{}'.", headers.user.email)
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if emergency_access.status != EmergencyAccessStatus::RecoveryInitiated as i32 {
|
if emergency_access.status != EmergencyAccessStatus::RecoveryInitiated as i32 {
|
||||||
err!(
|
err!("Emergency access not valid.")
|
||||||
"Emergency access not valid.",
|
|
||||||
format!("Emergency access '{emer_id}', not initiated for user '{}'.", headers.user.email)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(grantor_user) = User::find_by_uuid(&headers.user.uuid, &conn).await else {
|
let Some(grantor_user) = User::find_by_uuid(&headers.user.uuid, &conn).await else {
|
||||||
@@ -770,71 +747,52 @@ pub async fn emergency_request_timeout_job(pool: DbPool) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Ok(conn) = pool.get().await else {
|
if let Ok(conn) = pool.get().await {
|
||||||
error!("Failed to get DB connection for emergency time-outs!");
|
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
|
if emergency_access_list.is_empty() {
|
||||||
|
debug!("No emergency request timeout to approve");
|
||||||
if emergency_access_list.is_empty() {
|
|
||||||
debug!("No emergency request timeout to approve");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = Utc::now().naive_utc();
|
|
||||||
for mut emer in emergency_access_list {
|
|
||||||
match emer.is_recovery_allowed(&now) {
|
|
||||||
Ok(true) => {} // Recovery is allowed
|
|
||||||
Ok(false) => continue,
|
|
||||||
Err(e) => {
|
|
||||||
error!("Failed to check if recovery is allowed for '{}': {e}", emer.uuid);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get grantor user to send Accepted email
|
let now = Utc::now().naive_utc();
|
||||||
let Some(grantor_user) = User::find_by_uuid(&emer.grantor_uuid, &conn).await else {
|
for mut emer in emergency_access_list {
|
||||||
error!("Grantor user '{}' not found for emergency access '{}'", emer.grantor_uuid, emer.uuid);
|
// The find_all_recoveries_initiated already checks if the recovery_initiated_at is not null (None)
|
||||||
continue;
|
let recovery_allowed_at =
|
||||||
};
|
emer.recovery_initiated_at.unwrap() + TimeDelta::try_days(i64::from(emer.wait_time_days)).unwrap();
|
||||||
|
if recovery_allowed_at.le(&now) {
|
||||||
|
// Only update the access status
|
||||||
|
// Updating the whole record could cause issues when the emergency_notification_reminder_job is also active
|
||||||
|
emer.update_access_status_and_save(EmergencyAccessStatus::RecoveryApproved as i32, &now, &conn)
|
||||||
|
.await
|
||||||
|
.expect("Unable to update emergency access status");
|
||||||
|
|
||||||
let Some(grantee_uuid) = emer.grantee_uuid.as_ref() else {
|
if CONFIG.mail_enabled() {
|
||||||
error!("Missing grantee for emergency access '{}'", emer.uuid);
|
// get grantor user to send Accepted email
|
||||||
continue;
|
let grantor_user =
|
||||||
};
|
User::find_by_uuid(&emer.grantor_uuid, &conn).await.expect("Grantor user not found");
|
||||||
|
|
||||||
// Get grantee user to send Accepted email
|
// get grantee user to send Accepted email
|
||||||
let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else {
|
let grantee_user =
|
||||||
error!("Grantee user '{grantee_uuid}' not found for emergency access '{}'", emer.uuid);
|
User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &conn)
|
||||||
continue;
|
.await
|
||||||
};
|
.expect("Grantee user not found");
|
||||||
|
|
||||||
// Only update the access status
|
mail::send_emergency_access_recovery_timed_out(
|
||||||
// Updating the whole record could cause issues when the emergency_notification_reminder_job is also active
|
&grantor_user.email,
|
||||||
let status_initiated = EmergencyAccessStatus::RecoveryInitiated as i32;
|
&grantee_user.name,
|
||||||
let status_approved = EmergencyAccessStatus::RecoveryApproved as i32;
|
emer.get_type_as_str(),
|
||||||
if let Err(e) = emer.update_access_status_and_save(status_initiated, status_approved, &now, &conn).await {
|
)
|
||||||
error!("Unable to update emergency access status for '{}': {e}", emer.uuid);
|
.await
|
||||||
continue;
|
.expect("Error on sending email");
|
||||||
}
|
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name)
|
||||||
if let Err(e) = mail::send_emergency_access_recovery_timed_out(
|
.await
|
||||||
&grantor_user.email,
|
.expect("Error on sending email");
|
||||||
&grantee_user.name,
|
}
|
||||||
emer.get_type_as_str(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
error!("Failed to send emergency access recovery time-out email for '{}': {e}", emer.uuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name).await
|
|
||||||
{
|
|
||||||
error!("Failed to send emergency access recovery approved email for '{}': {e}", emer.uuid);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
error!("Failed to get DB connection while searching emergency request timed out");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -844,85 +802,55 @@ pub async fn emergency_notification_reminder_job(pool: DbPool) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Ok(conn) = pool.get().await else {
|
if let Ok(conn) = pool.get().await {
|
||||||
error!("Failed to get DB connection for emergency reminders!");
|
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await;
|
if emergency_access_list.is_empty() {
|
||||||
|
debug!("No emergency request reminder notification to send");
|
||||||
if emergency_access_list.is_empty() {
|
|
||||||
debug!("No emergency request reminder notification to send");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = Utc::now().naive_utc();
|
|
||||||
for mut emer in emergency_access_list {
|
|
||||||
let Ok(deadline_at) = emer.deadline_at() else {
|
|
||||||
error!("Invalid deadline for emergency access '{}'", emer.uuid);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(final_reminder_at) = deadline_at.checked_sub_days(chrono::Days::new(1)) else {
|
|
||||||
error!("Cannot calculate reminder date for emergency access '{}'", emer.uuid);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Only send reminders during the last 24 hours, else continue
|
|
||||||
if now < final_reminder_at || now >= deadline_at {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we did not sent a recovery mail in the past 24h already, else skip it.
|
let now = Utc::now().naive_utc();
|
||||||
// In case something goes wrong with the date calculation or `last_notification_at` is None we use now.
|
for mut emer in emergency_access_list {
|
||||||
// This is to prevent possible missed reminders, this might cause an extra reminder,
|
// The find_all_recoveries_initiated already checks if the recovery_initiated_at is not null (None)
|
||||||
// but that is better then no reminder at all.
|
// Calculate the day before the recovery will become active
|
||||||
let next_reminder_at = if let Some(last_notification_at) = emer.last_notification_at {
|
let final_recovery_reminder_at =
|
||||||
last_notification_at.checked_add_days(chrono::Days::new(1)).unwrap_or(now)
|
emer.recovery_initiated_at.unwrap() + TimeDelta::try_days(i64::from(emer.wait_time_days - 1)).unwrap();
|
||||||
} else {
|
// Calculate if a day has passed since the previous notification, else no notification has been sent before
|
||||||
now
|
let next_recovery_reminder_at = if let Some(last_notification_at) = emer.last_notification_at {
|
||||||
};
|
last_notification_at + TimeDelta::try_days(1).unwrap()
|
||||||
|
} else {
|
||||||
|
now
|
||||||
|
};
|
||||||
|
if final_recovery_reminder_at.le(&now) && next_recovery_reminder_at.le(&now) {
|
||||||
|
// Only update the last notification date
|
||||||
|
// Updating the whole record could cause issues when the emergency_request_timeout_job is also active
|
||||||
|
emer.update_last_notification_date_and_save(&now, &conn)
|
||||||
|
.await
|
||||||
|
.expect("Unable to update emergency access notification date");
|
||||||
|
|
||||||
// Only send a reminder if the `next_reminder_at` is less then or equal to `now`
|
if CONFIG.mail_enabled() {
|
||||||
// Else we can skip the current emergency access this run.
|
// get grantor user to send Accepted email
|
||||||
if next_reminder_at.gt(&now) {
|
let grantor_user =
|
||||||
continue;
|
User::find_by_uuid(&emer.grantor_uuid, &conn).await.expect("Grantor user not found");
|
||||||
}
|
|
||||||
|
// get grantee user to send Accepted email
|
||||||
// Get grantor user to send Reminder email
|
let grantee_user =
|
||||||
let Some(grantor_user) = User::find_by_uuid(&emer.grantor_uuid, &conn).await else {
|
User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &conn)
|
||||||
error!("Grantor user '{}' not found for emergency access '{}'", emer.grantor_uuid, emer.uuid);
|
.await
|
||||||
continue;
|
.expect("Grantee user not found");
|
||||||
};
|
|
||||||
|
mail::send_emergency_access_recovery_reminder(
|
||||||
let Some(grantee_uuid) = emer.grantee_uuid.as_ref() else {
|
&grantor_user.email,
|
||||||
error!("Missing grantee for emergency access '{}'", emer.uuid);
|
&grantee_user.name,
|
||||||
continue;
|
emer.get_type_as_str(),
|
||||||
};
|
"1", // This notification is only triggered one day before the activation
|
||||||
|
)
|
||||||
// Get grantee user to send Reminder email
|
.await
|
||||||
let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else {
|
.expect("Error on sending email");
|
||||||
error!("Grantee user '{grantee_uuid}' not found for emergency access '{}'", emer.uuid);
|
}
|
||||||
continue;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Only update the last notification date
|
|
||||||
// Updating the whole record could cause issues when the emergency_request_timeout_job is also active
|
|
||||||
if let Err(e) = emer.update_last_notification_date_and_save(&now, &conn).await {
|
|
||||||
error!("Unable to update emergency access reminder date for '{}': {e}", emer.uuid);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if CONFIG.mail_enabled()
|
|
||||||
&& let Err(e) = mail::send_emergency_access_recovery_reminder(
|
|
||||||
&grantor_user.email,
|
|
||||||
&grantee_user.name,
|
|
||||||
emer.get_type_as_str(),
|
|
||||||
"1", // This notification is only triggered one day before the activation
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
error!("Failed to send emergency access reminder email for '{}': {e}", emer.uuid);
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
error!("Failed to get DB connection while searching emergency notification reminder");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+7
-10
@@ -214,8 +214,8 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Client-side cipher events (viewed, copied, toggled, autofilled, HTTP auth).
|
// Client-side cipher events (viewed, copied, toggled, autofilled).
|
||||||
1107..=1114 | 1117 | 1119..=1133 => {
|
1107..=1114 | 1117 | 1119..=1132 => {
|
||||||
// The cipher determines the organization the event is logged to, so make sure the
|
// 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.
|
// user can actually access it instead of trusting the provided cipher uuid.
|
||||||
if let Some(cipher_uuid) = &event.cipher_id
|
if let Some(cipher_uuid) = &event.cipher_id
|
||||||
@@ -347,17 +347,14 @@ async fn log_event_impl(
|
|||||||
|
|
||||||
pub async fn event_cleanup_job(pool: DbPool) {
|
pub async fn event_cleanup_job(pool: DbPool) {
|
||||||
debug!("Start events cleanup job");
|
debug!("Start events cleanup job");
|
||||||
if CONFIG.events_days_retain().is_none_or(|d| d == 0) {
|
if CONFIG.events_days_retain().is_none() {
|
||||||
debug!("events_days_retain is not configured or set to 0, skipping");
|
debug!("events_days_retain is not configured, abort");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Ok(conn) = pool.get().await else {
|
if let Ok(conn) = pool.get().await {
|
||||||
|
Event::clean_events(&conn).await.ok();
|
||||||
|
} else {
|
||||||
error!("Failed to get DB connection while trying to cleanup the events table");
|
error!("Failed to get DB connection while trying to cleanup the events table");
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(e) = Event::clean_events(&conn).await {
|
|
||||||
error!("Failed to cleanup the events table: {e}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,7 +247,6 @@ fn config() -> Json<Value> {
|
|||||||
"notifications": format!("{domain}/notifications"),
|
"notifications": format!("{domain}/notifications"),
|
||||||
"sso": "",
|
"sso": "",
|
||||||
"cloudRegion": null,
|
"cloudRegion": null,
|
||||||
"fillAssistRules": CONFIG.fill_assist_rules_url(),
|
|
||||||
},
|
},
|
||||||
// Bitwarden uses this for the self-hosted servers to indicate the default push technology
|
// Bitwarden uses this for the self-hosted servers to indicate the default push technology
|
||||||
"push": {
|
"push": {
|
||||||
|
|||||||
+66
-106
@@ -8,7 +8,7 @@ use crate::{
|
|||||||
CONFIG,
|
CONFIG,
|
||||||
api::admin::FAKE_ADMIN_UUID,
|
api::admin::FAKE_ADMIN_UUID,
|
||||||
api::{
|
api::{
|
||||||
EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
|
ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType,
|
||||||
core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor},
|
core::{CipherSyncData, CipherSyncType, accept_org_invite, log_event, two_factor},
|
||||||
},
|
},
|
||||||
auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite},
|
auth::{AdminHeaders, Headers, ManagerHeaders, ManagerHeadersLoose, OrgMemberHeaders, OwnerHeaders, decode_invite},
|
||||||
@@ -16,9 +16,9 @@ use crate::{
|
|||||||
DbConn,
|
DbConn,
|
||||||
models::{
|
models::{
|
||||||
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, Device,
|
Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, Device,
|
||||||
EventType, FillAssistPolicyData, Group, GroupId, GroupUser, Invitation, Membership, MembershipId,
|
EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus,
|
||||||
MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey,
|
MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor,
|
||||||
OrganizationId, TwoFactor, TwoFactorType, User, UserId,
|
TwoFactorType, User, UserId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mail,
|
mail,
|
||||||
@@ -131,16 +131,13 @@ impl FullCollectionData {
|
|||||||
let org_groups = Group::find_by_organization(org_id, conn).await;
|
let org_groups = Group::find_by_organization(org_id, conn).await;
|
||||||
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect();
|
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect();
|
||||||
if let Some(e) = self.groups.iter().find(|g| !org_group_ids.contains(&g.id)) {
|
if let Some(e) = self.groups.iter().find(|g| !org_group_ids.contains(&g.id)) {
|
||||||
err!("Invalid group", format!("Group {} does not belong to organization {}!", e.id.escape_debug(), org_id))
|
err!("Invalid group", format!("Group {} does not belong to organization {}!", e.id, org_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
let org_memberships = Membership::find_by_org(org_id, conn).await;
|
let org_memberships = Membership::find_by_org(org_id, conn).await;
|
||||||
let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect();
|
let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect();
|
||||||
if let Some(e) = self.users.iter().find(|m| !org_membership_ids.contains(&m.id)) {
|
if let Some(e) = self.users.iter().find(|m| !org_membership_ids.contains(&m.id)) {
|
||||||
err!(
|
err!("Invalid member", format!("Member {} does not belong to organization {}!", e.id, org_id))
|
||||||
"Invalid member",
|
|
||||||
format!("Member {} does not belong to organization {}!", e.id.escape_debug(), org_id)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -555,7 +552,7 @@ async fn post_bulk_access_collections(
|
|||||||
let org_groups = Group::find_by_organization(&org_id, &conn).await;
|
let org_groups = Group::find_by_organization(&org_id, &conn).await;
|
||||||
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|g| &g.uuid).collect();
|
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|g| &g.uuid).collect();
|
||||||
if let Some(g) = data.groups.iter().find(|g| !org_group_ids.contains(&g.id)) {
|
if let Some(g) = data.groups.iter().find(|g| !org_group_ids.contains(&g.id)) {
|
||||||
err!("Invalid group", format!("Group {} does not belong to organization {}!", g.id.escape_debug(), org_id))
|
err!("Invalid group", format!("Group {} does not belong to organization {}!", g.id, org_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
for col_id in data.collection_ids {
|
for col_id in data.collection_ids {
|
||||||
@@ -978,16 +975,13 @@ impl InviteData {
|
|||||||
let org_collections = Collection::find_by_organization(org_id, conn).await;
|
let org_collections = Collection::find_by_organization(org_id, conn).await;
|
||||||
let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect();
|
let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect();
|
||||||
if let Some(e) = self.collections.iter().flatten().find(|c| !org_collection_ids.contains(&c.id)) {
|
if let Some(e) = self.collections.iter().flatten().find(|c| !org_collection_ids.contains(&c.id)) {
|
||||||
err!(
|
err!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id))
|
||||||
"Invalid collection",
|
|
||||||
format!("Collection {} does not belong to organization {}!", e.id.escape_debug(), org_id)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let org_groups = Group::find_by_organization(org_id, conn).await;
|
let org_groups = Group::find_by_organization(org_id, conn).await;
|
||||||
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect();
|
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect();
|
||||||
if let Some(e) = self.groups.iter().find(|g| !org_group_ids.contains(g)) {
|
if let Some(e) = self.groups.iter().find(|g| !org_group_ids.contains(g)) {
|
||||||
err!("Invalid group", format!("Group {} does not belong to organization {}!", e.escape_debug(), org_id))
|
err!("Invalid group", format!("Group {} does not belong to organization {}!", e, org_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1162,10 +1156,7 @@ async fn bulk_reinvite_members(
|
|||||||
for member_id in data.ids {
|
for member_id in data.ids {
|
||||||
let err_msg = match reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await {
|
let err_msg = match reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await {
|
||||||
Ok(()) => String::new(),
|
Ok(()) => String::new(),
|
||||||
Err(e) => {
|
Err(e) => format!("{e:?}"),
|
||||||
warn!("Unable to reinvite member {}: {e:?}", member_id.escape_debug());
|
|
||||||
e.message().to_owned()
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
bulk_response.push(json!(
|
bulk_response.push(json!(
|
||||||
@@ -1338,10 +1329,7 @@ async fn bulk_confirm_invite(
|
|||||||
let user_key = invite.key.unwrap_or_default();
|
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 {
|
let err_msg = match confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await {
|
||||||
Ok(()) => String::new(),
|
Ok(()) => String::new(),
|
||||||
Err(e) => {
|
Err(e) => format!("{e:?}"),
|
||||||
warn!("Unable to confirm member {}: {e:?}", member_id.escape_debug());
|
|
||||||
e.message().to_owned()
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
bulk_response.push(json!(
|
bulk_response.push(json!(
|
||||||
@@ -1622,10 +1610,7 @@ async fn bulk_delete_member(
|
|||||||
for member_id in data.ids {
|
for member_id in data.ids {
|
||||||
let err_msg = match delete_member_impl(&org_id, &member_id, &headers, &conn, &nt).await {
|
let err_msg = match delete_member_impl(&org_id, &member_id, &headers, &conn, &nt).await {
|
||||||
Ok(()) => String::new(),
|
Ok(()) => String::new(),
|
||||||
Err(e) => {
|
Err(e) => format!("{e:?}"),
|
||||||
warn!("Unable to delete member {}: {e:?}", member_id.escape_debug());
|
|
||||||
e.message().to_owned()
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
bulk_response.push(json!(
|
bulk_response.push(json!(
|
||||||
@@ -1846,7 +1831,7 @@ async fn post_org_import(
|
|||||||
cipher_data.folder_id = None;
|
cipher_data.folder_id = None;
|
||||||
// Replace the client-provided, unvalidated organizationId with the real target org
|
// Replace the client-provided, unvalidated organizationId with the real target org
|
||||||
cipher_data.organization_id = Some(org_id.clone());
|
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);
|
||||||
update_cipher_from_data(
|
update_cipher_from_data(
|
||||||
&mut cipher,
|
&mut cipher,
|
||||||
cipher_data,
|
cipher_data,
|
||||||
@@ -2054,29 +2039,6 @@ async fn put_policy(
|
|||||||
err!("Invalid or unsupported policy type")
|
err!("Invalid or unsupported policy type")
|
||||||
};
|
};
|
||||||
|
|
||||||
let current_policy = OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await;
|
|
||||||
let is_currently_enabled = current_policy.as_ref().is_some_and(|policy| policy.enabled);
|
|
||||||
|
|
||||||
// Fill Assist depends on Single Organization. Match Bitwarden's dependency checks by only checking when the
|
|
||||||
// policy changes state.
|
|
||||||
if pol_type_enum == OrgPolicyType::FillAssist && data.enabled && !is_currently_enabled {
|
|
||||||
let single_org_policy_enabled =
|
|
||||||
OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::SingleOrg, &conn).await.is_some_and(|p| p.enabled);
|
|
||||||
|
|
||||||
if !single_org_policy_enabled {
|
|
||||||
err!("Turn on the Single organization policy because it is required for the Activate fill assist policy.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if pol_type_enum == OrgPolicyType::SingleOrg && !data.enabled && is_currently_enabled {
|
|
||||||
let fill_assist_policy_enabled =
|
|
||||||
OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::FillAssist, &conn).await.is_some_and(|p| p.enabled);
|
|
||||||
|
|
||||||
if fill_assist_policy_enabled {
|
|
||||||
err!("Turn off the Activate fill assist policy because it requires the Single organization policy.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bitwarden only allows the Reset Password policy when Single Org policy is enabled
|
// Bitwarden only allows the Reset Password policy when Single Org policy is enabled
|
||||||
// Vaultwarden encouraged to use multiple orgs instead of groups because groups were not available in the past
|
// Vaultwarden encouraged to use multiple orgs instead of groups because groups were not available in the past
|
||||||
// Now that groups are available we can enforce this option when wanted.
|
// Now that groups are available we can enforce this option when wanted.
|
||||||
@@ -2109,37 +2071,6 @@ async fn put_policy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if pol_type_enum == OrgPolicyType::FillAssist {
|
|
||||||
let fill_assist_data = match data.data.as_ref() {
|
|
||||||
None | Some(Value::Null) => FillAssistPolicyData {
|
|
||||||
rules_url: None,
|
|
||||||
},
|
|
||||||
Some(data) => {
|
|
||||||
let Ok(data) = serde_json::from_value::<FillAssistPolicyData>(data.clone()) else {
|
|
||||||
err!("Invalid data for FillAssist policy: rulesUrl has an invalid value.")
|
|
||||||
};
|
|
||||||
data
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if data.enabled {
|
|
||||||
let Some(rules_url) = fill_assist_data.rules_url.filter(|url| !url.trim().is_empty()) else {
|
|
||||||
err!("The RulesUrl field is required.")
|
|
||||||
};
|
|
||||||
let Ok(rules_url) = url::Url::parse(&rules_url) else {
|
|
||||||
err!("The RulesUrl field is not a valid fully-qualified http, https, or ftp URL.")
|
|
||||||
};
|
|
||||||
|
|
||||||
if !rules_url.has_host() || !matches!(rules_url.scheme(), "http" | "https" | "ftp") {
|
|
||||||
err!("The RulesUrl field is not a valid fully-qualified http, https, or ftp URL.")
|
|
||||||
}
|
|
||||||
|
|
||||||
if rules_url.scheme() != "https" {
|
|
||||||
err!("RulesUrl must use HTTPS.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// When enabling the TwoFactorAuthentication policy, revoke all members that do not have 2FA
|
// When enabling the TwoFactorAuthentication policy, revoke all members that do not have 2FA
|
||||||
if pol_type_enum == OrgPolicyType::TwoFactorAuthentication && data.enabled {
|
if pol_type_enum == OrgPolicyType::TwoFactorAuthentication && data.enabled {
|
||||||
two_factor::enforce_2fa_policy_for_org(
|
two_factor::enforce_2fa_policy_for_org(
|
||||||
@@ -2171,7 +2102,7 @@ async fn put_policy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserRevokedSingleOrganizationNonCompliance,
|
EventType::OrganizationUserRemoved,
|
||||||
&member.uuid,
|
&member.uuid,
|
||||||
&org_id,
|
&org_id,
|
||||||
&headers.user.uuid,
|
&headers.user.uuid,
|
||||||
@@ -2187,7 +2118,7 @@ async fn put_policy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut policy = match current_policy {
|
let mut policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_owned()),
|
None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_owned()),
|
||||||
};
|
};
|
||||||
@@ -2291,10 +2222,7 @@ async fn bulk_revoke_members(
|
|||||||
for member_id in members {
|
for member_id in members {
|
||||||
let err_msg = match revoke_member_impl(&org_id, &member_id, &headers, &conn).await {
|
let err_msg = match revoke_member_impl(&org_id, &member_id, &headers, &conn).await {
|
||||||
Ok(()) => String::new(),
|
Ok(()) => String::new(),
|
||||||
Err(e) => {
|
Err(e) => format!("{e:?}"),
|
||||||
warn!("Unable to revoke member {}: {e:?}", member_id.escape_debug());
|
|
||||||
e.message().to_owned()
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
bulk_response.push(json!(
|
bulk_response.push(json!(
|
||||||
@@ -2397,10 +2325,7 @@ async fn bulk_restore_members(
|
|||||||
for member_id in data.ids {
|
for member_id in data.ids {
|
||||||
let err_msg = match restore_member_impl(&org_id, &member_id, &headers, &conn).await {
|
let err_msg = match restore_member_impl(&org_id, &member_id, &headers, &conn).await {
|
||||||
Ok(()) => String::new(),
|
Ok(()) => String::new(),
|
||||||
Err(e) => {
|
Err(e) => format!("{e:?}"),
|
||||||
warn!("Unable to restore member {}: {e:?}", member_id.escape_debug());
|
|
||||||
e.message().to_owned()
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
bulk_response.push(json!(
|
bulk_response.push(json!(
|
||||||
@@ -2553,16 +2478,13 @@ impl GroupRequest {
|
|||||||
let org_collections = Collection::find_by_organization(org_id, conn).await;
|
let org_collections = Collection::find_by_organization(org_id, conn).await;
|
||||||
let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect();
|
let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect();
|
||||||
if let Some(e) = self.collections.iter().find(|c| !org_collection_ids.contains(&c.id)) {
|
if let Some(e) = self.collections.iter().find(|c| !org_collection_ids.contains(&c.id)) {
|
||||||
err!(
|
err!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id))
|
||||||
"Invalid collection",
|
|
||||||
format!("Collection {} does not belong to organization {}!", e.id.escape_debug(), org_id)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let org_memberships = Membership::find_by_org(org_id, conn).await;
|
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();
|
let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect();
|
||||||
if let Some(e) = self.users.iter().find(|m| !org_membership_ids.contains(m)) {
|
if let Some(e) = self.users.iter().find(|m| !org_membership_ids.contains(m)) {
|
||||||
err!("Invalid member", format!("Member {} does not belong to organization {}!", e.escape_debug(), org_id))
|
err!("Invalid member", format!("Member {} does not belong to organization {}!", e, org_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -2849,8 +2771,26 @@ struct OrganizationUserRecoverAccountRequest {
|
|||||||
// But the clients do not seem to use this at all
|
// But the clients do not seem to use this at all
|
||||||
// Just add it here in case they will
|
// Just add it here in case they will
|
||||||
#[get("/organizations/<org_id>/public-key")]
|
#[get("/organizations/<org_id>/public-key")]
|
||||||
async fn get_organization_public_key(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> JsonResult {
|
async fn get_organization_public_key(
|
||||||
if org_id != headers.membership.org_uuid {
|
org_id: OrganizationId,
|
||||||
|
headers: Headers,
|
||||||
|
member: Result<OrgMemberHeaders, &'static str>,
|
||||||
|
conn: DbConn,
|
||||||
|
) -> JsonResult {
|
||||||
|
// SSO users without an org get the fake one, whose key the v2 JIT password flow fetches anyway
|
||||||
|
if org_id.eq_ignore_ascii_case(FAKE_SSO_IDENTIFIER) && headers.user.private_key.is_none() {
|
||||||
|
return Ok(Json(json!({
|
||||||
|
"object": "organizationPublicKey",
|
||||||
|
"publicKey": fake_sso_org_public_key().await?,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
let member = match member {
|
||||||
|
Ok(member) => member,
|
||||||
|
// The guard already logged it
|
||||||
|
Err(e) => return Err(crate::error::Error::new_msg(e).with_code(Status::Unauthorized.code)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if org_id != member.membership.org_uuid {
|
||||||
err!("Organization not found", "Organization id's do not match");
|
err!("Organization not found", "Organization id's do not match");
|
||||||
}
|
}
|
||||||
let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else {
|
let Some(org) = Organization::find_by_uuid(&org_id, &conn).await else {
|
||||||
@@ -2863,11 +2803,30 @@ async fn get_organization_public_key(org_id: OrganizationId, headers: OrgMemberH
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The SDK only uses this key to wrap an account recovery key that it never sends for the fake org,
|
||||||
|
/// so it is made once and its private half dropped.
|
||||||
|
async fn fake_sso_org_public_key() -> ApiResult<String> {
|
||||||
|
static PUBLIC_KEY: std::sync::LazyLock<Option<String>> = std::sync::LazyLock::new(|| {
|
||||||
|
let der = openssl::rsa::Rsa::generate(2048).and_then(|rsa| rsa.public_key_to_der()).ok()?;
|
||||||
|
Some(data_encoding::BASE64.encode(&der))
|
||||||
|
});
|
||||||
|
|
||||||
|
let Ok(Some(public_key)) = tokio::task::spawn_blocking(|| PUBLIC_KEY.clone()).await else {
|
||||||
|
err!("Failed to generate the organization key")
|
||||||
|
};
|
||||||
|
Ok(public_key)
|
||||||
|
}
|
||||||
|
|
||||||
// Obsolete - Renamed to public-key (2023.8), left for backwards compatibility with older clients
|
// Obsolete - Renamed to public-key (2023.8), left for backwards compatibility with older clients
|
||||||
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/OrganizationsController.cs#L487-L492
|
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Controllers/OrganizationsController.cs#L487-L492
|
||||||
#[get("/organizations/<org_id>/keys")]
|
#[get("/organizations/<org_id>/keys")]
|
||||||
async fn get_organization_keys(org_id: OrganizationId, headers: OrgMemberHeaders, conn: DbConn) -> JsonResult {
|
async fn get_organization_keys(
|
||||||
get_organization_public_key(org_id, headers, conn).await
|
org_id: OrganizationId,
|
||||||
|
headers: Headers,
|
||||||
|
member: Result<OrgMemberHeaders, &'static str>,
|
||||||
|
conn: DbConn,
|
||||||
|
) -> JsonResult {
|
||||||
|
get_organization_public_key(org_id, headers, member, conn).await
|
||||||
}
|
}
|
||||||
|
|
||||||
// Will allow to reset 2FA too
|
// Will allow to reset 2FA too
|
||||||
@@ -2916,11 +2875,12 @@ async fn recover_account(
|
|||||||
err!("Organization user must be confirmed for password reset functionality");
|
err!("Organization user must be confirmed for password reset functionality");
|
||||||
}
|
}
|
||||||
|
|
||||||
let fallback_2fa_email = req.reset_two_factor
|
let fallback_2fa_email =
|
||||||
&& CONFIG.mail_enabled()
|
if req.reset_two_factor && CONFIG.mail_enabled() && CONFIG.email_2fa_auto_fallback() && user.verified() {
|
||||||
&& CONFIG.email_2fa_auto_fallback()
|
TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await.is_none()
|
||||||
&& user.verified()
|
} else {
|
||||||
&& TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await.is_none();
|
false
|
||||||
|
};
|
||||||
|
|
||||||
// Check the new password before the email below, so that a rejected request doesn't tell the user
|
// Check the new password before the email below, so that a rejected request doesn't tell the user
|
||||||
// their password was reset
|
// their password was reset
|
||||||
|
|||||||
@@ -268,12 +268,11 @@ async fn post_send_file_v2(data: Json<SendData>, headers: Headers, conn: DbConn)
|
|||||||
let file_id = crate::crypto::generate_send_file_id();
|
let file_id = crate::crypto::generate_send_file_id();
|
||||||
|
|
||||||
let mut data_value: Value = serde_json::from_str(&send.data)?;
|
let mut data_value: Value = serde_json::from_str(&send.data)?;
|
||||||
let Some(o) = data_value.as_object_mut() else {
|
if let Some(o) = data_value.as_object_mut() {
|
||||||
err!("Invalid Send file data")
|
o.insert(String::from("id"), Value::String(file_id.clone()));
|
||||||
};
|
o.insert(String::from("size"), Value::Number(file_length.into()));
|
||||||
o.insert(String::from("id"), Value::String(file_id.clone()));
|
o.insert(String::from("sizeName"), Value::String(crate::util::get_display_size(file_length)));
|
||||||
o.insert(String::from("size"), Value::Number(file_length.into()));
|
}
|
||||||
o.insert(String::from("sizeName"), Value::String(crate::util::get_display_size(file_length)));
|
|
||||||
send.data = serde_json::to_string(&data_value)?;
|
send.data = serde_json::to_string(&data_value)?;
|
||||||
send.save(&conn).await?;
|
send.save(&conn).await?;
|
||||||
|
|
||||||
|
|||||||
@@ -84,26 +84,18 @@ async fn send_email_login(data: Json<SendEmailLoginData>, client_headers: Client
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if let Some(auth_request_id) = auth_request_id {
|
} else if let Some(auth_request_id) = auth_request_id {
|
||||||
|
let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else {
|
||||||
|
err!("AuthRequest doesn't exist", "User not found")
|
||||||
|
};
|
||||||
let Some(code) = &data.auth_request_access_code else {
|
let Some(code) = &data.auth_request_access_code else {
|
||||||
err!("no auth request access code")
|
err!("no auth request access code")
|
||||||
};
|
};
|
||||||
let Some(auth_request) = AuthRequest::find_by_uuid_and_user(auth_request_id, &user.uuid, &conn).await
|
|
||||||
else {
|
|
||||||
err!("AuthRequest doesn't exist", "Invalid auth request or does not belong to user")
|
|
||||||
};
|
|
||||||
|
|
||||||
let request_expired = Utc::now().naive_utc() >= auth_request.creation_date + TimeDelta::minutes(5);
|
if auth_request.device_type != client_headers.device_type
|
||||||
if !auth_request.approved.unwrap_or(false)
|
|
||||||
|| auth_request.authentication_date.is_some()
|
|
||||||
|| request_expired
|
|
||||||
|| auth_request.device_type != client_headers.device_type
|
|
||||||
|| auth_request.request_ip != client_headers.ip.ip.to_string()
|
|| auth_request.request_ip != client_headers.ip.ip.to_string()
|
||||||
|| !auth_request.check_access_code(code)
|
|| !auth_request.check_access_code(code)
|
||||||
{
|
{
|
||||||
err!(
|
err!("AuthRequest doesn't exist", "Invalid device, IP or code")
|
||||||
"AuthRequest doesn't exist",
|
|
||||||
"Auth request not approved, already used or expired, or invalid device, IP or code"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else if let Some(device_identifier) = &data.device_identifier {
|
} else if let Some(device_identifier) = &data.device_identifier {
|
||||||
// iOS/Android SSO logins send the email and device id but no password hash,
|
// iOS/Android SSO logins send the email and device id but no password hash,
|
||||||
@@ -302,7 +294,7 @@ pub async fn validate_email_code_str(
|
|||||||
|
|
||||||
if !crypto::ct_eq(issued_token, token) {
|
if !crypto::ct_eq(issued_token, token) {
|
||||||
email_data.add_attempt();
|
email_data.add_attempt();
|
||||||
if email_data.attempts >= u64::from(CONFIG.email_attempts_limit()) {
|
if email_data.attempts >= CONFIG.email_attempts_limit() {
|
||||||
email_data.reset_token();
|
email_data.reset_token();
|
||||||
}
|
}
|
||||||
twofactor.data = email_data.to_json();
|
twofactor.data = email_data.to_json();
|
||||||
@@ -321,7 +313,7 @@ pub async fn validate_email_code_str(
|
|||||||
twofactor.save(conn).await?;
|
twofactor.save(conn).await?;
|
||||||
|
|
||||||
let dt = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc();
|
let dt = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc();
|
||||||
let max_time = i64::from(CONFIG.email_expiration_time());
|
let max_time = CONFIG.email_expiration_time().cast_signed();
|
||||||
if dt + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() {
|
if dt + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() {
|
||||||
err!(
|
err!(
|
||||||
"Token has expired",
|
"Token has expired",
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ pub async fn enforce_2fa_policy(
|
|||||||
member.save(conn).await?;
|
member.save(conn).await?;
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
EventType::OrganizationUserRevokedTwoFactorNonCompliance,
|
EventType::OrganizationUserRevoked,
|
||||||
&member.uuid,
|
&member.uuid,
|
||||||
&member.org_uuid,
|
&member.org_uuid,
|
||||||
act_user_id,
|
act_user_id,
|
||||||
@@ -185,16 +185,8 @@ pub async fn enforce_2fa_policy_for_org(
|
|||||||
member.revoke();
|
member.revoke();
|
||||||
member.save(conn).await?;
|
member.save(conn).await?;
|
||||||
|
|
||||||
log_event(
|
log_event(EventType::OrganizationUserRevoked, &member.uuid, org_id, act_user_id, device_type, ip, conn)
|
||||||
EventType::OrganizationUserRevokedTwoFactorNonCompliance,
|
.await;
|
||||||
&member.uuid,
|
|
||||||
org_id,
|
|
||||||
act_user_id,
|
|
||||||
device_type,
|
|
||||||
ip,
|
|
||||||
conn,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +196,7 @@ pub async fn enforce_2fa_policy_for_org(
|
|||||||
pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
|
pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
|
||||||
debug!("Sending notifications for incomplete 2FA logins");
|
debug!("Sending notifications for incomplete 2FA logins");
|
||||||
|
|
||||||
if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() {
|
if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,21 +206,11 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
let Some(time_limit) = TimeDelta::try_minutes(i64::from(CONFIG.incomplete_2fa_time_limit())) else {
|
let time_limit = TimeDelta::try_minutes(CONFIG.incomplete_2fa_time_limit()).unwrap();
|
||||||
error!("Failed to calculate the time limit for sending incomplete 2fa attempts");
|
let time_before = now - time_limit;
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(time_before) = now.checked_sub_signed(time_limit) else {
|
|
||||||
error!("Failed to calculate the timestamp for sending incomplete 2fa attempts");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let incomplete_logins = TwoFactorIncomplete::find_logins_before(&time_before, &conn).await;
|
let incomplete_logins = TwoFactorIncomplete::find_logins_before(&time_before, &conn).await;
|
||||||
for login in incomplete_logins {
|
for login in incomplete_logins {
|
||||||
let Some(user) = User::find_by_uuid(&login.user_uuid, &conn).await else {
|
let user = User::find_by_uuid(&login.user_uuid, &conn).await.expect("User not found");
|
||||||
error!("User '{}', not found!", login.user_uuid);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
info!(
|
info!(
|
||||||
"User {} did not complete a 2FA login within the configured time limit. IP: {}",
|
"User {} did not complete a 2FA login within the configured time limit. IP: {}",
|
||||||
user.email, login.ip_address
|
user.email, login.ip_address
|
||||||
|
|||||||
@@ -128,24 +128,23 @@ pub async fn validate_protected_action_otp(
|
|||||||
.await
|
.await
|
||||||
.map_res("Protected action token not found, try sending the code again or restart the process")?;
|
.map_res("Protected action token not found, try sending the code again or restart the process")?;
|
||||||
let mut pa_data = ProtectedActionData::from_json(&pa.data)?;
|
let mut pa_data = ProtectedActionData::from_json(&pa.data)?;
|
||||||
|
pa_data.add_attempt();
|
||||||
|
pa.data = pa_data.to_json();
|
||||||
|
|
||||||
// Fail after x attempts if the token has been used too many times.
|
// Fail after x attempts if the token has been used too many times.
|
||||||
// Don't delete it, as we use it to keep track of attempts.
|
// Don't delete it, as we use it to keep track of attempts.
|
||||||
if pa_data.attempts >= u64::from(CONFIG.email_attempts_limit()) {
|
if pa_data.attempts >= CONFIG.email_attempts_limit() {
|
||||||
err!("Token has expired")
|
err!("Token has expired")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the token has expired (Using the email 2fa expiration time)
|
// Check if the token has expired (Using the email 2fa expiration time)
|
||||||
let max_time = i64::from(CONFIG.email_expiration_time());
|
let max_time = CONFIG.email_expiration_time().cast_signed();
|
||||||
if pa_data.time_since_sent().num_seconds() > max_time {
|
if pa_data.time_since_sent().num_seconds() > max_time {
|
||||||
pa.delete(conn).await?;
|
pa.delete(conn).await?;
|
||||||
err!("Token has expired")
|
err!("Token has expired")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !crypto::ct_eq(&pa_data.token, otp) {
|
if !crypto::ct_eq(&pa_data.token, otp) {
|
||||||
pa_data.add_attempt();
|
|
||||||
pa.data = pa_data.to_json();
|
|
||||||
|
|
||||||
pa.save(conn).await?;
|
pa.save(conn).await?;
|
||||||
err!("Token is invalid")
|
err!("Token is invalid")
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-16
@@ -62,7 +62,7 @@ static CLIENT: LazyLock<Client> = LazyLock::new(|| {
|
|||||||
// Generate the cookie store
|
// Generate the cookie store
|
||||||
let cookie_store = Arc::new(Jar::default());
|
let cookie_store = Arc::new(Jar::default());
|
||||||
|
|
||||||
let icon_download_timeout = Duration::from_secs(u64::from(CONFIG.icon_download_timeout()));
|
let icon_download_timeout = Duration::from_secs(CONFIG.icon_download_timeout());
|
||||||
let pool_idle_timeout = Duration::from_secs(10);
|
let pool_idle_timeout = Duration::from_secs(10);
|
||||||
// Reuse the client between requests
|
// Reuse the client between requests
|
||||||
get_reqwest_client_builder(true)
|
get_reqwest_client_builder(true)
|
||||||
@@ -193,14 +193,14 @@ async fn get_cached_icon(path: &str) -> Option<Vec<u8>> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn file_is_expired(path: &str, ttl: u32) -> Result<bool, Error> {
|
async fn file_is_expired(path: &str, ttl: u64) -> Result<bool, Error> {
|
||||||
let operator = CONFIG.opendal_operator_for_path_type(&PathType::IconCache)?;
|
let operator = CONFIG.opendal_operator_for_path_type(&PathType::IconCache)?;
|
||||||
let meta = operator.stat(path).await?;
|
let meta = operator.stat(path).await?;
|
||||||
let modified =
|
let modified =
|
||||||
meta.last_modified().ok_or_else(|| std::io::Error::other(format!("No last modified time for `{path}`")))?;
|
meta.last_modified().ok_or_else(|| std::io::Error::other(format!("No last modified time for `{path}`")))?;
|
||||||
let age = SystemTime::now().duration_since(modified.into())?;
|
let age = SystemTime::now().duration_since(modified.into())?;
|
||||||
|
|
||||||
Ok(ttl > 0 && u64::from(ttl) <= age.as_secs())
|
Ok(ttl > 0 && ttl <= age.as_secs())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn icon_is_negcached(path: &str) -> bool {
|
async fn icon_is_negcached(path: &str) -> bool {
|
||||||
@@ -264,7 +264,7 @@ fn get_favicons_node(dom: Tokenizer<StringReader<'_>, FaviconEmitter>, icons: &m
|
|||||||
TAG_BASE => {
|
TAG_BASE => {
|
||||||
base_url = if let Some(href) = token.tag.attributes.get(ATTR_HREF) {
|
base_url = if let Some(href) = token.tag.attributes.get(ATTR_HREF) {
|
||||||
let href = std::str::from_utf8(href).unwrap_or_default();
|
let href = std::str::from_utf8(href).unwrap_or_default();
|
||||||
debug!("Found base href: {}", href.escape_debug());
|
debug!("Found base href: {href}");
|
||||||
match base_url.join(href) {
|
match base_url.join(href) {
|
||||||
Ok(inner_url) => inner_url,
|
Ok(inner_url) => inner_url,
|
||||||
_ => continue,
|
_ => continue,
|
||||||
@@ -524,7 +524,7 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}) {
|
}) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
// Also check if the size is at least 67 bytes, which seems to be the smallest png I could create
|
// Also check if the size is atleast 67 bytes, which seems to be the smallest png i could create
|
||||||
if body.len() >= 67 {
|
if body.len() >= 67 {
|
||||||
// Check if the icon type is allowed, else try an icon from the list.
|
// Check if the icon type is allowed, else try an icon from the list.
|
||||||
icon_type = get_icon_type(&body);
|
icon_type = get_icon_type(&body);
|
||||||
@@ -663,17 +663,6 @@ impl reqwest::cookie::CookieStore for Jar {
|
|||||||
use time::Duration;
|
use time::Duration;
|
||||||
|
|
||||||
let mut cookie_store = self.0.write().unwrap();
|
let mut cookie_store = self.0.write().unwrap();
|
||||||
|
|
||||||
// The store only skips expired cookies when reading them, so remove them here or they are kept forever
|
|
||||||
let expired: Vec<_> = cookie_store
|
|
||||||
.iter_any()
|
|
||||||
.filter(|c| c.is_expired())
|
|
||||||
.map(|c| (String::from(&c.domain), String::from(&c.path), c.name().to_owned()))
|
|
||||||
.collect();
|
|
||||||
for (domain, path, name) in expired {
|
|
||||||
cookie_store.remove(&domain, &path, &name);
|
|
||||||
}
|
|
||||||
|
|
||||||
let cookies = cookie_headers.filter_map(|val| {
|
let cookies = cookie_headers.filter_map(|val| {
|
||||||
std::str::from_utf8(val.as_bytes())
|
std::str::from_utf8(val.as_bytes())
|
||||||
.map_err(RawCookieParseError::from)
|
.map_err(RawCookieParseError::from)
|
||||||
|
|||||||
+7
-56
@@ -46,7 +46,6 @@ pub fn routes() -> Vec<Route> {
|
|||||||
post_prelogin,
|
post_prelogin,
|
||||||
prelogin_password,
|
prelogin_password,
|
||||||
register_verification_email,
|
register_verification_email,
|
||||||
register_verification_email_clicked,
|
|
||||||
register_finish,
|
register_finish,
|
||||||
prevalidate,
|
prevalidate,
|
||||||
authorize,
|
authorize,
|
||||||
@@ -112,7 +111,7 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, conn: DbCo
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(Json(tokens.to_json()))
|
Ok(Json(tokens.to_json()))
|
||||||
}
|
}
|
||||||
t => err!("Invalid type", t.escape_debug().to_string()),
|
t => err!("Invalid type", t),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(user_id) = user_id {
|
if let Some(user_id) = user_id {
|
||||||
@@ -448,9 +447,9 @@ async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
|
|||||||
if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {
|
if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {
|
||||||
if user.last_verifying_at.is_none()
|
if user.last_verifying_at.is_none()
|
||||||
|| now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds()
|
|| now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds()
|
||||||
> i64::from(CONFIG.signups_verify_resend_time())
|
> CONFIG.signups_verify_resend_time().cast_signed()
|
||||||
{
|
{
|
||||||
let resend_limit = i32::from(CONFIG.signups_verify_resend_limit());
|
let resend_limit = CONFIG.signups_verify_resend_limit().cast_signed();
|
||||||
if resend_limit == 0 || user.login_verify_count < resend_limit {
|
if resend_limit == 0 || user.login_verify_count < resend_limit {
|
||||||
// We want to send another email verification if we require signups to verify
|
// We want to send another email verification if we require signups to verify
|
||||||
// their email address, and we haven't sent them a reminder in a while...
|
// their email address, and we haven't sent them a reminder in a while...
|
||||||
@@ -546,24 +545,13 @@ async fn authenticated_response(
|
|||||||
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
|
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
|
||||||
"MasterKeyEncryptedUserKey": user.akey,
|
"MasterKeyEncryptedUserKey": user.akey,
|
||||||
"MasterKeyWrappedUserKey": user.akey,
|
"MasterKeyWrappedUserKey": user.akey,
|
||||||
"Salt": user.email
|
"Salt": user.master_password_salt()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
Value::Null
|
Value::Null
|
||||||
};
|
};
|
||||||
|
|
||||||
let account_keys = if user.private_key.is_some() {
|
let account_keys = user.account_keys_json(conn).await;
|
||||||
json!({
|
|
||||||
"publicKeyEncryptionKeyPair": {
|
|
||||||
"wrappedPrivateKey": user.private_key,
|
|
||||||
"publicKey": user.public_key,
|
|
||||||
"Object": "publicKeyEncryptionKeyPair"
|
|
||||||
},
|
|
||||||
"Object": "privateKeys"
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
Value::Null
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut result = json!({
|
let mut result = json!({
|
||||||
"access_token": auth_tokens.access_token(),
|
"access_token": auth_tokens.access_token(),
|
||||||
@@ -703,24 +691,13 @@ async fn user_api_key_login(
|
|||||||
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
|
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
|
||||||
"MasterKeyEncryptedUserKey": user.akey,
|
"MasterKeyEncryptedUserKey": user.akey,
|
||||||
"MasterKeyWrappedUserKey": user.akey,
|
"MasterKeyWrappedUserKey": user.akey,
|
||||||
"Salt": user.email
|
"Salt": user.master_password_salt()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
Value::Null
|
Value::Null
|
||||||
};
|
};
|
||||||
|
|
||||||
let account_keys = if user.private_key.is_some() {
|
let account_keys = user.account_keys_json(conn).await;
|
||||||
json!({
|
|
||||||
"publicKeyEncryptionKeyPair": {
|
|
||||||
"wrappedPrivateKey": user.private_key,
|
|
||||||
"publicKey": user.public_key,
|
|
||||||
"Object": "publicKeyEncryptionKeyPair"
|
|
||||||
},
|
|
||||||
"Object": "privateKeys"
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
Value::Null
|
|
||||||
};
|
|
||||||
|
|
||||||
// Note: No refresh_token is returned. The CLI just repeats the
|
// Note: No refresh_token is returned. The CLI just repeats the
|
||||||
// client_credentials login flow when the existing token expires.
|
// client_credentials login flow when the existing token expires.
|
||||||
@@ -1035,13 +1012,6 @@ struct RegisterVerificationData {
|
|||||||
// receiveMarketingEmails: bool,
|
// receiveMarketingEmails: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
struct RegisterVerificationEmailClickedData {
|
|
||||||
email: String,
|
|
||||||
email_verification_token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(rocket::Responder)]
|
#[derive(rocket::Responder)]
|
||||||
enum RegisterVerificationResponse {
|
enum RegisterVerificationResponse {
|
||||||
#[response(status = 204)]
|
#[response(status = 204)]
|
||||||
@@ -1104,25 +1074,6 @@ async fn register_verification_email(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/accounts/register/verification-email-clicked", data = "<data>")]
|
|
||||||
async fn register_verification_email_clicked(
|
|
||||||
data: Json<RegisterVerificationEmailClickedData>,
|
|
||||||
conn: DbConn,
|
|
||||||
) -> EmptyResult {
|
|
||||||
let data = data.into_inner();
|
|
||||||
let token_valid = auth::decode_register_verify(&data.email_verification_token)
|
|
||||||
.is_ok_and(|claims| claims.verified && claims.sub.eq_ignore_ascii_case(&data.email));
|
|
||||||
let user_exists = User::find_by_mail(&data.email, &conn)
|
|
||||||
.await
|
|
||||||
.is_some_and(|user| !user.password_hash.is_empty() || user.private_key.is_some());
|
|
||||||
|
|
||||||
if !token_valid || user_exists {
|
|
||||||
err!("Expired link. Please restart registration or try logging in. You may already have an account")
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("/accounts/register/finish", data = "<data>")]
|
#[post("/accounts/register/finish", data = "<data>")]
|
||||||
async fn register_finish(data: Json<RegisterData>, ip: ClientIp, conn: DbConn) -> JsonResult {
|
async fn register_finish(data: Json<RegisterData>, ip: ClientIp, conn: DbConn) -> JsonResult {
|
||||||
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
|
||||||
|
|||||||
+4
-4
@@ -23,14 +23,14 @@ pub use crate::api::{
|
|||||||
icons::routes as icons_routes,
|
icons::routes as icons_routes,
|
||||||
identity::routes as identity_routes,
|
identity::routes as identity_routes,
|
||||||
notifications::routes as notifications_routes,
|
notifications::routes as notifications_routes,
|
||||||
notifications::{AnonymousNotify, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS},
|
notifications::{AnonymousNotify, LogOutReason, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS},
|
||||||
push::{
|
push::{
|
||||||
push_cipher_update, push_folder_update, push_logout, push_logout_to_relay, push_send_update, push_user_update,
|
push_cipher_update, push_folder_update, push_logout, push_send_update, push_user_update, register_push_device,
|
||||||
register_push_device, unregister_push_device,
|
unregister_push_device,
|
||||||
},
|
},
|
||||||
web::catchers as web_catchers,
|
web::catchers as web_catchers,
|
||||||
web::routes as web_routes,
|
web::routes as web_routes,
|
||||||
web::{STATIC_FILES, invalidate_css_cache},
|
web::{invalidate_css_cache, static_files},
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
CONFIG,
|
CONFIG,
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ fn websockets_hub<'r>(
|
|||||||
if last_received.elapsed() > WS_CLIENT_TIMEOUT {
|
if last_received.elapsed() > WS_CLIENT_TIMEOUT {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
yield Message::Ping(PING.to_vec());
|
yield Message::Ping(create_ping());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,7 +295,7 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R
|
|||||||
if last_received.elapsed() > WS_CLIENT_TIMEOUT {
|
if last_received.elapsed() > WS_CLIENT_TIMEOUT {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
yield Message::Ping(PING.to_vec());
|
yield Message::Ping(create_ping());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -359,7 +359,6 @@ fn convert_option<T: Into<Value>>(option: Option<T>) -> Value {
|
|||||||
|
|
||||||
const RECORD_SEPARATOR: u8 = 0x1e;
|
const RECORD_SEPARATOR: u8 = 0x1e;
|
||||||
const INITIAL_RESPONSE: [u8; 3] = [0x7b, 0x7d, RECORD_SEPARATOR]; // {, }, <RS>
|
const INITIAL_RESPONSE: [u8; 3] = [0x7b, 0x7d, RECORD_SEPARATOR]; // {, }, <RS>
|
||||||
const PING: [u8; 3] = [0x02, 0x91, 0x06];
|
|
||||||
|
|
||||||
#[derive(Deserialize, Copy, Clone, Eq, PartialEq)]
|
#[derive(Deserialize, Copy, Clone, Eq, PartialEq)]
|
||||||
struct InitialMessage<'a> {
|
struct InitialMessage<'a> {
|
||||||
@@ -412,23 +411,35 @@ impl WebSocketUsers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_logout(&self, user: &User, acting_device: Option<&Device>, conn: &DbConn) {
|
pub async fn send_logout(&self, user: &User, acting_device: Option<&Device>, conn: &DbConn) {
|
||||||
|
self.send_logout_with_reason(user, acting_device, None, conn).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A logout that clients may handle differently depending on `reason`
|
||||||
|
pub async fn send_logout_with_reason(
|
||||||
|
&self,
|
||||||
|
user: &User,
|
||||||
|
acting_device: Option<&Device>,
|
||||||
|
reason: Option<LogOutReason>,
|
||||||
|
conn: &DbConn,
|
||||||
|
) {
|
||||||
// Skip any processing if both WebSockets and Push are not active
|
// Skip any processing if both WebSockets and Push are not active
|
||||||
if *NOTIFICATIONS_DISABLED {
|
if *NOTIFICATIONS_DISABLED {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let acting_device_id = acting_device.map(|d| d.uuid.clone());
|
let acting_device_id = acting_device.map(|d| d.uuid.clone());
|
||||||
let data = create_update(
|
let mut payload =
|
||||||
vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))],
|
vec![("UserId".into(), user.uuid.to_string().into()), ("Date".into(), serialize_date(user.updated_at))];
|
||||||
UpdateType::LogOut,
|
if let Some(reason) = reason {
|
||||||
acting_device_id,
|
payload.push(("Reason".into(), (reason as i32).into()));
|
||||||
);
|
}
|
||||||
|
let data = create_update(payload, UpdateType::LogOut, acting_device_id);
|
||||||
|
|
||||||
if CONFIG.enable_websocket() {
|
if CONFIG.enable_websocket() {
|
||||||
self.send_update(&user.uuid, &data).await;
|
self.send_update(&user.uuid, &data).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
if CONFIG.push_enabled() {
|
if CONFIG.push_enabled() {
|
||||||
push_logout(user, acting_device, conn).await;
|
push_logout(user, acting_device, reason, conn).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,6 +703,18 @@ fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id
|
|||||||
serialize(&value)
|
serialize(&value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn create_ping() -> Vec<u8> {
|
||||||
|
serialize(&Value::Array(vec![6.into()]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a logout was pushed, absent for a plain one. `KdfChange` isn't sent yet.
|
||||||
|
///
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/Enums/PushNotificationLogOutReason.cs#L3-L7>
|
||||||
|
#[derive(Copy, Clone, Eq, PartialEq)]
|
||||||
|
pub enum LogOutReason {
|
||||||
|
KeyRotation = 1,
|
||||||
|
}
|
||||||
|
|
||||||
// https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs
|
// https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs
|
||||||
#[derive(Copy, Clone, Eq, PartialEq)]
|
#[derive(Copy, Clone, Eq, PartialEq)]
|
||||||
pub enum UpdateType {
|
pub enum UpdateType {
|
||||||
|
|||||||
+27
-36
@@ -12,7 +12,7 @@ use tokio::sync::RwLock;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
CONFIG,
|
CONFIG,
|
||||||
api::{ApiResult, EmptyResult, UpdateType},
|
api::{ApiResult, EmptyResult, LogOutReason, UpdateType},
|
||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId},
|
models::{AuthRequestId, Cipher, Device, Folder, PushId, Send, User, UserId},
|
||||||
@@ -139,25 +139,21 @@ pub async fn unregister_push_device(push_id: Option<&PushId>) -> EmptyResult {
|
|||||||
if !CONFIG.push_enabled() || push_id.is_none() {
|
if !CONFIG.push_enabled() || push_id.is_none() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let data = json!({
|
|
||||||
"id": push_id.as_ref().unwrap(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let auth_api_token = get_auth_api_token().await?;
|
let auth_api_token = get_auth_api_token().await?;
|
||||||
|
|
||||||
let auth_header = format!("Bearer {auth_api_token}");
|
let auth_header = format!("Bearer {auth_api_token}");
|
||||||
|
|
||||||
if let Err(e) = make_http_request(Method::POST, &(CONFIG.push_relay_uri() + "/push/delete"))?
|
match make_http_request(
|
||||||
.header(CONTENT_TYPE, "application/json")
|
Method::POST,
|
||||||
.header(ACCEPT, "application/json")
|
&format!("{}/push/delete/{}", CONFIG.push_relay_uri(), push_id.as_ref().unwrap()),
|
||||||
.header(AUTHORIZATION, auth_header)
|
)?
|
||||||
.json(&data)
|
.header(AUTHORIZATION, auth_header)
|
||||||
.send()
|
.send()
|
||||||
.await?
|
.await
|
||||||
.error_for_status()
|
|
||||||
{
|
{
|
||||||
err!(format!("An error occurred during device unregistration: {e}"));
|
Ok(r) => r,
|
||||||
}
|
Err(e) => err!(format!("An error occurred during device unregistration: {e}")),
|
||||||
|
};
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,28 +188,23 @@ pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn push_logout(user: &User, acting_device: Option<&Device>, conn: &DbConn) {
|
pub async fn push_logout(user: &User, acting_device: Option<&Device>, reason: Option<LogOutReason>, conn: &DbConn) {
|
||||||
if Device::check_user_has_push_device(&user.uuid, conn).await {
|
if Device::check_user_has_push_device(&user.uuid, conn).await {
|
||||||
push_logout_to_relay(user, acting_device);
|
tokio::task::spawn(send_to_push_relay(json!({
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sends the logout without checking for push devices first.
|
|
||||||
// Needed after deleting a user, when the devices are already gone from the database.
|
|
||||||
pub fn push_logout_to_relay(user: &User, acting_device: Option<&Device>) {
|
|
||||||
tokio::task::spawn(send_to_push_relay(json!({
|
|
||||||
"userId": user.uuid,
|
|
||||||
"organizationId": (),
|
|
||||||
"deviceId": acting_device.and_then(|d| d.push_uuid.as_ref()),
|
|
||||||
"identifier": acting_device.map(|d| &d.uuid),
|
|
||||||
"type": UpdateType::LogOut as i32,
|
|
||||||
"payload": {
|
|
||||||
"userId": user.uuid,
|
"userId": user.uuid,
|
||||||
"date": format_date(&user.updated_at)
|
"organizationId": (),
|
||||||
},
|
"deviceId": acting_device.and_then(|d| d.push_uuid.as_ref()),
|
||||||
"clientType": null,
|
"identifier": acting_device.map(|d| &d.uuid),
|
||||||
"installationId": null
|
"type": UpdateType::LogOut as i32,
|
||||||
})));
|
"payload": {
|
||||||
|
"userId": user.uuid,
|
||||||
|
"date": format_date(&user.updated_at),
|
||||||
|
"reason": reason.map(|r| r as i32),
|
||||||
|
},
|
||||||
|
"clientType": null,
|
||||||
|
"installationId": null
|
||||||
|
})));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn push_user_update(ut: UpdateType, user: &User, push_uuid: Option<&PushId>, conn: &DbConn) {
|
pub async fn push_user_update(ut: UpdateType, user: &User, push_uuid: Option<&PushId>, conn: &DbConn) {
|
||||||
|
|||||||
+32
-62
@@ -1,14 +1,13 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::{Arc, LazyLock, RwLock},
|
sync::{Arc, RwLock},
|
||||||
};
|
};
|
||||||
|
|
||||||
use rocket::{
|
use rocket::{
|
||||||
Catcher, Route,
|
Catcher, Route,
|
||||||
fs::NamedFile,
|
fs::NamedFile,
|
||||||
http::ContentType,
|
http::ContentType,
|
||||||
response::{Redirect, content::RawHtml as Html},
|
response::{Redirect, content::RawCss as Css, content::RawHtml as Html},
|
||||||
serde::json::Json,
|
serde::json::Json,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -80,12 +79,12 @@ pub fn invalidate_css_cache() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[get("/css/vaultwarden.css")]
|
#[get("/css/vaultwarden.css")]
|
||||||
fn vaultwarden_css() -> EtagCached<String> {
|
fn vaultwarden_css() -> EtagCached<Css<String>> {
|
||||||
// If reload_templates is false, and we already have the CSS Cached, return this
|
// If reload_templates is false, and we already have the CSS Cached, return this
|
||||||
if !CONFIG.reload_templates()
|
if !CONFIG.reload_templates()
|
||||||
&& let Some(cached) = CSS_CACHE.read().unwrap().as_ref()
|
&& let Some(cached) = CSS_CACHE.read().unwrap().as_ref()
|
||||||
{
|
{
|
||||||
return EtagCached::new(cached.css.clone(), ContentType::CSS, &cached.etag);
|
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
|
// Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS
|
||||||
@@ -148,7 +147,7 @@ fn vaultwarden_css() -> EtagCached<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Etag Caching will let the browser send us an etag to verify and send new content if needed
|
// Etag Caching will let the browser send us an etag to verify and send new content if needed
|
||||||
EtagCached::new(cached.css.clone(), ContentType::CSS, &cached.etag)
|
EtagCached::new(Css(cached.css.clone()), &cached.etag)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/")]
|
#[get("/")]
|
||||||
@@ -257,7 +256,7 @@ fn alive_head(_conn: DbConn) -> EmptyResult {
|
|||||||
// This endpoint/function is used during development and development only.
|
// This endpoint/function is used during development and development only.
|
||||||
// It allows to easily develop the admin interface by always loading the files from disk instead from a slice of bytes
|
// It allows to easily develop the admin interface by always loading the files from disk instead from a slice of bytes
|
||||||
// This will only be active during a debug build and only when `RELOAD_TEMPLATES` is set to `true`
|
// This will only be active during a debug build and only when `RELOAD_TEMPLATES` is set to `true`
|
||||||
// NOTE: Do not forget to add any new files added to the `STATIC_FILES` HashMap below!
|
// NOTE: Do not forget to add any new files added to the `static_files` function below!
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
#[get("/vw_static/<filename>", rank = 1)]
|
#[get("/vw_static/<filename>", rank = 1)]
|
||||||
pub async fn static_files_dev(filename: PathBuf) -> Option<NamedFile> {
|
pub async fn static_files_dev(filename: PathBuf) -> Option<NamedFile> {
|
||||||
@@ -277,61 +276,32 @@ pub async fn static_files_dev(filename: PathBuf) -> Option<NamedFile> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct StaticFile {
|
|
||||||
pub content_type: ContentType,
|
|
||||||
pub bytes: &'static [u8],
|
|
||||||
pub etag: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub static STATIC_FILES: LazyLock<HashMap<&'static str, StaticFile>> = LazyLock::new(|| {
|
|
||||||
[
|
|
||||||
("404.png", ContentType::PNG, &include_bytes!("../static/images/404.png")[..]),
|
|
||||||
("mail-github.png", ContentType::PNG, &include_bytes!("../static/images/mail-github.png")[..]),
|
|
||||||
("logo-gray.png", ContentType::PNG, &include_bytes!("../static/images/logo-gray.png")[..]),
|
|
||||||
("error-x.svg", ContentType::SVG, &include_bytes!("../static/images/error-x.svg")[..]),
|
|
||||||
("hibp.png", ContentType::PNG, &include_bytes!("../static/images/hibp.png")[..]),
|
|
||||||
("vaultwarden-icon.png", ContentType::PNG, &include_bytes!("../static/images/vaultwarden-icon.png")[..]),
|
|
||||||
("vaultwarden-favicon.png", ContentType::PNG, &include_bytes!("../static/images/vaultwarden-favicon.png")[..]),
|
|
||||||
("404.css", ContentType::CSS, &include_bytes!("../static/scripts/404.css")[..]),
|
|
||||||
("admin.css", ContentType::CSS, &include_bytes!("../static/scripts/admin.css")[..]),
|
|
||||||
("admin.js", ContentType::JavaScript, &include_bytes!("../static/scripts/admin.js")[..]),
|
|
||||||
("admin_settings.js", ContentType::JavaScript, &include_bytes!("../static/scripts/admin_settings.js")[..]),
|
|
||||||
("admin_users.js", ContentType::JavaScript, &include_bytes!("../static/scripts/admin_users.js")[..]),
|
|
||||||
(
|
|
||||||
"admin_organizations.js",
|
|
||||||
ContentType::JavaScript,
|
|
||||||
&include_bytes!("../static/scripts/admin_organizations.js")[..],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"admin_diagnostics.js",
|
|
||||||
ContentType::JavaScript,
|
|
||||||
&include_bytes!("../static/scripts/admin_diagnostics.js")[..],
|
|
||||||
),
|
|
||||||
("bootstrap.css", ContentType::CSS, &include_bytes!("../static/scripts/bootstrap.css")[..]),
|
|
||||||
("bootstrap.bundle.js", ContentType::JavaScript, &include_bytes!("../static/scripts/bootstrap.bundle.js")[..]),
|
|
||||||
("jdenticon-3.3.0.js", ContentType::JavaScript, &include_bytes!("../static/scripts/jdenticon-3.3.0.js")[..]),
|
|
||||||
("datatables.js", ContentType::JavaScript, &include_bytes!("../static/scripts/datatables.js")[..]),
|
|
||||||
("datatables.css", ContentType::CSS, &include_bytes!("../static/scripts/datatables.css")[..]),
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.map(|(name, content_type, bytes)| {
|
|
||||||
(
|
|
||||||
name,
|
|
||||||
StaticFile {
|
|
||||||
content_type,
|
|
||||||
bytes,
|
|
||||||
etag: sha256_hex(bytes),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
});
|
|
||||||
|
|
||||||
#[get("/vw_static/<filename>", rank = 2)]
|
#[get("/vw_static/<filename>", rank = 2)]
|
||||||
pub fn static_files(filename: &str) -> Result<EtagCached<&'static [u8]>, Error> {
|
pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Error> {
|
||||||
if let Some(file) = STATIC_FILES.get(filename) {
|
match filename {
|
||||||
Ok(EtagCached::new(file.bytes, file.content_type.clone(), &file.etag))
|
"404.png" => Ok((ContentType::PNG, include_bytes!("../static/images/404.png"))),
|
||||||
} else {
|
"mail-github.png" => Ok((ContentType::PNG, include_bytes!("../static/images/mail-github.png"))),
|
||||||
err!(format!("Static file not found: {}", filename.escape_debug()))
|
"logo-gray.png" => Ok((ContentType::PNG, include_bytes!("../static/images/logo-gray.png"))),
|
||||||
|
"error-x.svg" => Ok((ContentType::SVG, include_bytes!("../static/images/error-x.svg"))),
|
||||||
|
"hibp.png" => Ok((ContentType::PNG, include_bytes!("../static/images/hibp.png"))),
|
||||||
|
"vaultwarden-icon.png" => Ok((ContentType::PNG, include_bytes!("../static/images/vaultwarden-icon.png"))),
|
||||||
|
"vaultwarden-favicon.png" => Ok((ContentType::PNG, include_bytes!("../static/images/vaultwarden-favicon.png"))),
|
||||||
|
"404.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/404.css"))),
|
||||||
|
"admin.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/admin.css"))),
|
||||||
|
"admin.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin.js"))),
|
||||||
|
"admin_settings.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_settings.js"))),
|
||||||
|
"admin_users.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_users.js"))),
|
||||||
|
"admin_organizations.js" => {
|
||||||
|
Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_organizations.js")))
|
||||||
|
}
|
||||||
|
"admin_diagnostics.js" => {
|
||||||
|
Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_diagnostics.js")))
|
||||||
|
}
|
||||||
|
"bootstrap.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/bootstrap.css"))),
|
||||||
|
"bootstrap.bundle.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/bootstrap.bundle.js"))),
|
||||||
|
"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"))),
|
||||||
|
_ => err!(format!("Static file not found: {filename}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+31
-72
@@ -34,7 +34,7 @@ use crate::{
|
|||||||
models::{
|
models::{
|
||||||
AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId,
|
AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId,
|
||||||
EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId,
|
EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId,
|
||||||
SendFileId, SendId, User, UserId, UserStampException,
|
SendFileId, SendId, User, UserId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
error::Error,
|
error::Error,
|
||||||
@@ -314,17 +314,6 @@ impl LoginJwtClaims {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn calculate_invite_expire_hours(now: &DateTime<Utc>) -> Result<DateTime<Utc>, Error> {
|
|
||||||
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
|
||||||
let Some(hours_delta) = TimeDelta::try_hours(expire_hours) else {
|
|
||||||
err!("Unable to calculate time delta for invite expire hours. Check `INVITATION_EXPIRATION_HOURS`.");
|
|
||||||
};
|
|
||||||
let Some(exp) = now.checked_add_signed(hours_delta) else {
|
|
||||||
err!("Unable to calculate invitation expire date. Check `INVITATION_EXPIRATION_HOURS`.");
|
|
||||||
};
|
|
||||||
Ok(exp)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct InviteJwtClaims {
|
pub struct InviteJwtClaims {
|
||||||
// Not before
|
// Not before
|
||||||
@@ -348,19 +337,19 @@ pub fn generate_invite_claims(
|
|||||||
org_id: OrganizationId,
|
org_id: OrganizationId,
|
||||||
member_id: MembershipId,
|
member_id: MembershipId,
|
||||||
invited_by_email: Option<String>,
|
invited_by_email: Option<String>,
|
||||||
) -> Result<InviteJwtClaims, Error> {
|
) -> InviteJwtClaims {
|
||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
let exp = calculate_invite_expire_hours(&time_now)?;
|
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
||||||
Ok(InviteJwtClaims {
|
InviteJwtClaims {
|
||||||
nbf: time_now.timestamp(),
|
nbf: time_now.timestamp(),
|
||||||
exp: exp.timestamp(),
|
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
|
||||||
iss: JWT_INVITE_ISSUER.to_string(),
|
iss: JWT_INVITE_ISSUER.to_string(),
|
||||||
sub: user_id,
|
sub: user_id,
|
||||||
email,
|
email,
|
||||||
org_id,
|
org_id,
|
||||||
member_id,
|
member_id,
|
||||||
invited_by_email,
|
invited_by_email,
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@@ -386,19 +375,19 @@ pub fn generate_emergency_access_invite_claims(
|
|||||||
emer_id: EmergencyAccessId,
|
emer_id: EmergencyAccessId,
|
||||||
grantor_name: String,
|
grantor_name: String,
|
||||||
grantor_email: String,
|
grantor_email: String,
|
||||||
) -> Result<EmergencyAccessInviteJwtClaims, Error> {
|
) -> EmergencyAccessInviteJwtClaims {
|
||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
let exp = calculate_invite_expire_hours(&time_now)?;
|
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
||||||
Ok(EmergencyAccessInviteJwtClaims {
|
EmergencyAccessInviteJwtClaims {
|
||||||
nbf: time_now.timestamp(),
|
nbf: time_now.timestamp(),
|
||||||
exp: exp.timestamp(),
|
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
|
||||||
iss: JWT_EMERGENCY_ACCESS_INVITE_ISSUER.to_string(),
|
iss: JWT_EMERGENCY_ACCESS_INVITE_ISSUER.to_string(),
|
||||||
sub: user_id,
|
sub: user_id,
|
||||||
email,
|
email,
|
||||||
emer_id,
|
emer_id,
|
||||||
grantor_name,
|
grantor_name,
|
||||||
grantor_email,
|
grantor_email,
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@@ -503,17 +492,13 @@ pub fn generate_2fa_remember_claims(device_uuid: DeviceId, user_uuid: UserId) ->
|
|||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
TwoFactorRememberClaims {
|
TwoFactorRememberClaims {
|
||||||
nbf: time_now.timestamp(),
|
nbf: time_now.timestamp(),
|
||||||
exp: two_factor_remember_expiration(time_now, CONFIG.two_factor_remember_days()),
|
exp: (time_now + TimeDelta::try_days(30).unwrap()).timestamp(),
|
||||||
iss: JWT_2FA_REMEMBER_ISSUER.to_string(),
|
iss: JWT_2FA_REMEMBER_ISSUER.to_string(),
|
||||||
sub: device_uuid,
|
sub: device_uuid,
|
||||||
user_uuid,
|
user_uuid,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn two_factor_remember_expiration(time_now: DateTime<Utc>, remember_days: i64) -> i64 {
|
|
||||||
(time_now + TimeDelta::try_days(remember_days).expect("TWO_FACTOR_REMEMBER_DAYS is validated")).timestamp()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct BasicJwtClaims {
|
pub struct BasicJwtClaims {
|
||||||
// Not before
|
// Not before
|
||||||
@@ -536,43 +521,36 @@ impl BasicJwtClaims {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_delete_claims(uuid: String) -> Result<BasicJwtClaims, Error> {
|
pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims {
|
||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
let exp = calculate_invite_expire_hours(&time_now)?;
|
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
||||||
Ok(BasicJwtClaims {
|
BasicJwtClaims {
|
||||||
nbf: time_now.timestamp(),
|
nbf: time_now.timestamp(),
|
||||||
exp: exp.timestamp(),
|
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
|
||||||
iss: JWT_DELETE_ISSUER.to_string(),
|
iss: JWT_DELETE_ISSUER.to_string(),
|
||||||
sub: uuid,
|
sub: uuid,
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_verify_email_claims(user_id: &UserId) -> Result<BasicJwtClaims, Error> {
|
pub fn generate_verify_email_claims(user_id: &UserId) -> BasicJwtClaims {
|
||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
let exp = calculate_invite_expire_hours(&time_now)?;
|
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
||||||
Ok(BasicJwtClaims {
|
BasicJwtClaims {
|
||||||
nbf: time_now.timestamp(),
|
nbf: time_now.timestamp(),
|
||||||
exp: exp.timestamp(),
|
exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(),
|
||||||
iss: JWT_VERIFYEMAIL_ISSUER.to_string(),
|
iss: JWT_VERIFYEMAIL_ISSUER.to_string(),
|
||||||
sub: user_id.to_string(),
|
sub: user_id.to_string(),
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_admin_claims() -> Result<BasicJwtClaims, Error> {
|
pub fn generate_admin_claims() -> BasicJwtClaims {
|
||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
let lifetime_minutes = i64::from(CONFIG.admin_session_lifetime());
|
BasicJwtClaims {
|
||||||
let Some(minutes_delta) = TimeDelta::try_minutes(lifetime_minutes) else {
|
|
||||||
err!("Unable to calculate time delta for admin session lifetime. Check `ADMIN_SESSION_LIFETIME`.");
|
|
||||||
};
|
|
||||||
let Some(exp) = time_now.checked_add_signed(minutes_delta) else {
|
|
||||||
err!("Unable to calculate admin session lifetime. Check `ADMIN_SESSION_LIFETIME`.");
|
|
||||||
};
|
|
||||||
Ok(BasicJwtClaims {
|
|
||||||
nbf: time_now.timestamp(),
|
nbf: time_now.timestamp(),
|
||||||
exp: exp.timestamp(),
|
exp: (time_now + TimeDelta::try_minutes(CONFIG.admin_session_lifetime()).unwrap()).timestamp(),
|
||||||
iss: JWT_ADMIN_ISSUER.to_string(),
|
iss: JWT_ADMIN_ISSUER.to_string(),
|
||||||
sub: "admin_panel".to_owned(),
|
sub: "admin_panel".to_owned(),
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_send_claims(send_id: &SendId, file_id: &SendFileId) -> BasicJwtClaims {
|
pub fn generate_send_claims(send_id: &SendId, file_id: &SendFileId) -> BasicJwtClaims {
|
||||||
@@ -707,31 +685,12 @@ impl<'r> FromRequest<'r> for Headers {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if user.security_stamp != claims.sstamp {
|
if user.security_stamp != claims.sstamp {
|
||||||
if let Some(stamp_exception) =
|
let Some(current_route) = request.route().and_then(|r| r.name.as_deref()) else {
|
||||||
user.stamp_exception.as_deref().and_then(|s| serde_json::from_str::<UserStampException>(s).ok())
|
err_handler!("Error getting current route for stamp exception")
|
||||||
{
|
};
|
||||||
let Some(current_route) = request.route().and_then(|r| r.name.as_deref()) else {
|
|
||||||
err_handler!("Error getting current route for stamp exception")
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if the stamp exception has expired first.
|
// Expired exceptions are only dropped on reset; writing them back here could undo a newer reset
|
||||||
// Then, check if the current route matches any of the allowed routes.
|
if !user.stamp_exceptions().iter().any(|e| e.allows(&claims.sstamp, current_route)) {
|
||||||
// After that check the stamp in exception matches the one in the claims.
|
|
||||||
if Utc::now().timestamp() > stamp_exception.expire {
|
|
||||||
// If the stamp exception has been expired remove it from the database.
|
|
||||||
// This prevents checking this stamp exception for new requests.
|
|
||||||
let mut user = user;
|
|
||||||
user.reset_stamp_exception();
|
|
||||||
if let Err(e) = user.save(&conn).await {
|
|
||||||
error!("Error updating user: {e:#?}");
|
|
||||||
}
|
|
||||||
err_handler!("Stamp exception is expired")
|
|
||||||
} else if !stamp_exception.routes.contains(¤t_route.to_owned()) {
|
|
||||||
err_handler!("Invalid security stamp: Current route and exception route do not match")
|
|
||||||
} else if stamp_exception.security_stamp != claims.sstamp {
|
|
||||||
err_handler!("Invalid security stamp for matched stamp exception")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
err_handler!("Invalid security stamp")
|
err_handler!("Invalid security stamp")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-5
@@ -72,11 +72,7 @@ impl SendTokens {
|
|||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> ApiResult<SendTokens> {
|
) -> ApiResult<SendTokens> {
|
||||||
let Some(send_id) = Self::as_send_id(access_id) else {
|
let Some(send_id) = Self::as_send_id(access_id) else {
|
||||||
return Self::invalid_error(
|
return Self::invalid_error(&format!("Can't convert {access_id}"), "send_id_invalid", false);
|
||||||
&format!("Can't convert {}", access_id.escape_debug()),
|
|
||||||
"send_id_invalid",
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(send) = Send::find_by_uuid(&send_id, conn).await else {
|
let Some(send) = Send::find_by_uuid(&send_id, conn).await else {
|
||||||
|
|||||||
+42
-136
@@ -111,7 +111,6 @@ macro_rules! make_config {
|
|||||||
_usr: ConfigBuilder,
|
_usr: ConfigBuilder,
|
||||||
|
|
||||||
_overrides: Vec<&'static str>,
|
_overrides: Vec<&'static str>,
|
||||||
_config_file_status: ConfigFileStatus,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom Deserialize for ConfigBuilder, mainly based upon https://serde.rs/deserialize-struct.html
|
// Custom Deserialize for ConfigBuilder, mainly based upon https://serde.rs/deserialize-struct.html
|
||||||
@@ -260,18 +259,11 @@ macro_rules! make_config {
|
|||||||
builder
|
builder
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn from_file() -> Result<Option<Self>, Error> {
|
async fn from_file() -> Result<Self, Error> {
|
||||||
let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
|
let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?;
|
||||||
let config_bytes = match operator.read(&CONFIG_FILENAME).await {
|
let config_bytes = operator.read(&CONFIG_FILENAME).await?;
|
||||||
Ok(bytes) => bytes,
|
|
||||||
// No config file is fine, should not return any error
|
|
||||||
Err(e) if e.kind() == opendal::ErrorKind::NotFound => return Ok(None),
|
|
||||||
// Any other error might be an issue and should be passed back to the caller
|
|
||||||
Err(e) => return Err(e.into()),
|
|
||||||
};
|
|
||||||
let builder = serde_json::from_slice(&config_bytes.to_vec())?;
|
|
||||||
println!("[INFO] Using saved config from `{}` for configuration.\n", *CONFIG_FILE);
|
println!("[INFO] Using saved config from `{}` for configuration.\n", *CONFIG_FILE);
|
||||||
Ok(Some(builder))
|
serde_json::from_slice(&config_bytes.to_vec()).map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clear_non_editable(&mut self) {
|
fn clear_non_editable(&mut self) {
|
||||||
@@ -448,8 +440,6 @@ macro_rules! make_config {
|
|||||||
"domain_origin",
|
"domain_origin",
|
||||||
"domain_path",
|
"domain_path",
|
||||||
"domain",
|
"domain",
|
||||||
"duo_ikey",
|
|
||||||
"duo_host",
|
|
||||||
"helo_name",
|
"helo_name",
|
||||||
"org_creation_users",
|
"org_creation_users",
|
||||||
"signups_domains_whitelist",
|
"signups_domains_whitelist",
|
||||||
@@ -461,8 +451,6 @@ macro_rules! make_config {
|
|||||||
"sso_authority",
|
"sso_authority",
|
||||||
"sso_callback_path",
|
"sso_callback_path",
|
||||||
"sso_client_id",
|
"sso_client_id",
|
||||||
"yubico_client_id",
|
|
||||||
"yubico_server",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
let cfg = {
|
let cfg = {
|
||||||
@@ -495,10 +483,6 @@ macro_rules! make_config {
|
|||||||
};
|
};
|
||||||
overrides
|
overrides
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_config_file_status(&self) -> ConfigFileStatus {
|
|
||||||
self.inner.read().unwrap()._config_file_status
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -555,7 +539,7 @@ make_config! {
|
|||||||
jobs {
|
jobs {
|
||||||
/// Job scheduler poll interval |> How often the job scheduler thread checks for jobs to run.
|
/// Job scheduler poll interval |> How often the job scheduler thread checks for jobs to run.
|
||||||
/// Set to 0 to globally disable scheduled jobs.
|
/// Set to 0 to globally disable scheduled jobs.
|
||||||
job_poll_interval_ms: u32, false, def, 30_000;
|
job_poll_interval_ms: u64, false, def, 30_000;
|
||||||
/// Send purge schedule |> Cron schedule of the job that checks for Sends past their deletion date.
|
/// Send purge schedule |> Cron schedule of the job that checks for Sends past their deletion date.
|
||||||
/// Defaults to hourly. Set blank to disable this job.
|
/// Defaults to hourly. Set blank to disable this job.
|
||||||
send_purge_schedule: String, false, def, "0 5 * * * *".to_owned();
|
send_purge_schedule: String, false, def, "0 5 * * * *".to_owned();
|
||||||
@@ -603,9 +587,6 @@ make_config! {
|
|||||||
/// This setting applies globally to all users. To control this on a per-org basis instead, use the "Disable Send" org policy.
|
/// This setting applies globally to all users. To control this on a per-org basis instead, use the "Disable Send" org policy.
|
||||||
sends_allowed: bool, true, def, true;
|
sends_allowed: bool, true, def, true;
|
||||||
|
|
||||||
/// Fill Assist Rules URL |> HTTPS URL for the server-wide Fill Assist rules feed base directory.
|
|
||||||
fill_assist_rules_url: String, true, def, "https://fillassist.bitwarden.com".to_owned();
|
|
||||||
|
|
||||||
/// HIBP Api Key |> HaveIBeenPwned API Key, request it here: https://haveibeenpwned.com/API/Key
|
/// HIBP Api Key |> HaveIBeenPwned API Key, request it here: https://haveibeenpwned.com/API/Key
|
||||||
hibp_api_key: Pass, true, option;
|
hibp_api_key: Pass, true, option;
|
||||||
|
|
||||||
@@ -614,19 +595,19 @@ make_config! {
|
|||||||
/// Per-organization attachment storage limit (KB) |> Max kilobytes of attachment storage allowed per org. When this limit is reached, org members will not be allowed to upload further attachments for ciphers owned by that org.
|
/// Per-organization attachment storage limit (KB) |> Max kilobytes of attachment storage allowed per org. When this limit is reached, org members will not be allowed to upload further attachments for ciphers owned by that org.
|
||||||
org_attachment_limit: i64, true, option;
|
org_attachment_limit: i64, true, option;
|
||||||
/// Per-user send storage limit (KB) |> Max kilobytes of sends storage allowed per user. When this limit is reached, the user will not be allowed to upload further sends.
|
/// Per-user send storage limit (KB) |> Max kilobytes of sends storage allowed per user. When this limit is reached, the user will not be allowed to upload further sends.
|
||||||
user_send_limit: i64, true, option;
|
user_send_limit: i64, true, option;
|
||||||
|
|
||||||
/// Trash auto-delete days |> Number of days to wait before auto-deleting a trashed item.
|
/// Trash auto-delete days |> Number of days to wait before auto-deleting a trashed item.
|
||||||
/// If unset or 0, trashed items are not auto-deleted. This setting applies globally, so make
|
/// If unset, trashed items are not auto-deleted. This setting applies globally, so make
|
||||||
/// sure to inform all users of any changes to this setting.
|
/// sure to inform all users of any changes to this setting.
|
||||||
trash_auto_delete_days: u16, true, option;
|
trash_auto_delete_days: i64, true, option;
|
||||||
|
|
||||||
/// Incomplete 2FA time limit |> Number of minutes to wait before a 2FA-enabled login is
|
/// Incomplete 2FA time limit |> Number of minutes to wait before a 2FA-enabled login is
|
||||||
/// considered incomplete, resulting in an email notification. An incomplete 2FA login is one
|
/// considered incomplete, resulting in an email notification. An incomplete 2FA login is one
|
||||||
/// where the correct master password was provided but the required 2FA step was not completed,
|
/// where the correct master password was provided but the required 2FA step was not completed,
|
||||||
/// which potentially indicates a master password compromise. Set to 0 to disable this check.
|
/// which potentially indicates a master password compromise. Set to 0 to disable this check.
|
||||||
/// This setting applies globally to all users.
|
/// This setting applies globally to all users.
|
||||||
incomplete_2fa_time_limit: u16, true, def, 3;
|
incomplete_2fa_time_limit: i64, true, def, 3;
|
||||||
|
|
||||||
/// Disable icon downloads |> Set to true to disable icon downloading in the internal icon service.
|
/// Disable icon downloads |> Set to true to disable icon downloading in the internal icon service.
|
||||||
/// This still serves existing icons from $ICON_CACHE_FOLDER, without generating any external
|
/// This still serves existing icons from $ICON_CACHE_FOLDER, without generating any external
|
||||||
@@ -639,9 +620,9 @@ make_config! {
|
|||||||
/// this will prevent logins from succeeding until the address has been verified
|
/// this will prevent logins from succeeding until the address has been verified
|
||||||
signups_verify: bool, true, def, false;
|
signups_verify: bool, true, def, false;
|
||||||
/// If signups require email verification, automatically re-send verification email if it hasn't been sent for a while (in seconds)
|
/// If signups require email verification, automatically re-send verification email if it hasn't been sent for a while (in seconds)
|
||||||
signups_verify_resend_time: u32, true, def, 3_600;
|
signups_verify_resend_time: u64, true, def, 3_600;
|
||||||
/// If signups require email verification, limit how many emails are automatically sent when login is attempted (0 means no limit)
|
/// If signups require email verification, limit how many emails are automatically sent when login is attempted (0 means no limit)
|
||||||
signups_verify_resend_limit: u16, true, def, 6;
|
signups_verify_resend_limit: u32, true, def, 6;
|
||||||
/// Email domain whitelist |> Allow signups only from this list of comma-separated domains, even when signups are otherwise disabled
|
/// Email domain whitelist |> Allow signups only from this list of comma-separated domains, even when signups are otherwise disabled
|
||||||
signups_domains_whitelist: String, true, def, String::new();
|
signups_domains_whitelist: String, true, def, String::new();
|
||||||
/// Enable event logging |> Enables event logging for organizations.
|
/// Enable event logging |> Enables event logging for organizations.
|
||||||
@@ -653,7 +634,7 @@ make_config! {
|
|||||||
invitations_allowed: bool, true, def, true;
|
invitations_allowed: bool, true, def, true;
|
||||||
/// Invitation token expiration time (in hours) |> The number of hours after which an organization invite token, emergency access invite token,
|
/// Invitation token expiration time (in hours) |> The number of hours after which an organization invite token, emergency access invite token,
|
||||||
/// email verification token and deletion request token will expire (must be at least 1)
|
/// email verification token and deletion request token will expire (must be at least 1)
|
||||||
invitation_expiration_hours: u16, false, def, 120;
|
invitation_expiration_hours: u32, false, def, 120;
|
||||||
/// Enable emergency access |> Controls whether users can enable emergency access to their accounts. This setting applies globally to all users.
|
/// Enable emergency access |> Controls whether users can enable emergency access to their accounts. This setting applies globally to all users.
|
||||||
emergency_access_allowed: bool, true, def, true;
|
emergency_access_allowed: bool, true, def, true;
|
||||||
/// Allow email change |> Controls whether users can change their email. This setting applies globally to all users.
|
/// Allow email change |> Controls whether users can change their email. This setting applies globally to all users.
|
||||||
@@ -674,8 +655,8 @@ make_config! {
|
|||||||
/// Invitation organization name |> Name shown in the invitation emails that don't come from a specific organization
|
/// Invitation organization name |> Name shown in the invitation emails that don't come from a specific organization
|
||||||
invitation_org_name: String, true, def, "Vaultwarden".to_owned();
|
invitation_org_name: String, true, def, "Vaultwarden".to_owned();
|
||||||
|
|
||||||
/// Events days retain |> Number of days to retain events stored in the database. If unset or 0, events are kept indefinitely.
|
/// Events days retain |> Number of days to retain events stored in the database. If unset, events are kept indefinitely.
|
||||||
events_days_retain: u16, false, option;
|
events_days_retain: i64, false, option;
|
||||||
},
|
},
|
||||||
|
|
||||||
client {
|
client {
|
||||||
@@ -713,13 +694,13 @@ make_config! {
|
|||||||
/// Temporary redirects are useful while testing different icon services, but once a service
|
/// Temporary redirects are useful while testing different icon services, but once a service
|
||||||
/// has been decided on, consider using permanent redirects for cacheability. The legacy codes
|
/// has been decided on, consider using permanent redirects for cacheability. The legacy codes
|
||||||
/// are currently better supported by the Bitwarden clients.
|
/// are currently better supported by the Bitwarden clients.
|
||||||
icon_redirect_code: u16, true, def, 302;
|
icon_redirect_code: u32, true, def, 302;
|
||||||
/// Positive icon cache expiry |> Number of seconds to consider that an already cached icon is fresh. After this period, the icon will be refreshed
|
/// Positive icon cache expiry |> Number of seconds to consider that an already cached icon is fresh. After this period, the icon will be refreshed
|
||||||
icon_cache_ttl: u32, true, def, 2_592_000;
|
icon_cache_ttl: u64, true, def, 2_592_000;
|
||||||
/// Negative icon cache expiry |> Number of seconds before trying to download an icon that failed again.
|
/// Negative icon cache expiry |> Number of seconds before trying to download an icon that failed again.
|
||||||
icon_cache_negttl: u32, true, def, 259_200;
|
icon_cache_negttl: u64, true, def, 259_200;
|
||||||
/// Icon download timeout |> Number of seconds when to stop attempting to download an icon.
|
/// Icon download timeout |> Number of seconds when to stop attempting to download an icon.
|
||||||
icon_download_timeout: u16, true, def, 10;
|
icon_download_timeout: u64, true, def, 10;
|
||||||
|
|
||||||
/// [Deprecated] Icon blacklist Regex |> Use `http_request_block_regex` instead
|
/// [Deprecated] Icon blacklist Regex |> Use `http_request_block_regex` instead
|
||||||
icon_blacklist_regex: String, false, option;
|
icon_blacklist_regex: String, false, option;
|
||||||
@@ -736,8 +717,6 @@ make_config! {
|
|||||||
/// Disable Two-Factor remember |> Enabling this would force the users to use a second factor to login every time.
|
/// Disable Two-Factor remember |> Enabling this would force the users to use a second factor to login every time.
|
||||||
/// Note that the checkbox would still be present, but ignored.
|
/// Note that the checkbox would still be present, but ignored.
|
||||||
disable_2fa_remember: bool, true, def, false;
|
disable_2fa_remember: bool, true, def, false;
|
||||||
/// Two-Factor remember duration |> Number of days before a remembered Two-Factor login expires (min: 1, max: 90).
|
|
||||||
two_factor_remember_days: i64, true, def, 30;
|
|
||||||
|
|
||||||
/// Disable authenticator time drifted codes to be valid |> Enabling this only allows the current TOTP code to be valid
|
/// Disable authenticator time drifted codes to be valid |> Enabling this only allows the current TOTP code to be valid
|
||||||
/// TOTP codes of the previous and next 30 seconds will be invalid.
|
/// TOTP codes of the previous and next 30 seconds will be invalid.
|
||||||
@@ -772,11 +751,11 @@ make_config! {
|
|||||||
/// Max database connection retries |> Number of times to retry the database connection during startup, with 1 second between each retry, set to 0 to retry indefinitely
|
/// Max database connection retries |> Number of times to retry the database connection during startup, with 1 second between each retry, set to 0 to retry indefinitely
|
||||||
db_connection_retries: u32, false, def, 15;
|
db_connection_retries: u32, false, def, 15;
|
||||||
|
|
||||||
/// Timeout in seconds when acquiring database connection
|
/// Timeout when acquiring database connection
|
||||||
database_timeout: u32, false, def, 30;
|
database_timeout: u64, false, def, 30;
|
||||||
|
|
||||||
/// Timeout in seconds before idle connections to the database are closed
|
/// Timeout in seconds before idle connections to the database are closed
|
||||||
database_idle_timeout: u32, false, def, 600;
|
database_idle_timeout: u64, false, def, 600;
|
||||||
|
|
||||||
/// Database connection max pool size
|
/// Database connection max pool size
|
||||||
database_max_conns: u32, false, def, 10;
|
database_max_conns: u32, false, def, 10;
|
||||||
@@ -793,26 +772,26 @@ make_config! {
|
|||||||
/// Allowed iframe ancestors (Know the risks!) |> Allows other domains to embed the web vault into an iframe, useful for embedding into secure intranets
|
/// Allowed iframe ancestors (Know the risks!) |> Allows other domains to embed the web vault into an iframe, useful for embedding into secure intranets
|
||||||
allowed_iframe_ancestors: String, true, def, String::new();
|
allowed_iframe_ancestors: String, true, def, String::new();
|
||||||
|
|
||||||
/// Allowed connect-src (Know the risks!) |> Allows other domains for URLs which can be loaded using script interfaces like the Forwarded email alias feature
|
/// Allowed connect-src (Know the risks!) |> Allows other domains to URLs which can be loaded using script interfaces like the Forwarded email alias feature
|
||||||
allowed_connect_src: String, true, def, String::new();
|
allowed_connect_src: String, true, def, String::new();
|
||||||
|
|
||||||
/// Seconds between login requests |> Number of seconds, on average, between login and 2FA requests from the same IP address before rate limiting kicks in
|
/// Seconds between login requests |> Number of seconds, on average, between login and 2FA requests from the same IP address before rate limiting kicks in
|
||||||
login_ratelimit_seconds: u16, false, def, 60;
|
login_ratelimit_seconds: u64, false, def, 60;
|
||||||
/// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2
|
/// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2
|
||||||
login_ratelimit_max_burst: u16, false, def, 10;
|
login_ratelimit_max_burst: u32, false, def, 10;
|
||||||
|
|
||||||
/// Seconds between unauthenticated requests |> Number of seconds, on average, between requests from the same IP address to any of the rate limited unauthenticated endpoints
|
/// Seconds between unauthenticated requests |> Number of seconds, on average, between requests from the same IP address to any of the rate limited unauthenticated endpoints
|
||||||
unauthenticated_ratelimit_seconds: u16, false, def, 60;
|
unauthenticated_ratelimit_seconds: u64, false, def, 60;
|
||||||
/// Max burst size for unauthenticated requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `unauthenticated_ratelimit_seconds`. This is shared between several endpoints, so it needs to be more lenient than the login one
|
/// Max burst size for unauthenticated requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `unauthenticated_ratelimit_seconds`. This is shared between several endpoints, so it needs to be more lenient than the login one
|
||||||
unauthenticated_ratelimit_max_burst: u16, false, def, 50;
|
unauthenticated_ratelimit_max_burst: u32, false, def, 50;
|
||||||
|
|
||||||
/// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in
|
/// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in
|
||||||
admin_ratelimit_seconds: u16, false, def, 300;
|
admin_ratelimit_seconds: u64, false, def, 300;
|
||||||
/// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds`
|
/// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds`
|
||||||
admin_ratelimit_max_burst: u16, false, def, 3;
|
admin_ratelimit_max_burst: u32, false, def, 3;
|
||||||
|
|
||||||
/// Admin session lifetime |> Set the lifetime of admin sessions to this value (in minutes).
|
/// Admin session lifetime |> Set the lifetime of admin sessions to this value (in minutes).
|
||||||
admin_session_lifetime: u32, true, def, 20;
|
admin_session_lifetime: i64, true, def, 20;
|
||||||
|
|
||||||
/// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!).
|
/// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!).
|
||||||
org_groups_enabled: bool, false, def, false;
|
org_groups_enabled: bool, false, def, false;
|
||||||
@@ -865,8 +844,8 @@ make_config! {
|
|||||||
sso_master_password_policy: String, true, option;
|
sso_master_password_policy: String, true, option;
|
||||||
/// Use SSO only for auth not the session lifecycle |> Use default Vaultwarden session lifecycle (Idle refresh token valid for 30days)
|
/// Use SSO only for auth not the session lifecycle |> Use default Vaultwarden session lifecycle (Idle refresh token valid for 30days)
|
||||||
sso_auth_only_not_session: bool, true, def, false;
|
sso_auth_only_not_session: bool, true, def, false;
|
||||||
/// Client cache for discovery endpoint. |> Duration in seconds (0 to disable). More details: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect#client-cache
|
/// Client cache for discovery endpoint. |> Duration in seconds (0 or less to disable). More details: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect#client-cache
|
||||||
sso_client_cache_expiration: u32, true, def, 0;
|
sso_client_cache_expiration: u64, true, def, 0;
|
||||||
/// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required
|
/// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required
|
||||||
sso_debug_tokens: bool, true, def, false;
|
sso_debug_tokens: bool, true, def, false;
|
||||||
},
|
},
|
||||||
@@ -924,11 +903,11 @@ make_config! {
|
|||||||
/// SMTP Auth mechanism |> Defaults for SSL is "Plain" and "Login" and nothing for Non-SSL connections. Possible values: ["Plain", "Login", "Xoauth2"]. Multiple options need to be separated by a comma ','.
|
/// SMTP Auth mechanism |> Defaults for SSL is "Plain" and "Login" and nothing for Non-SSL connections. Possible values: ["Plain", "Login", "Xoauth2"]. Multiple options need to be separated by a comma ','.
|
||||||
smtp_auth_mechanism: String, true, option;
|
smtp_auth_mechanism: String, true, option;
|
||||||
/// SMTP connection timeout |> Number of seconds when to stop trying to connect to the SMTP server
|
/// SMTP connection timeout |> Number of seconds when to stop trying to connect to the SMTP server
|
||||||
smtp_timeout: u16, true, def, 15;
|
smtp_timeout: u64, true, def, 15;
|
||||||
/// Server name sent during HELO |> By default this value should be the machine's hostname, but might need to be changed in case it trips some anti-spam filters
|
/// Server name sent during HELO |> By default this value should be the machine's hostname, but might need to be changed in case it trips some anti-spam filters
|
||||||
helo_name: String, true, option;
|
helo_name: String, true, option;
|
||||||
/// Embed images as email attachments.
|
/// Embed images as email attachments.
|
||||||
smtp_embed_images: bool, true, def, true;
|
smtp_embed_images: bool, true, def, true;
|
||||||
/// _smtp_img_src
|
/// _smtp_img_src
|
||||||
_smtp_img_src: String, false, generated, |c| generate_smtp_img_src(c.smtp_embed_images, &c.domain);
|
_smtp_img_src: String, false, generated, |c| generate_smtp_img_src(c.smtp_embed_images, &c.domain);
|
||||||
/// Enable SMTP debugging (Know the risks!) |> DANGEROUS: Enabling this will output very detailed SMTP messages. This could contain sensitive information like passwords and usernames! Only enable this during troubleshooting!
|
/// Enable SMTP debugging (Know the risks!) |> DANGEROUS: Enabling this will output very detailed SMTP messages. This could contain sensitive information like passwords and usernames! Only enable this during troubleshooting!
|
||||||
@@ -946,9 +925,9 @@ make_config! {
|
|||||||
/// Email token size |> Number of digits in an email 2FA token (min: 6, max: 255). Note that the Bitwarden clients are hardcoded to mention 6 digit codes regardless of this setting.
|
/// Email token size |> Number of digits in an email 2FA token (min: 6, max: 255). Note that the Bitwarden clients are hardcoded to mention 6 digit codes regardless of this setting.
|
||||||
email_token_size: u8, true, def, 6;
|
email_token_size: u8, true, def, 6;
|
||||||
/// Token expiration time |> Maximum time in seconds a token is valid. The time the user has to open email client and copy token.
|
/// Token expiration time |> Maximum time in seconds a token is valid. The time the user has to open email client and copy token.
|
||||||
email_expiration_time: u32, true, def, 600;
|
email_expiration_time: u64, true, def, 600;
|
||||||
/// Maximum attempts |> Maximum attempts before an email token is reset and a new email will need to be sent
|
/// Maximum attempts |> Maximum attempts before an email token is reset and a new email will need to be sent
|
||||||
email_attempts_limit: u8, true, def, 3;
|
email_attempts_limit: u64, true, def, 3;
|
||||||
/// Setup email 2FA at signup |> Setup email 2FA provider on registration regardless of any organization policy
|
/// Setup email 2FA at signup |> Setup email 2FA provider on registration regardless of any organization policy
|
||||||
email_2fa_enforce_on_verified_invite: bool, true, def, false;
|
email_2fa_enforce_on_verified_invite: bool, true, def, false;
|
||||||
/// Auto-enable 2FA (Know the risks!) |> Automatically setup email 2FA as fallback provider when needed
|
/// Auto-enable 2FA (Know the risks!) |> Automatically setup email 2FA as fallback provider when needed
|
||||||
@@ -1004,15 +983,7 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cfg.database_min_conns > cfg.database_max_conns {
|
if cfg.database_min_conns > cfg.database_max_conns {
|
||||||
err!("`DATABASE_MIN_CONNS` must be less than or equal to `DATABASE_MAX_CONNS`.");
|
err!("`DATABASE_MIN_CONNS` must be smaller than or equal to `DATABASE_MAX_CONNS`.");
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.database_timeout < 1 {
|
|
||||||
err!("`DATABASE_TIMEOUT` must be at least 1 second");
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.database_idle_timeout < 1 {
|
|
||||||
err!("`DATABASE_IDLE_TIMEOUT` must be at least 1 second");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(log_file) = &cfg.log_file
|
if let Some(log_file) = &cfg.log_file
|
||||||
@@ -1028,21 +999,6 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let valid_fill_assist_rules_url = Url::parse(&cfg.fill_assist_rules_url).is_ok_and(|url| {
|
|
||||||
url.scheme() == "https"
|
|
||||||
&& url.has_host()
|
|
||||||
&& url.username().is_empty()
|
|
||||||
&& url.password().is_none()
|
|
||||||
&& url.query().is_none()
|
|
||||||
&& url.fragment().is_none()
|
|
||||||
&& cfg.fill_assist_rules_url.trim() == cfg.fill_assist_rules_url
|
|
||||||
});
|
|
||||||
if !valid_fill_assist_rules_url {
|
|
||||||
err!(
|
|
||||||
"`FILL_ASSIST_RULES_URL` must be a valid HTTPS URL without credentials, query parameters, fragments, or surrounding whitespace."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let connect_src = cfg.allowed_connect_src.to_lowercase();
|
let connect_src = cfg.allowed_connect_src.to_lowercase();
|
||||||
for url in connect_src.split_whitespace() {
|
for url in connect_src.split_whitespace() {
|
||||||
if !url.starts_with("https://") || Url::parse(url).is_err() {
|
if !url.starts_with("https://") || Url::parse(url).is_err() {
|
||||||
@@ -1277,10 +1233,6 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
err!("`INVITATION_EXPIRATION_HOURS` has a minimum duration of 1 hour")
|
err!("`INVITATION_EXPIRATION_HOURS` has a minimum duration of 1 hour")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !(1..=90).contains(&cfg.two_factor_remember_days) {
|
|
||||||
err!("`TWO_FACTOR_REMEMBER_DAYS` must be between 1 and 90 days")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate schedule crontab format
|
// Validate schedule crontab format
|
||||||
if !cfg.send_purge_schedule.is_empty() && cfg.send_purge_schedule.parse::<Schedule>().is_err() {
|
if !cfg.send_purge_schedule.is_empty() && cfg.send_purge_schedule.parse::<Schedule>().is_err() {
|
||||||
err!("`SEND_PURGE_SCHEDULE` is not a valid cron expression")
|
err!("`SEND_PURGE_SCHEDULE` is not a valid cron expression")
|
||||||
@@ -1322,30 +1274,6 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
|
|||||||
err!("`PURGE_INCOMPLETE_SSO_AUTH` is not a valid cron expression")
|
err!("`PURGE_INCOMPLETE_SSO_AUTH` is not a valid cron expression")
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.login_ratelimit_seconds < 1 {
|
|
||||||
err!("`LOGIN_RATELIMIT_SECONDS` must be at least 1 second");
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.login_ratelimit_max_burst < 1 {
|
|
||||||
err!("`LOGIN_RATELIMIT_MAX_BURST` must be at least 1");
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.admin_ratelimit_seconds < 1 {
|
|
||||||
err!("`ADMIN_RATELIMIT_SECONDS` must be at least 1 second");
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.admin_ratelimit_max_burst < 1 {
|
|
||||||
err!("`ADMIN_RATELIMIT_MAX_BURST` must be at least 1");
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.unauthenticated_ratelimit_seconds < 1 {
|
|
||||||
err!("`UNAUTHENTICATED_RATELIMIT_SECONDS` must be at least 1 second");
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.unauthenticated_ratelimit_max_burst < 1 {
|
|
||||||
err!("`UNAUTHENTICATED_RATELIMIT_MAX_BURST` must be at least 1");
|
|
||||||
}
|
|
||||||
|
|
||||||
if !cfg.disable_admin_token {
|
if !cfg.disable_admin_token {
|
||||||
match cfg.admin_token.as_ref() {
|
match cfg.admin_token.as_ref() {
|
||||||
Some(t) if t.starts_with("$argon2") => {
|
Some(t) if t.starts_with("$argon2") => {
|
||||||
@@ -1501,13 +1429,17 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
|
|||||||
// Autofill Team
|
// Autofill Team
|
||||||
"undetermined-cipher-scenario-logic",
|
"undetermined-cipher-scenario-logic",
|
||||||
"enable-basic-auth-response",
|
"enable-basic-auth-response",
|
||||||
"fill-assist-targeting-rules",
|
|
||||||
"ssh-agent-v2",
|
"ssh-agent-v2",
|
||||||
"windows-desktop-autotype",
|
"windows-desktop-autotype",
|
||||||
"windows-desktop-autotype-ga",
|
"windows-desktop-autotype-ga",
|
||||||
// Key Management Team
|
// Key Management Team
|
||||||
"biometrics-sdk-ipc",
|
"biometrics-sdk-ipc",
|
||||||
"windows-native-credential-sync",
|
"windows-native-credential-sync",
|
||||||
|
"pm-30144-sdk-key-rotation",
|
||||||
|
"pm-31050-no-logout-key-upgrade-rotation",
|
||||||
|
"enable-account-encryption-v2-jit-password-registration",
|
||||||
|
"pm-27278-v2-password-registration",
|
||||||
|
"force-upgrade-v2-encryption",
|
||||||
// Mobile Team
|
// Mobile Team
|
||||||
"pm-34171-card-scanner",
|
"pm-34171-card-scanner",
|
||||||
// Platform Team
|
// Platform Team
|
||||||
@@ -1516,34 +1448,11 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
|
|||||||
"pm-32009-new-item-types",
|
"pm-32009-new-item-types",
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Clone, Copy, Serialize)]
|
|
||||||
pub enum ConfigFileStatus {
|
|
||||||
NotFound,
|
|
||||||
Loaded,
|
|
||||||
Error,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub async fn load() -> Result<Self, Error> {
|
pub async fn load() -> Result<Self, Error> {
|
||||||
// Loading from env and file
|
// Loading from env and file
|
||||||
let env = ConfigBuilder::from_env();
|
let env = ConfigBuilder::from_env();
|
||||||
let (usr, config_file_status) = match ConfigBuilder::from_file().await {
|
let usr = ConfigBuilder::from_file().await.unwrap_or_default();
|
||||||
Ok(Some(usr)) => (usr, ConfigFileStatus::Loaded),
|
|
||||||
Ok(None) => (ConfigBuilder::default(), ConfigFileStatus::NotFound),
|
|
||||||
Err(e) => {
|
|
||||||
println!(
|
|
||||||
"[ERROR] Failed to load configuration from `{}`\n\
|
|
||||||
#####################################################################################\n\
|
|
||||||
# The file seems to exist but might contain invalid JSON or invalid values. #\n\
|
|
||||||
# Check the syntax and make sure every value is valid and within its allowed range. #\n\
|
|
||||||
# This file is not used until those errors are resolved. #\n\
|
|
||||||
#####################################################################################\n\
|
|
||||||
{e:?}\n",
|
|
||||||
*CONFIG_FILE
|
|
||||||
);
|
|
||||||
(ConfigBuilder::default(), ConfigFileStatus::Error)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create merged config, config file overwrites env
|
// Create merged config, config file overwrites env
|
||||||
let mut overrides = Vec::new();
|
let mut overrides = Vec::new();
|
||||||
@@ -1563,7 +1472,6 @@ impl Config {
|
|||||||
_env: env,
|
_env: env,
|
||||||
_usr: usr,
|
_usr: usr,
|
||||||
_overrides: overrides,
|
_overrides: overrides,
|
||||||
_config_file_status: config_file_status,
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1597,7 +1505,6 @@ impl Config {
|
|||||||
writer.config = config;
|
writer.config = config;
|
||||||
writer._usr = builder;
|
writer._usr = builder;
|
||||||
writer._overrides = overrides;
|
writer._overrides = overrides;
|
||||||
writer._config_file_status = ConfigFileStatus::Loaded;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//Save to file
|
//Save to file
|
||||||
@@ -1616,7 +1523,7 @@ impl Config {
|
|||||||
pub fn is_email_domain_allowed(&self, email: &str) -> bool {
|
pub fn is_email_domain_allowed(&self, email: &str) -> bool {
|
||||||
let e: Vec<&str> = email.rsplitn(2, '@').collect();
|
let e: Vec<&str> = email.rsplitn(2, '@').collect();
|
||||||
if e.len() != 2 || e[0].is_empty() || e[1].is_empty() {
|
if e.len() != 2 || e[0].is_empty() || e[1].is_empty() {
|
||||||
warn!("Failed to parse email address '{}'", email.escape_debug());
|
warn!("Failed to parse email address '{email}'");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let email_domain = e[0].to_lowercase();
|
let email_domain = e[0].to_lowercase();
|
||||||
@@ -1689,7 +1596,6 @@ impl Config {
|
|||||||
writer.config = config;
|
writer.config = config;
|
||||||
writer._usr = usr;
|
writer._usr = usr;
|
||||||
writer._overrides = Vec::new();
|
writer._overrides = Vec::new();
|
||||||
writer._config_file_status = ConfigFileStatus::NotFound;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate CSS Cache because several config items might have impact on the rendered CSS
|
// Invalidate CSS Cache because several config items might have impact on the rendered CSS
|
||||||
|
|||||||
+3
-3
@@ -212,8 +212,8 @@ impl DbPool {
|
|||||||
let pool = Pool::builder()
|
let pool = Pool::builder()
|
||||||
.max_size(max_conns)
|
.max_size(max_conns)
|
||||||
.min_idle(Some(CONFIG.database_min_conns()))
|
.min_idle(Some(CONFIG.database_min_conns()))
|
||||||
.idle_timeout(Some(Duration::from_secs(u64::from(CONFIG.database_idle_timeout()))))
|
.idle_timeout(Some(Duration::from_secs(CONFIG.database_idle_timeout())))
|
||||||
.connection_timeout(Duration::from_secs(u64::from(CONFIG.database_timeout())))
|
.connection_timeout(Duration::from_secs(CONFIG.database_timeout()))
|
||||||
.connection_customizer(Box::new(DbConnOptions {
|
.connection_customizer(Box::new(DbConnOptions {
|
||||||
init_stmts: conn_type.get_init_stmts(),
|
init_stmts: conn_type.get_init_stmts(),
|
||||||
}))
|
}))
|
||||||
@@ -233,7 +233,7 @@ impl DbPool {
|
|||||||
|
|
||||||
// Get a connection from the pool
|
// Get a connection from the pool
|
||||||
pub async fn get(&self) -> Result<DbConn, Error> {
|
pub async fn get(&self) -> Result<DbConn, Error> {
|
||||||
let duration = Duration::from_secs(u64::from(CONFIG.database_timeout()));
|
let duration = Duration::from_secs(CONFIG.database_timeout());
|
||||||
let permit = match timeout(duration, Arc::clone(&self.semaphore).acquire_owned()).await {
|
let permit = match timeout(duration, Arc::clone(&self.semaphore).acquire_owned()).await {
|
||||||
Ok(p) => p.expect("Semaphore should be open"),
|
Ok(p) => p.expect("Semaphore should be open"),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
|
|||||||
@@ -166,15 +166,6 @@ impl AuthRequest {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
|
||||||
conn.run(move |conn| {
|
|
||||||
diesel::delete(auth_requests::table.filter(auth_requests::user_uuid.eq(user_uuid)))
|
|
||||||
.execute(conn)
|
|
||||||
.map_res("Error deleting auth requests for user")
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Marks the request as used to log in. Returns false when it already was, so it only works once.
|
/// 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> {
|
pub async fn set_authentication_date(uuid: &AuthRequestId, conn: &DbConn) -> ApiResult<bool> {
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
@@ -197,7 +188,7 @@ impl AuthRequest {
|
|||||||
pub async fn purge_expired_auth_requests(conn: &DbConn) {
|
pub async fn purge_expired_auth_requests(conn: &DbConn) {
|
||||||
// delete auth requests older than 15 minutes which is functionally equivalent to upstream:
|
// delete auth requests older than 15 minutes which is functionally equivalent to upstream:
|
||||||
// https://github.com/bitwarden/server/blob/f8ee2270409f7a13125cd414c450740af605a175/src/Sql/dbo/Auth/Stored%20Procedures/AuthRequest_DeleteIfExpired.sql
|
// https://github.com/bitwarden/server/blob/f8ee2270409f7a13125cd414c450740af605a175/src/Sql/dbo/Auth/Stored%20Procedures/AuthRequest_DeleteIfExpired.sql
|
||||||
let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::minutes(15);
|
let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::try_minutes(15).unwrap();
|
||||||
for auth_request in Self::find_created_before(&expiry_time, conn).await {
|
for auth_request in Self::find_created_before(&expiry_time, conn).await {
|
||||||
auth_request.delete(conn).await.ok();
|
auth_request.delete(conn).await.ok();
|
||||||
}
|
}
|
||||||
|
|||||||
+109
-86
@@ -19,7 +19,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
error::MapResult,
|
error::MapResult,
|
||||||
util::LowerCase,
|
util::{LowerCase, convert_json_key_lcase_first},
|
||||||
};
|
};
|
||||||
use macros::UuidFromParam;
|
use macros::UuidFromParam;
|
||||||
|
|
||||||
@@ -63,6 +63,18 @@ pub struct Cipher {
|
|||||||
pub reprompt: Option<i32>,
|
pub reprompt: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `data` is a v2 cipher blob, recognized like upstream by a top-level `format_version` key.
|
||||||
|
///
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/Vault/Entities/Cipher.cs#L36-L41>
|
||||||
|
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 {
|
pub enum RepromptType {
|
||||||
None = 0,
|
None = 0,
|
||||||
Password = 1,
|
Password = 1,
|
||||||
@@ -70,7 +82,8 @@ pub enum RepromptType {
|
|||||||
|
|
||||||
/// Local methods
|
/// Local methods
|
||||||
impl Cipher {
|
impl Cipher {
|
||||||
pub fn new(atype: i32, name: String) -> Self {
|
/// The name is set when the data is saved, since a blob-encrypted cipher has it inside `data`
|
||||||
|
pub fn new(atype: i32) -> Self {
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -84,7 +97,7 @@ impl Cipher {
|
|||||||
key: None,
|
key: None,
|
||||||
|
|
||||||
atype,
|
atype,
|
||||||
name,
|
name: String::new(),
|
||||||
|
|
||||||
notes: None,
|
notes: None,
|
||||||
fields: None,
|
fields: None,
|
||||||
@@ -110,6 +123,11 @@ impl Cipher {
|
|||||||
.insert(format!("Ciphers[{index}].Notes"), serde_json::to_value([&max_note_size_msg]).unwrap());
|
.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
|
// 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 {
|
if let Some(Value::Array(password_history)) = &cipher.password_history {
|
||||||
for pwh in password_history {
|
for pwh in password_history {
|
||||||
@@ -154,6 +172,10 @@ impl Cipher {
|
|||||||
) -> Result<Value, crate::Error> {
|
) -> Result<Value, crate::Error> {
|
||||||
use crate::util::{format_date, validate_and_format_date};
|
use crate::util::{format_date, validate_and_format_date};
|
||||||
|
|
||||||
|
// Parsed once, since `data` can be large and this runs for every cipher in a sync
|
||||||
|
let type_data = serde_json::from_str::<Value>(&self.data);
|
||||||
|
let is_blob_encrypted = type_data.as_ref().is_ok_and(is_blob_value);
|
||||||
|
|
||||||
let mut attachments_json: Value = Value::Null;
|
let mut attachments_json: Value = Value::Null;
|
||||||
if let Some(cipher_sync_data) = cipher_sync_data {
|
if let Some(cipher_sync_data) = cipher_sync_data {
|
||||||
if let Some(attachments) = cipher_sync_data.cipher_attachments.get(&self.uuid)
|
if let Some(attachments) = cipher_sync_data.cipher_attachments.get(&self.uuid)
|
||||||
@@ -250,63 +272,9 @@ impl Cipher {
|
|||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Get the type_data or a default to an empty json object '{}'.
|
// Like upstream, fields and password history stored as NULL are sent as null, not `[]`
|
||||||
// If not passing an empty object, mobile clients will crash.
|
let fields_json = self.fields.is_some().then_some(fields_json);
|
||||||
let mut type_data_json = serde_json::from_str::<LowerCase<Value>>(&self.data)
|
let password_history_json = self.password_history.is_some().then_some(password_history_json);
|
||||||
.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 collection_ids = if let Some(cipher_sync_data) = cipher_sync_data {
|
let collection_ids = if let Some(cipher_sync_data) = cipher_sync_data {
|
||||||
if let Some(cipher_collections) = cipher_sync_data.cipher_collections.get(&self.uuid) {
|
if let Some(cipher_collections) = cipher_sync_data.cipher_collections.get(&self.uuid) {
|
||||||
@@ -403,10 +371,84 @@ impl Cipher {
|
|||||||
_ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)),
|
_ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)),
|
||||||
};
|
};
|
||||||
|
|
||||||
json_object[key] = type_data_json;
|
if is_blob_encrypted {
|
||||||
|
// Like upstream, sent as-is with the structured fields null
|
||||||
|
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)
|
Ok(json_object)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The per-type data of a legacy cipher, with fixups for values 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 = if let Ok(data @ Value::Object(_)) = type_data {
|
||||||
|
convert_json_key_lcase_first(data)
|
||||||
|
} else {
|
||||||
|
warn!("Error parsing data field for {}", self.uuid);
|
||||||
|
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> {
|
pub async fn update_users_revision(&self, conn: &DbConn) -> Vec<UserId> {
|
||||||
let mut user_uuids = Vec::new();
|
let mut user_uuids = Vec::new();
|
||||||
match self.user_uuid {
|
match self.user_uuid {
|
||||||
@@ -494,22 +536,12 @@ impl Cipher {
|
|||||||
|
|
||||||
/// Purge all ciphers that are old enough to be auto-deleted.
|
/// Purge all ciphers that are old enough to be auto-deleted.
|
||||||
pub async fn purge_trash(conn: &DbConn) {
|
pub async fn purge_trash(conn: &DbConn) {
|
||||||
let Some(auto_delete_days) = CONFIG.trash_auto_delete_days().filter(|&d| d != 0) else {
|
if let Some(auto_delete_days) = CONFIG.trash_auto_delete_days() {
|
||||||
debug!("trash_auto_delete_days is not configured or set to 0, skipping");
|
let now = Utc::now().naive_utc();
|
||||||
return;
|
let dt = now - TimeDelta::try_days(auto_delete_days).unwrap();
|
||||||
};
|
for cipher in Self::find_deleted_before(&dt, conn).await {
|
||||||
|
cipher.delete(conn).await.ok();
|
||||||
let Some(days) = TimeDelta::try_days(i64::from(auto_delete_days)) else {
|
}
|
||||||
error!("Unable to calculate auto delete days!");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let now = Utc::now().naive_utc();
|
|
||||||
let Some(dt) = now.checked_sub_signed(days) else {
|
|
||||||
error!("Unable to calculate trash purge date!");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
for cipher in Self::find_deleted_before(&dt, conn).await {
|
|
||||||
cipher.delete(conn).await.ok();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,16 +552,7 @@ impl Cipher {
|
|||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
User::update_uuid_revision(user_uuid, conn).await;
|
User::update_uuid_revision(user_uuid, conn).await;
|
||||||
self.move_to_folder_without_revision_update(folder_uuid, user_uuid, conn).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Same as `move_to_folder`, but the caller is responsible for updating the user's revision.
|
|
||||||
pub async fn move_to_folder_without_revision_update(
|
|
||||||
&self,
|
|
||||||
folder_uuid: Option<FolderId>,
|
|
||||||
user_uuid: &UserId,
|
|
||||||
conn: &DbConn,
|
|
||||||
) -> EmptyResult {
|
|
||||||
match (self.get_folder_uuid(user_uuid, conn).await, folder_uuid) {
|
match (self.get_folder_uuid(user_uuid, conn).await, folder_uuid) {
|
||||||
// No changes
|
// No changes
|
||||||
(None, None) => Ok(()),
|
(None, None) => Ok(()),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use chrono::{NaiveDateTime, TimeDelta, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
use derive_more::{AsRef, Deref, Display, From};
|
use derive_more::{AsRef, Deref, Display, From};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -112,26 +112,8 @@ impl EmergencyAccess {
|
|||||||
"object": "emergencyAccessGranteeDetails",
|
"object": "emergencyAccessGranteeDetails",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn deadline_at(&self) -> Result<NaiveDateTime, crate::Error> {
|
|
||||||
if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&self.wait_time_days) {
|
|
||||||
err!("Emergency access wait time is out-of-bounds!")
|
|
||||||
}
|
|
||||||
|
|
||||||
let initiated_at = self.recovery_initiated_at.map_res("Emergency access initiation date missing!")?;
|
|
||||||
|
|
||||||
TimeDelta::try_days(i64::from(self.wait_time_days))
|
|
||||||
.and_then(|w| initiated_at.checked_add_signed(w))
|
|
||||||
.map_res("Emergency access deadline is invalid")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_recovery_allowed(&self, now: &NaiveDateTime) -> Result<bool, crate::Error> {
|
|
||||||
Ok(self.deadline_at()?.le(now))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const EMERGENCY_ACCESS_TIMEOUT_RANGE: std::ops::RangeInclusive<i32> = 1..=90;
|
|
||||||
|
|
||||||
#[derive(Copy, Clone)]
|
#[derive(Copy, Clone)]
|
||||||
pub enum EmergencyAccessType {
|
pub enum EmergencyAccessType {
|
||||||
View = 0,
|
View = 0,
|
||||||
@@ -187,42 +169,27 @@ impl EmergencyAccess {
|
|||||||
|
|
||||||
pub async fn update_access_status_and_save(
|
pub async fn update_access_status_and_save(
|
||||||
&mut self,
|
&mut self,
|
||||||
from_status: i32,
|
status: i32,
|
||||||
to_status: i32,
|
|
||||||
date: &NaiveDateTime,
|
date: &NaiveDateTime,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
let uuid = self.uuid.clone();
|
|
||||||
let updated_at = *date;
|
|
||||||
let res = conn
|
|
||||||
.run(move |conn| {
|
|
||||||
crate::util::retry(
|
|
||||||
|| {
|
|
||||||
diesel::update(
|
|
||||||
emergency_access::table
|
|
||||||
.filter(emergency_access::uuid.eq(&uuid))
|
|
||||||
.filter(emergency_access::status.eq(from_status)),
|
|
||||||
)
|
|
||||||
.set((emergency_access::status.eq(to_status), emergency_access::updated_at.eq(updated_at)))
|
|
||||||
.execute(conn)
|
|
||||||
},
|
|
||||||
10,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match res {
|
|
||||||
Ok(1) => {} // One record updated, which is valid
|
|
||||||
Ok(_) => err!("Emergency access status was changed already"),
|
|
||||||
Err(e) => err!("Error updating emergency access status", format!("{e}")),
|
|
||||||
}
|
|
||||||
|
|
||||||
self.status = to_status;
|
|
||||||
self.updated_at = updated_at;
|
|
||||||
|
|
||||||
// Update the grantee so that it will refresh it's status.
|
// Update the grantee so that it will refresh it's status.
|
||||||
User::update_uuid_revision(self.grantee_uuid.as_ref().expect("Error getting grantee"), conn).await;
|
User::update_uuid_revision(self.grantee_uuid.as_ref().expect("Error getting grantee"), conn).await;
|
||||||
Ok(())
|
self.status = status;
|
||||||
|
date.clone_into(&mut self.updated_at);
|
||||||
|
|
||||||
|
conn.run(move |conn| {
|
||||||
|
crate::util::retry(
|
||||||
|
|| {
|
||||||
|
diesel::update(emergency_access::table.filter(emergency_access::uuid.eq(&self.uuid)))
|
||||||
|
.set((emergency_access::status.eq(status), emergency_access::updated_at.eq(date)))
|
||||||
|
.execute(conn)
|
||||||
|
},
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
.map_res("Error updating emergency access status")
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_last_notification_date_and_save(&mut self, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult {
|
pub async fn update_last_notification_date_and_save(&mut self, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult {
|
||||||
|
|||||||
+13
-20
@@ -79,7 +79,6 @@ pub enum EventType {
|
|||||||
CipherSoftDeleted = 1115,
|
CipherSoftDeleted = 1115,
|
||||||
CipherRestored = 1116,
|
CipherRestored = 1116,
|
||||||
CipherClientToggledCardNumberVisible = 1117,
|
CipherClientToggledCardNumberVisible = 1117,
|
||||||
CipherClientHttpAuthReleased = 1133,
|
|
||||||
|
|
||||||
// Collection
|
// Collection
|
||||||
CollectionCreated = 1300,
|
CollectionCreated = 1300,
|
||||||
@@ -112,8 +111,8 @@ pub enum EventType {
|
|||||||
// OrganizationUserAutomaticallyConfirmed = 1517,
|
// OrganizationUserAutomaticallyConfirmed = 1517,
|
||||||
// OrganizationUserSelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy
|
// OrganizationUserSelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy
|
||||||
OrganizationUserAdminResetTwoFactor = 1519,
|
OrganizationUserAdminResetTwoFactor = 1519,
|
||||||
OrganizationUserRevokedTwoFactorNonCompliance = 1520,
|
// OrganizationUserRevoked_TwoFactorNonCompliance = 1520,
|
||||||
OrganizationUserRevokedSingleOrganizationNonCompliance = 1521,
|
// OrganizationUserRevoked_SingleOrganizationNonCompliance = 1521,
|
||||||
OrganizationUserNotificationBannerActionClicked = 1522,
|
OrganizationUserNotificationBannerActionClicked = 1522,
|
||||||
|
|
||||||
// Organization
|
// Organization
|
||||||
@@ -348,23 +347,17 @@ impl Event {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn clean_events(conn: &DbConn) -> EmptyResult {
|
pub async fn clean_events(conn: &DbConn) -> EmptyResult {
|
||||||
let Some(days_to_retain) = CONFIG.events_days_retain().filter(|&d| d != 0) else {
|
if let Some(days_to_retain) = CONFIG.events_days_retain() {
|
||||||
return Ok(());
|
let dt = Utc::now().naive_utc() - TimeDelta::try_days(days_to_retain).unwrap();
|
||||||
};
|
conn.run(move |conn| {
|
||||||
|
diesel::delete(event::table.filter(event::event_date.lt(dt)))
|
||||||
let Some(days_to_retain) = TimeDelta::try_days(i64::from(days_to_retain)) else {
|
.execute(conn)
|
||||||
err!("Unable to calculate the event retention date, check 'EVENTS_DAYS_RETAIN'")
|
.map_res("Error cleaning old events")
|
||||||
};
|
})
|
||||||
|
.await
|
||||||
let Some(dt) = Utc::now().naive_utc().checked_sub_signed(days_to_retain) else {
|
} else {
|
||||||
err!("Unable to calculate the event retention date, check 'EVENTS_DAYS_RETAIN'")
|
Ok(())
|
||||||
};
|
}
|
||||||
conn.run(move |conn| {
|
|
||||||
diesel::delete(event::table.filter(event::event_date.lt(dt)))
|
|
||||||
.execute(conn)
|
|
||||||
.map_res("Error cleaning old events")
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -137,15 +137,15 @@ impl CollectionGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_json_details_for_group(&self) -> Value {
|
pub fn to_json_details_for_group(&self) -> Value {
|
||||||
// Return the stored manage flag. Deriving it from !read_only && !hide_passwords turned the group
|
// If both read_only and hide_passwords are false, then manage should be true
|
||||||
// permission "Can edit items" (readOnly=false, hidePasswords=false, manage=false) into "Can manage"
|
// You can't have an entry with read_only and manage, or hide_passwords and manage
|
||||||
// in GET /organizations/<org>/collections/details, while GET /groups/<id>/details reported the stored
|
// Or an entry with everything to false
|
||||||
// value. Saving the collection access dialog then persisted manage=true.
|
// For backwards compatibility and migration proposes we keep checking read_only and hide_password
|
||||||
json!({
|
json!({
|
||||||
"id": self.groups_uuid,
|
"id": self.groups_uuid,
|
||||||
"readOnly": self.read_only,
|
"readOnly": self.read_only,
|
||||||
"hidePasswords": self.hide_passwords,
|
"hidePasswords": self.hide_passwords,
|
||||||
"manage": self.manage,
|
"manage": self.manage || (!self.read_only && !self.hide_passwords),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,21 +17,20 @@ mod two_factor;
|
|||||||
mod two_factor_duo_context;
|
mod two_factor_duo_context;
|
||||||
mod two_factor_incomplete;
|
mod two_factor_incomplete;
|
||||||
mod user;
|
mod user;
|
||||||
|
mod user_signature_key_pair;
|
||||||
|
|
||||||
pub use self::archive::Archive;
|
pub use self::archive::Archive;
|
||||||
pub use self::attachment::{Attachment, AttachmentId};
|
pub use self::attachment::{Attachment, AttachmentId};
|
||||||
pub use self::auth_request::{AuthRequest, AuthRequestId};
|
pub use self::auth_request::{AuthRequest, AuthRequestId};
|
||||||
pub use self::cipher::{Cipher, CipherId, RepromptType};
|
pub use self::cipher::{Cipher, CipherId, RepromptType, is_data_blob_encrypted};
|
||||||
pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser};
|
pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser};
|
||||||
pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId};
|
pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId};
|
||||||
pub use self::emergency_access::{
|
pub use self::emergency_access::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType};
|
||||||
EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType,
|
|
||||||
};
|
|
||||||
pub use self::event::{Event, EventType};
|
pub use self::event::{Event, EventType};
|
||||||
pub use self::favorite::Favorite;
|
pub use self::favorite::Favorite;
|
||||||
pub use self::folder::{Folder, FolderCipher, FolderId};
|
pub use self::folder::{Folder, FolderCipher, FolderId};
|
||||||
pub use self::group::{CollectionGroup, Group, GroupId, GroupUser};
|
pub use self::group::{CollectionGroup, Group, GroupId, GroupUser};
|
||||||
pub use self::org_policy::{FillAssistPolicyData, OrgPolicy, OrgPolicyId, OrgPolicyType};
|
pub use self::org_policy::{OrgPolicy, OrgPolicyId, OrgPolicyType};
|
||||||
pub use self::organization::{
|
pub use self::organization::{
|
||||||
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey,
|
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey,
|
||||||
OrganizationId,
|
OrganizationId,
|
||||||
@@ -41,4 +40,5 @@ pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth};
|
|||||||
pub use self::two_factor::{TwoFactor, TwoFactorType};
|
pub use self::two_factor::{TwoFactor, TwoFactorType};
|
||||||
pub use self::two_factor_duo_context::TwoFactorDuoContext;
|
pub use self::two_factor_duo_context::TwoFactorDuoContext;
|
||||||
pub use self::two_factor_incomplete::TwoFactorIncomplete;
|
pub use self::two_factor_incomplete::TwoFactorIncomplete;
|
||||||
pub use self::user::{Invitation, KeyId, SsoUser, User, UserId, UserKdfType, UserStampException};
|
pub use self::user::{Invitation, KeyId, SsoUser, User, UserId, UserKdfType};
|
||||||
|
pub use self::user_signature_key_pair::{SignatureAlgorithm, UserSignatureKeyPair};
|
||||||
@@ -53,8 +53,6 @@ pub enum OrgPolicyType {
|
|||||||
// AutoConfirm = 18, // Not supported (not implemented yet)
|
// AutoConfirm = 18, // Not supported (not implemented yet)
|
||||||
// BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed)
|
// BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed)
|
||||||
OrganizationUserNotification = 20,
|
OrganizationUserNotification = 20,
|
||||||
// SendControls = 21, // Not supported yet
|
|
||||||
FillAssist = 22,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendOptionsPolicyData.cs#L5
|
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendOptionsPolicyData.cs#L5
|
||||||
@@ -73,14 +71,6 @@ pub struct ResetPasswordDataModel {
|
|||||||
pub auto_enroll_enabled: bool,
|
pub auto_enroll_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/bitwarden/server/blob/13bc813de8e715a71d3410f1e584e87ccdf76025/src/Core/AdminConsole/Models/Data/Organizations/Policies/FillAssistPolicyData.cs
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct FillAssistPolicyData {
|
|
||||||
#[serde(alias = "RulesUrl")]
|
|
||||||
pub rules_url: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Local methods
|
/// Local methods
|
||||||
impl OrgPolicy {
|
impl OrgPolicy {
|
||||||
pub fn new(org_uuid: OrganizationId, atype: OrgPolicyType, enabled: bool, data: String) -> Self {
|
pub fn new(org_uuid: OrganizationId, atype: OrgPolicyType, enabled: bool, data: String) -> Self {
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ pub struct Membership {
|
|||||||
pub atype: i32,
|
pub atype: i32,
|
||||||
pub reset_password_key: Option<String>,
|
pub reset_password_key: Option<String>,
|
||||||
pub external_id: Option<String>,
|
pub external_id: Option<String>,
|
||||||
|
/// The user's v2 upgrade token, for the org to unwrap the v2 user key after an upgrade. Opaque to us.
|
||||||
|
pub v2_upgrade_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
||||||
@@ -271,6 +273,7 @@ impl Membership {
|
|||||||
atype: MembershipType::User as i32,
|
atype: MembershipType::User as i32,
|
||||||
reset_password_key: None,
|
reset_password_key: None,
|
||||||
external_id: None,
|
external_id: None,
|
||||||
|
v2_upgrade_token: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,8 +526,7 @@ impl Membership {
|
|||||||
|
|
||||||
// These are per user
|
// These are per user
|
||||||
"userId": self.user_uuid,
|
"userId": self.user_uuid,
|
||||||
// Accepted (not yet confirmed) members have no key yet; send null like upstream, not ""
|
"key": self.akey,
|
||||||
"key": (!self.akey.is_empty()).then_some(&self.akey),
|
|
||||||
"status": self.status,
|
"status": self.status,
|
||||||
"type": membership_type,
|
"type": membership_type,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
@@ -334,6 +334,7 @@ impl Send {
|
|||||||
pub async fn size_by_user(user_uuid: &UserId, conn: &DbConn) -> Option<i64> {
|
pub async fn size_by_user(user_uuid: &UserId, conn: &DbConn) -> Option<i64> {
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct FileData {
|
struct FileData {
|
||||||
|
#[serde(rename = "size", alias = "Size")]
|
||||||
size: NumberOrString,
|
size: NumberOrString,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ impl TwoFactorIncomplete {
|
|||||||
ip: &ClientIp,
|
ip: &ClientIp,
|
||||||
conn: &DbConn,
|
conn: &DbConn,
|
||||||
) -> EmptyResult {
|
) -> EmptyResult {
|
||||||
if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() {
|
if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ impl TwoFactorIncomplete {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn mark_complete(user_uuid: &UserId, device_uuid: &DeviceId, conn: &DbConn) -> EmptyResult {
|
pub async fn mark_complete(user_uuid: &UserId, device_uuid: &DeviceId, conn: &DbConn) -> EmptyResult {
|
||||||
if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() {
|
if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+124
-18
@@ -6,6 +6,7 @@ use serde_json::Value;
|
|||||||
use crate::{
|
use crate::{
|
||||||
CONFIG,
|
CONFIG,
|
||||||
api::EmptyResult,
|
api::EmptyResult,
|
||||||
|
auth::DEFAULT_ACCESS_VALIDITY,
|
||||||
crypto,
|
crypto,
|
||||||
db::{
|
db::{
|
||||||
DbConn,
|
DbConn,
|
||||||
@@ -19,8 +20,8 @@ use crate::{
|
|||||||
use macros::UuidFromParam;
|
use macros::UuidFromParam;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
AuthRequest, Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor,
|
Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, TwoFactorIncomplete,
|
||||||
TwoFactorIncomplete,
|
UserSignatureKeyPair,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
|
||||||
@@ -72,6 +73,13 @@ pub struct User {
|
|||||||
pub external_id: Option<String>, // Todo: Needs to be removed in the future, this is not used anymore.
|
pub external_id: Option<String>, // Todo: Needs to be removed in the future, this is not used anymore.
|
||||||
|
|
||||||
pub key_id: Option<KeyId>,
|
pub key_id: Option<KeyId>,
|
||||||
|
|
||||||
|
// The v2 state: all set, with a `user_signature_key_pairs` row, or none, see `User::is_v2`
|
||||||
|
pub signed_public_key: Option<String>,
|
||||||
|
pub security_state: Option<String>,
|
||||||
|
pub security_version: Option<i32>,
|
||||||
|
/// JSON `{"wrappedUserKey1", "wrappedUserKey2"}` for sessions that still hold the v1 user key. Opaque to us.
|
||||||
|
pub v2_upgrade_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Identifiable, Queryable, Insertable)]
|
#[derive(Identifiable, Queryable, Insertable)]
|
||||||
@@ -100,13 +108,28 @@ enum UserStatus {
|
|||||||
_Disabled = 2,
|
_Disabled = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A previous security stamp that is still accepted, until `expire`.
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct UserStampException {
|
pub struct UserStampException {
|
||||||
pub routes: Vec<String>,
|
/// The routes the stamp is still accepted on, or `None` for any route.
|
||||||
|
pub routes: Option<Vec<String>>,
|
||||||
pub security_stamp: String,
|
pub security_stamp: String,
|
||||||
pub expire: i64,
|
pub expire: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl UserStampException {
|
||||||
|
pub fn is_expired(&self) -> bool {
|
||||||
|
Utc::now().timestamp() > self.expire
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this exception lets a token carrying `security_stamp` through on `route`.
|
||||||
|
pub fn allows(&self, security_stamp: &str, route: &str) -> bool {
|
||||||
|
!self.is_expired()
|
||||||
|
&& self.security_stamp == security_stamp
|
||||||
|
&& self.routes.as_ref().is_none_or(|routes| routes.iter().any(|r| r == route))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Local methods
|
/// Local methods
|
||||||
impl User {
|
impl User {
|
||||||
pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32;
|
pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32;
|
||||||
@@ -159,9 +182,18 @@ impl User {
|
|||||||
external_id: None, // Todo: Needs to be removed in the future, this is not used anymore.
|
external_id: None, // Todo: Needs to be removed in the future, this is not used anymore.
|
||||||
|
|
||||||
key_id: None,
|
key_id: None,
|
||||||
|
|
||||||
|
signed_public_key: None,
|
||||||
|
security_state: None,
|
||||||
|
security_version: None,
|
||||||
|
v2_upgrade_token: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_v2(&self) -> bool {
|
||||||
|
self.signed_public_key.is_some() && self.security_state.is_some() && self.security_version.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn check_valid_password(&self, password: &str) -> bool {
|
pub fn check_valid_password(&self, password: &str) -> bool {
|
||||||
crypto::verify_password_hash(
|
crypto::verify_password_hash(
|
||||||
password.as_bytes(),
|
password.as_bytes(),
|
||||||
@@ -171,6 +203,7 @@ impl User {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Upstream's `GetMasterPasswordSalt`: there is no separate salt, so the normalized email
|
||||||
pub fn master_password_salt(&self) -> String {
|
pub fn master_password_salt(&self) -> String {
|
||||||
self.email.trim().to_lowercase()
|
self.email.trim().to_lowercase()
|
||||||
}
|
}
|
||||||
@@ -225,10 +258,33 @@ impl User {
|
|||||||
|
|
||||||
pub async fn reset_security_stamp(&mut self, conn: &DbConn) -> EmptyResult {
|
pub async fn reset_security_stamp(&mut self, conn: &DbConn) -> EmptyResult {
|
||||||
self.security_stamp = get_uuid();
|
self.security_stamp = get_uuid();
|
||||||
|
// Keeps the unexpired route exceptions, set for the stamp being replaced, and drops a rotation's grace
|
||||||
|
let mut exceptions = self.stamp_exceptions();
|
||||||
|
exceptions.retain(|e| e.routes.is_some() && !e.is_expired());
|
||||||
|
self.set_stamp_exceptions(&exceptions);
|
||||||
Device::rotate_refresh_tokens_by_user(&self.uuid, conn).await?;
|
Device::rotate_refresh_tokens_by_user(&self.uuid, conn).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resets the stamp but lets issued access tokens run out, as upstream only checks it on refresh.
|
||||||
|
pub async fn reset_security_stamp_after_key_rotation(&mut self, conn: &DbConn) -> EmptyResult {
|
||||||
|
let previous_stamp = self.security_stamp.clone();
|
||||||
|
let mut grace: Vec<UserStampException> =
|
||||||
|
self.stamp_exceptions().into_iter().filter(|e| e.routes.is_none() && !e.is_expired()).collect();
|
||||||
|
|
||||||
|
self.reset_security_stamp(conn).await?;
|
||||||
|
|
||||||
|
grace.push(UserStampException {
|
||||||
|
routes: None,
|
||||||
|
security_stamp: previous_stamp,
|
||||||
|
expire: (Utc::now() + *DEFAULT_ACCESS_VALIDITY).timestamp(),
|
||||||
|
});
|
||||||
|
let mut exceptions = self.stamp_exceptions();
|
||||||
|
exceptions.extend(grace);
|
||||||
|
self.set_stamp_exceptions(&exceptions);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Set the stamp_exception to only allow a subsequent request matching a specific route using the current security-stamp.
|
/// Set the stamp_exception to only allow a subsequent request matching a specific route using the current security-stamp.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
@@ -237,17 +293,30 @@ impl User {
|
|||||||
/// After these 2 minutes this stamp will expire.
|
/// After these 2 minutes this stamp will expire.
|
||||||
///
|
///
|
||||||
pub fn set_stamp_exception(&mut self, route_exception: Vec<String>) {
|
pub fn set_stamp_exception(&mut self, route_exception: Vec<String>) {
|
||||||
let stamp_exception = UserStampException {
|
self.set_stamp_exceptions(&[UserStampException {
|
||||||
routes: route_exception,
|
routes: Some(route_exception),
|
||||||
security_stamp: self.security_stamp.clone(),
|
security_stamp: self.security_stamp.clone(),
|
||||||
expire: (Utc::now() + TimeDelta::try_minutes(2).unwrap()).timestamp(),
|
expire: (Utc::now() + TimeDelta::try_minutes(2).unwrap()).timestamp(),
|
||||||
};
|
}]);
|
||||||
self.stamp_exception = Some(serde_json::to_string(&stamp_exception).unwrap_or_default());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resets the stamp_exception to prevent re-use of the previous security-stamp
|
/// The previous security stamps that are still accepted, expired ones included.
|
||||||
pub fn reset_stamp_exception(&mut self) {
|
pub fn stamp_exceptions(&self) -> Vec<UserStampException> {
|
||||||
self.stamp_exception = None;
|
let Some(stored) = self.stamp_exception.as_deref() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
// Before key rotations kept sessions alive, only a single route exception was stored.
|
||||||
|
serde_json::from_str::<Vec<UserStampException>>(stored)
|
||||||
|
.or_else(|_| serde_json::from_str::<UserStampException>(stored).map(|e| vec![e]))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_stamp_exceptions(&mut self, exceptions: &[UserStampException]) {
|
||||||
|
self.stamp_exception = if exceptions.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(serde_json::to_string(&exceptions).unwrap_or_default())
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn display_name(&self) -> &str {
|
pub fn display_name(&self) -> &str {
|
||||||
@@ -266,18 +335,37 @@ impl User {
|
|||||||
!CONFIG.mail_enabled() || self.verified_at.is_some()
|
!CONFIG.mail_enabled() || self.verified_at.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn v2_signature_key_pair(&self, conn: &DbConn) -> Option<UserSignatureKeyPair> {
|
||||||
|
if !self.is_v2() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
UserSignatureKeyPair::find_by_user(&self.uuid, conn).await
|
||||||
|
}
|
||||||
|
|
||||||
/// The `accountKeys` object (upstream's `PrivateKeysResponseModel`), null without a key pair
|
/// The `accountKeys` object (upstream's `PrivateKeysResponseModel`), null without a key pair
|
||||||
pub fn account_keys_json(&self) -> Value {
|
pub async fn account_keys_json(&self, conn: &DbConn) -> Value {
|
||||||
if self.private_key.is_some() {
|
if self.private_key.is_some() {
|
||||||
|
let (signed_public_key, signature_key_pair, security_state) = match self.v2_signature_key_pair(conn).await {
|
||||||
|
Some(key_pair) => (
|
||||||
|
json!(self.signed_public_key),
|
||||||
|
key_pair.to_json(),
|
||||||
|
json!({
|
||||||
|
"securityState": self.security_state,
|
||||||
|
"securityVersion": self.security_version,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
None => (Value::Null, Value::Null, Value::Null),
|
||||||
|
};
|
||||||
|
|
||||||
json!({
|
json!({
|
||||||
"publicKeyEncryptionKeyPair": {
|
"publicKeyEncryptionKeyPair": {
|
||||||
"wrappedPrivateKey": self.private_key,
|
"wrappedPrivateKey": self.private_key,
|
||||||
"publicKey": self.public_key,
|
"publicKey": self.public_key,
|
||||||
"signedPublicKey": null,
|
"signedPublicKey": signed_public_key,
|
||||||
"object": "publicKeyEncryptionKeyPair",
|
"object": "publicKeyEncryptionKeyPair",
|
||||||
},
|
},
|
||||||
"securityState": null,
|
"securityState": security_state,
|
||||||
"signatureKeyPair": null,
|
"signatureKeyPair": signature_key_pair,
|
||||||
"object": "privateKeys"
|
"object": "privateKeys"
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -285,6 +373,24 @@ impl User {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn public_keys_json(&self, conn: &DbConn) -> Value {
|
||||||
|
let (signed_public_key, verifying_key) = match self.v2_signature_key_pair(conn).await {
|
||||||
|
Some(key_pair) => (json!(self.signed_public_key), json!(key_pair.verifying_key)),
|
||||||
|
None => (Value::Null, Value::Null),
|
||||||
|
};
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"publicKey": self.public_key,
|
||||||
|
"signedPublicKey": signed_public_key,
|
||||||
|
"verifyingKey": verifying_key,
|
||||||
|
"object": "publicKeys"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn v2_upgrade_token_json(&self) -> Option<Value> {
|
||||||
|
self.v2_upgrade_token.as_ref().and_then(|token| serde_json::from_str(token).ok())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn to_json(&self, conn: &DbConn) -> Value {
|
pub async fn to_json(&self, conn: &DbConn) -> Value {
|
||||||
let mut orgs_json = Vec::new();
|
let mut orgs_json = Vec::new();
|
||||||
for c in Membership::find_confirmed_by_user(&self.uuid, conn).await {
|
for c in Membership::find_confirmed_by_user(&self.uuid, conn).await {
|
||||||
@@ -305,7 +411,7 @@ impl User {
|
|||||||
UserStatus::Enabled
|
UserStatus::Enabled
|
||||||
};
|
};
|
||||||
|
|
||||||
let account_keys = self.account_keys_json();
|
let account_keys = self.account_keys_json(conn).await;
|
||||||
|
|
||||||
json!({
|
json!({
|
||||||
"_status": status as i32,
|
"_status": status as i32,
|
||||||
@@ -362,7 +468,7 @@ impl User {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
|
pub async fn delete(self, conn: &DbConn) -> EmptyResult {
|
||||||
for member in Membership::find_confirmed_by_user(&self.uuid, conn).await {
|
for member in Membership::find_confirmed_by_user(&self.uuid, conn).await {
|
||||||
if member.atype == MembershipType::Owner
|
if member.atype == MembershipType::Owner
|
||||||
&& Membership::count_confirmed_by_org_and_type(&member.org_uuid, MembershipType::Owner, conn).await <= 1
|
&& Membership::count_confirmed_by_org_and_type(&member.org_uuid, MembershipType::Owner, conn).await <= 1
|
||||||
@@ -378,14 +484,14 @@ impl User {
|
|||||||
Cipher::delete_all_by_user(&self.uuid, conn).await?;
|
Cipher::delete_all_by_user(&self.uuid, conn).await?;
|
||||||
Favorite::delete_all_by_user(&self.uuid, conn).await?;
|
Favorite::delete_all_by_user(&self.uuid, conn).await?;
|
||||||
Folder::delete_all_by_user(&self.uuid, conn).await?;
|
Folder::delete_all_by_user(&self.uuid, conn).await?;
|
||||||
AuthRequest::delete_all_by_user(&self.uuid, conn).await?;
|
|
||||||
Device::delete_all_by_user(&self.uuid, conn).await?;
|
Device::delete_all_by_user(&self.uuid, conn).await?;
|
||||||
TwoFactor::delete_all_by_user(&self.uuid, conn).await?;
|
TwoFactor::delete_all_by_user(&self.uuid, conn).await?;
|
||||||
TwoFactorIncomplete::delete_all_by_user(&self.uuid, conn).await?;
|
TwoFactorIncomplete::delete_all_by_user(&self.uuid, conn).await?;
|
||||||
|
UserSignatureKeyPair::delete_all_by_user(&self.uuid, conn).await?;
|
||||||
Invitation::take(&self.email, conn).await; // Delete invitation if any
|
Invitation::take(&self.email, conn).await; // Delete invitation if any
|
||||||
|
|
||||||
conn.run(move |conn| {
|
conn.run(move |conn| {
|
||||||
diesel::delete(users::table.filter(users::uuid.eq(&self.uuid))).execute(conn).map_res("Error deleting user")
|
diesel::delete(users::table.filter(users::uuid.eq(self.uuid))).execute(conn).map_res("Error deleting user")
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
use chrono::{NaiveDateTime, Utc};
|
||||||
|
use derive_more::{AsRef, Deref, Display, From};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::EmptyResult,
|
||||||
|
db::{DbConn, schema::user_signature_key_pairs},
|
||||||
|
error::MapResult,
|
||||||
|
util::get_uuid,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::UserId;
|
||||||
|
|
||||||
|
/// A user's signature key pair, part of the v2 state, in its own table like upstream.
|
||||||
|
///
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Sql/dbo/KeyManagement/Tables/UserSignatureKeyPair.sql#L1-L15>
|
||||||
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
|
||||||
|
#[diesel(table_name = user_signature_key_pairs)]
|
||||||
|
#[diesel(treat_none_as_null = true)]
|
||||||
|
#[diesel(primary_key(uuid))]
|
||||||
|
pub struct UserSignatureKeyPair {
|
||||||
|
pub uuid: UserSignatureKeyPairId,
|
||||||
|
pub user_uuid: UserId,
|
||||||
|
|
||||||
|
pub signature_algorithm: i32,
|
||||||
|
/// The signing (private) key, wrapped by the user key.
|
||||||
|
pub signing_key: String,
|
||||||
|
/// The COSE-encoded public verifying key.
|
||||||
|
pub verifying_key: String,
|
||||||
|
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub updated_at: NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ref: <https://github.com/bitwarden/server/blob/9030c42bf7d8f9ac2ff9fee85c39588d5eb81499/src/Core/KeyManagement/Enums/SignatureAlgorithm.cs#L6-L10>
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum SignatureAlgorithm {
|
||||||
|
Ed25519 = 0,
|
||||||
|
MlDsa44 = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignatureAlgorithm {
|
||||||
|
pub fn parse(algorithm: &str) -> Option<Self> {
|
||||||
|
match algorithm {
|
||||||
|
"ed25519" => Some(Self::Ed25519),
|
||||||
|
"mldsa44" => Some(Self::MlDsa44),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local methods
|
||||||
|
impl UserSignatureKeyPair {
|
||||||
|
pub fn new(
|
||||||
|
user_uuid: UserId,
|
||||||
|
signature_algorithm: SignatureAlgorithm,
|
||||||
|
signing_key: String,
|
||||||
|
verifying_key: String,
|
||||||
|
) -> Self {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
uuid: UserSignatureKeyPairId(get_uuid()),
|
||||||
|
user_uuid,
|
||||||
|
signature_algorithm: signature_algorithm as i32,
|
||||||
|
signing_key,
|
||||||
|
verifying_key,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_json(&self) -> Value {
|
||||||
|
json!({
|
||||||
|
"wrappedSigningKey": self.signing_key,
|
||||||
|
"verifyingKey": self.verifying_key,
|
||||||
|
"object": "signatureKeyPair",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Database methods
|
||||||
|
impl UserSignatureKeyPair {
|
||||||
|
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
|
||||||
|
self.updated_at = Utc::now().naive_utc();
|
||||||
|
|
||||||
|
db_run! { conn:
|
||||||
|
mysql {
|
||||||
|
diesel::insert_into(user_signature_key_pairs::table)
|
||||||
|
.values(&*self)
|
||||||
|
.on_conflict(diesel::dsl::DuplicatedKeys)
|
||||||
|
.do_update()
|
||||||
|
.set(&*self)
|
||||||
|
.execute(conn)
|
||||||
|
.map_res("Error saving user signature key pair")
|
||||||
|
}
|
||||||
|
postgresql, sqlite {
|
||||||
|
diesel::insert_into(user_signature_key_pairs::table)
|
||||||
|
.values(&*self)
|
||||||
|
.on_conflict(user_signature_key_pairs::user_uuid)
|
||||||
|
.do_update()
|
||||||
|
.set(&*self)
|
||||||
|
.execute(conn)
|
||||||
|
.map_res("Error saving user signature key pair")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user's key pair. There is at most one, enforced by a unique index on `user_uuid`.
|
||||||
|
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Option<Self> {
|
||||||
|
conn.run(move |conn| {
|
||||||
|
user_signature_key_pairs::table
|
||||||
|
.filter(user_signature_key_pairs::user_uuid.eq(user_uuid))
|
||||||
|
.first::<Self>(conn)
|
||||||
|
.ok()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
||||||
|
conn.run(move |conn| {
|
||||||
|
diesel::delete(user_signature_key_pairs::table.filter(user_signature_key_pairs::user_uuid.eq(user_uuid)))
|
||||||
|
.execute(conn)
|
||||||
|
.map_res("Error deleting user signature key pairs")
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, AsRef, Deref, DieselNewType, Display, From, Hash, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct UserSignatureKeyPairId(String);
|
||||||
@@ -219,6 +219,22 @@ table! {
|
|||||||
avatar_color -> Nullable<Text>,
|
avatar_color -> Nullable<Text>,
|
||||||
external_id -> Nullable<Text>,
|
external_id -> Nullable<Text>,
|
||||||
key_id -> Nullable<Text>,
|
key_id -> Nullable<Text>,
|
||||||
|
signed_public_key -> Nullable<Text>,
|
||||||
|
security_state -> Nullable<Text>,
|
||||||
|
security_version -> Nullable<Integer>,
|
||||||
|
v2_upgrade_token -> Nullable<Text>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table! {
|
||||||
|
user_signature_key_pairs (uuid) {
|
||||||
|
uuid -> Text,
|
||||||
|
user_uuid -> Text,
|
||||||
|
signature_algorithm -> Integer,
|
||||||
|
signing_key -> Text,
|
||||||
|
verifying_key -> Text,
|
||||||
|
created_at -> Timestamp,
|
||||||
|
updated_at -> Timestamp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +260,7 @@ table! {
|
|||||||
atype -> Integer,
|
atype -> Integer,
|
||||||
reset_password_key -> Nullable<Text>,
|
reset_password_key -> Nullable<Text>,
|
||||||
external_id -> Nullable<Text>,
|
external_id -> Nullable<Text>,
|
||||||
|
v2_upgrade_token -> Nullable<Text>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,6 +401,7 @@ joinable!(collections_groups -> groups (groups_uuid));
|
|||||||
joinable!(event -> users_organizations (uuid));
|
joinable!(event -> users_organizations (uuid));
|
||||||
joinable!(auth_requests -> users (user_uuid));
|
joinable!(auth_requests -> users (user_uuid));
|
||||||
joinable!(sso_users -> users (user_uuid));
|
joinable!(sso_users -> users (user_uuid));
|
||||||
|
joinable!(user_signature_key_pairs -> users (user_uuid));
|
||||||
|
|
||||||
allow_tables_to_appear_in_same_query!(
|
allow_tables_to_appear_in_same_query!(
|
||||||
archives,
|
archives,
|
||||||
@@ -410,4 +428,5 @@ allow_tables_to_appear_in_same_query!(
|
|||||||
collections_groups,
|
collections_groups,
|
||||||
event,
|
event,
|
||||||
auth_requests,
|
auth_requests,
|
||||||
|
user_signature_key_pairs,
|
||||||
);
|
);
|
||||||
+2
-2
@@ -425,12 +425,12 @@ macro_rules! err_json {
|
|||||||
macro_rules! err_handler {
|
macro_rules! err_handler {
|
||||||
($expr:expr) => {{
|
($expr:expr) => {{
|
||||||
error!(target: "auth", "Unauthorized Error: {}", $expr);
|
error!(target: "auth", "Unauthorized Error: {}", $expr);
|
||||||
return ::rocket::request::Outcome::Error((::rocket::http::Status::Unauthorized, $expr));
|
return ::rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, $expr));
|
||||||
}};
|
}};
|
||||||
($usr_msg:expr, $log_value:expr) => {{
|
($usr_msg:expr, $log_value:expr) => {{
|
||||||
let usr_msg = $usr_msg;
|
let usr_msg = $usr_msg;
|
||||||
let log_value = $log_value;
|
let log_value = $log_value;
|
||||||
error!(target: "auth", "Unauthorized Error: {usr_msg}. {log_value}");
|
error!(target: "auth", "Unauthorized Error: {usr_msg}. {log_value}");
|
||||||
return ::rocket::request::Outcome::Error((::rocket::http::Status::Unauthorized, usr_msg));
|
return ::rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, usr_msg));
|
||||||
}};
|
}};
|
||||||
}
|
}
|
||||||
+9
-9
@@ -36,7 +36,7 @@ fn smtp_transport() -> AsyncSmtpTransport<Tokio1Executor> {
|
|||||||
|
|
||||||
let smtp_client = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host.as_str())
|
let smtp_client = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host.as_str())
|
||||||
.port(CONFIG.smtp_port())
|
.port(CONFIG.smtp_port())
|
||||||
.timeout(Some(Duration::from_secs(u64::from(CONFIG.smtp_timeout()))));
|
.timeout(Some(Duration::from_secs(CONFIG.smtp_timeout())));
|
||||||
|
|
||||||
// Determine security
|
// Determine security
|
||||||
let smtp_client = if CONFIG.smtp_security() == *"off" {
|
let smtp_client = if CONFIG.smtp_security() == *"off" {
|
||||||
@@ -169,7 +169,7 @@ pub async fn send_password_hint(address: &str, hint: Option<String>) -> EmptyRes
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult {
|
pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult {
|
||||||
let claims = generate_delete_claims(user_id.to_string())?;
|
let claims = generate_delete_claims(user_id.to_string());
|
||||||
let delete_token = encode_jwt(&claims);
|
let delete_token = encode_jwt(&claims);
|
||||||
|
|
||||||
let (subject, body_html, body_text) = get_text(
|
let (subject, body_html, body_text) = get_text(
|
||||||
@@ -187,7 +187,7 @@ pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult {
|
pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult {
|
||||||
let claims = generate_verify_email_claims(user_id)?;
|
let claims = generate_verify_email_claims(user_id);
|
||||||
let verify_email_token = encode_jwt(&claims);
|
let verify_email_token = encode_jwt(&claims);
|
||||||
|
|
||||||
let (subject, body_html, body_text) = get_text(
|
let (subject, body_html, body_text) = get_text(
|
||||||
@@ -206,7 +206,7 @@ pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult {
|
|||||||
|
|
||||||
pub async fn send_register_verify_email(email: &str, token: &str) -> EmptyResult {
|
pub async fn send_register_verify_email(email: &str, token: &str) -> EmptyResult {
|
||||||
let mut query = url::Url::parse("https://query.builder").unwrap();
|
let mut query = url::Url::parse("https://query.builder").unwrap();
|
||||||
query.query_pairs_mut().append_pair("email", email).append_pair("token", token).append_pair("fromEmail", "true");
|
query.query_pairs_mut().append_pair("email", email).append_pair("token", token);
|
||||||
let Some(query_string) = query.query() else {
|
let Some(query_string) = query.query() else {
|
||||||
err!("Failed to build verify URL query parameters")
|
err!("Failed to build verify URL query parameters")
|
||||||
};
|
};
|
||||||
@@ -237,7 +237,7 @@ pub async fn send_welcome(address: &str) -> EmptyResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send_welcome_must_verify(address: &str, user_id: &UserId) -> EmptyResult {
|
pub async fn send_welcome_must_verify(address: &str, user_id: &UserId) -> EmptyResult {
|
||||||
let claims = generate_verify_email_claims(user_id)?;
|
let claims = generate_verify_email_claims(user_id);
|
||||||
let verify_email_token = encode_jwt(&claims);
|
let verify_email_token = encode_jwt(&claims);
|
||||||
|
|
||||||
let (subject, body_html, body_text) = get_text(
|
let (subject, body_html, body_text) = get_text(
|
||||||
@@ -292,7 +292,7 @@ pub async fn send_invite(
|
|||||||
org_id.clone(),
|
org_id.clone(),
|
||||||
member_id.clone(),
|
member_id.clone(),
|
||||||
invited_by_email,
|
invited_by_email,
|
||||||
)?;
|
);
|
||||||
let invite_token = encode_jwt(&claims);
|
let invite_token = encode_jwt(&claims);
|
||||||
let mut query = url::Url::parse("https://query.builder").unwrap();
|
let mut query = url::Url::parse("https://query.builder").unwrap();
|
||||||
{
|
{
|
||||||
@@ -351,7 +351,7 @@ pub async fn send_emergency_access_invite(
|
|||||||
emer_id.clone(),
|
emer_id.clone(),
|
||||||
String::from(grantor_name),
|
String::from(grantor_name),
|
||||||
String::from(grantor_email),
|
String::from(grantor_email),
|
||||||
)?;
|
);
|
||||||
|
|
||||||
// Build the query here to ensure proper escaping
|
// Build the query here to ensure proper escaping
|
||||||
let mut query = url::Url::parse("https://query.builder").unwrap();
|
let mut query = url::Url::parse("https://query.builder").unwrap();
|
||||||
@@ -738,8 +738,8 @@ async fn send_email(address: &str, subject: &str, body_html: String, body_text:
|
|||||||
let smtp_from = Address::from_str(&CONFIG.smtp_from())?;
|
let smtp_from = Address::from_str(&CONFIG.smtp_from())?;
|
||||||
|
|
||||||
let body = if CONFIG.smtp_embed_images() {
|
let body = if CONFIG.smtp_embed_images() {
|
||||||
let logo_gray_body = Body::new(crate::api::STATIC_FILES["logo-gray.png"].bytes.to_vec());
|
let logo_gray_body = Body::new(crate::api::static_files("logo-gray.png").unwrap().1.to_vec());
|
||||||
let mail_github_body = Body::new(crate::api::STATIC_FILES["mail-github.png"].bytes.to_vec());
|
let mail_github_body = Body::new(crate::api::static_files("mail-github.png").unwrap().1.to_vec());
|
||||||
MultiPart::alternative().singlepart(SinglePart::plain(body_text)).multipart(
|
MultiPart::alternative().singlepart(SinglePart::plain(body_text)).multipart(
|
||||||
MultiPart::related()
|
MultiPart::related()
|
||||||
.singlepart(SinglePart::html(body_html))
|
.singlepart(SinglePart::html(body_html))
|
||||||
|
|||||||
+3
-5
@@ -595,7 +595,7 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error>
|
|||||||
.manage(Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS))
|
.manage(Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS))
|
||||||
.attach(util::AppHeaders())
|
.attach(util::AppHeaders())
|
||||||
.attach(util::Cors())
|
.attach(util::Cors())
|
||||||
.attach(util::BetterLogging::new(extra_debug, basepath))
|
.attach(util::BetterLogging(extra_debug))
|
||||||
.ignite()
|
.ignite()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -729,7 +729,7 @@ fn schedule_jobs(pool: db::DbPool) {
|
|||||||
// Cleanup the event table of records x days old.
|
// Cleanup the event table of records x days old.
|
||||||
if CONFIG.org_events_enabled()
|
if CONFIG.org_events_enabled()
|
||||||
&& !CONFIG.event_cleanup_schedule().is_empty()
|
&& !CONFIG.event_cleanup_schedule().is_empty()
|
||||||
&& CONFIG.events_days_retain().is_some_and(|d| d != 0)
|
&& CONFIG.events_days_retain().is_some()
|
||||||
{
|
{
|
||||||
sched.add(Job::new(CONFIG.event_cleanup_schedule().parse().unwrap(), || {
|
sched.add(Job::new(CONFIG.event_cleanup_schedule().parse().unwrap(), || {
|
||||||
runtime.spawn(api::event_cleanup_job(pool.clone()));
|
runtime.spawn(api::event_cleanup_job(pool.clone()));
|
||||||
@@ -754,9 +754,7 @@ fn schedule_jobs(pool: db::DbPool) {
|
|||||||
// tick, the one that was added earlier will run first.
|
// tick, the one that was added earlier will run first.
|
||||||
loop {
|
loop {
|
||||||
sched.tick();
|
sched.tick();
|
||||||
runtime.block_on(tokio::time::sleep(tokio::time::Duration::from_millis(u64::from(
|
runtime.block_on(tokio::time::sleep(tokio::time::Duration::from_millis(CONFIG.job_poll_interval_ms())));
|
||||||
CONFIG.job_poll_interval_ms(),
|
|
||||||
))));
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.expect("Error spawning job scheduler thread");
|
.expect("Error spawning job scheduler thread");
|
||||||
|
|||||||
+6
-6
@@ -7,20 +7,20 @@ use crate::{CONFIG, Error};
|
|||||||
type Limiter<T = IpAddr> = RateLimiter<T, DashMapStateStore<T>, DefaultClock>;
|
type Limiter<T = IpAddr> = RateLimiter<T, DashMapStateStore<T>, DefaultClock>;
|
||||||
|
|
||||||
static LIMITER_LOGIN: LazyLock<Limiter> = LazyLock::new(|| {
|
static LIMITER_LOGIN: LazyLock<Limiter> = LazyLock::new(|| {
|
||||||
let seconds = Duration::from_secs(u64::from(CONFIG.login_ratelimit_seconds()));
|
let seconds = Duration::from_secs(CONFIG.login_ratelimit_seconds());
|
||||||
let burst = NonZeroU32::new(u32::from(CONFIG.login_ratelimit_max_burst())).expect("Non-zero login ratelimit burst");
|
let burst = NonZeroU32::new(CONFIG.login_ratelimit_max_burst()).expect("Non-zero login ratelimit burst");
|
||||||
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero login ratelimit seconds").allow_burst(burst))
|
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero login ratelimit seconds").allow_burst(burst))
|
||||||
});
|
});
|
||||||
|
|
||||||
static LIMITER_ADMIN: LazyLock<Limiter> = LazyLock::new(|| {
|
static LIMITER_ADMIN: LazyLock<Limiter> = LazyLock::new(|| {
|
||||||
let seconds = Duration::from_secs(u64::from(CONFIG.admin_ratelimit_seconds()));
|
let seconds = Duration::from_secs(CONFIG.admin_ratelimit_seconds());
|
||||||
let burst = NonZeroU32::new(u32::from(CONFIG.admin_ratelimit_max_burst())).expect("Non-zero admin ratelimit burst");
|
let burst = NonZeroU32::new(CONFIG.admin_ratelimit_max_burst()).expect("Non-zero admin ratelimit burst");
|
||||||
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst))
|
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst))
|
||||||
});
|
});
|
||||||
|
|
||||||
static LIMITER_UNAUTHENTICATED: LazyLock<Limiter> = LazyLock::new(|| {
|
static LIMITER_UNAUTHENTICATED: LazyLock<Limiter> = LazyLock::new(|| {
|
||||||
let seconds = Duration::from_secs(u64::from(CONFIG.unauthenticated_ratelimit_seconds()));
|
let seconds = Duration::from_secs(CONFIG.unauthenticated_ratelimit_seconds());
|
||||||
let burst = NonZeroU32::new(u32::from(CONFIG.unauthenticated_ratelimit_max_burst()))
|
let burst = NonZeroU32::new(CONFIG.unauthenticated_ratelimit_max_burst())
|
||||||
.expect("Non-zero unauthenticated ratelimit burst");
|
.expect("Non-zero unauthenticated ratelimit burst");
|
||||||
RateLimiter::keyed(
|
RateLimiter::keyed(
|
||||||
Quota::with_period(seconds).expect("Non-zero unauthenticated ratelimit seconds").allow_burst(burst),
|
Quota::with_period(seconds).expect("Non-zero unauthenticated ratelimit seconds").allow_burst(burst),
|
||||||
|
|||||||
+3
-7
@@ -266,8 +266,8 @@ pub async fn exchange_code(
|
|||||||
(_, Some(re)) => {
|
(_, Some(re)) => {
|
||||||
let error_msg = format!(
|
let error_msg = format!(
|
||||||
"SSO authorization failed: {}, {}",
|
"SSO authorization failed: {}, {}",
|
||||||
re.error.escape_debug(),
|
re.error,
|
||||||
re.error_description.as_deref().unwrap_or_default().escape_debug()
|
re.error_description.as_ref().unwrap_or(&String::new())
|
||||||
);
|
);
|
||||||
sso_auth.delete(conn).await?;
|
sso_auth.delete(conn).await?;
|
||||||
err!(error_msg);
|
err!(error_msg);
|
||||||
@@ -309,11 +309,7 @@ pub async fn exchange_code(
|
|||||||
user_name: user_name.clone(),
|
user_name: user_name.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if CONFIG.sso_debug_tokens() {
|
debug!("Authenticated user {authenticated_user:?}");
|
||||||
debug!("Authenticated user {authenticated_user:?}");
|
|
||||||
} else {
|
|
||||||
debug!("Authenticated user {identifier}");
|
|
||||||
}
|
|
||||||
sso_auth.auth_response = Some(authenticated_user.clone());
|
sso_auth.auth_response = Some(authenticated_user.clone());
|
||||||
sso_auth.updated_at = Utc::now().naive_utc();
|
sso_auth.updated_at = Utc::now().naive_utc();
|
||||||
sso_auth.save(conn).await?;
|
sso_auth.save(conn).await?;
|
||||||
|
|||||||
+2
-2
@@ -29,7 +29,7 @@ static CLIENT_CACHE_KEY: LazyLock<String> = LazyLock::new(|| "sso-client".to_own
|
|||||||
static CLIENT_CACHE: LazyLock<moka::sync::Cache<String, Client>> = LazyLock::new(|| {
|
static CLIENT_CACHE: LazyLock<moka::sync::Cache<String, Client>> = LazyLock::new(|| {
|
||||||
moka::sync::Cache::builder()
|
moka::sync::Cache::builder()
|
||||||
.max_capacity(1)
|
.max_capacity(1)
|
||||||
.time_to_live(Duration::from_secs(u64::from(CONFIG.sso_client_cache_expiration())))
|
.time_to_live(Duration::from_secs(CONFIG.sso_client_cache_expiration()))
|
||||||
.build()
|
.build()
|
||||||
});
|
});
|
||||||
static REFRESH_CACHE: LazyLock<moka::future::Cache<String, Result<RefreshTokenResponse, String>>> =
|
static REFRESH_CACHE: LazyLock<moka::future::Cache<String, Result<RefreshTokenResponse, String>>> =
|
||||||
@@ -260,7 +260,7 @@ impl Client {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if CONFIG.sso_debug_tokens() {
|
if CONFIG.sso_debug_tokens() {
|
||||||
debug!("Id token: {id_token}");
|
debug!("Id token: {}", id_token.to_string());
|
||||||
debug!("Access token: {}", token_response.access_token().secret());
|
debug!("Access token: {}", token_response.access_token().secret());
|
||||||
debug!("Refresh token: {:?}", token_response.refresh_token().map(RefreshToken::secret));
|
debug!("Refresh token: {:?}", token_response.refresh_token().map(RefreshToken::secret));
|
||||||
debug!("Expiration time: {:?}", token_response.expires_in());
|
debug!("Expiration time: {:?}", token_response.expires_in());
|
||||||
|
|||||||
+2
-9
@@ -93,9 +93,7 @@ async function generateSupportString(event, dj) {
|
|||||||
supportString += `* Running within a container: ${yn(dj.running_within_container)} (Base: ${dj.container_base_image})\n`;
|
supportString += `* Running within a container: ${yn(dj.running_within_container)} (Base: ${dj.container_base_image})\n`;
|
||||||
supportString += `* Database type: ${dj.db_type}\n`;
|
supportString += `* Database type: ${dj.db_type}\n`;
|
||||||
supportString += `* Database version: ${dj.db_version}\n`;
|
supportString += `* Database version: ${dj.db_version}\n`;
|
||||||
supportString += `* Uses config.json: ${yn(dj.config_file_status !== "NotFound")}`;
|
supportString += `* Uses config.json: ${yn(dj.overrides !== "")}\n`;
|
||||||
supportString += dj.config_file_status !== "NotFound" ? ` (Valid: ${chk(dj.config_file_status === "Loaded")})\n` : "\n";
|
|
||||||
supportString += `* Environment variables are valid: ${chk(dj.invalid_env_vars == "")}\n`;
|
|
||||||
supportString += `* Uses custom templates: ${yn(dj.template_overrides !== "")}${template_overrides}\n`;
|
supportString += `* Uses custom templates: ${yn(dj.template_overrides !== "")}${template_overrides}\n`;
|
||||||
supportString += `* Uses a reverse proxy: ${yn(dj.ip_header_exists)}\n`;
|
supportString += `* Uses a reverse proxy: ${yn(dj.ip_header_exists)}\n`;
|
||||||
if (dj.ip_header_exists) {
|
if (dj.ip_header_exists) {
|
||||||
@@ -118,7 +116,7 @@ async function generateSupportString(event, dj) {
|
|||||||
}
|
}
|
||||||
supportString += `* HTTP Response Checks: ${chk(httpResponseCheck)}\n`;
|
supportString += `* HTTP Response Checks: ${chk(httpResponseCheck)}\n`;
|
||||||
if (dj.invalid_feature_flags != "") {
|
if (dj.invalid_feature_flags != "") {
|
||||||
supportString += "* Invalid feature flags: true :x:\n";
|
supportString += "* Invalid feature flags: true\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, {
|
const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, {
|
||||||
@@ -134,11 +132,6 @@ async function generateSupportString(event, dj) {
|
|||||||
supportString += "\n### Config & Details (Generated via diagnostics page)\n\n";
|
supportString += "\n### Config & Details (Generated via diagnostics page)\n\n";
|
||||||
supportString += "<details><summary>Show Config & Details</summary>\n";
|
supportString += "<details><summary>Show Config & Details</summary>\n";
|
||||||
|
|
||||||
// Show invalid environment variables if they exists
|
|
||||||
if (dj.invalid_env_vars != "") {
|
|
||||||
supportString += `\n**Invalid environment variables:** ${dj.invalid_env_vars}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add overrides if they exists
|
// Add overrides if they exists
|
||||||
if (dj.overrides != "") {
|
if (dj.overrides != "") {
|
||||||
supportString += `\n**Environment settings which are overridden:** ${dj.overrides}\n`;
|
supportString += `\n**Environment settings which are overridden:** ${dj.overrides}\n`;
|
||||||
|
|||||||
Vendored
+2
-2
@@ -4,10 +4,10 @@
|
|||||||
*
|
*
|
||||||
* To rebuild or modify this file with the latest versions of the included
|
* To rebuild or modify this file with the latest versions of the included
|
||||||
* software please visit:
|
* software please visit:
|
||||||
* https://datatables.net/download/#bs5/dt-3.1.4
|
* https://datatables.net/download/#bs5/dt-3.1.3
|
||||||
*
|
*
|
||||||
* Included libraries:
|
* Included libraries:
|
||||||
* DataTables 3.1.4
|
* DataTables 3.1.3
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/*! DataTables Bootstrap 5 integration
|
/*! DataTables Bootstrap 5 integration
|
||||||
|
|||||||
Vendored
+7
-13
@@ -4,13 +4,13 @@
|
|||||||
*
|
*
|
||||||
* To rebuild or modify this file with the latest versions of the included
|
* To rebuild or modify this file with the latest versions of the included
|
||||||
* software please visit:
|
* software please visit:
|
||||||
* https://datatables.net/download/#bs5/dt-3.1.4
|
* https://datatables.net/download/#bs5/dt-3.1.3
|
||||||
*
|
*
|
||||||
* Included libraries:
|
* Included libraries:
|
||||||
* DataTables 3.1.4
|
* DataTables 3.1.3
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/*! DataTables 3.1.4
|
/*! DataTables 3.1.3
|
||||||
* Copyright (c) SpryMedia Ltd - datatables.net/license
|
* Copyright (c) SpryMedia Ltd - datatables.net/license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -453,9 +453,6 @@ function assignDeep(out, ...inputs) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (const [key, value] of Object.entries(input)) {
|
for (const [key, value] of Object.entries(input)) {
|
||||||
if (key === '__proto__') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
if (!Array.isArray(out[key])) {
|
if (!Array.isArray(out[key])) {
|
||||||
out[key] = [];
|
out[key] = [];
|
||||||
@@ -492,9 +489,6 @@ function assignDeep(out, ...inputs) {
|
|||||||
function assignDeepObjects(out, extender, breakRefs = false) {
|
function assignDeepObjects(out, extender, breakRefs = false) {
|
||||||
let val;
|
let val;
|
||||||
for (let prop in extender) {
|
for (let prop in extender) {
|
||||||
if (prop === '__proto__') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (Object.prototype.hasOwnProperty.call(extender, prop)) {
|
if (Object.prototype.hasOwnProperty.call(extender, prop)) {
|
||||||
val = extender[prop];
|
val = extender[prop];
|
||||||
if (plainObject(val)) {
|
if (plainObject(val)) {
|
||||||
@@ -2700,7 +2694,7 @@ class Dom {
|
|||||||
*/
|
*/
|
||||||
prepend(content) {
|
prepend(content) {
|
||||||
return this.each(el => {
|
return this.each(el => {
|
||||||
if (arrayLike(content) || dom(content)) {
|
if (content instanceof Dom) {
|
||||||
// Reverse the array, so if there are multiple elements, they
|
// Reverse the array, so if there are multiple elements, they
|
||||||
// end up being added sequentially, just like jQuery
|
// end up being added sequentially, just like jQuery
|
||||||
let itemsReversed = Array.from(content).reverse();
|
let itemsReversed = Array.from(content).reverse();
|
||||||
@@ -8720,7 +8714,7 @@ const ext = {
|
|||||||
/**
|
/**
|
||||||
* DataTables build type (expanded by the download builder)
|
* DataTables build type (expanded by the download builder)
|
||||||
*/
|
*/
|
||||||
builder: 'bs5/dt-3.1.4',
|
builder: 'bs5/dt-3.1.3',
|
||||||
/**
|
/**
|
||||||
* Buttons. For use with the Buttons extension for DataTables. This is
|
* Buttons. For use with the Buttons extension for DataTables. This is
|
||||||
* defined here so other extensions can define buttons regardless of load
|
* defined here so other extensions can define buttons regardless of load
|
||||||
@@ -8866,7 +8860,7 @@ const ext = {
|
|||||||
* Software version
|
* Software version
|
||||||
* @type string
|
* @type string
|
||||||
*/
|
*/
|
||||||
version: '3.1.4'
|
version: '3.1.3'
|
||||||
};
|
};
|
||||||
//
|
//
|
||||||
// Backwards compatibility. Alias to pre 1.10 Hungarian notation counter parts
|
// Backwards compatibility. Alias to pre 1.10 Hungarian notation counter parts
|
||||||
@@ -9565,7 +9559,7 @@ register('destroy()', function (remove) {
|
|||||||
callbackFire(settings, 'destroy', 'destroy', [settings], true);
|
callbackFire(settings, 'destroy', 'destroy', [settings], true);
|
||||||
// If not being removed from the document, make all columns visible
|
// If not being removed from the document, make all columns visible
|
||||||
if (!remove) {
|
if (!remove) {
|
||||||
new Api(settings).columns().visible(true);
|
new Api(settings).columns().visible();
|
||||||
}
|
}
|
||||||
// Container width change listener
|
// Container width change listener
|
||||||
if (settings.resizeObserver) {
|
if (settings.resizeObserver) {
|
||||||
|
|||||||
@@ -69,26 +69,12 @@
|
|||||||
</dd>
|
</dd>
|
||||||
<dt class="col-sm-5">Uses config.json</dt>
|
<dt class="col-sm-5">Uses config.json</dt>
|
||||||
<dd class="col-sm-7">
|
<dd class="col-sm-7">
|
||||||
{{#if (eq page_data.config_file_status "Loaded")}}
|
{{#if page_data.overrides}}
|
||||||
<span class="d-inline"><b>Yes</b></span>
|
<span class="d-inline"><b>Yes</b></span>
|
||||||
{{#if page_data.overrides}}
|
|
||||||
<span class="badge bg-info text-dark abbr-badge" title="Environment variables are overwritten by a config.json.
{{page_data.overrides}}">Details</span>
|
<span class="badge bg-info text-dark abbr-badge" title="Environment variables are overwritten by a config.json.
{{page_data.overrides}}">Details</span>
|
||||||
{{/if}}
|
|
||||||
{{~else if (eq page_data.config_file_status "Error")}}
|
|
||||||
<span class="d-inline"><b>Errors</b></span>
|
|
||||||
<span class="badge bg-danger abbr-badge" title="Config file could not be loaded and was ignored. Check server logs for details.">Details</span>
|
|
||||||
{{~else}}
|
|
||||||
<span class="d-inline"><b>No</b></span>
|
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</dd>
|
{{#unless page_data.overrides}}
|
||||||
<dt class="col-sm-5">Environment variables are valid</dt>
|
<span class="d-block"><b>No</b></span>
|
||||||
<dd class="col-sm-7">
|
|
||||||
{{#if page_data.invalid_env_vars}}
|
|
||||||
<span class="d-inline"><b>No</b></span>
|
|
||||||
<span class="badge bg-danger abbr-badge" title="Invalid environment variables are found and defaults are used instead.
{{page_data.invalid_env_vars}}">Details</span>
|
|
||||||
{{/if}}
|
|
||||||
{{#unless page_data.invalid_env_vars}}
|
|
||||||
<span class="d-block"><b>Yes</b></span>
|
|
||||||
{{/unless}}
|
{{/unless}}
|
||||||
</dd>
|
</dd>
|
||||||
<dt class="col-sm-5">Uses custom templates</dt>
|
<dt class="col-sm-5">Uses custom templates</dt>
|
||||||
|
|||||||
+39
-78
@@ -210,7 +210,7 @@ impl Fairing for Cors {
|
|||||||
pub struct Cached<R> {
|
pub struct Cached<R> {
|
||||||
response: R,
|
response: R,
|
||||||
is_immutable: bool,
|
is_immutable: bool,
|
||||||
ttl: u32,
|
ttl: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<R> Cached<R> {
|
impl<R> Cached<R> {
|
||||||
@@ -230,7 +230,7 @@ impl<R> Cached<R> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ttl(response: R, ttl: u32, is_immutable: bool) -> Cached<R> {
|
pub fn ttl(response: R, ttl: u64, is_immutable: bool) -> Cached<R> {
|
||||||
Self {
|
Self {
|
||||||
response,
|
response,
|
||||||
is_immutable,
|
is_immutable,
|
||||||
@@ -251,7 +251,7 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for Cache
|
|||||||
res.set_raw_header("Cache-Control", cache_control_header);
|
res.set_raw_header("Cache-Control", cache_control_header);
|
||||||
|
|
||||||
let time_now = Local::now();
|
let time_now = Local::now();
|
||||||
let expiry_time = time_now + chrono::TimeDelta::try_seconds(self.ttl.into()).unwrap();
|
let expiry_time = time_now + chrono::TimeDelta::try_seconds(self.ttl.try_into().unwrap()).unwrap();
|
||||||
res.set_raw_header("Expires", format_datetime_http(&expiry_time));
|
res.set_raw_header("Expires", format_datetime_http(&expiry_time));
|
||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
@@ -259,16 +259,14 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for Cache
|
|||||||
|
|
||||||
pub struct EtagCached<R> {
|
pub struct EtagCached<R> {
|
||||||
response: R,
|
response: R,
|
||||||
content_type: ContentType,
|
|
||||||
etag: String,
|
etag: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<R> EtagCached<R> {
|
impl<R> EtagCached<R> {
|
||||||
/// An `etag` response should always be quoted
|
/// An `etag` response should always be quoted
|
||||||
pub fn new(response: R, content_type: ContentType, etag: &str) -> Self {
|
pub fn new(response: R, etag: &str) -> Self {
|
||||||
Self {
|
Self {
|
||||||
response,
|
response,
|
||||||
content_type,
|
|
||||||
etag: format!("\"{etag}\""),
|
etag: format!("\"{etag}\""),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,8 +288,7 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for EtagC
|
|||||||
self.response.respond_to(request)?
|
self.response.respond_to(request)?
|
||||||
};
|
};
|
||||||
|
|
||||||
// Both 200 (OK) and 304 (Not Modified) need to return the Content-Type Etag and Cache-Control
|
// Both 200 (OK) and 304 (Not Modified) need to return the etag and cache-control
|
||||||
res.set_header(self.content_type);
|
|
||||||
res.set_raw_header("Etag", self.etag);
|
res.set_raw_header("Etag", self.etag);
|
||||||
res.set_raw_header("Cache-Control", "public, no-cache");
|
res.set_raw_header("Cache-Control", "public, no-cache");
|
||||||
Ok(res)
|
Ok(res)
|
||||||
@@ -302,55 +299,19 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for EtagC
|
|||||||
// Effectively ignores, any static file route, and the alive endpoint
|
// Effectively ignores, any static file route, and the alive endpoint
|
||||||
const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"];
|
const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"];
|
||||||
|
|
||||||
// When extra is true, we ignore the whitelist above and also print the mounts
|
// Boolean is extra debug, when true, we ignore the whitelist above and also print the mounts
|
||||||
pub struct BetterLogging {
|
pub struct BetterLogging(pub bool);
|
||||||
extra: bool,
|
|
||||||
log_requests: bool,
|
|
||||||
log_response: bool,
|
|
||||||
domain_path: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BetterLogging {
|
|
||||||
pub fn new(extra: bool, domain_path: &str) -> Self {
|
|
||||||
Self {
|
|
||||||
extra,
|
|
||||||
log_requests: log_enabled!(target: "request", log::Level::Info),
|
|
||||||
log_response: log_enabled!(target: "response", log::Level::Info),
|
|
||||||
domain_path: rocket::http::RawStr::new(&domain_path).percent_decode_lossy().into_owned(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn log_path<'p>(&self, method: Method, path: &'p rocket::http::RawStr) -> Option<std::borrow::Cow<'p, str>> {
|
|
||||||
if !self.extra && method == Method::Options {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let path = path.percent_decode_lossy();
|
|
||||||
if self.extra {
|
|
||||||
return Some(path);
|
|
||||||
}
|
|
||||||
let subpath = path.strip_prefix(&self.domain_path).unwrap_or(&path);
|
|
||||||
LOGGED_ROUTES.iter().any(|r| subpath.starts_with(r)).then_some(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rocket::async_trait]
|
#[rocket::async_trait]
|
||||||
impl Fairing for BetterLogging {
|
impl Fairing for BetterLogging {
|
||||||
fn info(&self) -> Info {
|
fn info(&self) -> Info {
|
||||||
let mut kind = Kind::Liftoff;
|
|
||||||
if self.log_requests {
|
|
||||||
kind = kind | Kind::Request;
|
|
||||||
}
|
|
||||||
if self.log_response {
|
|
||||||
kind = kind | Kind::Response;
|
|
||||||
}
|
|
||||||
Info {
|
Info {
|
||||||
name: "Better Logging",
|
name: "Better Logging",
|
||||||
kind,
|
kind: Kind::Liftoff | Kind::Request | Kind::Response,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_liftoff(&self, rocket: &Rocket<Orbit>) {
|
async fn on_liftoff(&self, rocket: &Rocket<Orbit>) {
|
||||||
if self.extra {
|
if self.0 {
|
||||||
info!(target: "routes", "Routes loaded:");
|
info!(target: "routes", "Routes loaded:");
|
||||||
let mut routes: Vec<_> = rocket.routes().collect();
|
let mut routes: Vec<_> = rocket.routes().collect();
|
||||||
routes.sort_by_key(|r| r.uri.path());
|
routes.sort_by_key(|r| r.uri.path());
|
||||||
@@ -374,28 +335,36 @@ impl Fairing for BetterLogging {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn on_request(&self, request: &mut Request<'_>, _data: &mut Data<'_>) {
|
async fn on_request(&self, request: &mut Request<'_>, _data: &mut Data<'_>) {
|
||||||
let uri = request.uri();
|
|
||||||
let method = request.method();
|
let method = request.method();
|
||||||
let Some(path) = self.log_path(method, uri.path().raw()) else {
|
if !self.0 && method == Method::Options {
|
||||||
return;
|
return;
|
||||||
};
|
}
|
||||||
match uri.query() {
|
let uri = request.uri();
|
||||||
Some(q) => {
|
let uri_path = uri.path();
|
||||||
let q = q.as_str();
|
let uri_path_str = uri_path.url_decode_lossy();
|
||||||
info!(target: "request", "{method} {}?{}", path.escape_debug(), q[..q.floor_char_boundary(30)].escape_debug());
|
let uri_subpath = uri_path_str.strip_prefix(&CONFIG.domain_path()).unwrap_or(&uri_path_str);
|
||||||
|
if self.0 || LOGGED_ROUTES.iter().any(|r| uri_subpath.starts_with(r)) {
|
||||||
|
match uri.query() {
|
||||||
|
Some(q) => info!(target: "request", "{method} {uri_path_str}?{}", &q[..q.len().min(30)]),
|
||||||
|
None => info!(target: "request", "{method} {uri_path_str}"),
|
||||||
}
|
}
|
||||||
None => info!(target: "request", "{method} {}", path.escape_debug()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_response<'r>(&self, request: &'r Request<'_>, response: &mut Response<'r>) {
|
async fn on_response<'r>(&self, request: &'r Request<'_>, response: &mut Response<'r>) {
|
||||||
if self.log_path(request.method(), request.uri().path().raw()).is_none() {
|
if !self.0 && request.method() == Method::Options {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let status = response.status();
|
let uri_path = request.uri().path();
|
||||||
match request.route() {
|
let uri_path_str = uri_path.url_decode_lossy();
|
||||||
Some(r) => info!(target: "response", "{r} => {status}"),
|
let uri_subpath = uri_path_str.strip_prefix(&CONFIG.domain_path()).unwrap_or(&uri_path_str);
|
||||||
None => info!(target: "response", "{status}"),
|
if self.0 || LOGGED_ROUTES.iter().any(|r| uri_subpath.starts_with(r)) {
|
||||||
|
let status = response.status();
|
||||||
|
if let Some(route) = request.route() {
|
||||||
|
info!(target: "response", "{route} => {status}");
|
||||||
|
} else {
|
||||||
|
info!(target: "response", "{status}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -478,34 +447,20 @@ pub fn get_env_str_value(key: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub static INVALID_ENV_VARS: std::sync::Mutex<Vec<&'static str>> = std::sync::Mutex::new(Vec::new());
|
pub fn get_env<V>(key: &str) -> Option<V>
|
||||||
|
|
||||||
pub fn get_env<V>(key: &'static str) -> Option<V>
|
|
||||||
where
|
where
|
||||||
V: FromStr,
|
V: FromStr,
|
||||||
{
|
{
|
||||||
let value = get_env_str_value(key);
|
try_parse_string(get_env_str_value(key))
|
||||||
let value_is_some = value.is_some();
|
|
||||||
let parsed: Option<V> = try_parse_string(value);
|
|
||||||
if parsed.is_none() && value_is_some {
|
|
||||||
println!("[ERROR] `{key}` contains an invalid value for its type and is ignored, the default is used instead.");
|
|
||||||
INVALID_ENV_VARS.lock().unwrap().push(key);
|
|
||||||
}
|
|
||||||
parsed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_env_bool(key: &'static str) -> Option<bool> {
|
pub fn get_env_bool(key: &str) -> Option<bool> {
|
||||||
const TRUE_VALUES: &[&str] = &["true", "t", "yes", "y", "1"];
|
const TRUE_VALUES: &[&str] = &["true", "t", "yes", "y", "1"];
|
||||||
const FALSE_VALUES: &[&str] = &["false", "f", "no", "n", "0"];
|
const FALSE_VALUES: &[&str] = &["false", "f", "no", "n", "0"];
|
||||||
|
|
||||||
match get_env_str_value(key) {
|
match get_env_str_value(key) {
|
||||||
Some(val) if TRUE_VALUES.contains(&val.to_lowercase().as_ref()) => Some(true),
|
Some(val) if TRUE_VALUES.contains(&val.to_lowercase().as_ref()) => Some(true),
|
||||||
Some(val) if FALSE_VALUES.contains(&val.to_lowercase().as_ref()) => Some(false),
|
Some(val) if FALSE_VALUES.contains(&val.to_lowercase().as_ref()) => Some(false),
|
||||||
Some(_) => {
|
|
||||||
println!("[ERROR] `{key}` contains an invalid boolean value and is ignored, the default is used instead.");
|
|
||||||
INVALID_ENV_VARS.lock().unwrap().push(key);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -884,6 +839,12 @@ pub fn parse_experimental_client_feature_flags(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the admin enabled this supported client feature flag
|
||||||
|
pub fn is_client_feature_flag_enabled(flag: &str) -> bool {
|
||||||
|
parse_experimental_client_feature_flags(&CONFIG.experimental_client_feature_flags(), &FeatureFlagFilter::ValidOnly)
|
||||||
|
.contains_key(flag)
|
||||||
|
}
|
||||||
|
|
||||||
/// TODO: This is extracted from IpAddr::is_global, which is unstable:
|
/// TODO: This is extracted from IpAddr::is_global, which is unstable:
|
||||||
/// https://doc.rust-lang.org/nightly/std/net/enum.IpAddr.html#method.is_global
|
/// https://doc.rust-lang.org/nightly/std/net/enum.IpAddr.html#method.is_global
|
||||||
/// Remove once https://github.com/rust-lang/rust/issues/27709 is merged
|
/// Remove once https://github.com/rust-lang/rust/issues/27709 is merged
|
||||||
|
|||||||
Reference in new issue
Block a user