Vaultwarden có an toàn không? Cách hardening đúng
Vaultwarden mã hóa vault trên client, nhưng admin token và file backup vẫn là rủi ro lớn. Kiểm tra plaintext, cổng container và quyền truy cập trước khi bị lấy sạch.
Vaultwarden có an toàn không? Câu trả lời ngắn gọn
Vaultwarden an toàn ở nơi quan trọng nhất: mọi mục trong vault đều được mã hóa trên thiết bị của bạn trước khi được gửi đến server. Server chỉ lưu các blob mà nó không thể đọc. Ngay cả khi ai đó sao chép toàn bộ database, họ vẫn cần master password để lấy được thông tin hữu ích.
Câu trả lời này phụ thuộc nhiều vào cách bạn cấu hình. Admin panel được bảo vệ bằng token dễ đoán. Một cổng của container được publish cho toàn bộ Internet. Một config.json dạng plaintext. Một file tar backup nằm trong thư mục home trên chính máy đó. Không vấn đề nào trong số này liên quan đến cryptography. Nhưng tất cả đều có thể khiến vault tự host bị lấy sạch.
Phần dưới đây giả định rằng bạn đã cài đặt thành công. Nếu chưa có hệ thống, trước tiên hãy thiết lập bằng hướng dẫn cài đặt Vaultwarden trên VPS, sau đó quay lại và thực hiện lần lượt danh sách này.
Những gì server thực sự lưu trữ
Vaultwarden triển khai data model của Bitwarden. Tên, username, password, ghi chú và URI của một mục trong vault được mã hóa bằng key bắt nguồn từ master password trong client, trước khi client gửi bất kỳ request nào. Nội dung file đính kèm cũng được mã hóa theo cách tương tự. Server nhận dữ liệu không thể đọc được, kèm theo một UUID (universally unique identifier).
Một số dữ liệu không phải ciphertext. Bạn cần biết chính xác đó là những gì:
- Địa chỉ email của tài khoản, ở dạng plaintext.
- Các thiết lập KDF (key derivation function) và salt, vì client cần chúng để tạo lại key trong lần đăng nhập tiếp theo.
- Hash phía server của password hash do client gửi, được dùng để xác thực chính lần đăng nhập đó.
- Metadata: thành viên trong organisation, tên thiết bị, thời điểm đăng nhập gần nhất.
- Secret của phương thức two-factor bảo vệ đăng nhập Vaultwarden. Secret này nằm trong bảng
twofactorở dạng không mã hóa, vì server phải tính code dự kiến để so sánh với code của bạn. Secret này không giống secret TOTP (time-based one-time password) mà bạn lưu bên trong một mục vault; secret đó được mã hóa như mọi field khác.
Thư mục data có kích thước nhỏ. Với cài đặt bằng Docker, đó là thư mục bạn mount tại /data.
sudo ls -l /vw-data/db.sqlite3 chứa gần như toàn bộ state. attachments/ chứa các file đã upload, mỗi file tương ứng với một UUID, và đây là loại data quan trọng duy nhất không nằm trong các bảng database. sends/ chứa attachment của Send và được thiết kế để dùng tạm thời. icon_cache/ có thể xóa bỏ. rsa_key.pem cùng các file liên quan dùng để ký JWT (JSON web token) của người dùng đã đăng nhập, nên bản sao của private key này có thể được dùng để giả mạo session đăng nhập vault. config.json chỉ xuất hiện sau khi bạn bật trang admin. Project nói rõ về việc này: file đó chứa admin token và thông tin xác thực SMTP của bạn ở dạng plaintext.
Vì vậy, threat model thực tế nằm ở quyền truy cập filesystem, không phải cryptography trên network. Quyền đọc vào thư mục đó sẽ làm lộ địa chỉ email của mọi người dùng, secret 2FA đăng nhập của họ, key có thể dùng để giả mạo session và bản sao offline của toàn bộ vault để tấn công tùy ý. Mọi bước bên dưới đều nhằm ngăn người khác truy cập thư mục đó.
Trước tiên, sửa admin token
/admin là một control panel có toàn quyền: danh sách người dùng, lời mời, xóa người dùng và mọi runtime setting. Nó chỉ được bảo vệ bằng một shared secret duy nhất. Không có username. Không có two-factor riêng cho từng người dùng.
Các hướng dẫn cũ yêu cầu bạn tạo ADMIN_TOKEN bằng openssl rand -base64 48. Cách này vẫn hoạt động, nhưng nó ghi secret dưới dạng plaintext vào config.json và file compose của bạn. Vaultwarden cũng chấp nhận chuỗi Argon2 PHC (password hashing competition), nên giá trị được lưu sẽ là hash. Tạo chuỗi này trong một container đang chạy:
docker exec -it vaultwarden /vaultwarden hashHoặc không cần tác động đến container đang chạy:
docker run --rm -it vaultwarden/server /vaultwarden hashLệnh yêu cầu nhập password hai lần, sau đó in ra một dòng bắt đầu bằng $argon2id$. Với cài đặt bare-metal, chạy ./vaultwarden hash. Nếu muốn dùng trực tiếp argon2 CLI, project công bố các tham số tối thiểu theo OWASP:
echo -n 'MySecretPassword' | argon2 "$(openssl rand -base64 32)" -e -id -k 19456 -t 2 -p 1Đây là lỗi dễ khiến bạn mất cả giờ để xử lý. Chuỗi PHC chứa nhiều ký tự $, còn Docker Compose dùng $ cho variable interpolation. Nếu dán chuỗi đó vào một block environment: mà không escape, giá trị truyền vào container sẽ bị biến dạng, nên /admin từ chối token mà bạn biết là đúng. Có 2 cách an toàn. Trong docker-compose.yml, nhân đôi mọi ký tự $:
environment:
ADMIN_TOKEN: $$argon2id$$v=19$$m=19456,t=2,p=1$$UUZxK1FZMkZoRHFQRlVrTXZvS0E3bHpNQW55c2dBN2NORzdsa0Nxd1JhND0$$cUoId+JBUsJutlG4rfDZayExfjq4TCt48aBc9qsc3UITrong file .env, không cần escape, nhưng phải dùng single quotes:
ADMIN_TOKEN='$argon2id$v=19$m=65540,t=3,p=4$MmeKRnGK5RW5mJS7h3TOL89GrpLPXJPAtTK8FTqj9HM$DqsstvoSAETl9YhnsXbf43WeaUwJC6JhViIvuPoig78'Sau đó giới hạn rate của panel và rút ngắn thời gian session:
ADMIN_RATELIMIT_SECONDS=300
ADMIN_RATELIMIT_MAX_BURST=3
ADMIN_SESSION_LIFETIME=20Nếu có 3 lần thử không hợp lệ trong 5 phút, panel sẽ ngừng phản hồi client đó. Admin session hết hạn sau 20 phút không hoạt động.
Tốt hơn tất cả các cách trên là tắt trang này. Hầu hết instance chỉ cần trang này một lần để cấu hình SMTP và mời những người dùng đầu tiên, sau đó không cần nữa. Để tắt, không đặt ADMIN_TOKEN hoặc DISABLE_ADMIN_TOKEN, xóa mọi key "admin_token" khỏi config.json, rồi tạo lại container. Việc xóa key khỏi file rất quan trọng vì admin page ghi các setting vào đó, và giá trị trong config.json sẽ được ưu tiên hơn environment. Chỉ xóa biến thôi thì trang vẫn mở.
Đóng đăng ký trước khi có người phát hiện domain
SIGNUPS_ALLOWED=false
SIGNUPS_VERIFY=true
SHOW_PASSWORD_HINT=falseSIGNUPS_ALLOWED mặc định là true. Hãy giữ nguyên thiết lập này thì bất kỳ ai truy cập được domain của bạn đều có thể tạo account, và dữ liệu của họ nằm trong cùng db.sqlite3 với dữ liệu của bạn. Đặt thành false rồi thêm người dùng qua lời mời từ trang admin; cách này yêu cầu SMTP hoạt động. INVITATIONS_ALLOWED cũng mặc định là true và cho phép owner của organisation mời người khác. Thiết lập này phù hợp khi bạn tin tưởng người dùng, và nên được đặt thành false trên instance chỉ dành cho một người dùng. Nếu chỉ một số domain nhất định được phép đăng ký, SIGNUPS_DOMAINS_WHITELIST=example.com hẹp hơn open signup nhưng kém an toàn hơn nhiều so với lời mời.
SHOW_PASSWORD_HINT mặc định là false và nên giữ nguyên như vậy. Khi bật tùy chọn này, nhập một địa chỉ email hợp lệ vào form đăng nhập sẽ trả về gợi ý master password của account đó. Điều này vừa làm lộ gợi ý, vừa xác nhận địa chỉ đó tồn tại.
Nếu instance của bạn từng mở đăng ký trong bất kỳ khoảng thời gian nào, hãy mở trang admin và đọc danh sách user trước khi cho rằng instance chỉ có account của bạn.
Cổng bạn không định public
Docker image lắng nghe trên cổng 80 bên trong container. Bản cài bare-metal mặc định dùng ROCKET_PORT=8000. Lệnh run được tài liệu hướng dẫn publish cổng như sau:
--publish 127.0.0.1:8000:80Prefix 127.0.0.1: mới là phần quyết định. Nếu viết -p 8000:80 thay vì vậy, Docker sẽ bind 0.0.0.0 và tạo các rule DNAT (destination network address translation) trong table nat. Các rule này được xử lý trước những chain filter do ufw quản lý. Vì vậy, ufw status báo cổng bị deny, nhưng cổng đó vẫn trả lời lưu lượng từ Internet. Cơ chế đầy đủ được giải thích trong hướng dẫn về việc Docker bypass ufw khi publish port.
Kiểm tra tiến trình nào thực sự đang lắng nghe:
sudo ss -tlnp | grep 8000Kết quả đúng chỉ có một dòng bind vào 127.0.0.1:8000. Nếu có dòng bind vào 0.0.0.0:8000, vault đang được expose trực tiếp. Sửa mapping, sau đó recreate container. Port binding được cố định khi container được tạo và docker compose restart sẽ không thay đổi binding đó:
docker compose up -d --force-recreateMột cổng khác vẫn xuất hiện trong các guide cũ: 3012, là cổng WebSocket riêng. Vaultwarden đã loại bỏ hỗ trợ cổng này trong phiên bản 1.31.0 vì traffic notification đã chuyển sang cổng HTTP chính. WEBSOCKET_ENABLED và WEBSOCKET_PORT đã bị bỏ qua kể từ phiên bản 1.29.0. Tùy chọn hiện tại là ENABLE_WEBSOCKET, mặc định là true. Nếu firewall hoặc file compose của bạn vẫn mở cổng 3012, hãy đóng cổng đó.
Kết thúc TLS tại reverse proxy, không phải trong Rocket
Vaultwarden có thể tự phục vụ TLS (bảo mật lớp truyền tải) thông qua Rocket, framework web của nó, nhưng dự án khuyến cáo không làm vậy trong môi trường production. TLS tích hợp sẵn của Rocket không hỗ trợ SNI (chỉ báo tên máy chủ) nghiêm ngặt. Đây cũng là lý do hướng dẫn hardening yêu cầu bạn truy cập instance bằng hostname và không bao giờ dùng địa chỉ IP trần. Các dải IP public luôn bị quét liên tục. Một vault phản hồi trên địa chỉ IP sẽ bị phát hiện.
Các phần quan trọng trong server block của nginx:
client_max_body_size 525M;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}nginx mặc định client_max_body_size ở mức 1 MB. Nếu thiếu dòng đó, việc upload attachment sẽ fail với 413 Request Entity Too Large trong error log của nginx, còn log của Vaultwarden hoàn toàn không có gì. Các header Upgrade và Connection chuyển WebSocket handshake đến /notifications/hub. Nếu bỏ chúng, vault vẫn hoạt động, nhưng các thay đổi sẽ không xuất hiện trên những thiết bị khác cho đến khi bạn tự reload trang.
Caddy có cấu hình ngắn hơn và tự lấy certificate:
vw.example.com {
reverse_proxy 127.0.0.1:8000 {
header_up X-Real-IP {remote_host}
}
}Sau đó khai báo thông tin này cho Vaultwarden:
DOMAIN=https://vw.example.com
IP_HEADER=X-Real-IPIP_HEADER vốn mặc định là X-Real-IP. Vì vậy, việc cần làm là bảo đảm proxy thực sự đặt header đó. Nếu không, mọi dòng log và mọi rate limit đăng nhập đều thấy 127.0.0.1, tức chính proxy. Khi đó, các lần đăng nhập thất bại của một attacker sẽ bị tính cho tất cả user trên instance. Đồng thời đặt DOMAIN thành URL https thực tế, vì Vaultwarden tạo link invitation và reset password từ giá trị này, còn security key WebAuthn được ràng buộc với origin đó.
Có một chi tiết nhiều người bỏ sót: kết nối WebSocket truyền session token trong query string, dưới dạng /notifications/hub?access_token=[JWT]. Giá trị này xuất hiện rõ trong access log của proxy. Hãy redacted parameter access_token trong log format, hoặc bảo đảm các log đó không được gửi đến bất kỳ nơi nào bạn không kiểm soát.
Chặn brute force tại endpoint đăng nhập
Rate limit được bật mặc định (LOGIN_RATELIMIT_SECONDS=60, LOGIN_RATELIMIT_MAX_BURST=10). Chúng làm chậm attacker. Chúng không ngăn được attacker. fail2ban mới làm được việc đó, nhưng Vaultwarden phải ghi log vào file trước, trong khi mặc định nó không làm vậy:
LOG_FILE=/data/vaultwarden.log
LOG_LEVEL=info
EXTENDED_LOGGING=trueMột lần đăng nhập thất bại sau đó sẽ tạo đúng một dòng. Đây là chuỗi mà filter của bạn phải khớp:
[2026-08-07 09:14:22][vaultwarden::api::identity][ERROR] Username or password is incorrect. Try again. IP: 203.0.113.10. Username: user@example.com.Ghi filter vào /etc/fail2ban/filter.d/vaultwarden.local:
[INCLUDES]
before = common.conf
[Definition]
failregex = ^.*?Username or password is incorrect\. Try again\. IP: <ADDR>\. Username:.*$
ignoreregex =Và jail vào /etc/fail2ban/jail.d/vaultwarden.local:
[vaultwarden]
enabled = true
port = 80,443
filter = vaultwarden
banaction = %(banaction_allports)s
logpath = /vw-data/vaultwarden.log
maxretry = 3
bantime = 14400
findtime = 14400Nếu bạn vẫn giữ trang admin, hãy thêm một jail thứ hai có failregex là ^.*Invalid admin token\. IP: <ADDR>.*$, vì các lần đăng nhập admin thất bại được ghi bằng thông báo khác và login filter sẽ không bao giờ bắt được chúng. Sau đó kiểm tra cấu hình:
sudo systemctl restart fail2ban
sudo fail2ban-client status vaultwardenJail hoạt động sẽ liệt kê file log của bạn dưới File list và báo cáo Currently failed: 0. Nhập sai mật khẩu 3 lần từ một network khác. Bộ đếm sẽ tăng, rồi địa chỉ đó xuất hiện dưới Banned IP list. Nếu bộ đếm không tăng, nguyên nhân thường gặp là logpath: giá trị này phải là đường dẫn của file trên host, không phải đường dẫn /data/... bên trong container. Nguyên nhân thường gặp thứ hai là thiếu X-Real-IP, khiến mọi lệnh ban đều nhắm vào chính proxy của bạn. Phần còn lại của cấu hình, bao gồm SSH jail mà bạn đáng lẽ đã chạy, có trong hướng dẫn fail2ban cho Ubuntu 24.04.
Mật khẩu master vẫn là toàn bộ hệ thống
Mã hóa phía client có nghĩa là mật khẩu master chính là key. Một mật khẩu master ngắn trên instance có database đã bị attacker sao chép sẽ không được bảo vệ bởi bất kỳ thiết lập nào trong bài này, vì attacker có thể tấn công bản sao đó offline với tốc độ phần cứng của họ. Không có thiết lập nào trên server có thể tác động đến máy riêng của attacker.
PASSWORD_ITERATIONS=600000 là số vòng lặp KDF được gửi cho client khi họ tạo account mới. Các account hiện có vẫn giữ giá trị tại thời điểm được tạo, nên tăng giá trị này không thay đổi gì đối với người dùng đã đăng ký từ năm ngoái. Họ phải tự thay đổi trong phần thiết lập bảo mật của web vault, thao tác này sẽ mã hóa lại key của họ. Hãy nói rõ cho họ biết, vì giao diện không hiển thị thông tin này.
Sau đó, hãy bật xác thực hai yếu tố cho từng account. Tính năng này không bảo vệ ciphertext, vì vault key chỉ được tạo từ mật khẩu master. Tuy nhiên, nó ngăn việc mật khẩu bị đánh cắp đủ để attacker đăng nhập và sync một bản sao. REQUIRE_DEVICE_EMAIL=true thêm bước xác nhận qua email trong lần đầu account đăng nhập từ một thiết bị chưa được nhận diện.
Sao lưu là điểm dễ làm hỏng hệ thống vault tự host
Một tar czf của thư mục dữ liệu, nằm trong thư mục home trên cùng VPS, sẽ vô hiệu hóa mọi bước ở trên. Archive đó chứa db.sqlite3 với ciphertext của mọi người dùng, rsa_key.pem dùng để giả mạo các login session và config.json chứa admin token cùng mật khẩu SMTP ở dạng plaintext. Có quyền đọc file đó đồng nghĩa với có quyền đọc vault.
Chỉ cần tuân thủ 2 quy tắc. Đưa archive ra khỏi máy chủ. Mã hóa archive trước khi truyền đi.
Còn có một vấn đề về tính đúng đắn của dữ liệu. Sao chép db.sqlite3 bằng cp khi service đang chạy có thể tạo ra file đang được ghi dở nên không mở được. Bạn sẽ không phát hiện ra cho đến khi restore. Hãy dùng snapshot của chính SQLite:
sqlite3 /vw-data/db.sqlite3 ".backup '/tmp/vw-db-backup.sqlite3'"Phần restore, vốn thường không được ai kiểm thử, được trình bày trong hướng dẫn sao lưu và restore Vaultwarden.
Bạn phải tự đảm nhận những gì khi không dùng Bitwarden hosted
Đánh giá thẳng thắn. Dịch vụ hosted của Bitwarden do những người làm công việc vận hành toàn thời gian quản lý, có các cuộc audit do bên thứ ba công bố và luôn có người trực lúc 3 giờ sáng. Tự host đồng nghĩa với việc bạn phải tự quản lý lịch cập nhật bản vá.
Vaultwarden phát hành các bản sửa lỗi bảo mật dưới dạng các release thông thường. Version 1.37.0, được phát hành vào ngày 24 July 2026, là bản hiện tại tính đến August 2026, và ghi chú phát hành yêu cầu người dùng cập nhật sớm nhất có thể. Một instance bạn thiết lập từ một năm trước rồi quên mất vẫn đang chạy code cũ một năm. Tag latest tự nó không giúp được gì: container đang chạy vẫn dùng image mà nó đã khởi động cùng cho đến khi bạn chạy docker compose pull và tạo lại container. Hãy thiết lập unattended upgrades trên Ubuntu cho các package của host, đồng thời đặt lịch nhắc cập nhật container vào một thời điểm bạn chắc chắn sẽ đọc.
Kết luận mà người đọc cần rút ra là: phần cryptography ở đây dựa trên thiết kế của Bitwarden và thiết kế đó vẫn vững, còn rủi ro vận hành hoàn toàn chuyển sang bạn. Nếu bạn cập nhật bản vá và backup dữ liệu sang một nơi khác, một instance Vaultwarden trên VPS do bạn kiểm soát là nơi hợp lý để lưu password. Nếu bạn không thể duy trì hai thói quen đó, hãy trả phí cho dịch vụ hosted và dành thời gian cho việc khác. So sánh từng tính năng nằm tại Vaultwarden so với Bitwarden tự host.
Củng cố máy chủ bên dưới container
Vaultwarden là một process trên máy Linux. root trên máy đó vẫn có thể đọc /vw-data bất kể ứng dụng được cấu hình thế nào. Chạy container bằng user không có đặc quyền với user: "1000:1000" trong file compose, đặt quyền sở hữu thư mục dữ liệu cho phù hợp, và mount mọi thứ container không ghi vào ở chế độ chỉ đọc bằng :ro. Sau đó đóng cửa ngõ chính: Củng cố SSH trên VPS hướng dẫn chỉ cho phép đăng nhập bằng key và tắt xác thực bằng password. Đây là cách ngăn kiểu tấn công đơn giản vượt qua tất cả các lớp bảo vệ trên.
FAQ
Tôi có thể bị lộ mật khẩu nếu kẻ tấn công đánh cắp database Vaultwarden không?
Không trực tiếp. Mỗi mục trong vault được mã hóa trên client bằng một key được tạo từ master password, vì vậy db.sqlite3 chỉ chứa ciphertext. Dữ liệu họ lấy được ngay gồm email của từng account, các thiết lập KDF, metadata đăng nhập và thiết bị, cùng các secret two-factor trong bảng twofactor. Các secret này được lưu không mã hóa vì server phải tính code dự kiến. Họ cũng có thể tấn công ciphertext của vault offline trong thời gian tùy ý. Vì vậy, độ dài master password là yếu tố quyết định kết quả.
Tôi nên dùng ADMIN_TOKEN hay tắt hoàn toàn trang admin?
Nếu có thể, hãy tắt trang này. Hầu hết instance chỉ cần dùng nó một lần để cấu hình SMTP và mời user, sau đó không cần nữa. Để tắt, không đặt ADMIN_TOKEN và DISABLE_ADMIN_TOKEN, xóa mọi key "admin_token" khỏi config.json, rồi tạo lại container. Chỉ xóa environment variable là chưa đủ, vì các thiết lập được ghi từ trang admin nằm trong config.json và được ưu tiên hơn. Nếu vẫn bật trang này, hãy lưu token dưới dạng Argon2 hash được tạo bằng vaultwarden hash thay vì một chuỗi random plaintext, rồi đặt ADMIN_RATELIMIT_MAX_BURST=3.
ADMIN_TOKEN của tôi đúng nhưng /admin từ chối. Lỗi ở đâu?
Gần như luôn là do interpolation của $. Chuỗi Argon2 PHC chứa nhiều ký tự $. Docker Compose mở rộng chúng thành variable bên trong block docker-compose.yml environment:, nên container nhận giá trị bị biến dạng dù file của bạn trông vẫn đúng. Trong compose file, hãy nhân đôi mỗi $ thành $$, hoặc chuyển giá trị vào một file .env được bọc bằng dấu nháy đơn. Cách này không cần escaping. Sau đó tạo lại container, vì restart không nhận các thay đổi về environment.
Tôi còn cần mở port 3012 cho notification không?
Không. Hỗ trợ WebSocket traffic trên port 3012 đã bị xóa trong Vaultwarden 1.31.0 vì notification đã chuyển sang HTTP port chính. WEBSOCKET_ENABLED và WEBSOCKET_PORT cũng đã bị bỏ qua từ 1.29.0. Thiết lập hiện tại là ENABLE_WEBSOCKET, mặc định là true. Hãy đóng 3012 trên firewall và xóa port này khỏi compose file. Sau đó bảo đảm reverse proxy forward các header Upgrade và Connection, vì real-time sync hiện phụ thuộc vào chúng.