fix(vaultwarden): the admin token reached the container mangled; escape $ for compose #157

Merged
jfraeys merged 1 commit from jfraeys/fix/vaultwarden-admin-token-dollar into main 2026-10-14 14:38:15 -04:00
Owner

Security-relevant fix. Found on 2026-10-14 because no admin token worked at /admin.

Cause. The vault holds VAULTWARDEN_ADMIN_TOKEN as an Argon2 PHC string ($argon2id$v=19$m=65536,t=2,p=4$<salt>$<hash>). roles/vaultwarden/templates/docker-compose.yml.j2 injected it unescaped, so Docker Compose expanded each $... as an unset variable. The running container on services has ADMIN_TOKEN = 17 characters starting =19=65536, with no $ (shape checked, value not printed). Vaultwarden treats a non-PHC ADMIN_TOKEN as a plaintext password, so /admin has been accepting the predictable string =19=65536,t=2,p=4 (the same for every hash with these parameters), and no real token ever worked. Exposure: /admin has its own Traefik router with authelia@file (anonymous request: HTTP 302 to the login), so an attacker needed a valid SSO login first; the second layer was broken, not the only one. Likely broken since hashing was introduced (the "/admin works" check in #116 was never confirmed).

Fix

  • Template: replace('$', '$$'). Proven with a real container on services and a sample hash: the old line gives =19=65536,t=2,p=4 (exactly what production has), the fixed line gives the hash byte-for-byte.
  • Staging (validate-services.yml): assert the running ADMIN_TOKEN equals the vault's value (no_log), so this cannot regress silently.
  • rotate-secret.sh: hash with the argon2 CLI only; the docker run vaultwarden/server hash path never worked (the image's entrypoint is not the binary) and the function silently fell back to storing plaintext if argon2 was missing; it now fails instead. The Bitwarden note body is now the token alone (Vaultwarden and LLDAP), without the *_PLAIN= prefix.
  • gen-auth-secrets.sh: same dead docker branch removed (it would have left the token empty).

After merge: staging runs (roles/ and playbooks/ changed) and checks the new assertion; then promote and ./infra deploy --tags vaultwarden --limit services. The vault's current hash is untouched, so after that deploy the plaintext that matches it works. If the Bitwarden note is not that plaintext (it may be from an older rotation), rotate once more with ./infra rotate vaultwarden --save-to-bitwarden (the note is then the bare token) and deploy vaultwarden,authelia.

Checked: ansible-lint, yamllint, bash -n, test-rotation-lock, test-bitwarden-save, test-infra-cli.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ge5tovcwdEUBtPwUktsZZZ

**Security-relevant fix. Found on 2026-10-14 because no admin token worked at `/admin`.** **Cause.** The vault holds `VAULTWARDEN_ADMIN_TOKEN` as an Argon2 PHC string (`$argon2id$v=19$m=65536,t=2,p=4$<salt>$<hash>`). `roles/vaultwarden/templates/docker-compose.yml.j2` injected it unescaped, so Docker Compose expanded each `$...` as an unset variable. The running container on services has `ADMIN_TOKEN` = 17 characters starting `=19=65536,` with no `$` (shape checked, value not printed). Vaultwarden treats a non-PHC `ADMIN_TOKEN` as a **plaintext password**, so `/admin` has been accepting the predictable string `=19=65536,t=2,p=4` (the same for every hash with these parameters), and no real token ever worked. **Exposure:** `/admin` has its own Traefik router with `authelia@file` (anonymous request: HTTP 302 to the login), so an attacker needed a valid SSO login first; the second layer was broken, not the only one. Likely broken since hashing was introduced (the "/admin works" check in #116 was never confirmed). **Fix** - Template: `replace('$', '$$')`. Proven with a real container on services and a *sample* hash: the old line gives `=19=65536,t=2,p=4` (exactly what production has), the fixed line gives the hash byte-for-byte. - Staging (`validate-services.yml`): assert the running `ADMIN_TOKEN` equals the vault's value (`no_log`), so this cannot regress silently. - `rotate-secret.sh`: hash with the `argon2` CLI only; the `docker run vaultwarden/server hash` path never worked (the image's entrypoint is not the binary) and the function silently fell back to storing plaintext if argon2 was missing; it now fails instead. **The Bitwarden note body is now the token alone** (Vaultwarden and LLDAP), without the `*_PLAIN=` prefix. - `gen-auth-secrets.sh`: same dead docker branch removed (it would have left the token empty). **After merge:** staging runs (roles/ and playbooks/ changed) and checks the new assertion; then promote and `./infra deploy --tags vaultwarden --limit services`. The vault's current hash is untouched, so after that deploy the plaintext that matches it works. If the Bitwarden note is not that plaintext (it may be from an older rotation), rotate once more with `./infra rotate vaultwarden --save-to-bitwarden` (the note is then the bare token) and deploy `vaultwarden,authelia`. Checked: ansible-lint, yamllint, `bash -n`, test-rotation-lock, test-bitwarden-save, test-infra-cli. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Ge5tovcwdEUBtPwUktsZZZ
fix(vaultwarden): the admin token reached the container mangled; escape $ for compose
All checks were successful
Lint / lint (pull_request) Successful in 2m59s
Secret scan / scan (pull_request) Successful in 15s
469d771e68
The vault holds VAULTWARDEN_ADMIN_TOKEN as an Argon2 PHC string
($argon2id$v=19$m=65536,t=2,p=4$<salt>$<hash>). The compose template injected it
unescaped, so Docker Compose expanded every $... as an unset variable and the
container received "=19=65536,t=2,p=4" (17 characters, confirmed on services).
Vaultwarden treats a non-PHC ADMIN_TOKEN as a plaintext password, so /admin
accepted that predictable string and no real token ever worked. /admin is behind
Authelia, so this broke the second layer, not the only one.

- Template: replace('$', '$$'). Checked with a real container on services and a
  sample hash: the old line yields "=19=65536,t=2,p=4", the fixed one the hash
  byte-for-byte.
- validate-services.yml (staging): assert the running ADMIN_TOKEN equals the
  vault's, no_log, so this cannot come back silently.
- rotate-secret.sh: hash with the argon2 CLI only (the `docker run
  vaultwarden/server hash` path never worked: the image's entrypoint is not the
  binary) and fail instead of silently storing the plaintext; the Bitwarden note
  body is now the token alone (also LLDAP), without the *_PLAIN= prefix.
- gen-auth-secrets.sh: drop the same dead docker branch (it would have left the
  token empty).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ge5tovcwdEUBtPwUktsZZZ
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jfraeys/infra!157
No description provided.