mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-07-28 22:05:06 +03:00
Compare commits
18
Commits
d626ea81ab
..
1.37.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46ae59eaf4 | ||
|
|
a6a88e7929 | ||
|
|
5040bcb7c0 | ||
|
|
660faee68e | ||
|
|
683a23e43c | ||
|
|
4a9bcb0694 | ||
|
|
169aa5efcc | ||
|
|
64d28ab66e | ||
|
|
4720cdbe86 | ||
|
|
5447ee6af2 | ||
|
|
5c5e8e1a6f | ||
|
|
7320a1db4b | ||
|
|
a058a35ccd | ||
|
|
a16b5afaaa | ||
|
|
fddc16d2b8 | ||
|
|
ec7fa137b7 | ||
|
|
b25f715364 | ||
|
|
d6a3d539ed |
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+38
-36
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 \
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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?;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+52
-10
@@ -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,13 +385,6 @@ impl Send {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// separate namespace to avoid name collision with std::marker::Send
|
|
||||||
pub mod id {
|
|
||||||
use derive_more::{AsRef, Deref, Display, From};
|
|
||||||
use macros::{IdFromParam, UuidFromParam};
|
|
||||||
use std::marker::Send;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
#[derive(
|
#[derive(
|
||||||
Clone,
|
Clone,
|
||||||
Debug,
|
Debug,
|
||||||
@@ -378,4 +421,3 @@ pub mod id {
|
|||||||
Path::new(&self.0)
|
Path::new(&self.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
+54
-21
@@ -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
@@ -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,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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user