fix(vaultwarden): the admin token reached the container mangled; escape $ for compose #157
Loading…
Reference in a new issue
No description provided.
Delete branch "jfraeys/fix/vaultwarden-admin-token-dollar"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Security-relevant fix. Found on 2026-10-14 because no admin token worked at
/admin.Cause. The vault holds
VAULTWARDEN_ADMIN_TOKENas an Argon2 PHC string ($argon2id$v=19$m=65536,t=2,p=4$<salt>$<hash>).roles/vaultwarden/templates/docker-compose.yml.j2injected it unescaped, so Docker Compose expanded each$...as an unset variable. The running container on services hasADMIN_TOKEN= 17 characters starting=19=65536,with no$(shape checked, value not printed). Vaultwarden treats a non-PHCADMIN_TOKENas a plaintext password, so/adminhas 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:/adminhas its own Traefik router withauthelia@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
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.validate-services.yml): assert the runningADMIN_TOKENequals the vault's value (no_log), so this cannot regress silently.rotate-secret.sh: hash with theargon2CLI only; thedocker run vaultwarden/server hashpath 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 deployvaultwarden,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
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