Compare commits

...
18 Commits
Author SHA1 Message Date
Daniel GarcíaandGitHub 46ae59eaf4 Trusted proxy support, unauthenticated rate limit & other fixes (#7472)
* Trusted proxies, unauthenticated rate limits and various fixes

* Fix get_groups_data

* Fix get_groups_data when not using full_access

* Fmt

* Fix org import

* deduplicate send validation
2026-07-24 18:27:32 +02:00
Mathijs van VeluwandGitHub a6a88e7929 Update API response, crates and GHA (#7470)
- Updated API response to more closely match v2026.6.0+ server versions.
- Updated all the crates
- Updated Rust to v1.97.1
- Updated the web-vault to v2026.6.4
- Updated GitHub Actions

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-07-24 17:33:10 +02:00
5040bcb7c0 Remove unused fields (#7458)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-07-24 16:40:34 +02:00
TomandGitHub 660faee68e Fix custom role dialog selectors (#7442) 2026-07-21 22:06:45 +02:00
DanielandGitHub 683a23e43c Fix compilation with newer rust-musl version (#7453) 2026-07-21 21:54:10 +02:00
4a9bcb0694 Remove old compatibility code (#7434)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-07-21 19:59:40 +02:00
Mathijs van VeluwandGitHub 169aa5efcc Misc updates and fixes (#7406)
* Misc updates and fixes

- Updated Rust to v1.96.1
- Updated all the crates
- Updated GitHub Actions
- Updated the web-vault to v2026.6.2
- Updated Alpine to v3.24
- Fixed several clippy lints
- The `send` UUID wrappers didn't need the special namespace anymore since an updated crate, so removed this extra mod.

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

* Update MSRV to v1.94.1

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

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-07-08 22:10:29 +02:00
Denis PisarevandGitHub 64d28ab66e improve CI (#6991)
* ci: remove dead BASE_TAGS reference in release bake step

  steps.determine-version doesn't exist in docker-build; the expression
  resolves to empty string. The HCL default (testing) would have
  applied, but it's moot - the bake uses push-by-digest=true so tags are
  only set in merge-manifests. Dead code.

* ci: replace unsecured curl hadolint download with an official action

  hadolint/hadolint-action uses a Docker-based runner with hadolint
  pre-bundled in ghcr.io/hadolint/hadolint:v2.14.0-debian,so no binary
  downloaded at runtime. Pinning the action to a commit SHA covers the
  Dockerfile that specifies the image version, closing the supply-chain
  gap from the previous unverified curl | sudo install.

  Split {debian,alpine}: the action takes a single dockerfile argument,
  so debian and alpine are linted separately.

* ci: pin ubuntu-latest to ubuntu-24.04 in merge-manifests and zizmor

  ubuntu-latest is a moving target that can silently change the runner OS
  on the next GitHub-side update. All other jobs in this repo already pin
  to ubuntu-24.04; this makes merge-manifests and zizmor consistent.

* ci: return BASE_TAGS - it's needed for bake step
2026-07-08 22:08:20 +02:00
4720cdbe86 Add pm-26340-linux-biometrics-v2 feature flag (#7358)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:59:57 +02:00
5447ee6af2 SSO use ClientSecretPost if ClientSecretBasic is not available (#7357)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-07-07 15:59:48 +02:00
5c5e8e1a6f 2026.6.0 send support (#7346)
* 2026.6.0 send support

* Prevent creating and editing a Send with email verification

* Review fixes

---------

Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-07-07 15:59:36 +02:00
7320a1db4b PutPolicy now using vnext format (#7296)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-07-07 15:59:26 +02:00
a058a35ccd [v2026.5.0] Registration request update (#7295)
* Registration request update

* Review fix

---------

Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-07-07 15:59:17 +02:00
a16b5afaaa Org membership delete remove Invitation (#7284)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-07-07 15:59:06 +02:00
kvdbandGitHub fddc16d2b8 fix(sends): emit hideEmail as non-null boolean in sync response (#7283)
The /api/sync response serialized a Send hide_email field directly from
Option<bool>, so a NULL value in the sends table (the column is
Nullable<Bool> with no default) produced "hideEmail": null.

The Bitwarden Android client deserializes SyncResponseJson.Send.hideEmail
as a non-null Kotlin Boolean and aborts the entire sync with a
JsonDecodingException when it encounters null. Web, desktop and CLI
clients coerce null to false, so only accounts with at least one Send
are affected and only on Android.

Default None to false at the serialization boundary, matching the
official Bitwarden server where hideEmail is non-nullable. This needs no
database migration and fixes both legacy NULL rows and any future NULLs.
The hide_email field stays Option<bool> internally.
2026-07-07 15:58:54 +02:00
ec7fa137b7 Admin password recovery endpoint change (#7270)
* Admin password recovery endpoint change

* Use default to keep compatibility

---------

Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-07-07 15:58:41 +02:00
b25f715364 Fix enforce blocked (#7246)
Co-authored-by: Timshel <timshel@users.noreply.github.com>
2026-07-07 15:58:34 +02:00
Mathijs van VeluwandGitHub d6a3d539ed Update Rust, Crates and GHA (#7307)
- Updated Rust to v1.96.0
- Updated all the crates, and adjusted code where needed
- Fixed some nightly reported clippy lints
- Updated all the GitHub actions

Signed-off-by: BlackDex <black.dex@gmail.com>
2026-06-05 21:52:52 +02:00
46 changed files with 1834 additions and 1027 deletions
+16
View File
@@ -324,6 +324,14 @@
## Set to the string "none" (without quotes), to disable any headers and just use the remote IP ## Set to the string "none" (without quotes), to disable any headers and just use the remote IP
# IP_HEADER=X-Real-IP # IP_HEADER=X-Real-IP
## Which addresses the header above is accepted from, defaults to "local".
## Anyone able to reach Vaultwarden can set the header, and the client IP is used for the login and
## admin rate limits, so it is only trusted when the request comes from a proxy listed here.
## "local" accepts it from any non global address, which covers a reverse proxy running on the same
## host or container network. Use "all" to accept it from anywhere, or list the addresses of your
## proxy as IPs and CIDR ranges if it connects from a public address.
# IP_HEADER_TRUSTED_PROXIES=local
## Icon service ## Icon service
## The predefined icon services are: internal, bitwarden, duckduckgo, google. ## The predefined icon services are: internal, bitwarden, duckduckgo, google.
## To specify a custom icon service, set a URL template with exactly one instance of `{}`, ## To specify a custom icon service, set a URL template with exactly one instance of `{}`,
@@ -378,6 +386,7 @@
## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1) ## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1)
## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Clients >= 2024.12.0) ## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Clients >= 2024.12.0)
## - "pm-25373-windows-biometrics-v2": Enable the new implementation of biometrics on Windows. (Desktop >= 2025.11.0) ## - "pm-25373-windows-biometrics-v2": Enable the new implementation of biometrics on Windows. (Desktop >= 2025.11.0)
## - "pm-26340-linux-biometrics-v2": Enable the new implementation of biometrics on Linux. (Desktop >= 2025.11.0)
## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0) ## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0)
## - "simple-login-self-host-alias": Enable configuring self-hosted Simple Login alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0) ## - "simple-login-self-host-alias": Enable configuring self-hosted Simple Login alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0)
## - "mutual-tls": Enable the use of mutual TLS on Android (Clients >= 2025.2.0) ## - "mutual-tls": Enable the use of mutual TLS on Android (Clients >= 2025.2.0)
@@ -460,6 +469,13 @@
## Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2. ## Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2.
# LOGIN_RATELIMIT_MAX_BURST=10 # LOGIN_RATELIMIT_MAX_BURST=10
## Number of seconds, on average, between requests from the same IP address to one of the rate limited
## unauthenticated endpoints, like the password hint, the account recovery mails or accessing a Send.
# UNAUTHENTICATED_RATELIMIT_SECONDS=60
## Allow a burst of requests of up to this size, while maintaining the average indicated by `UNAUTHENTICATED_RATELIMIT_SECONDS`.
## This budget is shared between all of those endpoints, so it is more lenient than the login one.
# UNAUTHENTICATED_RATELIMIT_MAX_BURST=50
## BETA FEATURE: Groups ## BETA FEATURE: Groups
## Controls whether group support is enabled for organizations ## Controls whether group support is enabled for organizations
## This setting applies to organizations. ## This setting applies to organizations.
+1 -1
View File
@@ -62,7 +62,7 @@ jobs:
# Checkout the repo # Checkout the repo
- name: "Checkout" - name: "Checkout"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
fetch-depth: 0 fetch-depth: 0
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
steps: steps:
# Checkout the repo # Checkout the repo
- name: "Checkout" - name: "Checkout"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
# End Checkout the repo # End Checkout the repo
+13 -12
View File
@@ -20,7 +20,7 @@ jobs:
steps: steps:
# Start Docker Buildx # Start Docker Buildx
- name: Setup Docker Buildx - name: Setup Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
# https://github.com/moby/buildkit/issues/3969 # https://github.com/moby/buildkit/issues/3969
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills # Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
with: with:
@@ -30,24 +30,25 @@ jobs:
driver-opts: | driver-opts: |
network=host network=host
# Download hadolint - https://github.com/hadolint/hadolint/releases
- name: Download hadolint
run: |
sudo curl -L https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION}/hadolint-$(uname -s)-$(uname -m) -o /usr/local/bin/hadolint && \
sudo chmod +x /usr/local/bin/hadolint
env:
HADOLINT_VERSION: 2.14.0
# End Download hadolint
# Checkout the repo # Checkout the repo
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
# End Checkout the repo # End Checkout the repo
# Test Dockerfiles with hadolint # Test Dockerfiles with hadolint
- name: Run hadolint # Uses the Docker-based action (hadolint pre-bundled in ghcr.io/hadolint/hadolint:v2.14.0-debian)
run: hadolint docker/Dockerfile.{debian,alpine} # so no binary is downloaded at runtime. Pinned by commit SHA for supply-chain safety.
- name: Run hadolint on Dockerfile.debian
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
with:
dockerfile: docker/Dockerfile.debian
- name: Run hadolint on Dockerfile.alpine
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0
with:
dockerfile: docker/Dockerfile.alpine
# End Test Dockerfiles with hadolint # End Test Dockerfiles with hadolint
# Test Dockerfiles with docker build checks # Test Dockerfiles with docker build checks
+15 -15
View File
@@ -58,13 +58,13 @@ jobs:
steps: steps:
- name: Initialize QEMU binfmt support - name: Initialize QEMU binfmt support
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
with: with:
platforms: "arm64,arm" platforms: "arm64,arm"
# Start Docker Buildx # Start Docker Buildx
- name: Setup Docker Buildx - name: Setup Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
# https://github.com/moby/buildkit/issues/3969 # https://github.com/moby/buildkit/issues/3969
# Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills # Also set max parallelism to 2, the default of 4 breaks GitHub Actions and causes OOMKills
with: with:
@@ -77,7 +77,7 @@ jobs:
# Checkout the repo # Checkout the repo
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# We need fetch-depth of 0 so we also get all the tag metadata # We need fetch-depth of 0 so we also get all the tag metadata
with: with:
persist-credentials: false persist-credentials: false
@@ -106,7 +106,7 @@ jobs:
# Login to Docker Hub # Login to Docker Hub
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
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@a66e1c87e2eca0503c343edf1d208c716d54b8a8 # v7.1.0 uses: docker/bake-action@d3418bd7d0e9324001bca92fa8ba175ea7e6dc9b # v7.3.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 }}"
@@ -237,7 +237,7 @@ jobs:
# Upload artifacts to Github Actions and Attest the binaries # Upload artifacts to Github Actions and Attest the binaries
- name: Attest binaries - name: Attest binaries
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
with: with:
subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }} subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }}
@@ -249,7 +249,7 @@ jobs:
merge-manifests: merge-manifests:
name: Merge manifests name: Merge manifests
runs-on: ubuntu-latest runs-on: ubuntu-24.04
needs: docker-build needs: docker-build
environment: environment:
name: release name: release
@@ -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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with: with:
registry: quay.io registry: quay.io
username: ${{ secrets.QUAY_USERNAME }} username: ${{ secrets.QUAY_USERNAME }}
@@ -365,7 +365,7 @@ jobs:
# Attest container images # Attest container images
- name: Attest - docker.io - ${{ matrix.base_image }} - name: Attest - docker.io - ${{ matrix.base_image }}
if: ${{ vars.DOCKERHUB_REPO != '' && env.DIGEST_SHA != ''}} if: ${{ vars.DOCKERHUB_REPO != '' && env.DIGEST_SHA != ''}}
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
with: with:
subject-name: ${{ vars.DOCKERHUB_REPO }} subject-name: ${{ vars.DOCKERHUB_REPO }}
subject-digest: ${{ env.DIGEST_SHA }} subject-digest: ${{ env.DIGEST_SHA }}
@@ -373,7 +373,7 @@ jobs:
- name: Attest - ghcr.io - ${{ matrix.base_image }} - name: Attest - ghcr.io - ${{ matrix.base_image }}
if: ${{ vars.GHCR_REPO != '' && env.DIGEST_SHA != ''}} if: ${{ vars.GHCR_REPO != '' && env.DIGEST_SHA != ''}}
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
with: with:
subject-name: ${{ vars.GHCR_REPO }} subject-name: ${{ vars.GHCR_REPO }}
subject-digest: ${{ env.DIGEST_SHA }} subject-digest: ${{ env.DIGEST_SHA }}
@@ -381,7 +381,7 @@ jobs:
- name: Attest - quay.io - ${{ matrix.base_image }} - name: Attest - quay.io - ${{ matrix.base_image }}
if: ${{ vars.QUAY_REPO != '' && env.DIGEST_SHA != ''}} if: ${{ vars.QUAY_REPO != '' && env.DIGEST_SHA != ''}}
uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
with: with:
subject-name: ${{ vars.QUAY_REPO }} subject-name: ${{ vars.QUAY_REPO }}
subject-digest: ${{ env.DIGEST_SHA }} subject-digest: ${{ env.DIGEST_SHA }}
+2 -2
View File
@@ -33,7 +33,7 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
@@ -50,6 +50,6 @@ jobs:
severity: CRITICAL,HIGH severity: CRITICAL,HIGH
- name: Upload Trivy scan results to GitHub Security tab - name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
with: with:
sarif_file: 'trivy-results.sarif' sarif_file: 'trivy-results.sarif'
+2 -2
View File
@@ -16,11 +16,11 @@ jobs:
steps: steps:
# Checkout the repo # Checkout the repo
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
# End Checkout the repo # End Checkout the repo
# When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too # When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too
- name: Spell Check Repo - name: Spell Check Repo
uses: crate-ci/typos@5374cbf686e897b15713110e233094e2874de7ef # v1.46.1 uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.48.0
+3 -3
View File
@@ -14,17 +14,17 @@ on:
jobs: jobs:
zizmor: zizmor:
name: Run zizmor name: Run zizmor
runs-on: ubuntu-latest runs-on: ubuntu-24.04
permissions: permissions:
security-events: write # To write the security report security-events: write # To write the security report
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
persist-credentials: false persist-credentials: false
- name: Run zizmor - name: Run zizmor
uses: zizmorcore/zizmor-action@b572f7b1a1c2d41efaab43d504f68d215c3cd727 # v0.5.4 uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
with: with:
# intentionally not scanning the entire repository, # intentionally not scanning the entire repository,
# since it contains integration tests. # since it contains integration tests.
+1 -1
View File
@@ -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: 5374cbf686e897b15713110e233094e2874de7ef # v1.46.1 rev: bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.48.0
hooks: hooks:
- id: typos - id: typos
Generated
+610 -676
View File
File diff suppressed because it is too large Load Diff
+38 -36
View File
@@ -1,6 +1,6 @@
[workspace.package] [workspace.package]
edition = "2024" edition = "2024"
rust-version = "1.93.0" rust-version = "1.95.0"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
repository = "https://github.com/dani-garcia/vaultwarden" repository = "https://github.com/dani-garcia/vaultwarden"
publish = false publish = false
@@ -14,7 +14,6 @@ version = "1.0.0"
authors = ["Daniel García <dani-garcia@users.noreply.github.com>"] authors = ["Daniel García <dani-garcia@users.noreply.github.com>"]
readme = "README.md" readme = "README.md"
build = "build.rs" build = "build.rs"
resolver = "2"
repository.workspace = true repository.workspace = true
edition.workspace = true edition.workspace = true
rust-version.workspace = true rust-version.workspace = true
@@ -66,7 +65,7 @@ syslog = "7.0.0"
macros = { path = "./macros" } macros = { path = "./macros" }
# Logging # Logging
log = "0.4.29" log = "0.4.33"
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"] }
@@ -76,7 +75,7 @@ dotenvy = { version = "0.15.7", default-features = false }
# Numerical libraries # Numerical libraries
num-traits = "0.2.19" num-traits = "0.2.19"
num-derive = "0.4.2" num-derive = "0.5.1"
bigdecimal = "0.4.10" bigdecimal = "0.4.10"
# Web framework # Web framework
@@ -87,11 +86,11 @@ rocket_ws = { version = "0.1.1" }
rmpv = "1.3.1" # MessagePack library rmpv = "1.3.1" # MessagePack library
# Concurrent HashMap used for WebSocket messaging and favicons # Concurrent HashMap used for WebSocket messaging and favicons
dashmap = "6.1.0" dashmap = "6.2.1"
# Async futures # Async futures
futures = "0.3.32" futures = "0.3.33"
tokio = { version = "1.52.3", features = [ tokio = { version = "1.53.1", features = [
"fs", "fs",
"io-util", "io-util",
"net", "net",
@@ -100,14 +99,14 @@ tokio = { version = "1.52.3", features = [
"signal", "signal",
"time", "time",
] } ] }
tokio-util = { version = "0.7.18", features = ["compat"] } tokio-util = { version = "0.7.19", features = ["compat"] }
# A generic serialization/deserialization framework # A generic serialization/deserialization framework
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.151"
# A safe, extensible ORM and Query builder # A safe, extensible ORM and Query builder
diesel = { version = "2.3.9", features = ["chrono", "r2d2", "numeric"] } diesel = { version = "2.3.11", features = ["chrono", "r2d2", "numeric"] }
diesel_migrations = "2.3.2" diesel_migrations = "2.3.2"
derive_more = { version = "2.1.1", features = [ derive_more = { version = "2.1.1", features = [
@@ -117,24 +116,24 @@ derive_more = { version = "2.1.1", features = [
"from", "from",
"into", "into",
] } ] }
diesel-derive-newtype = "2.1.2" diesel-derive-newtype = "2.1.3"
# SQLite, statically bundled unless the `sqlite_system` feature is enabled # SQLite, statically bundled unless the `sqlite_system` feature is enabled
libsqlite3-sys = { version = "0.37.0", optional = true } libsqlite3-sys = { version = "0.37.0", optional = true }
# Crypto-related libraries # Crypto-related libraries
rand = "0.10.1" rand = "0.10.2"
ring = "0.17.14" ring = "0.17.14"
rustls = { version = "0.23.40", features = ["ring", "std"], default-features = false } rustls = { version = "0.23.42", features = ["ring", "std"], default-features = false }
subtle = "2.6.1" subtle = "2.6.1"
# UUID generation # UUID generation
uuid = { version = "1.23.1", features = ["v4"] } uuid = { version = "1.24.0", features = ["v4"] }
# Date and time libraries # Date and time libraries
chrono = { version = "0.4.44", default-features = false, features = ["clock", "serde"] } chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] }
chrono-tz = "0.10.4" chrono-tz = "0.10.4"
time = "0.3.47" time = "0.3.54"
# Job scheduler # Job scheduler
job_scheduler_ng = "2.4.0" job_scheduler_ng = "2.4.0"
@@ -143,7 +142,7 @@ job_scheduler_ng = "2.4.0"
data-encoding = "2.11.0" data-encoding = "2.11.0"
# JWT library # JWT library
jsonwebtoken = { version = "10.4.0", default-features = false, features = ["rust_crypto", "use_pem"] } jsonwebtoken = { version = "11.0.0", default-features = false, features = ["rust_crypto", "use_pem"] }
# TOTP library # TOTP library
totp-lite = "2.0.1" totp-lite = "2.0.1"
@@ -180,10 +179,10 @@ percent-encoding = "2.3.2" # URL encoding library used for URL's in the emails
email_address = "0.2.9" email_address = "0.2.9"
# HTML Template library # HTML Template library
handlebars = { version = "6.4.0", features = ["dir_source"] } handlebars = { version = "6.4.3", features = ["dir_source"] }
# HTTP client (Used for favicons, version check, DUO and HIBP API) # HTTP client (Used for favicons, version check, DUO and HIBP API)
reqwest = { version = "0.13.3", default-features = false, features = [ reqwest = { version = "0.13.4", default-features = false, features = [
# Misc # Misc
"charset", "charset",
"cookies", "cookies",
@@ -204,33 +203,36 @@ reqwest = { version = "0.13.3", default-features = false, features = [
hickory-resolver = "0.26.1" hickory-resolver = "0.26.1"
# Favicon extraction libraries # Favicon extraction libraries
html5gum = "0.8.3" html5gum = "0.8.4"
regex = { version = "1.12.3", default-features = false, features = [ regex = { version = "1.13.1", default-features = false, features = [
"perf", "perf",
"std", "std",
"unicode-perl", "unicode-perl",
] } ] }
data-url = "0.3.2" data-url = "0.3.2"
bytes = "1.11.1" bytes = "1.12.1"
svg-hush = "0.9.6" svg-hush = "0.9.6"
# Cache function results (Used for version check and favicon fetching) # Cache function results (Used for version check and favicon fetching)
cached = { version = "0.59.0", features = ["async"] } cached = { version = "2.0.2", features = ["async"] }
# Used for custom short lived cookie jar during favicon extraction # Used for custom short lived cookie jar during favicon extraction
cookie = "0.18.1" cookie = "0.18.1"
cookie_store = "0.22.1" cookie_store = "0.22.1"
# Used by U2F, JWT and PostgreSQL # Used by U2F, JWT and PostgreSQL
openssl = "0.10.79" openssl = "0.10.81"
# CLI argument parsing # CLI argument parsing
pico-args = "0.5.0" pico-args = "0.5.0"
# Macro ident concatenation # Macro ident concatenation
pastey = "0.2.2" pastey = "0.2.3"
governor = "0.10.4" governor = "0.10.4"
# CIDR parsing for the trusted proxies of the client IP header
ipnet = "2.12.0"
# OIDC for SSO # OIDC for SSO
openidconnect = { version = "4.0.1", default-features = false } openidconnect = { version = "4.0.1", default-features = false }
moka = { version = "0.12.15", features = ["future"] } moka = { version = "0.12.15", features = ["future"] }
@@ -240,34 +242,34 @@ semver = "1.0.28"
# Allow overriding the default memory allocator # Allow overriding the default memory allocator
# Mainly used for the musl builds, since the default musl malloc is very slow # Mainly used for the musl builds, since the default musl malloc is very slow
mimalloc = { version = "0.1.50", optional = true, default-features = false, features = ["secure"] } mimalloc = { version = "0.1.52", optional = true, default-features = false, features = ["secure"] }
which = "8.0.2" which = "8.0.5"
# Argon2 library with support for the PHC format # Argon2 library with support for the PHC format
argon2 = "0.5.3" argon2 = "0.5.3"
# Reading a password from the cli for generating the Argon2id ADMIN_TOKEN # Reading a password from the cli for generating the Argon2id ADMIN_TOKEN
rpassword = "7.5.2" rpassword = "7.5.4"
# Loading a dynamic CSS Stylesheet # Loading a dynamic CSS Stylesheet
grass_compiler = { version = "0.13.4", default-features = false } grass_compiler = { version = "0.13.4", default-features = false }
# File are accessed through Apache OpenDAL # File are accessed through Apache OpenDAL
opendal = { version = "0.56.0", default-features = false, features = ["services-fs"] } opendal = { version = "0.57.0", default-features = false, features = ["services-fs"] }
# For retrieving AWS credentials, including temporary SSO credentials # For retrieving AWS credentials, including temporary SSO credentials
aws-config = { version = "1.8.16", optional = true, default-features = false, features = [ aws-config = { version = "1.10.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.2.14", optional = true } aws-credential-types = { version = "1.3.0", optional = true }
aws-smithy-runtime-api = { version = "1.12.0", optional = true } aws-smithy-runtime-api = { version = "1.14.0", optional = true }
http = { version = "1.4.0", optional = true } http = { version = "1.4.2", optional = true }
reqsign-aws-v4 = { version = "3.0.0", optional = true } reqsign-aws-v4 = { version = "3.0.2", optional = true }
reqsign-core = { version = "3.0.0", optional = true } reqsign-core = { version = "3.1.0", optional = true }
# Strip debuginfo from the release builds # Strip debuginfo from the release builds
# The debug symbols are to provide better panic traces # The debug symbols are to provide better panic traces
+4 -4
View File
@@ -1,13 +1,13 @@
--- ---
vault_version: "v2026.4.1" vault_version: "v2026.6.4"
vault_image_digest: "sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe" vault_image_digest: "sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427"
# Cross Compile Docker Helper Scripts v1.9.0 # Cross Compile Docker Helper Scripts v1.9.0
# We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts # We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts
# https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags # https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags
xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707" xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707"
rust_version: 1.95.0 # Rust version to be used rust_version: 1.97.1 # Rust version to be used
debian_version: trixie # Debian release name to be used debian_version: trixie # Debian release name to be used
alpine_version: "3.23" # Alpine version to be used alpine_version: "3.24" # Alpine version to be used
# For which platforms/architectures will we try to build images # For which platforms/architectures will we try to build images
platforms: ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"] platforms: ["linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v6"]
# Determine the build images per OS/Arch # Determine the build images per OS/Arch
+12 -12
View File
@@ -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.4.1 # $ docker pull docker.io/vaultwarden/web-vault:v2026.6.4
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.4.1 # $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.6.4
# [docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe] # [docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427]
# #
# - 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:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe # $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427
# [docker.io/vaultwarden/web-vault:v2026.4.1] # [docker.io/vaultwarden/web-vault:v2026.6.4]
# #
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe AS vault FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427 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.95.0 AS build_amd64 FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.97.1 AS build_amd64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.95.0 AS build_arm64 FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.97.1 AS build_arm64
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.95.0 AS build_armv7 FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.97.1 AS build_armv7
FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.95.0 AS build_armv6 FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.97.1 AS build_armv6
########################## BUILD IMAGE ########################## ########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006 # hadolint ignore=DL3006
@@ -66,7 +66,7 @@ RUN USER=root cargo new --bin /app
WORKDIR /app WORKDIR /app
# Environment variables for Cargo on Alpine based builds # Environment variables for Cargo on Alpine based builds
RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \ RUN echo "export CARGO_TARGET=${CARGO_BUILD_TARGET}" >> /env-cargo && \
# Output the current contents of the file # Output the current contents of the file
cat /env-cargo cat /env-cargo
@@ -126,7 +126,7 @@ RUN source /env-cargo && \
# To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*' # To uninstall: docker run --privileged --rm tonistiigi/binfmt --uninstall 'qemu-*'
# #
# We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742 # We need to add `--platform` here, because of a podman bug: https://github.com/containers/buildah/issues/4742
FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.23 FROM --platform=$TARGETPLATFORM docker.io/library/alpine:3.24
ENV ROCKET_PROFILE="release" \ ENV ROCKET_PROFILE="release" \
ROCKET_ADDRESS=0.0.0.0 \ ROCKET_ADDRESS=0.0.0.0 \
+7 -7
View File
@@ -19,15 +19,15 @@
# - From https://hub.docker.com/r/vaultwarden/web-vault/tags, # - From https://hub.docker.com/r/vaultwarden/web-vault/tags,
# click the tag name to view the digest of the image it currently points to. # click the tag name to view the digest of the image it currently points to.
# - From the command line: # - From the command line:
# $ docker pull docker.io/vaultwarden/web-vault:v2026.4.1 # $ docker pull docker.io/vaultwarden/web-vault:v2026.6.4
# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.4.1 # $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.6.4
# [docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe] # [docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427]
# #
# - 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:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe # $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427
# [docker.io/vaultwarden/web-vault:v2026.4.1] # [docker.io/vaultwarden/web-vault:v2026.6.4]
# #
FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ca2a4251c4e63c9ad428262b4dd452789a1b9f6fce71da351e93dceed0d2edbe AS vault FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:e7d3b31ec6a991a6bf447721ea341b4192ce5d3b920929211672fd4f3f891427 AS vault
########################## Cross Compile Docker Helper Scripts ########################## ########################## Cross Compile Docker Helper Scripts ##########################
## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts ## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts
@@ -36,7 +36,7 @@ FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f
########################## BUILD IMAGE ########################## ########################## BUILD IMAGE ##########################
# hadolint ignore=DL3006 # hadolint ignore=DL3006
FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.95.0-slim-trixie AS build FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.97.1-slim-trixie AS build
COPY --from=xx / / COPY --from=xx / /
ARG TARGETARCH ARG TARGETARCH
ARG TARGETVARIANT ARG TARGETVARIANT
+1 -1
View File
@@ -106,7 +106,7 @@ WORKDIR /app
{% if base == "alpine" %} {% if base == "alpine" %}
# Environment variables for Cargo on Alpine based builds # Environment variables for Cargo on Alpine based builds
RUN echo "export CARGO_TARGET=${RUST_MUSL_CROSS_TARGET}" >> /env-cargo && \ RUN echo "export CARGO_TARGET=${CARGO_BUILD_TARGET}" >> /env-cargo && \
# Output the current contents of the file # Output the current contents of the file
cat /env-cargo cat /env-cargo
+2 -2
View File
@@ -13,8 +13,8 @@ path = "src/lib.rs"
proc-macro = true proc-macro = true
[dependencies] [dependencies]
quote = "1.0.45" quote = "1.0.47"
syn = "2.0.117" syn = "3.0.3"
[lints] [lints]
workspace = true workspace = true
@@ -40,6 +40,16 @@ test('Invite users', async ({ page }) => {
await createAccount(test, page, users.user1, mail1Buffer); await createAccount(test, page, users.user1, mail1Buffer);
await orgs.create(test, page, 'Test'); await orgs.create(test, page, 'Test');
await test.step(`Set account recovery`, async () => {
await orgs.policies(test, page, 'Test');
await page.getByRole('button', { name: 'Account recovery' }).click();
await page.getByRole('checkbox', { name: 'Turn on' }).check();
await page.getByRole('checkbox', { name: 'Require new members' }).check();
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Edited policy Account recovery');
});
await orgs.members(test, page, 'Test'); await orgs.members(test, page, 'Test');
await orgs.invite(test, page, 'Test', users.user2.email); await orgs.invite(test, page, 'Test', users.user2.email);
await orgs.invite(test, page, 'Test', users.user3.email, { await orgs.invite(test, page, 'Test', users.user3.email, {
@@ -117,3 +127,27 @@ test('Organization is visible', async ({ page }) => {
await page.getByRole('button', { name: 'vault: Test', exact: true }).click(); await page.getByRole('button', { name: 'vault: Test', exact: true }).click();
await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); await expect(page.getByLabel('Filter: Default collection')).toBeVisible();
}); });
test('Recover user password', async ({ page }) => {
await logUser(test, page, users.user1, mail1Buffer);
let newPassword = "TotoNewPassword";
await orgs.members(test, page, 'Test');
await test.step(`Rrcover ${users.user2.email}`, async () => {
await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible();
await page.getByRole('row').filter({hasText: users.user2.email}).getByLabel('Options').click();
await page.getByRole('menuitem', { name: 'Recover account' }).click();
await page.getByRole('textbox', { name: 'New master password (required)', exact: true }).fill(newPassword);
await page.getByRole('textbox', { name: 'Confirm new master password (' }).fill(newPassword);
await page.getByRole('button', { name: 'Save' }).click();
await utils.checkNotification(page, 'Password reset success');
});
let user2 = {
email: users.user2.email,
name: users.user2.name,
password: newPassword,
};
await logUser(test, page, user2, mail2Buffer);
});
+72
View File
@@ -0,0 +1,72 @@
import { test, expect, type Page, type TestInfo } from '@playwright/test';
import * as OTPAuth from "otpauth";
import * as utils from "../global-utils";
import { createAccount } from './setups/user';
let users = utils.loadEnv();
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
await utils.startVault(browser, testInfo, {});
});
test.afterAll('Teardown', async ({}) => {
utils.stopVault();
});
test('Send', async ({ browser, page }) => {
await createAccount(test, page, users.user1);
const send_url = await test.step('Create', async () => {
await page.getByRole('link', { name: 'Send' }).click();
await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'New', exact: true }).click();
await page.getByRole('menuitem', { name: 'Text' }).click();
await page.getByRole('textbox', { name: 'Send name (required)' }).fill('Test');
await page.getByRole('textbox', { name: 'Text to share (required)' }).fill('test');
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('footer').getByRole('button', { name: 'Copy link' }).click();
return await page.evaluate(() => navigator.clipboard.readText());
});
const context2 = await browser.newContext();
const page2 = await context2.newPage();
await test.step('View', async () => {
await page2.goto(send_url, { waitUntil: 'domcontentloaded' });
await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible();
await expect(await page2.getByRole('paragraph').filter({ hasText: 'Test' })).toBeVisible();
});
const pwd_url = await test.step('Create with password', async () => {
await page.getByRole('link', { name: 'Send' }).click();
await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'New', exact: true }).click();
await page.getByRole('menuitem', { name: 'Text' }).click();
await page.getByRole('textbox', { name: 'Send name (required)' }).fill('Password');
await page.getByRole('textbox', { name: 'Text to share (required)' }).fill('password');
await page.getByRole('combobox', { name: 'Who can view' }).click();
await page.getByText('Anyone with a password set by you').click();
await page.getByRole('textbox', { name: 'Password (required)' }).fill('password');
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('footer').getByRole('button', { name: 'Copy link' }).click();
return await page.evaluate(() => navigator.clipboard.readText());
});
await test.step('View with password', async () => {
await page2.goto(pwd_url, { waitUntil: 'domcontentloaded' });
await expect(page2.getByRole('heading', { name: 'Enter the password to view' })).toBeVisible();
await page2.getByRole('textbox', { name: 'Password (required)' }).fill('password');
await page2.getByRole('button', { name: 'Continue' }).click();
await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible();
await expect(await page2.getByRole('paragraph').filter({ hasText: 'Password' })).toBeVisible();
});
});
+1 -1
View File
@@ -1,4 +1,4 @@
[toolchain] [toolchain]
channel = "1.95.0" channel = "1.97.1"
components = [ "rustfmt", "clippy" ] components = [ "rustfmt", "clippy" ]
profile = "minimal" profile = "minimal"
+2 -2
View File
@@ -643,11 +643,11 @@ async fn has_http_access() -> bool {
} }
} }
use cached::proc_macro::cached; use cached::macros::cached;
/// Cache this function to prevent API call rate limit. Github only allows 60 requests per hour, and we use 3 here already /// Cache this function to prevent API call rate limit. Github only allows 60 requests per hour, and we use 3 here already
/// It will cache this function for 600 seconds (10 minutes) which should prevent the exhaustion of the rate limit /// It will cache this function for 600 seconds (10 minutes) which should prevent the exhaustion of the rate limit
/// Any cache will be lost if Vaultwarden is restarted /// Any cache will be lost if Vaultwarden is restarted
#[cached(time = 600, sync_writes = "default")] #[cached(ttl = 600, sync_writes = "default")]
async fn get_release_info(has_http_access: bool) -> (String, String, String) { async fn get_release_info(has_http_access: bool) -> (String, String, String) {
// If the HTTP Check failed, do not even attempt to check for new versions since we were not able to connect with github.com anyway. // If the HTTP Check failed, do not even attempt to check for new versions since we were not able to connect with github.com anyway.
if has_http_access { if has_http_access {
+124 -25
View File
@@ -15,7 +15,7 @@ use crate::{
core::{accept_org_invite, log_user_event, two_factor::email}, core::{accept_org_invite, log_user_event, two_factor::email},
master_password_policy, register_push_device, unregister_push_device, master_password_policy, register_push_device, unregister_push_device,
}, },
auth::{ClientHeaders, Headers, decode_delete, decode_invite, decode_verify_email}, auth::{ClientHeaders, ClientIp, Headers, decode_delete, decode_invite, decode_verify_email},
crypto, crypto,
db::{ db::{
DbConn, DbPool, DbConn, DbPool,
@@ -97,14 +97,11 @@ pub struct RegisterData {
email: String, email: String,
#[serde(flatten)] #[serde(flatten)]
kdf: KDFData, compat: RegisterDataCompat,
#[serde(alias = "userSymmetricKey")]
key: String,
#[serde(alias = "userAsymmetricKeys")] #[serde(alias = "userAsymmetricKeys")]
keys: Option<KeysData>, keys: Option<KeysData>,
master_password_hash: String,
master_password_hint: Option<String>, master_password_hint: Option<String>,
name: Option<String>, name: Option<String>,
@@ -119,6 +116,102 @@ pub struct RegisterData {
org_invite_token: Option<String>, org_invite_token: Option<String>,
} }
impl RegisterData {
fn hash(&self) -> String {
self.compat.fold(|rdc| &rdc.master_password_hash, |rdcu| &rdcu.master_password_authentication.hash).to_owned()
}
fn kdf(&self) -> &KDFData {
self.compat.fold(|rdc| &rdc.kdf, |rdcu| &rdcu.master_password_authentication.kdf)
}
fn key(&self) -> String {
self.compat.fold(|rdc| &rdc.key, |rdcu| &rdcu.master_password_unlock.key).to_owned()
}
// When comparing with salt, email need to be normalized:
// - https://github.com/bitwarden/clients/blob/web-v2026.5.0/libs/common/src/key-management/master-password/services/master-password.service.ts#L171
fn unprocessable(&self) -> bool {
let mut unprocessable = false;
*self.compat.fold(
|_| &false,
|rdcu| {
let email = self.email.trim().to_lowercase();
unprocessable = rdcu.master_password_authentication.kdf != rdcu.master_password_unlock.kdf
|| rdcu.master_password_authentication.salt != email
|| rdcu.master_password_unlock.salt != email;
&unprocessable
},
)
}
}
#[derive(Debug, Deserialize)]
struct RegisterDataOld {
#[serde(flatten)]
kdf: KDFData,
#[serde(alias = "userSymmetricKey")]
key: String,
#[serde(alias = "masterPasswordHash")]
master_password_hash: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RegisterDataCur {
master_password_authentication: MasterPasswordAuthentication,
master_password_unlock: MasterPasswordUnlock,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum RegisterDataCompat {
RegisterDataOld(RegisterDataOld),
RegisterDataCur(RegisterDataCur),
}
impl RegisterDataCompat {
fn fold<'a, T>(
&'a self,
fct: impl FnOnce(&'a RegisterDataOld) -> &'a T,
fcu: impl FnOnce(&'a RegisterDataCur) -> &'a T,
) -> &'a T {
match self {
RegisterDataCompat::RegisterDataOld(rdc) => fct(rdc),
RegisterDataCompat::RegisterDataCur(rdcu) => fcu(rdcu),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KeysData {
encrypted_private_key: String,
public_key: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MasterPasswordAuthentication {
kdf: KDFData,
salt: String,
#[serde(alias = "masterPasswordAuthenticationHash")]
hash: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MasterPasswordUnlock {
kdf: KDFData,
salt: String,
#[serde(alias = "masterKeyWrappedUserKey")]
key: String,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SetPasswordData { pub struct SetPasswordData {
@@ -132,13 +225,6 @@ pub struct SetPasswordData {
org_identifier: Option<String>, org_identifier: Option<String>,
} }
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KeysData {
encrypted_private_key: String,
public_key: String,
}
/// Trims whitespace from password hints, and converts blank password hints to `None`. /// Trims whitespace from password hints, and converts blank password hints to `None`.
fn clean_password_hint(password_hint: Option<&String>) -> Option<String> { fn clean_password_hint(password_hint: Option<&String>) -> Option<String> {
match password_hint { match password_hint {
@@ -177,6 +263,10 @@ pub async fn register(data: Json<RegisterData>, email_verification: bool, conn:
let mut pending_emergency_access = None; let mut pending_emergency_access = None;
if data.unprocessable() {
err_code!("Unexpected RegisterData format", Status::UnprocessableEntity.code);
}
// First, validate the provided verification tokens // First, validate the provided verification tokens
if email_verification { if email_verification {
match ( match (
@@ -257,8 +347,8 @@ pub async fn register(data: Json<RegisterData>, email_verification: bool, conn:
err!("Registration not allowed or user already exists") err!("Registration not allowed or user already exists")
} }
if let Some(token) = data.org_invite_token { if let Some(token) = data.org_invite_token.as_ref() {
let claims = decode_invite(&token)?; let claims = decode_invite(token)?;
if claims.email == email { if claims.email == email {
// Verify the email address when signing up via a valid invite token // Verify the email address when signing up via a valid invite token
email_verified = true; email_verified = true;
@@ -296,9 +386,9 @@ pub async fn register(data: Json<RegisterData>, email_verification: bool, conn:
// Make sure we don't leave a lingering invitation. // Make sure we don't leave a lingering invitation.
Invitation::take(&email, &conn).await; Invitation::take(&email, &conn).await;
set_kdf_data(&mut user, &data.kdf)?; set_kdf_data(&mut user, data.kdf())?;
user.set_password(&data.master_password_hash, Some(data.key), true, None, &conn).await?; user.set_password(&data.hash(), Some(data.key()), true, None, &conn).await?;
user.password_hint = password_hint; user.password_hint = password_hint;
// Add extra fields if present // Add extra fields if present
@@ -603,10 +693,6 @@ struct UnlockData {
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct ChangeKdfData { struct ChangeKdfData {
#[allow(dead_code)]
new_master_password_hash: String,
#[allow(dead_code)]
key: String,
authentication_data: AuthenticationData, authentication_data: AuthenticationData,
unlock_data: UnlockData, unlock_data: UnlockData,
master_password_hash: String, master_password_hash: String,
@@ -1107,7 +1193,9 @@ struct DeleteRecoverData {
} }
#[post("/accounts/delete-recover", data = "<data>")] #[post("/accounts/delete-recover", data = "<data>")]
async fn post_delete_recover(data: Json<DeleteRecoverData>, conn: DbConn) -> EmptyResult { async fn post_delete_recover(data: Json<DeleteRecoverData>, ip: ClientIp, conn: DbConn) -> EmptyResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
let data: DeleteRecoverData = data.into_inner(); let data: DeleteRecoverData = data.into_inner();
if CONFIG.mail_enabled() { if CONFIG.mail_enabled() {
@@ -1180,9 +1268,11 @@ struct PasswordHintData {
} }
#[post("/accounts/password-hint", data = "<data>")] #[post("/accounts/password-hint", data = "<data>")]
async fn password_hint(data: Json<PasswordHintData>, conn: DbConn) -> EmptyResult { async fn password_hint(data: Json<PasswordHintData>, ip: ClientIp, conn: DbConn) -> EmptyResult {
const NO_HINT: &str = "Sorry, you have no password hint..."; const NO_HINT: &str = "Sorry, you have no password hint...";
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
if !CONFIG.password_hints_allowed() || (!CONFIG.mail_enabled() && !CONFIG.show_password_hint()) { if !CONFIG.password_hints_allowed() || (!CONFIG.mail_enabled() && !CONFIG.show_password_hint()) {
err!("This server is not configured to provide password hints."); err!("This server is not configured to provide password hints.");
} }
@@ -1244,6 +1334,13 @@ pub async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
"kdfIterations": kdf_iter, "kdfIterations": kdf_iter,
"kdfMemory": kdf_mem, "kdfMemory": kdf_mem,
"kdfParallelism": kdf_para, "kdfParallelism": kdf_para,
"kdfSettings": {
"iterations": kdf_iter,
"kdfType": kdf_type,
"memory": kdf_mem,
"parallelism": kdf_para
},
"salt": null,
})) }))
} }
@@ -1420,7 +1517,9 @@ async fn put_device_token(device_id: DeviceId, data: Json<PushToken>, headers: H
} }
#[put("/devices/identifier/<device_id>/clear-token")] #[put("/devices/identifier/<device_id>/clear-token")]
async fn put_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResult { async fn put_clear_device_token(device_id: DeviceId, ip: ClientIp, conn: DbConn) -> EmptyResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
// This only clears push token // This only clears push token
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Controllers/DevicesController.cs#L215 // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Controllers/DevicesController.cs#L215
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Services/Implementations/DeviceService.cs#L37 // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/Services/Implementations/DeviceService.cs#L37
@@ -1442,8 +1541,8 @@ async fn put_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResul
// On upstream server, both PUT and POST are declared. Implementing the POST method in case it would be useful somewhere // On upstream server, both PUT and POST are declared. Implementing the POST method in case it would be useful somewhere
#[post("/devices/identifier/<device_id>/clear-token")] #[post("/devices/identifier/<device_id>/clear-token")]
async fn post_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResult { async fn post_clear_device_token(device_id: DeviceId, ip: ClientIp, conn: DbConn) -> EmptyResult {
put_clear_device_token(device_id, conn).await put_clear_device_token(device_id, ip, conn).await
} }
#[get("/tasks")] #[get("/tasks")]
+14 -5
View File
@@ -450,7 +450,9 @@ pub async fn update_cipher_from_data(
match Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &org_id, conn).await { match Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &org_id, conn).await {
None => err!("You don't have permission to add item to organization"), None => err!("You don't have permission to add item to organization"),
Some(member) => { Some(member) => {
if shared_to_collections.is_some() // A non-empty list of collections implies the caller already validated the user's write
// access to them, so we can move the cipher into the organization on that basis.
if shared_to_collections.as_ref().is_some_and(|cols| !cols.is_empty())
|| member.has_full_access() || member.has_full_access()
|| cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await || cipher.is_write_accessible_to_user(&headers.user.uuid, conn).await
{ {
@@ -629,7 +631,7 @@ async fn post_ciphers_import(data: Json<ImportData>, headers: Headers, conn: DbC
// Read and create the ciphers // Read and create the ciphers
for (index, mut cipher_data) in data.ciphers.into_iter().enumerate() { for (index, mut cipher_data) in data.ciphers.into_iter().enumerate() {
let folder_id = relations_map.get(&index).map(|i| folders[*i].clone()); let folder_id = relations_map.get(&index).and_then(|i| folders.get(*i).cloned());
cipher_data.folder_id = folder_id; cipher_data.folder_id = folder_id;
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
@@ -1043,6 +1045,13 @@ async fn share_cipher_by_uuid(
err!("Cipher doesn't exist") err!("Cipher doesn't exist")
}; };
// `update_cipher_from_data()` rejects this too, but only after the collections below were
// already linked. There are no transactions, so that would leave the cipher linked to a
// collection of another organization.
if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.cipher.organization_id {
err!("Organization mismatch. Please resync the client before updating the cipher")
}
let mut shared_to_collections = vec![]; let mut shared_to_collections = vec![];
if let Some(organization_id) = &data.cipher.organization_id { if let Some(organization_id) = &data.cipher.organization_id {
@@ -2135,9 +2144,9 @@ impl CipherSyncData {
// Organization Sync does not support Folders, Favorites, or Archives. // Organization Sync does not support Folders, Favorites, or Archives.
// If these are set, it will cause issues in the web-vault. // If these are set, it will cause issues in the web-vault.
CipherSyncType::Organization => { CipherSyncType::Organization => {
cipher_folders = HashMap::with_capacity(0); cipher_folders = HashMap::new();
cipher_favorites = HashSet::with_capacity(0); cipher_favorites = HashSet::new();
cipher_archives = HashMap::with_capacity(0); cipher_archives = HashMap::new();
} }
} }
+10 -4
View File
@@ -52,7 +52,7 @@ async fn get_org_events(org_id: OrganizationId, data: EventRange, headers: Admin
.map(Event::to_json) .map(Event::to_json)
.collect() .collect()
} else { } else {
Vec::with_capacity(0) Vec::new()
}; };
Ok(Json(json!({ Ok(Json(json!({
@@ -78,7 +78,7 @@ async fn get_cipher_events(cipher_id: CipherId, data: EventRange, headers: Heade
Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn).await.iter().map(Event::to_json).collect() Event::find_by_cipher_uuid(&cipher_id, &start_date, &end_date, &conn).await.iter().map(Event::to_json).collect()
} else { } else {
Vec::with_capacity(0) Vec::new()
}; };
Ok(Json(json!({ Ok(Json(json!({
@@ -115,7 +115,7 @@ async fn get_user_events(
.map(Event::to_json) .map(Event::to_json)
.collect() .collect()
} else { } else {
Vec::with_capacity(0) Vec::new()
}; };
Ok(Json(json!({ Ok(Json(json!({
@@ -182,7 +182,10 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
.await; .await;
} }
1600..=1699 => { 1600..=1699 => {
if let Some(org_id) = &event.organization_id { // Only allow logging events for an organization the user is actually a member of.
if let Some(org_id) = &event.organization_id
&& Membership::find_confirmed_by_user_and_org(&headers.user.uuid, org_id, &conn).await.is_some()
{
log_event_impl( log_event_impl(
event.r#type, event.r#type,
org_id, org_id,
@@ -197,8 +200,11 @@ async fn post_events_collect(data: Json<Vec<EventCollection>>, headers: Headers,
} }
} }
_ => { _ => {
// The cipher determines the organization the event is logged to, so make sure the
// user can actually access it instead of trusting the provided cipher uuid.
if let Some(cipher_uuid) = &event.cipher_id if let Some(cipher_uuid) = &event.cipher_id
&& let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await && let Some(cipher) = Cipher::find_by_uuid(cipher_uuid, &conn).await
&& cipher.is_accessible_to_user(&headers.user.uuid, &conn).await
&& let Some(org_id) = cipher.organization_uuid && let Some(org_id) = cipher.organization_uuid
{ {
log_event_impl( log_event_impl(
+9 -2
View File
@@ -228,14 +228,17 @@ fn config() -> Json<Value> {
// Version history: // Version history:
// - Individual cipher key encryption: 2024.2.0 // - Individual cipher key encryption: 2024.2.0
// - Mobile app support for MasterPasswordUnlockData: 2025.8.0 // - Mobile app support for MasterPasswordUnlockData: 2025.8.0
"version": "2025.12.0", "version": "2026.6.0",
"gitHash": option_env!("GIT_REV"), "gitHash": option_env!("GIT_REV"),
"server": { "server": {
"name": "Vaultwarden", "name": "Vaultwarden",
"url": "https://github.com/dani-garcia/vaultwarden" "url": "https://github.com/dani-garcia/vaultwarden"
}, },
"settings": { "settings": {
"disableUserRegistration": CONFIG.is_signup_disabled() "disableUserRegistration": CONFIG.is_signup_disabled(),
// When enabled, this setting signals to clients that onboarding interstitials
// (post-login welcome dialogs, extension install prompts, setup extension redirects, and premium upsell modals) should be suppressed
"suppressOnboardingInterstitials": false
}, },
"environment": { "environment": {
"vault": domain, "vault": domain,
@@ -251,6 +254,10 @@ fn config() -> Json<Value> {
"vapidPublicKey": null "vapidPublicKey": null
}, },
"featureStates": feature_states, "featureStates": feature_states,
// Not supported right now
// Used for by clients to learn if the server requires extra work to establish a connection.
// See: https://github.com/bitwarden/server/pull/6892 | https://github.com/bitwarden/server/commit/52955d1860b4dfb905f67bbe39d9b10bbd61ded0
"communication": null,
"object": "config", "object": "config",
})) }))
} }
+117 -32
View File
@@ -96,6 +96,7 @@ pub fn routes() -> Vec<Route> {
put_reset_password_enrollment, put_reset_password_enrollment,
get_reset_password_details, get_reset_password_details,
put_reset_password, put_reset_password,
put_recover_account,
get_org_export, get_org_export,
post_api_key, post_api_key,
rotate_api_key, rotate_api_key,
@@ -469,7 +470,7 @@ async fn get_org_collections_details(org_id: OrganizationId, headers: ManagerHea
.map(CollectionGroup::to_json_details_for_group) .map(CollectionGroup::to_json_details_for_group)
.collect() .collect()
} else { } else {
Vec::with_capacity(0) Vec::new()
}; };
let mut json_object = col.to_json_details(&headers.user.uuid, None, &conn).await; let mut json_object = col.to_json_details(&headers.user.uuid, None, &conn).await;
@@ -576,6 +577,13 @@ async fn post_bulk_access_collections(
err!("Can't find organization details") err!("Can't find organization details")
} }
// The collections and members are checked below, the groups only here.
let org_groups = Group::find_by_organization(&org_id, &conn).await;
let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|g| &g.uuid).collect();
if let Some(g) = data.groups.iter().find(|g| !org_group_ids.contains(&g.id)) {
err!("Invalid group", format!("Group {} does not belong to organization {}!", g.id, org_id))
}
for col_id in data.collection_ids { for col_id in data.collection_ids {
let Some(collection) = Collection::find_by_uuid_and_org(&col_id, &org_id, &conn).await else { let Some(collection) = Collection::find_by_uuid_and_org(&col_id, &org_id, &conn).await else {
err!("Collection not found") err!("Collection not found")
@@ -805,7 +813,7 @@ async fn get_org_collection_detail(
} else { } else {
// The Bitwarden clients seem to call this API regardless of whether groups are enabled, // The Bitwarden clients seem to call this API regardless of whether groups are enabled,
// so just act as if there are no groups. // so just act as if there are no groups.
Vec::with_capacity(0) Vec::new()
}; };
// Generate a HashMap to get the correct MembershipType per user to determine the manage permission // Generate a HashMap to get the correct MembershipType per user to determine the manage permission
@@ -945,6 +953,11 @@ async fn get_members(
if org_id != headers.membership.org_uuid { if org_id != headers.membership.org_uuid {
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
} }
if !headers.membership.has_full_access() {
err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code);
}
let mut users_json = Vec::new(); let mut users_json = Vec::new();
for u in Membership::find_by_org(&org_id, &conn).await { for u in Membership::find_by_org(&org_id, &conn).await {
users_json.push( users_json.push(
@@ -1089,10 +1102,14 @@ async fn send_invite(
err!(format!("User already in organization: {email}")) err!(format!("User already in organization: {email}"))
} }
if !CONFIG.mail_enabled() {
if user.password_hash.is_empty() {
Invitation::new(email).save(&conn).await?;
} else {
// automatically accept existing users if mail is disabled // automatically accept existing users if mail is disabled
if !CONFIG.mail_enabled() && !user.password_hash.is_empty() {
member_status = MembershipStatus::Accepted as i32; member_status = MembershipStatus::Accepted as i32;
} }
}
user user
} }
}; };
@@ -1162,6 +1179,9 @@ async fn send_invite(
} }
for group_id in &data.groups { for group_id in &data.groups {
if Group::find_by_uuid_and_org(group_id, &org_id, &conn).await.is_none() {
err!("Group not found in Organization")
}
let mut group_entry = GroupUser::new(group_id.clone(), new_member.uuid.clone()); let mut group_entry = GroupUser::new(group_id.clone(), new_member.uuid.clone());
group_entry.save(&conn).await?; group_entry.save(&conn).await?;
} }
@@ -1609,6 +1629,9 @@ async fn edit_member(
GroupUser::delete_all_by_member(&member_to_edit.uuid, &conn).await?; GroupUser::delete_all_by_member(&member_to_edit.uuid, &conn).await?;
for group_id in data.groups.iter().flatten() { for group_id in data.groups.iter().flatten() {
if Group::find_by_uuid_and_org(group_id, &org_id, &conn).await.is_none() {
err!("Group not found in Organization")
}
let mut group_entry = GroupUser::new(group_id.clone(), member_to_edit.uuid.clone()); let mut group_entry = GroupUser::new(group_id.clone(), member_to_edit.uuid.clone());
group_entry.save(&conn).await?; group_entry.save(&conn).await?;
} }
@@ -1713,6 +1736,15 @@ async fn delete_member_impl(
if let Some(user) = User::find_by_uuid(&member_to_delete.user_uuid, conn).await { if let Some(user) = User::find_by_uuid(&member_to_delete.user_uuid, conn).await {
nt.send_user_update(UpdateType::SyncOrgKeys, &user, headers.device.push_uuid.as_ref(), conn).await; nt.send_user_update(UpdateType::SyncOrgKeys, &user, headers.device.push_uuid.as_ref(), conn).await;
if !CONFIG.mail_enabled()
&& !Membership::find_invited_by_user(&user.uuid, conn)
.await
.into_iter()
.any(|m| m.uuid != member_to_delete.uuid)
{
Invitation::take(&user.email, conn).await;
}
} }
member_to_delete.delete(conn).await member_to_delete.delete(conn).await
@@ -1799,19 +1831,19 @@ async fn post_org_import(
// TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks. // TODO: See if we can optimize the whole cipher adding/importing and prevent duplicate code and checks.
Cipher::validate_cipher_data(&data.ciphers)?; Cipher::validate_cipher_data(&data.ciphers)?;
let existing_collections: HashSet<Option<CollectionId>> = let existing_collections: HashMap<CollectionId, Collection> =
Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| Some(c.uuid)).collect(); Collection::find_by_organization(&org_id, &conn).await.into_iter().map(|c| (c.uuid.clone(), c)).collect();
let mut collections: Vec<CollectionId> = Vec::with_capacity(data.collections.len()); let mut collections: Vec<CollectionId> = Vec::with_capacity(data.collections.len());
for col in data.collections { for col in data.collections {
let collection_uuid = if existing_collections.contains(&col.id) { let existing = col.id.as_ref().and_then(|col_id| existing_collections.get(col_id));
let col_id = col.id.unwrap(); let collection_uuid = if let Some(collection) = existing {
// When not an Owner or Admin, check if the member is allowed to access the collection. // When not an Owner or Admin, check if the member is allowed to write to the collection.
if headers.membership.atype < MembershipType::Admin if headers.membership.atype < MembershipType::Admin
&& !Collection::can_access_collection(&headers.membership, &col_id, &conn).await && !collection.is_writable_by_user(&headers.membership.user_uuid, &conn).await
{ {
err!(Compact, "The current user isn't allowed to manage this collection") err!(Compact, "The current user isn't allowed to manage this collection")
} }
col_id collection.uuid.clone()
} else { } else {
// We do not allow users or managers which can not manage all collections to create new collections // We do not allow users or managers which can not manage all collections to create new collections
// If there is any collection other than an existing import collection, abort the import. // If there is any collection other than an existing import collection, abort the import.
@@ -1839,6 +1871,8 @@ async fn post_org_import(
for mut cipher_data in data.ciphers { for mut cipher_data in data.ciphers {
// Always clear folder_id's via an organization import // Always clear folder_id's via an organization import
cipher_data.folder_id = None; cipher_data.folder_id = None;
// Replace the client-provided, unvalidated organizationId with the real target org
cipher_data.organization_id = Some(org_id.clone());
let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone());
update_cipher_from_data( update_cipher_from_data(
&mut cipher, &mut cipher,
@@ -1856,8 +1890,9 @@ async fn post_org_import(
// Assign the collections // Assign the collections
for (cipher_index, col_index) in relations { for (cipher_index, col_index) in relations {
let cipher_id = &ciphers[cipher_index]; let (Some(cipher_id), Some(col_id)) = (ciphers.get(cipher_index), collections.get(col_index)) else {
let col_id = &collections[col_index]; err!(Compact, "Invalid collection relationship")
};
CollectionCipher::save(cipher_id, col_id, &conn).await?; CollectionCipher::save(cipher_id, col_id, &conn).await?;
} }
@@ -2020,18 +2055,27 @@ struct PolicyData {
data: Option<Value>, data: Option<Value>,
} }
#[derive(Deserialize)]
struct PutPolicy {
policy: PolicyData,
// Ignore metadata for now as we do not yet support this
// "metadata": {
// "defaultUserCollectionName": "2.xx|xx==|xx="
// }
}
#[put("/organizations/<org_id>/policies/<pol_type>", data = "<data>")] #[put("/organizations/<org_id>/policies/<pol_type>", data = "<data>")]
async fn put_policy( async fn put_policy(
org_id: OrganizationId, org_id: OrganizationId,
pol_type: i32, pol_type: i32,
data: Json<PolicyData>, data: Json<PutPolicy>,
headers: AdminHeaders, headers: AdminHeaders,
conn: DbConn, conn: DbConn,
) -> JsonResult { ) -> JsonResult {
if org_id != headers.org_id { if org_id != headers.org_id {
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
} }
let data: PolicyData = data.into_inner(); let data: PolicyData = data.into_inner().policy;
let Some(pol_type_enum) = OrgPolicyType::from_i32(pol_type) else { let Some(pol_type_enum) = OrgPolicyType::from_i32(pol_type) else {
err!("Invalid or unsupported policy type") err!("Invalid or unsupported policy type")
@@ -2139,26 +2183,16 @@ async fn put_policy(
Ok(Json(policy.to_json())) Ok(Json(policy.to_json()))
} }
#[derive(Deserialize)] // Deprecated with client v2026.5.0
struct PolicyDataVnext {
policy: PolicyData,
// Ignore metadata for now as we do not yet support this
// "metadata": {
// "defaultUserCollectionName": "2.xx|xx==|xx="
// }
}
#[put("/organizations/<org_id>/policies/<pol_type>/vnext", data = "<data>")] #[put("/organizations/<org_id>/policies/<pol_type>/vnext", data = "<data>")]
async fn put_policy_vnext( async fn put_policy_vnext(
org_id: OrganizationId, org_id: OrganizationId,
pol_type: i32, pol_type: i32,
data: Json<PolicyDataVnext>, data: Json<PutPolicy>,
headers: AdminHeaders, headers: AdminHeaders,
conn: DbConn, conn: DbConn,
) -> JsonResult { ) -> JsonResult {
let data: PolicyDataVnext = data.into_inner(); put_policy(org_id, pol_type, data, headers, conn).await
let policy: PolicyData = data.policy;
put_policy(org_id, pol_type, Json(policy), headers, conn).await
} }
#[get("/plans")] #[get("/plans")]
@@ -2428,6 +2462,23 @@ async fn get_groups_data(
if org_id != headers.membership.org_uuid { if org_id != headers.membership.org_uuid {
err!("Organization not found", "Organization id's do not match"); err!("Organization not found", "Organization id's do not match");
} }
// The details view (group→collection/user mappings) needs full org access; the plain list only
// needs manage access to a collection, so a manager of a collection (directly or via a group)
// can load it to assign groups.
let has_full_access = headers.membership.has_full_access()
|| (CONFIG.org_groups_enabled()
&& GroupUser::has_full_access_by_member(&org_id, &headers.membership.uuid, &conn).await);
let allowed = if details {
has_full_access
} else {
has_full_access
|| Collection::has_manageable_collection_by_user(&org_id, &headers.membership.user_uuid, &conn).await
};
if !allowed {
err_code!("Resource not found.", "User does not have access", rocket::http::Status::NotFound.code);
}
let groups: Vec<Value> = if CONFIG.org_groups_enabled() { let groups: Vec<Value> = if CONFIG.org_groups_enabled() {
let groups = Group::find_by_organization(&org_id, &conn).await; let groups = Group::find_by_organization(&org_id, &conn).await;
let mut groups_json = Vec::with_capacity(groups.len()); let mut groups_json = Vec::with_capacity(groups.len());
@@ -2445,7 +2496,7 @@ async fn get_groups_data(
} else { } else {
// The Bitwarden clients seem to call this API regardless of whether groups are enabled, // The Bitwarden clients seem to call this API regardless of whether groups are enabled,
// so just act as if there are no groups. // so just act as if there are no groups.
Vec::with_capacity(0) Vec::new()
}; };
Ok(Json(json!({ Ok(Json(json!({
@@ -2875,9 +2926,14 @@ struct OrganizationUserResetPasswordEnrollmentRequest {
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct OrganizationUserResetPasswordRequest { struct OrganizationUserRecoverAccountRequest {
new_master_password_hash: String, new_master_password_hash: String,
key: String, key: String,
#[serde(default)]
reset_master_password: bool,
#[serde(default)]
reset_two_factor: bool,
} }
// Upstream reports this is the renamed endpoint instead of `/keys` // Upstream reports this is the renamed endpoint instead of `/keys`
@@ -2905,12 +2961,43 @@ async fn get_organization_keys(org_id: OrganizationId, headers: OrgMemberHeaders
get_organization_public_key(org_id, headers, conn).await get_organization_public_key(org_id, headers, conn).await
} }
// Will allow to reset 2FA too
// https://github.com/bitwarden/clients/blob/web-v2026.4.2/libs/admin-console/src/common/organization-user/models/requests/organization-user-reset-password.request.ts
#[put("/organizations/<org_id>/users/<member_id>/recover-account", data = "<data>")]
async fn put_recover_account(
org_id: OrganizationId,
member_id: MembershipId,
headers: AdminHeaders,
data: Json<OrganizationUserRecoverAccountRequest>,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
let req = data.into_inner();
if req.reset_master_password && !req.reset_two_factor {
recover_account(org_id, member_id, headers, req, conn, nt).await
} else {
err!("Unsupported operation")
}
}
// Deprecated since `v2026.4.2`
#[put("/organizations/<org_id>/users/<member_id>/reset-password", data = "<data>")] #[put("/organizations/<org_id>/users/<member_id>/reset-password", data = "<data>")]
async fn put_reset_password( async fn put_reset_password(
org_id: OrganizationId, org_id: OrganizationId,
member_id: MembershipId, member_id: MembershipId,
headers: AdminHeaders, headers: AdminHeaders,
data: Json<OrganizationUserResetPasswordRequest>, data: Json<OrganizationUserRecoverAccountRequest>,
conn: DbConn,
nt: Notify<'_>,
) -> EmptyResult {
recover_account(org_id, member_id, headers, data.into_inner(), conn, nt).await
}
async fn recover_account(
org_id: OrganizationId,
member_id: MembershipId,
headers: AdminHeaders,
reset_request: OrganizationUserRecoverAccountRequest,
conn: DbConn, conn: DbConn,
nt: Notify<'_>, nt: Notify<'_>,
) -> EmptyResult { ) -> EmptyResult {
@@ -2944,8 +3031,6 @@ async fn put_reset_password(
err!(format!("Error sending user reset password email: {e:#?}")); err!(format!("Error sending user reset password email: {e:#?}"));
} }
let reset_request = data.into_inner();
let mut user = user; let mut user = user;
user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None, &conn) user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None, &conn)
.await?; .await?;
+9 -2
View File
@@ -14,7 +14,7 @@ use crate::{
db::{ db::{
DbConn, DbConn,
models::{ models::{
Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, Organization, Group, GroupUser, Invitation, Membership, MembershipStatus, MembershipType, OrgPolicy, Organization,
OrganizationApiKey, OrganizationId, User, OrganizationApiKey, OrganizationId, User,
}, },
}, },
@@ -84,8 +84,15 @@ async fn ldap_import(data: Json<OrgImportData>, token: PublicToken, conn: DbConn
} }
// If user is part of the organization, restore it // If user is part of the organization, restore it
} else if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await { } else if let Some(mut member) = Membership::find_by_email_and_org(&user_data.email, &org_id, &conn).await {
let restored = member.restore(); let mut restored = member.restore();
let ext_modified = member.set_external_id(Some(user_data.external_id.clone())); let ext_modified = member.set_external_id(Some(user_data.external_id.clone()));
// Enforce org policies as every other restore path does.
// If the user is not allowed, we revoke again and continue so the external_id is still updated.
if restored && let Err(e) = OrgPolicy::check_user_allowed(&member, "restore", &conn).await {
warn!("Not restoring {}: {e:?}", user_data.email);
member.revoke();
restored = false;
}
if restored || ext_modified { if restored || ext_modified {
member.save(&conn).await?; member.save(&conn).await?;
} }
+67 -31
View File
@@ -12,7 +12,7 @@ use serde_json::Value;
use crate::{ use crate::{
CONFIG, CONFIG,
api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType}, api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType},
auth::{ClientIp, Headers, Host}, auth::{ClientIp, Headers, Host, SendHeaders},
config::PathType, config::PathType,
db::{ db::{
DbConn, DbPool, DbConn, DbPool,
@@ -48,7 +48,9 @@ pub fn routes() -> Vec<rocket::Route> {
post_send, post_send,
post_send_file, post_send_file,
post_access, post_access,
post_access_legacy,
post_access_file, post_access_file,
post_access_file_legacy,
put_send, put_send,
delete_send, delete_send,
put_remove_password, put_remove_password,
@@ -78,6 +80,7 @@ pub struct SendData {
deletion_date: DateTime<Utc>, deletion_date: DateTime<Utc>,
disabled: bool, disabled: bool,
hide_email: Option<bool>, hide_email: Option<bool>,
emails: Option<String>,
// Data field // Data field
name: String, name: String,
@@ -148,6 +151,10 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult<Send> {
); );
} }
if data.emails.is_some() {
err!("Sends with email verification is not supported");
}
let mut send = Send::new(data.r#type, data.name, data_str, data.key, data.deletion_date.naive_utc()); let mut send = Send::new(data.r#type, data.name, data_str, data.key, data.deletion_date.naive_utc());
send.user_uuid = Some(user_id); send.user_uuid = Some(user_id);
send.notes = data.notes; send.notes = data.notes;
@@ -371,7 +378,7 @@ pub struct SendFileData {
} }
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L195 // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L195
#[post("/sends/<send_id>/file/<file_id>", format = "multipart/form-data", data = "<data>")] #[post("/sends/<send_id>/file/<file_id>", format = "multipart/form-data", data = "<data>", rank = 2)]
async fn post_send_file_v2_data( async fn post_send_file_v2_data(
send_id: SendId, send_id: SendId,
file_id: SendFileId, file_id: SendFileId,
@@ -441,20 +448,34 @@ async fn post_send_file_v2_data(
Ok(()) Ok(())
} }
#[post("/sends/access")]
async fn post_access(headers: SendHeaders, conn: DbConn, nt: Notify<'_>) -> JsonResult {
let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404)
};
if !send.is_accessible() {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
process_access(send, conn, nt).await
}
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct SendAccessData { pub struct SendAccessData {
pub password: Option<String>, pub password: Option<String>,
} }
// Legacy since web-2026.6.0
#[post("/sends/access/<access_id>", data = "<data>")] #[post("/sends/access/<access_id>", data = "<data>")]
async fn post_access( async fn post_access_legacy(
access_id: &str, access_id: &str,
data: Json<SendAccessData>, data: Json<SendAccessData>,
conn: DbConn, conn: DbConn,
ip: ClientIp, ip: ClientIp,
nt: Notify<'_>, nt: Notify<'_>,
) -> JsonResult { ) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
let Some(mut send) = Send::find_by_access_id(access_id, &conn).await else { let Some(mut send) = Send::find_by_access_id(access_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
}; };
@@ -465,17 +486,7 @@ async fn post_access(
err_code!(SEND_INACCESSIBLE_MSG, 404); err_code!(SEND_INACCESSIBLE_MSG, 404);
} }
if let Some(expiration) = send.expiration_date if !send.is_accessible() {
&& Utc::now().naive_utc() >= expiration
{
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
if Utc::now().naive_utc() >= send.deletion_date {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
if send.disabled {
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
} }
@@ -489,11 +500,17 @@ async fn post_access(
// Files are incremented during the download // Files are incremented during the download
if send.atype == SendType::Text as i32 { if send.atype == SendType::Text as i32 {
send.access_count += 1; if !send.register_access(&conn).await? {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
} else {
send.save(&conn).await?;
} }
send.save(&conn).await?; process_access(send, conn, nt).await
}
async fn process_access(send: Send, conn: DbConn, nt: Notify<'_>) -> JsonResult {
nt.send_send_update( nt.send_send_update(
UpdateType::SyncSendUpdate, UpdateType::SyncSendUpdate,
&send, &send,
@@ -506,15 +523,36 @@ async fn post_access(
Ok(Json(send.to_json_access(&conn).await)) Ok(Json(send.to_json_access(&conn).await))
} }
#[post("/sends/<send_id>/access/file/<file_id>", data = "<data>")] #[post("/sends/access/file/<file_id>", rank = 1)]
async fn post_access_file( async fn post_access_file(
file_id: SendFileId,
headers: SendHeaders,
host: Host,
conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404)
};
if !send.is_accessible() {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
process_access_file(send, file_id, host, conn, nt).await
}
// Legacy since web-2026.6.0
#[post("/sends/<send_id>/access/file/<file_id>", data = "<data>")]
async fn post_access_file_legacy(
send_id: SendId, send_id: SendId,
file_id: SendFileId, file_id: SendFileId,
data: Json<SendAccessData>, data: Json<SendAccessData>,
host: Host, host: Host,
conn: DbConn, conn: DbConn,
ip: ClientIp,
nt: Notify<'_>, nt: Notify<'_>,
) -> JsonResult { ) -> JsonResult {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
let Some(mut send) = Send::find_by_uuid(&send_id, &conn).await else { let Some(mut send) = Send::find_by_uuid(&send_id, &conn).await else {
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
}; };
@@ -525,17 +563,7 @@ async fn post_access_file(
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
} }
if let Some(expiration) = send.expiration_date if !send.is_accessible() {
&& Utc::now().naive_utc() >= expiration
{
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
if Utc::now().naive_utc() >= send.deletion_date {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
if send.disabled {
err_code!(SEND_INACCESSIBLE_MSG, 404) err_code!(SEND_INACCESSIBLE_MSG, 404)
} }
@@ -547,10 +575,14 @@ async fn post_access_file(
} }
} }
send.access_count += 1; if !send.register_access(&conn).await? {
err_code!(SEND_INACCESSIBLE_MSG, 404)
}
send.save(&conn).await?; process_access_file(send, file_id, host, conn, nt).await
}
async fn process_access_file(send: Send, file_id: SendFileId, host: Host, conn: DbConn, nt: Notify<'_>) -> JsonResult {
nt.send_send_update( nt.send_send_update(
UpdateType::SyncSendUpdate, UpdateType::SyncSendUpdate,
&send, &send,
@@ -563,7 +595,7 @@ async fn post_access_file(
Ok(Json(json!({ Ok(Json(json!({
"object": "send-fileDownload", "object": "send-fileDownload",
"id": file_id, "id": file_id,
"url": download_url(&host, &send_id, &file_id).await?, "url": download_url(&host, &send.uuid, &file_id).await?,
}))) })))
} }
@@ -601,6 +633,10 @@ async fn put_send(send_id: SendId, data: Json<SendData>, headers: Headers, conn:
err!("Send not found", "Send send_id is invalid or does not belong to user") err!("Send not found", "Send send_id is invalid or does not belong to user")
}; };
if data.emails.is_some() {
err!("Sends with email verification is not supported");
}
update_send_from_data(&mut send, data, &headers, &conn, &nt, UpdateType::SyncSendUpdate).await?; update_send_from_data(&mut send, data, &headers, &conn, &nt, UpdateType::SyncSendUpdate).await?;
Ok(Json(send.to_json())) Ok(Json(send.to_json()))
+17 -1
View File
@@ -65,7 +65,7 @@ static CLIENT: LazyLock<Client> = LazyLock::new(|| {
let icon_download_timeout = Duration::from_secs(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() get_reqwest_client_builder(true)
.cookie_provider(Arc::clone(&cookie_store)) .cookie_provider(Arc::clone(&cookie_store))
.timeout(icon_download_timeout) .timeout(icon_download_timeout)
.pool_max_idle_per_host(5) // Configure the Hyper Pool to only have max 5 idle connections .pool_max_idle_per_host(5) // Configure the Hyper Pool to only have max 5 idle connections
@@ -405,6 +405,22 @@ async fn get_page(url: &str) -> Result<Response, Error> {
} }
async fn get_page_with_referer(url: &str, referer: &str) -> Result<Response, Error> { async fn get_page_with_referer(url: &str, referer: &str) -> Result<Response, Error> {
// The resolver only sees hosts needing name resolution, so IP-literal hrefs from
// attacker-controlled HTML never reach `post_resolve()`. Check them here.
let Ok(parsed_url) = url::Url::parse(url) else {
err_silent!("Invalid URL", url)
};
if !matches!(parsed_url.scheme(), "http" | "https") {
err_silent!("Invalid scheme", url)
}
let Some(host) = parsed_url.host() else {
err_silent!("Invalid host", url)
};
should_block_host(&host)?;
let mut client = CLIENT.get(url); let mut client = CLIENT.get(url);
if !referer.is_empty() { if !referer.is_empty() {
client = client.header("Referer", referer); client = client.header("Referer", referer);
+23 -2
View File
@@ -31,8 +31,8 @@ use crate::{
DbConn, DbConn,
models::{ models::{
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError, AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError,
OrganizationApiKey, OrganizationId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete,
UserId, TwoFactorType, User, UserId,
}, },
}, },
error::MapResult, error::MapResult,
@@ -108,6 +108,20 @@ async fn login(
sso_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await sso_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await
} }
"authorization_code" => err!("SSO sign-in is not available"), "authorization_code" => err!("SSO sign-in is not available"),
"send_access" => {
crate::ratelimit::check_limit_unauthenticated(&client_header.ip.ip)?;
check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?;
check_is_some(data.send_id.as_ref(), "send_id cannot be blank")?;
let tokens = auth::SendTokens::generate_tokens(
data.send_id.as_ref().unwrap(),
data.password_hash_b64,
&client_header.ip,
&conn,
)
.await?;
Ok(Json(tokens.to_json()))
}
t => err!("Invalid type", t), t => err!("Invalid type", t),
}; };
@@ -1042,8 +1056,11 @@ enum RegisterVerificationResponse {
#[post("/accounts/register/send-verification-email", data = "<data>")] #[post("/accounts/register/send-verification-email", data = "<data>")]
async fn register_verification_email( async fn register_verification_email(
data: Json<RegisterVerificationData>, data: Json<RegisterVerificationData>,
ip: ClientIp,
conn: DbConn, conn: DbConn,
) -> ApiResult<RegisterVerificationResponse> { ) -> ApiResult<RegisterVerificationResponse> {
crate::ratelimit::check_limit_unauthenticated(&ip.ip)?;
let data = data.into_inner(); let data = data.into_inner();
// the registration can only continue if signup is allowed or there exists an invitation // the registration can only continue if signup is allowed or there exists an invitation
@@ -1144,6 +1161,10 @@ struct ConnectData {
code: Option<OIDCCode>, code: Option<OIDCCode>,
#[field(name = uncased("code_verifier"))] #[field(name = uncased("code_verifier"))]
code_verifier: Option<OIDCCodeVerifier>, code_verifier: Option<OIDCCodeVerifier>,
// Needed for send access
send_id: Option<SendId>,
password_hash_b64: Option<String>,
} }
fn check_is_some<T>(value: Option<&T>, msg: &str) -> EmptyResult { fn check_is_some<T>(value: Option<&T>, msg: &str) -> EmptyResult {
if value.is_none() { if value.is_none() {
+59 -9
View File
@@ -33,9 +33,14 @@ pub static WS_USERS: LazyLock<Arc<WebSocketUsers>> = LazyLock::new(|| {
pub static WS_ANONYMOUS_SUBSCRIPTIONS: LazyLock<Arc<AnonymousWebSocketSubscriptions>> = LazyLock::new(|| { pub static WS_ANONYMOUS_SUBSCRIPTIONS: LazyLock<Arc<AnonymousWebSocketSubscriptions>> = LazyLock::new(|| {
Arc::new(AnonymousWebSocketSubscriptions { Arc::new(AnonymousWebSocketSubscriptions {
map: Arc::new(dashmap::DashMap::new()), map: Arc::new(dashmap::DashMap::new()),
connections: Arc::new(dashmap::DashMap::new()),
}) })
}); });
/// The anonymous hub needs no authentication, so bound how much a single client can hold open.
/// One connection is needed per pending login request, several at once are only expected behind NAT.
const MAX_ANONYMOUS_CONNECTIONS_PER_IP: u32 = 25;
static NOTIFICATIONS_DISABLED: LazyLock<bool> = LazyLock::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled()); static NOTIFICATIONS_DISABLED: LazyLock<bool> = LazyLock::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled());
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
@@ -82,14 +87,21 @@ impl Drop for WSEntryMapGuard {
struct WSAnonymousEntryMapGuard { struct WSAnonymousEntryMapGuard {
subscriptions: Arc<AnonymousWebSocketSubscriptions>, subscriptions: Arc<AnonymousWebSocketSubscriptions>,
token: String, token: String,
entry_uuid: uuid::Uuid,
addr: IpAddr, addr: IpAddr,
} }
impl WSAnonymousEntryMapGuard { impl WSAnonymousEntryMapGuard {
fn new(subscriptions: Arc<AnonymousWebSocketSubscriptions>, token: String, addr: IpAddr) -> Self { fn new(
subscriptions: Arc<AnonymousWebSocketSubscriptions>,
token: String,
entry_uuid: uuid::Uuid,
addr: IpAddr,
) -> Self {
Self { Self {
subscriptions, subscriptions,
token, token,
entry_uuid,
addr, addr,
} }
} }
@@ -98,7 +110,11 @@ impl WSAnonymousEntryMapGuard {
impl Drop for WSAnonymousEntryMapGuard { impl Drop for WSAnonymousEntryMapGuard {
fn drop(&mut self) { fn drop(&mut self) {
info!("Closing WS connection from {}", self.addr); info!("Closing WS connection from {}", self.addr);
self.subscriptions.map.remove(&self.token); if let Some(mut entry) = self.subscriptions.map.get_mut(&self.token) {
entry.retain(|(uuid, _)| uuid != &self.entry_uuid);
}
self.subscriptions.map.remove_if(&self.token, |_, senders| senders.is_empty());
self.subscriptions.release(self.addr);
} }
} }
@@ -194,12 +210,19 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R
let (mut rx, guard) = { let (mut rx, guard) = {
let subscriptions = Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS); let subscriptions = Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS);
// Add a channel to send messages to this client to the map if !subscriptions.try_reserve(ip.ip) {
err_code!("Too many connections", 429)
}
// Add a channel to send messages to this client to the map.
// Clients reconnect with the same token while a login request is still pending, so keep
// every subscriber instead of replacing, otherwise the older one takes the newer one down.
let (tx, rx) = tokio::sync::mpsc::channel::<Message>(100); let (tx, rx) = tokio::sync::mpsc::channel::<Message>(100);
subscriptions.map.insert(token.clone(), tx); let entry_uuid = uuid::Uuid::new_v4();
subscriptions.map.entry(token.clone()).or_default().push((entry_uuid, tx));
// Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map // Once the guard goes out of scope, the connection will have been closed and the entry will be deleted from the map
(rx, WSAnonymousEntryMapGuard::new(subscriptions, token, ip.ip)) (rx, WSAnonymousEntryMapGuard::new(subscriptions, token, entry_uuid, ip.ip))
}; };
Ok({ Ok({
@@ -534,17 +557,44 @@ impl WebSocketUsers {
#[derive(Clone)] #[derive(Clone)]
pub struct AnonymousWebSocketSubscriptions { pub struct AnonymousWebSocketSubscriptions {
map: Arc<dashmap::DashMap<String, Sender<Message>>>, map: Arc<dashmap::DashMap<String, Vec<UserSenders>>>,
connections: Arc<dashmap::DashMap<IpAddr, u32>>,
} }
impl AnonymousWebSocketSubscriptions { impl AnonymousWebSocketSubscriptions {
/// Takes a connection slot for this address, returns false when it already reached the limit.
fn try_reserve(&self, addr: IpAddr) -> bool {
let mut count = self.connections.entry(addr).or_insert(0);
if *count >= MAX_ANONYMOUS_CONNECTIONS_PER_IP {
return false;
}
*count += 1;
true
}
/// Releases a slot taken by `try_reserve`.
fn release(&self, addr: IpAddr) {
let empty = if let Some(mut count) = self.connections.get_mut(&addr) {
*count = count.saturating_sub(1);
*count == 0
} else {
false
};
// Only remove once the guard above is dropped, otherwise this deadlocks.
if empty {
self.connections.remove_if(&addr, |_, count| *count == 0);
}
}
async fn send_update(&self, token: &str, data: &[u8]) { async fn send_update(&self, token: &str, data: &[u8]) {
if let Some(sender) = self.map.get(token).map(|v| v.clone()) // Clone the senders so the map isn't kept locked while sending.
&& let Err(e) = sender.send(Message::binary(data)).await let senders = self.map.get(token).map(|v| v.clone()).unwrap_or_default();
{ for (_, sender) in senders {
if let Err(e) = sender.send(Message::binary(data)).await {
error!("Error sending WS update {e}"); error!("Error sending WS update {e}");
} }
} }
}
pub async fn send_auth_response(&self, user_id: &UserId, auth_request_id: &AuthRequestId) { pub async fn send_auth_response(&self, user_id: &UserId, auth_request_id: &AuthRequestId) {
if !CONFIG.enable_websocket() { if !CONFIG.enable_websocket() {
+57 -16
View File
@@ -1,3 +1,8 @@
#[path = "auth/send.rs"]
pub mod send;
pub type SendTokens = send::SendTokens;
pub type SendHeaders = send::SendHeaders;
use std::{ use std::{
env, env,
net::IpAddr, net::IpAddr,
@@ -5,6 +10,7 @@ use std::{
}; };
use chrono::{DateTime, TimeDelta, Utc}; use chrono::{DateTime, TimeDelta, Utc};
use ipnet::IpNet;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, errors::ErrorKind}; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, errors::ErrorKind};
use num_traits::FromPrimitive; use num_traits::FromPrimitive;
use openssl::rsa::Rsa; use openssl::rsa::Rsa;
@@ -487,6 +493,16 @@ pub struct BasicJwtClaims {
pub sub: String, pub sub: String,
} }
impl BasicJwtClaims {
pub fn expires_in(&self) -> i64 {
self.exp - Utc::now().timestamp()
}
pub fn token(&self) -> String {
encode_jwt(&self)
}
}
pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims { pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims {
let time_now = Utc::now(); let time_now = Utc::now();
let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
@@ -1039,12 +1055,44 @@ pub struct ClientIp {
pub ip: IpAddr, pub ip: IpAddr,
} }
/// Parses a single entry of `ip_header_trusted_proxies`, which can be a CIDR range or a plain IP.
pub fn parse_trusted_proxy(entry: &str) -> Option<IpNet> {
let entry = entry.trim();
match entry.parse::<IpNet>() {
Ok(net) => Some(net),
// Without a prefix length it is a single address, which is a valid way to write this.
Err(_) => entry.parse::<IpAddr>().ok().map(IpNet::from),
}
}
/// The client IP header can be set by anyone able to reach us, so only accept it from a proxy we trust.
fn ip_header_is_trusted(remote: Option<IpAddr>) -> bool {
let trusted = CONFIG.ip_header_trusted_proxies();
let trusted = trusted.trim();
if trusted.eq_ignore_ascii_case("all") {
return true;
}
let Some(remote) = remote else {
return false;
};
// A dual stack listener reports IPv4 clients as IPv4-mapped IPv6, which `is_global()` reports as
// non global. That is what we want when blocking outgoing requests, but here it would trust them.
let remote = remote.to_canonical();
if trusted.eq_ignore_ascii_case("local") {
return !crate::util::is_global(remote);
}
trusted.split(',').filter_map(parse_trusted_proxy).any(|net| net.contains(&remote))
}
#[rocket::async_trait] #[rocket::async_trait]
impl<'r> FromRequest<'r> for ClientIp { impl<'r> FromRequest<'r> for ClientIp {
type Error = (); type Error = ();
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> { async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let ip = if CONFIG._ip_header_enabled() { let remote = req.remote().map(|r| r.ip());
let ip = if CONFIG._ip_header_enabled() && ip_header_is_trusted(remote) {
req.headers().get_one(&CONFIG.ip_header()).and_then(|ip| { req.headers().get_one(&CONFIG.ip_header()).and_then(|ip| {
match ip.find(',') { match ip.find(',') {
Some(idx) => &ip[..idx], Some(idx) => &ip[..idx],
@@ -1055,10 +1103,15 @@ impl<'r> FromRequest<'r> for ClientIp {
.ok() .ok()
}) })
} else { } else {
if CONFIG._ip_header_enabled() && req.headers().get_one(&CONFIG.ip_header()).is_some() {
// Log the canonical IP, which is what the user filter will need to match against
let remote = remote.map(|ip| ip.to_canonical());
debug!("Ignoring the '{}' header, {remote:?} is not a trusted proxy", CONFIG.ip_header());
}
None None
}; };
let ip = ip.or_else(|| req.remote().map(|r| r.ip())).unwrap_or_else(|| "0.0.0.0".parse().unwrap()); let ip = ip.or(remote).unwrap_or_else(|| "0.0.0.0".parse().unwrap());
Outcome::Success(ClientIp { Outcome::Success(ClientIp {
ip, ip,
@@ -1253,20 +1306,8 @@ pub async fn refresh_tokens(
) -> ApiResult<(Device, AuthTokens)> { ) -> ApiResult<(Device, AuthTokens)> {
let refresh_claims = match decode_refresh(refresh_token) { let refresh_claims = match decode_refresh(refresh_token) {
Err(err) => { Err(err) => {
error!("Failed to decode {} refresh_token: {refresh_token}: {err:?}", ip.ip); error!("Failed to decode refresh_token from {}: {err:?}", ip.ip);
//err_silent!(format!("Impossible to read refresh_token: {}", err.message())) err_silent!("Invalid refresh token")
// If the token failed to decode, it was probably one of the old style tokens that was just a Base64 string.
// We can generate a claim for them for backwards compatibility. Note that the password refresh claims don't
// check expiration or issuer, so they're not included here.
RefreshJwtClaims {
nbf: 0,
exp: 0,
iss: String::new(),
sub: AuthMethod::Password,
device_token: refresh_token.into(),
token: None,
}
} }
Ok(claims) => claims, Ok(claims) => claims,
}; };
+147
View File
@@ -0,0 +1,147 @@
use chrono::{TimeDelta, Utc};
use rocket::request::{FromRequest, Outcome, Request};
use crate::{
api::ApiResult,
auth,
auth::{BasicJwtClaims, ClientIp},
db::{
DbConn,
models::{Send, SendId},
},
error::{Error, ErrorKind},
};
fn generate_send_access_claims(send_id: &SendId) -> BasicJwtClaims {
let time_now = Utc::now();
BasicJwtClaims {
nbf: time_now.timestamp(),
exp: (time_now + TimeDelta::try_minutes(2).unwrap()).timestamp(),
iss: auth::JWT_SEND_ISSUER.to_string(),
sub: format!("{send_id}"),
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SendTokens {
pub access_claims: BasicJwtClaims,
}
impl SendTokens {
pub fn as_send_id(access_id: &str) -> Option<SendId> {
data_encoding::BASE64URL_NOPAD
.decode(access_id.as_bytes())
.ok()
.and_then(|uuid_vec| uuid::Uuid::from_slice(&uuid_vec).ok().map(|u| SendId::from(u.to_string())))
}
pub fn to_json(&self) -> serde_json::Value {
json!({
"access_token": self.access_claims.token(),
"expires_in": self.access_claims.expires_in(),
"token_type": "Bearer",
"scope": "api.send.access",
})
}
fn expected_error(msg: &str, error_type: &str) -> ApiResult<SendTokens> {
let err = json!({
"kind": "expected_server",
"error": "invalid_request",
"send_access_error_type": error_type,
});
Err(Error::new_msg(msg).with_kind(ErrorKind::Json(err)).silent())
}
fn invalid_error(msg: &str, error_type: &str, silent: bool) -> ApiResult<SendTokens> {
let err = json!({
"kind": "expected_server",
"error": "invalid_grant",
"send_access_error_type": error_type,
});
Err(Error::new_msg(msg).with_kind(ErrorKind::Json(err)).with_code(404).with_silent(silent))
}
pub async fn generate_tokens(
access_id: &str,
password: Option<String>,
ip: &ClientIp,
conn: &DbConn,
) -> ApiResult<SendTokens> {
let Some(send_id) = Self::as_send_id(access_id) else {
return Self::invalid_error(&format!("Can't convert {access_id}"), "send_id_invalid", false);
};
let Some(mut send) = Send::find_by_uuid(&send_id, conn).await else {
return Self::invalid_error(&format!("Can't find {send_id}"), "send_id_invalid", false);
};
if let Some(max_access_count) = send.max_access_count
&& send.access_count >= max_access_count
{
return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true);
}
if !send.is_accessible() {
return Self::invalid_error(&format!("Send {send_id}, not accessible"), "send_id_invalid", true);
}
if send.password_hash.is_some() {
match password {
Some(ref p) if send.check_password(p) => { /* Nothing to do here */ }
Some(_) => {
return Self::invalid_error(
&format!("Send {send_id}, Invalid password from {}", ip.ip),
"password_hash_b64_invalid",
false,
);
}
None => return Self::expected_error("Password required", "password_hash_b64_required"),
}
}
if !send.register_access(conn).await? {
return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true);
}
Ok(Self {
access_claims: generate_send_access_claims(&send_id),
})
}
}
pub struct SendHeaders {
pub send_id: SendId,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for SendHeaders {
type Error = &'static str;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let headers = request.headers();
// Get access_token
let access_token: &str = if let Some(a) = headers.get_one("Authorization") {
if let Some(split) = a.rsplit("Bearer ").next() {
split
} else {
err_handler!("No access token provided")
}
} else {
err_handler!("No access token provided")
};
// Check JWT token is valid and get send_id
let Ok(claims) = auth::decode_send(access_token) else {
err_handler!("Invalid claim")
};
Outcome::Success(SendHeaders {
send_id: claims.sub.into(),
})
}
}
+25 -1
View File
@@ -666,6 +666,12 @@ make_config! {
ip_header: String, true, def, "X-Real-IP".to_owned(); ip_header: String, true, def, "X-Real-IP".to_owned();
/// Internal IP header property, used to avoid recomputing each time /// Internal IP header property, used to avoid recomputing each time
_ip_header_enabled: bool, false, generated, |c| &c.ip_header.trim().to_lowercase() != "none"; _ip_header_enabled: bool, false, generated, |c| &c.ip_header.trim().to_lowercase() != "none";
/// Trusted proxies |> Which addresses the client IP header is accepted from. Requests from any
/// other address use the remote IP instead, so a client can't spoof the header.
/// Either the string "local" (the default, any non-global address, which covers a reverse proxy
/// running on the same host or container network), the string "all" to accept it from anywhere,
/// or a comma separated list of IPs and CIDR ranges.
ip_header_trusted_proxies: String, true, def, "local".to_owned();
/// Icon service |> The predefined icon services are: internal, bitwarden, duckduckgo, google. /// Icon service |> The predefined icon services are: internal, bitwarden, duckduckgo, google.
/// To specify a custom icon service, set a URL template with exactly one instance of `{}`, /// To specify a custom icon service, set a URL template with exactly one instance of `{}`,
/// which is replaced with the domain. For example: `https://icon.example.com/domain/{}`. /// which is replaced with the domain. For example: `https://icon.example.com/domain/{}`.
@@ -768,6 +774,11 @@ make_config! {
/// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2 /// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2
login_ratelimit_max_burst: u32, false, def, 10; login_ratelimit_max_burst: u32, false, def, 10;
/// Seconds between unauthenticated requests |> Number of seconds, on average, between requests from the same IP address to any of the rate limited unauthenticated endpoints
unauthenticated_ratelimit_seconds: u64, false, def, 60;
/// Max burst size for unauthenticated requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `unauthenticated_ratelimit_seconds`. This is shared between several endpoints, so it needs to be more lenient than the login one
unauthenticated_ratelimit_max_burst: u32, false, def, 50;
/// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in /// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in
admin_ratelimit_seconds: u64, false, def, 300; admin_ratelimit_seconds: u64, false, def, 300;
/// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds` /// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds`
@@ -942,6 +953,18 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> {
} }
} }
let trusted_proxies = cfg.ip_header_trusted_proxies.trim();
if !trusted_proxies.eq_ignore_ascii_case("all") && !trusted_proxies.eq_ignore_ascii_case("local") {
for entry in trusted_proxies.split(',').filter(|e| !e.trim().is_empty()) {
if crate::auth::parse_trusted_proxy(entry).is_none() {
err!(format!(
"Invalid IP_HEADER_TRUSTED_PROXIES entry `{}`, expected an IP or CIDR range",
entry.trim()
));
}
}
}
if cfg.password_iterations < 100_000 { if cfg.password_iterations < 100_000 {
err!("PASSWORD_ITERATIONS should be at least 100000 or higher. The default is 600000!"); err!("PASSWORD_ITERATIONS should be at least 100000 or higher. The default is 600000!");
} }
@@ -956,7 +979,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!(format!("`DATABASE_MIN_CONNS` must be smaller than or equal to `DATABASE_MAX_CONNS`.",)); err!("`DATABASE_MIN_CONNS` must be smaller than or equal to `DATABASE_MAX_CONNS`.");
} }
if let Some(log_file) = &cfg.log_file if let Some(log_file) = &cfg.log_file
@@ -1404,6 +1427,7 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[
// Key Management Team // Key Management Team
"ssh-key-vault-item", "ssh-key-vault-item",
"pm-25373-windows-biometrics-v2", "pm-25373-windows-biometrics-v2",
"pm-26340-linux-biometrics-v2",
// Mobile Team // Mobile Team
"anon-addy-self-host-alias", "anon-addy-self-host-alias",
"simple-login-self-host-alias", "simple-login-self-host-alias",
+16 -20
View File
@@ -42,13 +42,15 @@ pub struct Cipher {
pub key: Option<String>, pub key: Option<String>,
/* // See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/Vault/Enums/CipherType.cs
Login = 1, // Login = 1,
SecureNote = 2, // SecureNote = 2,
Card = 3, // Card = 3,
Identity = 4, // Identity = 4,
SshKey = 5 // SSHKey = 5
*/ // BankAccount = 6,
// DriversLicense = 7,
// Passport = 8,
pub atype: i32, pub atype: i32,
pub name: String, pub name: String,
pub notes: Option<String>, pub notes: Option<String>,
@@ -306,21 +308,11 @@ impl Cipher {
type_data_json = Value::Null; type_data_json = Value::Null;
} }
// Clone the type_data and add some default value.
let mut data_json = type_data_json.clone();
// NOTE: This was marked as *Backwards Compatibility Code*, but as of January 2021 this is still being used by upstream
// data_json should always contain the following keys with every atype
data_json["fields"] = json!(fields_json);
data_json["name"] = json!(self.name);
data_json["notes"] = json!(self.notes);
data_json["passwordHistory"] = Value::Array(password_history_json.clone());
let collection_ids = if let Some(cipher_sync_data) = cipher_sync_data { let collection_ids = if let Some(cipher_sync_data) = cipher_sync_data {
if let Some(cipher_collections) = cipher_sync_data.cipher_collections.get(&self.uuid) { if let Some(cipher_collections) = cipher_sync_data.cipher_collections.get(&self.uuid) {
Cow::from(cipher_collections) Cow::from(cipher_collections)
} else { } else {
Cow::from(Vec::with_capacity(0)) Cow::from(Vec::new())
} }
} else { } else {
Cow::from(self.get_admin_collections(user_uuid.clone(), conn).await) Cow::from(self.get_admin_collections(user_uuid.clone(), conn).await)
@@ -355,8 +347,6 @@ impl Cipher {
"notes": self.notes, "notes": self.notes,
"fields": fields_json, "fields": fields_json,
"data": data_json,
"passwordHistory": password_history_json, "passwordHistory": password_history_json,
// All Cipher types are included by default as null, but only the matching one will be populated // All Cipher types are included by default as null, but only the matching one will be populated
@@ -365,6 +355,9 @@ impl Cipher {
"card": null, "card": null,
"identity": null, "identity": null,
"sshKey": null, "sshKey": null,
"bankAccount": null,
"driversLicense": null,
"passport": null,
}); });
// These values are only needed for user/default syncs // These values are only needed for user/default syncs
@@ -404,6 +397,9 @@ impl Cipher {
3 => "card", 3 => "card",
4 => "identity", 4 => "identity",
5 => "sshKey", 5 => "sshKey",
6 => "bankAccount",
7 => "driversLicense",
8 => "passport",
_ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)), _ => err!(format!("Cipher {} has an invalid type {}", self.uuid, self.atype)),
}; };
+47
View File
@@ -21,6 +21,7 @@ use super::{
User, UserId, User, UserId,
}; };
// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = collections)] #[diesel(table_name = collections)]
#[diesel(treat_none_as_null = true)] #[diesel(treat_none_as_null = true)]
@@ -71,6 +72,11 @@ impl Collection {
"id": self.uuid, "id": self.uuid,
"organizationId": self.org_uuid, "organizationId": self.org_uuid,
"name": self.name, "name": self.name,
// Collection types are either 0: SharedCollection or 1: DefaultUserCollection, of which we do not yet support DefaultUserCollection.
// See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Enums/CollectionType.cs
"type": 0,
// This is only used together with MyItems/DefaultUserCollection, which we do not yet support.
"defaultUserCollectionEmail": null,
"object": "collection", "object": "collection",
}) })
} }
@@ -623,6 +629,47 @@ impl Collection {
pub async fn is_manageable_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool { pub async fn is_manageable_by_user(&self, user_uuid: &UserId, conn: &DbConn) -> bool {
Self::is_coll_manageable_by_user(&self.uuid, user_uuid, conn).await Self::is_coll_manageable_by_user(&self.uuid, user_uuid, conn).await
} }
// Whether the user has manage access to at least one collection in the org, directly or via a
// group. Org-scoped counterpart of is_coll_manageable_by_user.
pub async fn has_manageable_collection_by_user(
org_uuid: &OrganizationId,
user_uuid: &UserId,
conn: &DbConn,
) -> bool {
let org_uuid = org_uuid.to_string();
let user_uuid = user_uuid.to_string();
conn.run(move |conn| {
collections::table
.left_join(
users_collections::table.on(users_collections::collection_uuid
.eq(collections::uuid)
.and(users_collections::user_uuid.eq(user_uuid.clone()))),
)
.left_join(
users_organizations::table.on(collections::org_uuid
.eq(users_organizations::org_uuid)
.and(users_organizations::user_uuid.eq(user_uuid))),
)
.left_join(groups_users::table.on(groups_users::users_organizations_uuid.eq(users_organizations::uuid)))
.left_join(
collections_groups::table.on(collections_groups::groups_uuid
.eq(groups_users::groups_uuid)
.and(collections_groups::collections_uuid.eq(collections::uuid))),
)
.filter(collections::org_uuid.eq(&org_uuid))
.filter(
// Manage permission on a collection assigned directly or via a group.
users_collections::manage.eq(true).or(collections_groups::manage.eq(true)),
)
.count()
.first::<i64>(conn)
.ok()
.unwrap_or(0)
!= 0
})
.await
}
} }
/// Database methods /// Database methods
+3 -1
View File
@@ -271,7 +271,9 @@ impl Group {
groups::table groups::table
.inner_join(groups_users::table.on(groups_users::groups_uuid.eq(groups::uuid))) .inner_join(groups_users::table.on(groups_users::groups_uuid.eq(groups::uuid)))
.inner_join( .inner_join(
users_organizations::table.on(users_organizations::uuid.eq(groups_users::users_organizations_uuid)), users_organizations::table.on(users_organizations::uuid
.eq(groups_users::users_organizations_uuid)
.and(users_organizations::org_uuid.eq(groups::organizations_uuid))),
) )
.filter(users_organizations::user_uuid.eq(user_uuid)) .filter(users_organizations::user_uuid.eq(user_uuid))
.filter(groups::organizations_uuid.eq(org_uuid)) .filter(groups::organizations_uuid.eq(org_uuid))
+1 -4
View File
@@ -34,10 +34,7 @@ pub use self::organization::{
Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey,
OrganizationId, OrganizationId,
}; };
pub use self::send::{ pub use self::send::{Send, SendFileId, SendId, SendType};
Send, SendType,
id::{SendFileId, SendId},
};
pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth}; pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth};
pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor::{TwoFactor, TwoFactorType};
pub use self::two_factor_duo_context::TwoFactorDuoContext; pub use self::two_factor_duo_context::TwoFactorDuoContext;
+15 -2
View File
@@ -217,11 +217,18 @@ impl Organization {
"useSecretsManager": false, // Not supported (Not AGPLv3 Licensed) "useSecretsManager": false, // Not supported (Not AGPLv3 Licensed)
"selfHost": true, "selfHost": true,
"useApi": true, "useApi": true,
"useDisableSMAdsForUsers": true, // Hide Secrets Manager ads
"useInviteLinks": false, // Not (yet) supported
"useMyItems": false, // Not (yet) supported
"useOrganizationDomains": false, // Not supported (Linked to SSO)
"usePam": false, // Not supported
"usePhishingBlocker": false,
"hasPublicAndPrivateKeys": self.private_key.is_some() && self.public_key.is_some(), "hasPublicAndPrivateKeys": self.private_key.is_some() && self.public_key.is_some(),
"useResetPassword": CONFIG.mail_enabled(), "useResetPassword": CONFIG.mail_enabled(),
"allowAdminAccessToAllCollectionItems": true, "allowAdminAccessToAllCollectionItems": true,
"limitCollectionCreation": true, "limitCollectionCreation": true,
"limitCollectionDeletion": true, "limitCollectionDeletion": true,
"limitItemDeletion": false,
"businessName": self.name, "businessName": self.name,
"businessAddress1": null, "businessAddress1": null,
@@ -495,6 +502,12 @@ impl Membership {
"useActivateAutofillPolicy": false, "useActivateAutofillPolicy": false,
"useAdminSponsoredFamilies": false, "useAdminSponsoredFamilies": false,
"useRiskInsights": false, // Not supported (Not AGPLv3 Licensed) "useRiskInsights": false, // Not supported (Not AGPLv3 Licensed)
"useDisableSMAdsForUsers": true, // Hide Secrets Manager ads
"useInviteLinks": false, // Not (yet) supported
"useMyItems": false, // Not (yet) supported
"useOrganizationDomains": false, // Not supported (Linked to SSO)
"usePam": false, // Not supported
"usePhishingBlocker": false,
"organizationUserId": self.uuid, "organizationUserId": self.uuid,
"providerId": null, "providerId": null,
@@ -550,7 +563,7 @@ impl Membership {
} else { } else {
// The Bitwarden clients seem to call this API regardless of whether groups are enabled, // The Bitwarden clients seem to call this API regardless of whether groups are enabled,
// so just act as if there are no groups. // so just act as if there are no groups.
Vec::with_capacity(0) Vec::new()
}; };
// Check if a user is in a group which has access to all collections // Check if a user is in a group which has access to all collections
@@ -604,7 +617,7 @@ impl Membership {
}) })
.collect() .collect()
} else { } else {
Vec::with_capacity(0) Vec::new()
}; };
// HACK: Convert the manager type to a custom type // HACK: Convert the manager type to a custom type
+68 -26
View File
@@ -1,6 +1,10 @@
use std::path::Path;
use chrono::{NaiveDateTime, Utc}; use chrono::{NaiveDateTime, Utc};
use data_encoding::BASE64URL_NOPAD; use data_encoding::BASE64URL_NOPAD;
use derive_more::{AsRef, Deref, Display, From};
use diesel::prelude::*; use diesel::prelude::*;
use macros::{IdFromParam, UuidFromParam};
use serde_json::Value; use serde_json::Value;
use uuid::Uuid; use uuid::Uuid;
@@ -14,7 +18,6 @@ use crate::{
}; };
use super::{OrganizationId, User, UserId}; use super::{OrganizationId, User, UserId};
use id::SendId;
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = sends)] #[diesel(table_name = sends)]
@@ -161,7 +164,7 @@ impl Send {
"password": self.password_hash.as_deref().map(|h| BASE64URL_NOPAD.encode(h)), "password": self.password_hash.as_deref().map(|h| BASE64URL_NOPAD.encode(h)),
"authType": if self.password_hash.is_some() { SendAuthType::Password as i32 } else { SendAuthType::None as i32 }, "authType": if self.password_hash.is_some() { SendAuthType::Password as i32 } else { SendAuthType::None as i32 },
"disabled": self.disabled, "disabled": self.disabled,
"hideEmail": self.hide_email, "hideEmail": self.hide_email.unwrap_or(false),
"revisionDate": format_date(&self.revision_date), "revisionDate": format_date(&self.revision_date),
"expirationDate": self.expiration_date.as_ref().map(format_date), "expirationDate": self.expiration_date.as_ref().map(format_date),
@@ -228,6 +231,53 @@ impl Send {
} }
} }
/// Registers an access, incrementing `access_count` only while below `max_access_count`.
/// Returns false when the limit was already reached. The check and the increment are a single
/// statement, otherwise concurrent accesses can both pass the check and exceed the limit.
pub async fn register_access(&mut self, conn: &DbConn) -> Result<bool, crate::Error> {
self.update_users_revision(conn).await;
let revision_date = Utc::now().naive_utc();
let uuid = self.uuid.clone();
let updated = conn
.run(move |conn| {
diesel::update(sends::table)
.filter(sends::uuid.eq(uuid))
.filter(
sends::max_access_count
.is_null()
.or(sends::access_count.nullable().lt(sends::max_access_count)),
)
.set((sends::access_count.eq(sends::access_count + 1), sends::revision_date.eq(revision_date)))
.execute(conn)
})
.await?;
if updated == 0 {
return Ok(false);
}
self.access_count += 1;
self.revision_date = revision_date;
Ok(true)
}
/// Whether the Send is currently within its validity window: not disabled, not past its
/// expiration date, and not past its deletion date. Does not consider `max_access_count`
/// (consumed at token issuance) or the password.
pub fn is_accessible(&self) -> bool {
let now = Utc::now().naive_utc();
if self.disabled {
return false;
}
if let Some(expiration) = self.expiration_date
&& now >= expiration
{
return false;
}
now < self.deletion_date
}
pub async fn delete(&self, conn: &DbConn) -> EmptyResult { pub async fn delete(&self, conn: &DbConn) -> EmptyResult {
self.update_users_revision(conn).await; self.update_users_revision(conn).await;
@@ -335,14 +385,7 @@ impl Send {
} }
} }
// separate namespace to avoid name collision with std::marker::Send #[derive(
pub mod id {
use derive_more::{AsRef, Deref, Display, From};
use macros::{IdFromParam, UuidFromParam};
use std::marker::Send;
use std::path::Path;
#[derive(
Clone, Clone,
Debug, Debug,
AsRef, AsRef,
@@ -357,25 +400,24 @@ pub mod id {
Serialize, Serialize,
Deserialize, Deserialize,
UuidFromParam, UuidFromParam,
)] )]
pub struct SendId(String); pub struct SendId(String);
impl AsRef<Path> for SendId { impl AsRef<Path> for SendId {
#[inline]
fn as_ref(&self) -> &Path {
Path::new(&self.0)
}
}
#[derive(
Clone, Debug, AsRef, Deref, Display, From, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize, IdFromParam,
)]
pub struct SendFileId(String);
impl AsRef<Path> for SendFileId {
#[inline] #[inline]
fn as_ref(&self) -> &Path { fn as_ref(&self) -> &Path {
Path::new(&self.0) Path::new(&self.0)
} }
}
#[derive(
Clone, Debug, AsRef, Deref, Display, From, FromForm, Hash, PartialEq, Eq, Serialize, Deserialize, IdFromParam,
)]
pub struct SendFileId(String);
impl AsRef<Path> for SendFileId {
#[inline]
fn as_ref(&self) -> &Path {
Path::new(&self.0)
}
}
} }
+17
View File
@@ -268,8 +268,25 @@ impl User {
UserStatus::Enabled UserStatus::Enabled
}; };
let account_keys = if self.private_key.is_some() {
json!({
"publicKeyEncryptionKeyPair": {
"wrappedPrivateKey": self.private_key,
"publicKey": self.public_key,
"signedPublicKey": null,
"object": "publicKeyEncryptionKeyPair",
},
"securityState": null,
"signatureKeyPair": null,
"object": "privateKeys"
})
} else {
Value::Null
};
json!({ json!({
"_status": status as i32, "_status": status as i32,
"accountKeys": account_keys,
"id": self.uuid, "id": self.uuid,
"name": self.name, "name": self.name,
"email": self.email, "email": self.email,
+16 -2
View File
@@ -15,14 +15,14 @@ macro_rules! make_error {
#[derive(Debug)] #[derive(Debug)]
pub struct ErrorEvent { pub event: EventType } pub struct ErrorEvent { pub event: EventType }
pub struct Error { message: String, kind: ErrorKind, code: u16, event: Option<ErrorEvent> } pub struct Error { message: String, kind: ErrorKind, code: u16, event: Option<ErrorEvent>, silent: bool }
$(impl From<$ty> for Error { $(impl From<$ty> for Error {
fn from(err: $ty) -> Self { Error::from((stringify!($name), err)) } fn from(err: $ty) -> Self { Error::from((stringify!($name), err)) }
})+ })+
$(impl<S: Into<String>> From<(S, $ty)> for Error { $(impl<S: Into<String>> From<(S, $ty)> for Error {
fn from(val: (S, $ty)) -> Self { fn from(val: (S, $ty)) -> Self {
Error { message: val.0.into(), kind: ErrorKind::$name(val.1), code: BAD_REQUEST, event: None } Error { message: val.0.into(), kind: ErrorKind::$name(val.1), code: BAD_REQUEST, event: None, silent: false }
} }
})+ })+
impl StdError for Error { impl StdError for Error {
@@ -172,6 +172,18 @@ impl Error {
pub fn message(&self) -> &str { pub fn message(&self) -> &str {
&self.message &self.message
} }
#[must_use]
pub fn silent(mut self) -> Self {
self.silent = true;
self
}
#[must_use]
pub fn with_silent(mut self, silent: bool) -> Self {
self.silent = silent;
self
}
} }
pub trait MapResult<S> { pub trait MapResult<S> {
@@ -309,10 +321,12 @@ use rocket::{
impl Responder<'_, 'static> for Error { impl Responder<'_, 'static> for Error {
fn respond_to(self, _: &Request<'_>) -> response::Result<'static> { fn respond_to(self, _: &Request<'_>) -> response::Result<'static> {
if !self.silent {
match self.kind { match self.kind {
ErrorKind::Empty(_) | ErrorKind::Simple(_) | ErrorKind::Compact(_) => {} // Don't print the error in this situation ErrorKind::Empty(_) | ErrorKind::Simple(_) | ErrorKind::Compact(_) => {} // Don't print the error in this situation
_ => error!(target: "error", "{self:#?}"), _ => error!(target: "error", "{self:#?}"),
} }
}
let code = Status::from_code(self.code).unwrap_or(Status::BadRequest); let code = Status::from_code(self.code).unwrap_or(Status::BadRequest);
let body = self.to_string(); let body = self.to_string();
+55 -22
View File
@@ -18,7 +18,7 @@ use crate::{CONFIG, util::is_global};
pub fn make_http_request(method: reqwest::Method, url: &str) -> Result<reqwest::RequestBuilder, crate::Error> { pub fn make_http_request(method: reqwest::Method, url: &str) -> Result<reqwest::RequestBuilder, crate::Error> {
static INSTANCE: LazyLock<Client> = static INSTANCE: LazyLock<Client> =
LazyLock::new(|| get_reqwest_client_builder().build().expect("Failed to build client")); LazyLock::new(|| get_reqwest_client_builder(true).build().expect("Failed to build client"));
let Ok(url) = url::Url::parse(url) else { let Ok(url) = url::Url::parse(url) else {
err!("Invalid URL"); err!("Invalid URL");
@@ -32,7 +32,7 @@ pub fn make_http_request(method: reqwest::Method, url: &str) -> Result<reqwest::
Ok(INSTANCE.request(method, url)) Ok(INSTANCE.request(method, url))
} }
pub fn get_reqwest_client_builder() -> ClientBuilder { pub fn get_reqwest_client_builder(enforce_block: bool) -> ClientBuilder {
let mut headers = header::HeaderMap::new(); let mut headers = header::HeaderMap::new();
headers.insert(header::USER_AGENT, header::HeaderValue::from_static("Vaultwarden")); headers.insert(header::USER_AGENT, header::HeaderValue::from_static("Vaultwarden"));
@@ -55,7 +55,7 @@ pub fn get_reqwest_client_builder() -> ClientBuilder {
Client::builder() Client::builder()
.default_headers(headers) .default_headers(headers)
.redirect(redirect_policy) .redirect(redirect_policy)
.dns_resolver(CustomDnsResolver::instance()) .dns_resolver(CustomDns::instance(enforce_block))
.timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(10))
} }
@@ -174,6 +174,27 @@ pub enum CustomHttpClientError {
} }
impl CustomHttpClientError { impl CustomHttpClientError {
/// Attach the domain that resolved to this address, which `should_block_host()` can't know.
fn with_domain(self, name: &str) -> Self {
match self {
Self::NonGlobalIp {
ip,
..
} => Self::NonGlobalIp {
domain: Some(name.to_owned()),
ip,
},
Self::Blocked {
domain,
} => Self::Blocked {
domain: format!("{name} ({domain})"),
},
other @ Self::Invalid {
..
} => other,
}
}
pub fn downcast_ref(e: &dyn std::error::Error) -> Option<&Self> { pub fn downcast_ref(e: &dyn std::error::Error) -> Option<&Self> {
let mut source = e.source(); let mut source = e.source();
@@ -210,6 +231,11 @@ impl fmt::Display for CustomHttpClientError {
impl std::error::Error for CustomHttpClientError {} impl std::error::Error for CustomHttpClientError {}
pub struct CustomDns {
enforce_block: bool,
resolver: Arc<CustomDnsResolver>,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
enum CustomDnsResolver { enum CustomDnsResolver {
Default(), Default(),
@@ -217,12 +243,18 @@ enum CustomDnsResolver {
} }
type BoxError = Box<dyn std::error::Error + Send + Sync>; type BoxError = Box<dyn std::error::Error + Send + Sync>;
impl CustomDnsResolver { impl CustomDns {
fn instance() -> Arc<Self> { fn instance(enforce_block: bool) -> Self {
static INSTANCE: LazyLock<Arc<CustomDnsResolver>> = LazyLock::new(CustomDnsResolver::new); static INSTANCE: LazyLock<Arc<CustomDnsResolver>> = LazyLock::new(CustomDnsResolver::new);
Arc::clone(&*INSTANCE)
}
CustomDns {
enforce_block,
resolver: Arc::clone(&*INSTANCE),
}
}
}
impl CustomDnsResolver {
fn new() -> Arc<Self> { fn new() -> Arc<Self> {
TokioResolver::builder(TokioRuntimeProvider::default()) TokioResolver::builder(TokioRuntimeProvider::default())
.and_then(|mut builder| { .and_then(|mut builder| {
@@ -239,30 +271,32 @@ impl CustomDnsResolver {
} }
// Note that we get an iterator of addresses, but we only grab the first one for convenience // Note that we get an iterator of addresses, but we only grab the first one for convenience
async fn resolve_domain(&self, name: &str) -> Result<Vec<SocketAddr>, BoxError> { async fn resolve_domain(&self, name: &str, enforce_block: bool) -> Result<Vec<SocketAddr>, BoxError> {
pre_resolve(name)?; pre_resolve(name, enforce_block)?;
let results: Vec<SocketAddr> = match self { let results: Vec<SocketAddr> = match self {
Self::Default() => tokio::net::lookup_host((name, 0)).await?.collect(), Self::Default() => tokio::net::lookup_host((name, 0)).await?.collect(),
Self::Hickory(r) => r.lookup_ip(name).await?.iter().map(|i| SocketAddr::new(i, 0)).collect(), Self::Hickory(r) => r.lookup_ip(name).await?.iter().map(|i| SocketAddr::new(i, 0)).collect(),
}; };
if enforce_block {
for addr in &results { for addr in &results {
post_resolve(name, addr.ip())?; post_resolve(name, addr.ip())?;
} }
}
Ok(results) Ok(results)
} }
} }
fn pre_resolve(name: &str) -> Result<(), CustomHttpClientError> { fn pre_resolve(name: &str, enforce_block: bool) -> Result<(), CustomHttpClientError> {
let Ok(host) = get_valid_host(name) else { let Ok(host) = get_valid_host(name) else {
return Err(CustomHttpClientError::Invalid { return Err(CustomHttpClientError::Invalid {
domain: name.to_owned(), domain: name.to_owned(),
}); });
}; };
if should_block_host(&host).is_err() { if enforce_block && should_block_host(&host).is_err() {
return Err(CustomHttpClientError::Blocked { return Err(CustomHttpClientError::Blocked {
domain: name.to_owned(), domain: name.to_owned(),
}); });
@@ -272,22 +306,21 @@ fn pre_resolve(name: &str) -> Result<(), CustomHttpClientError> {
} }
fn post_resolve(name: &str, ip: IpAddr) -> Result<(), CustomHttpClientError> { fn post_resolve(name: &str, ip: IpAddr) -> Result<(), CustomHttpClientError> {
if should_block_ip(ip) { let host: Host<&str> = match ip {
Err(CustomHttpClientError::NonGlobalIp { IpAddr::V4(ip) => Host::Ipv4(ip),
domain: Some(name.to_owned()), IpAddr::V6(ip) => Host::Ipv6(ip),
ip, };
})
} else { should_block_host(&host).map_err(|e| e.with_domain(name))
Ok(())
}
} }
impl Resolve for CustomDnsResolver { impl Resolve for CustomDns {
fn resolve(&self, name: Name) -> Resolving { fn resolve(&self, name: Name) -> Resolving {
let this = self.clone(); let enforce_block = self.enforce_block;
let this = Arc::clone(&self.resolver);
Box::pin(async move { Box::pin(async move {
let name = name.as_str(); let name = name.as_str();
let results = this.resolve_domain(name).await?; let results = this.resolve_domain(name, enforce_block).await?;
if results.is_empty() { if results.is_empty() {
warn!("Unable to resolve {name} to any valid IP address"); warn!("Unable to resolve {name} to any valid IP address");
} }
+1 -1
View File
@@ -2,7 +2,7 @@
// The recursion_limit is mainly triggered by the json!() macro. // The recursion_limit is mainly triggered by the json!() macro.
// The more key/value pairs there are the more recursion occurs. // The more key/value pairs there are the more recursion occurs.
// We want to keep this as low as possible! // We want to keep this as low as possible!
#![recursion_limit = "165"] #![recursion_limit = "192"]
// When enabled use MiMalloc as malloc instead of the default malloc // When enabled use MiMalloc as malloc instead of the default malloc
#[cfg(feature = "enable_mimalloc")] #[cfg(feature = "enable_mimalloc")]
+18
View File
@@ -18,6 +18,24 @@ static LIMITER_ADMIN: LazyLock<Limiter> = LazyLock::new(|| {
RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst)) RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst))
}); });
static LIMITER_UNAUTHENTICATED: LazyLock<Limiter> = LazyLock::new(|| {
let seconds = Duration::from_secs(CONFIG.unauthenticated_ratelimit_seconds());
let burst = NonZeroU32::new(CONFIG.unauthenticated_ratelimit_max_burst())
.expect("Non-zero unauthenticated ratelimit burst");
RateLimiter::keyed(
Quota::with_period(seconds).expect("Non-zero unauthenticated ratelimit seconds").allow_burst(burst),
)
});
pub fn check_limit_unauthenticated(ip: &IpAddr) -> Result<(), Error> {
match LIMITER_UNAUTHENTICATED.check_key(ip) {
Ok(()) => Ok(()),
Err(_e) => {
err_code!("Too many requests", 429);
}
}
}
pub fn check_limit_login(ip: &IpAddr) -> Result<(), Error> { pub fn check_limit_login(ip: &IpAddr) -> Result<(), Error> {
match LIMITER_LOGIN.check_key(ip) { match LIMITER_LOGIN.check_key(ip) {
Ok(()) => Ok(()), Ok(()) => Ok(()),
+35 -14
View File
@@ -1,16 +1,16 @@
use std::{borrow::Cow, future::Future, pin::Pin, sync::LazyLock, time::Duration}; use std::{borrow::Cow, collections::HashSet, future::Future, pin::Pin, sync::LazyLock, time::Duration};
use openidconnect::{ use openidconnect::{
AccessToken, AsyncHttpClient, AuthDisplay, AuthPrompt, AuthenticationFlow, AuthorizationCode, AuthorizationRequest, AccessToken, AsyncHttpClient, AuthDisplay, AuthPrompt, AuthType, AuthenticationFlow, AuthorizationCode,
ClientId, ClientSecret, CsrfToken, EmptyAdditionalClaims, EmptyExtraTokenFields, EndpointNotSet, EndpointSet, AuthorizationRequest, ClientId, ClientSecret, CsrfToken, EmptyAdditionalClaims, EmptyExtraTokenFields,
HttpClientError, HttpRequest, HttpResponse, IdTokenClaims, IdTokenFields, Nonce, OAuth2TokenResponse, EndpointNotSet, EndpointSet, HttpClientError, HttpRequest, HttpResponse, IdTokenClaims, IdTokenFields, Nonce,
PkceCodeChallenge, PkceCodeVerifier, RefreshToken, ResponseType, Scope, StandardErrorResponse, OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RefreshToken, ResponseType, Scope, StandardErrorResponse,
StandardTokenResponse, StandardTokenResponse,
core::{ core::{
CoreAuthDisplay, CoreAuthPrompt, CoreClient, CoreErrorResponseType, CoreGenderClaim, CoreIdTokenVerifier, CoreAuthDisplay, CoreAuthPrompt, CoreClient, CoreClientAuthMethod, CoreErrorResponseType, CoreGenderClaim,
CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreJwsSigningAlgorithm, CoreProviderMetadata, CoreIdTokenVerifier, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreJwsSigningAlgorithm,
CoreResponseType, CoreRevocableToken, CoreRevocationErrorResponse, CoreTokenIntrospectionResponse, CoreProviderMetadata, CoreResponseType, CoreRevocableToken, CoreRevocationErrorResponse,
CoreTokenResponse, CoreTokenType, CoreUserInfoClaims, CoreTokenIntrospectionResponse, CoreTokenResponse, CoreTokenType, CoreUserInfoClaims,
}, },
http, url, http, url,
}; };
@@ -71,7 +71,7 @@ pub struct OidcHttpClient {
impl OidcHttpClient { impl OidcHttpClient {
fn new() -> Result<Self, reqwest::Error> { fn new() -> Result<Self, reqwest::Error> {
get_reqwest_client_builder().redirect(reqwest::redirect::Policy::none()).build().map(|client| Self { get_reqwest_client_builder(false).redirect(reqwest::redirect::Policy::none()).build().map(|client| Self {
client, client,
}) })
} }
@@ -83,7 +83,10 @@ impl<'c> AsyncHttpClient<'c> for OidcHttpClient {
fn call(&'c self, request: HttpRequest) -> Self::Future { fn call(&'c self, request: HttpRequest) -> Self::Future {
Box::pin(async move { Box::pin(async move {
let response = self.client.execute(request.try_into().map_err(Box::new)?).await.map_err(Box::new)?; let response = self.client.execute(request.try_into().map_err(Box::new)?).await.map_err(|e| {
debug!("Request failed {e:?}");
Box::new(e)
})?;
let mut builder = http::Response::builder().status(response.status()).version(response.version()); let mut builder = http::Response::builder().status(response.status()).version(response.version());
@@ -91,7 +94,11 @@ impl<'c> AsyncHttpClient<'c> for OidcHttpClient {
builder = builder.header(name, value); builder = builder.header(name, value);
} }
builder.body(response.bytes().await.map_err(Box::new)?.to_vec()).map_err(HttpClientError::Http) let body = response.bytes().await.map_err(Box::new)?;
if CONFIG.sso_debug_tokens() {
debug!("Response body {}", String::from_utf8_lossy(&body));
}
builder.body(body.to_vec()).map_err(HttpClientError::Http)
}) })
} }
} }
@@ -114,7 +121,21 @@ impl Client {
Ok(metadata) => metadata, Ok(metadata) => metadata,
}; };
let base_client = CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret)); let auth_methods: Option<HashSet<CoreClientAuthMethod>> = provider_metadata
.token_endpoint_auth_methods_supported()
.map(|v| v.iter().map(ToOwned::to_owned).collect());
let mut base_client = CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret));
if let Some(am) = auth_methods {
if am.contains(&CoreClientAuthMethod::ClientSecretBasic) {
base_client = base_client.set_auth_type(AuthType::BasicAuth); // Default
} else if am.contains(&CoreClientAuthMethod::ClientSecretPost) {
base_client = base_client.set_auth_type(AuthType::RequestBody);
} else {
err!(format!("No supported auth_methods (only basic or request body), advertised: {am:?}"));
}
}
let token_uri = if let Some(uri) = base_client.token_uri() { let token_uri = if let Some(uri) = base_client.token_uri() {
uri.clone() uri.clone()
@@ -219,7 +240,7 @@ impl Client {
} else { } else {
let challenge = PkceCodeChallenge::from_code_verifier_sha256(&verifier); let challenge = PkceCodeChallenge::from_code_verifier_sha256(&verifier);
if challenge.as_str() != String::from(sso_auth.client_challenge.clone()) { if challenge.as_str() != String::from(sso_auth.client_challenge.clone()) {
err!(format!("PKCE client challenge failed")) err!("PKCE client challenge failed")
// Might need to notify admin ? how ? // Might need to notify admin ? how ?
} }
} }
@@ -116,8 +116,8 @@ app-security > app-two-factor-setup > form {
} }
/* Hide unsupported Custom Role options */ /* Hide unsupported Custom Role options */
bit-dialog div.tw-ml-4:has(bit-form-control input), :is(bit-dialog, [bit-dialog]) div.tw-ml-4:has(bit-form-control input),
bit-dialog div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) { :is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) {
@extend %vw-hide; @extend %vw-hide;
} }