Ghép lại: bảo mật của một trang web thật, từ Cloudflare tới file .env

Mô hình mối đe dọa cho viet.sarl, đi qua từng lớp (Cloudflare, TLS, Caddy, cookie, token, Argon2id, OAuth, quyền admin, SQLite, SSH, secret), các lỗ hổng web phổ biến và cách trang này chặn chúng, những việc còn thiếu, và một danh sách kiểm tra.

Tám bài trước là từng công cụ: số ngẫu nhiên, hàm băm, mật khẩu, khóa công khai, TLS, mã hóa dữ liệu lúc lưu, OAuth, SSH. Bài cuối ghép chúng lại trên một hệ thống thật: chính viet.sarl. Mục tiêu không phải khoe rằng trang an toàn, mà là cho thấy cách suy nghĩ về bảo mật của một hệ thống: bắt đầu từ câu hỏi "đang chống ai", đi qua từng lớp, rồi ghi ra trung thực những gì còn thiếu.

Lộ trình: mô hình mối đe dọa, sơ đồ các lớp, đi qua từng lớp, các lỗ hổng web phổ biến (OWASP Top 10) đối chiếu với trang này, chuỗi cung ứng, những việc còn thiếu, rồi danh sách kiểm tra cuối seri. Mọi thông tin về trang lấy từ code trong repo và từ các lệnh chỉ đọc chạy trên trang công khai. Bài không in địa chỉ máy chủ, secret hay cấu hình nội bộ nào.

Mô hình mối đe dọa

Mô hình mối đe dọa (threat model) trả lời bốn câu: có gì đáng bảo vệ, ai muốn lấy, họ đi vào từ đâu, và mất thì thiệt hại bao nhiêu. Với một trang blog kèm vài trò chơi có bảng xếp hạng:

Tài sản Vì sao quan trọng
Email và bản băm mật khẩu Người dùng dùng lại mật khẩu ở nơi khác (bài 3)
Phiên đăng nhập, quyền admin Chiếm tài khoản; admin khóa được người dùng và sửa bảng xếp hạng
Secret: JWT_SECRET, khóa Google OAuth, mật khẩu SMTP Có JWT_SECRET là tự ký được phiên của bất kỳ ai
Máy chủ và quyền SSH Có máy chủ là có tất cả những thứ trên
Tính sẵn sàng, uy tín Trang bị sập hoặc bị chèn nội dung lạ

Kẻ tấn công thực tế của một trang cỡ này không phải một nhà nước. Đó là các bot tự động quét hàng triệu trang để tìm lỗi phổ biến, như file .env để lộ, mật khẩu yếu hay phần mềm cũ; người gian lận bảng xếp hạng; kẻ dò mật khẩu bằng danh sách đã lộ; và chính mình, khi lỡ đẩy secret lên git. Mô hình này quyết định đâu là việc nên làm trước: chặn lỗi phổ biến quan trọng hơn chống thuật toán lượng tử. Thật ra phần lượng tử thì Cloudflare đã lo sẵn.

Các lớp của trang

Trình duyệt
   │  TLS 1.3, X25519MLKEM768 (bài 4, 5)
Cloudflare      ── cache, chống tấn công từ chối dịch vụ, giấu địa chỉ máy chủ gốc
   │  TLS, chứng chỉ Let's Encrypt của Caddy
Caddy           ── HTTPS, chuyển www, cân bằng tải giữa các container khi deploy
   │  HTTP trong mạng Docker
App FastAPI     ── cookie, token, Argon2id, OAuth + PKCE, kiểm quyền, làm sạch HTML
   │
SQLite          ── email, bản băm mật khẩu, liên kết Google, điểm số
Máy chủ (VPS)   ── SSH bằng khóa, .env.production

Đi qua từng lớp

Cloudflare. Đứng trước mọi request, nên địa chỉ IP thật của máy chủ không xuất hiện trong DNS công khai. Nếu địa chỉ đó lộ ra, ví dụ qua header email hay một bản ghi DNS cũ, kẻ tấn công có thể đi vòng qua Cloudflare. Biện pháp đầy đủ là để tường lửa của máy chủ chỉ nhận cổng 80 và 443 từ dải IP của Cloudflare. Đó cũng là lý do mọi bài trên trang không bao giờ in địa chỉ máy chủ.

TLS. Bài 5 đã đọc chi tiết: TLS 1.3, AES-256-GCM, trao đổi khóa lai hậu lượng tử, chứng chỉ 90 ngày tự gia hạn. HTTP được chuyển hướng 308 sang HTTPS. Còn thiếu HSTS (mục cuối).

Caddy. Chỉ cổng 80 và 443 được mở ra ngoài. App chỉ expose cổng 8000 trong mạng Docker, không có ports, nên không ai từ Internet gọi thẳng được vào app mà bỏ qua Caddy.

Cookie. Mọi cookie đăng nhập được đặt bằng cùng một hàm:

response.set_cookie(
    key, value, max_age=max_age, path=path, httponly=True, secure=settings.ENVIRONMENT.is_deployed, samesite="lax"
)
  • HttpOnly: JavaScript không đọc được cookie, nên dù có lỗi XSS, kẻ tấn công cũng không lấy được phiên đem đi nơi khác.
  • Secure: chỉ gửi qua HTTPS.
  • SameSite=Lax: trang khác không gửi được form POST kèm cookie này, chặn phần lớn CSRF.
  • Refresh token chỉ được gửi tới /auth (path), nên không đi kèm hàng nghìn request bình thường.

Token. Phiên dùng token có chữ ký HMAC, với access token 15 phút và refresh token 30 ngày. Mỗi token có trường typ, và token loại này không bao giờ được nhận như loại khác. Thư viện được gọi với danh sách thuật toán cố định (algorithms=[...]), và app từ chối khởi động ở production nếu JWT_SECRET ngắn hơn 32 ký tự hoặc còn là secret dành cho môi trường dev. Tài liệu ghi rõ một đánh đổi: không có danh sách thu hồi, nên token bị chép vẫn dùng được tới khi hết hạn, và đổi JWT_SECRET là cách đăng xuất tất cả mọi người.

Mật khẩu. Argon2id 64 MiB (bài 3). Có bản băm giả để email không tồn tại cũng mất cùng thời gian. Cùng một thông báo lỗi cho sai email và sai mật khẩu. Trang quên mật khẩu hiện cùng một thông báo dù email có tồn tại hay không. Link đặt lại mật khẩu mang dấu vân tay của bản băm hiện tại, nên tự hết hiệu lực khi mật khẩu đã đổi, tức chỉ dùng được một lần.

OAuth. Authorization code với PKCE, state so bằng compare_digest, scope tối thiểu, liên kết bằng sub, chỉ tin email đã được Google xác minh, và xóa mật khẩu của tài khoản chưa xác minh khi chủ thật của email xuất hiện (bài 7).

Quyền admin. Đây là chỗ dễ sai nhất trong mọi ứng dụng. Token có chứa roles, nhưng chỉ để giao diện hiển thị menu. Quyền thật luôn được đọc lại từ database ở mỗi request:

def require_permission(permission: Permission):
    """For staff pages: roles are read from the database on every request, never trusted from the token."""

Nhờ vậy, khóa một tài khoản hay gỡ quyền admin có hiệu lực ngay, không phải đợi token hết hạn. Các thao tác của admin còn kiểm thêm header Origin phải là chính trang (same_origin), một lớp chống CSRF nữa ngoài SameSite.

Nội dung. Bài blog viết bằng Markdown, được dựng thành HTML rồi qua nh3, một bộ làm sạch HTML chỉ giữ các thẻ và thuộc tính trong danh sách cho phép. Template Jinja tự escape mọi biến. Chỗ duy nhất dùng | safe là HTML đã được làm sạch đó.

Database. Truy vấn dùng SQLAlchemy với tham số ràng buộc, không ghép chuỗi SQL. File SQLite nằm trong thư mục data/ (bị .gitignore chặn). Mật khẩu chỉ có bản băm. Không có cột nào cần mã hóa ở tầng ứng dụng (bài 6).

Máy chủ và secret. Deploy qua SSH bằng khóa (bài 8). .env và .env.production bị .gitignore chặn. Địa chỉ máy chủ đọc từ DEPLOY_HOST trong .env.production chứ không ghi trong deploy.sh, nên repo công khai không làm lộ máy chủ. Thử truy cập https://viet.sarl/.env trả về 404.

OWASP Top 10 đối chiếu với trang này

OWASP Top 10 là danh sách các nhóm lỗ hổng web phổ biến nhất, cập nhật vài năm một lần. Đối chiếu từng nhóm chính:

Nhóm Trang này
Kiểm soát truy cập hỏng (xem, sửa dữ liệu của người khác) Quyền đọc từ database mỗi request; mọi thao tác điểm số gắn với user.id từ phiên, không lấy từ form
Lỗi mật mã Argon2id, TLS 1.3, token HMAC với secret 32+ ký tự, secrets cho mọi số ngẫu nhiên
Injection (SQL, lệnh hệ thống) SQLAlchemy với tham số ràng buộc; không gọi shell với dữ liệu người dùng
XSS Jinja tự escape; HTML của bài viết qua nh3; cookie HttpOnly. Thiếu CSP
CSRF SameSite=Lax, kiểm Origin cho thao tác admin, state cho OAuth
Cấu hình sai App không mở cổng ra ngoài; secret dev bị từ chối ở production. Thiếu một số header bảo mật
Thành phần có lỗ hổng uv.lock ghim phiên bản; ảnh Docker dựng lại mỗi lần deploy. Chưa có quét tự động
Lỗi xác thực Thông báo lỗi giống nhau, thời gian giống nhau, link đặt lại dùng một lần. Chưa giới hạn số lần đăng nhập
SSRF (máy chủ bị lừa gọi tới địa chỉ nội bộ) App chỉ gọi tới các URL cố định của Google; không tải URL do người dùng đưa vào
Thiếu log và theo dõi Có log, chưa có cảnh báo cho các mẫu bất thường

Chuỗi cung ứng

Code của chính trang chỉ là phần nhỏ. Phần lớn code chạy trên máy chủ đến từ thư viện bên thứ ba: FastAPI, SQLAlchemy, PyJWT, pwdlib, nh3, cùng các thư viện của chúng, ảnh Docker python:3.12-slim, Caddy. Một thư viện bị chiếm quyền phát hành là một con đường vào thẳng máy chủ.

Những gì trang đang làm và nên làm:

  • Ghim phiên bản và hash: uv.lock ghi chính xác phiên bản và mã băm của từng gói (bài 2). Cài lại sẽ ra đúng các byte đó, không lấy bản mới lạ.
  • Ít phụ thuộc hơn: mỗi gói bỏ đi là một chỗ ít phải tin.
  • Quét lỗ hổng (pip-audit, Dependabot) và cập nhật đều đặn: chưa có, nằm trong danh sách việc cần làm.
  • Cẩn thận với gói mới: kiểm tra tên gói (gõ nhầm tên là một kiểu tấn công phổ biến), người phát hành, và lịch sử của gói trước khi thêm.

Những việc còn thiếu

Viết seri này cũng là một lần tự kiểm tra. Các mục dưới đây là những gì trang chưa có, theo thứ tự nên làm:

Việc Vì sao Độ khó
Header Strict-Transport-Security Chặn yêu cầu HTTP đầu tiên bị chặn giữa đường (bài 5). Bắt đầu với max-age ngắn Một dòng trong Caddyfile
Header X-Content-Type-Options: nosniff, Referrer-Policy, frame-ancestors Chặn trình duyệt đoán kiểu file, hạn chế lộ URL qua Referer, chặn bị nhúng vào trang khác Vài dòng trong Caddyfile
Content-Security-Policy Lớp chặn thứ hai cho XSS: trình duyệt chỉ chạy script từ nguồn được cho phép Cần thử kỹ vì trang dùng HTMX và script nội tuyến
Giới hạn số lần đăng nhập theo tài khoản và theo IP Làm chậm việc dò mật khẩu bằng danh sách đã lộ Vừa
Mật khẩu tối thiểu dài hơn, kiểm tra danh sách mật khẩu đã lộ NIST khuyên tối thiểu 15 ký tự khi không có MFA (bài 3); trang đang để 8 Nhỏ
Container chạy bằng user không phải root Lỗi trong app không thành quyền root trong container Một dòng USER trong Dockerfile, kèm quyền ghi cho thư mục data/
Quét lỗ hổng thư viện tự động Biết sớm khi một gói đang dùng có lỗ hổng Nhỏ
Tường lửa máy chủ chỉ nhận 80/443 từ Cloudflare Không đi vòng qua Cloudflare được kể cả khi địa chỉ gốc bị lộ Vừa
Bản sao lưu SQLite mã hóa, có thử khôi phục Bản sao lưu chứa email và bản băm mật khẩu (bài 6) Vừa

Không mục nào là lỗ hổng đang bị khai thác. Phần lớn là lớp phòng thủ thứ hai: khi lớp đầu, như escape hay SameSite, có lỗi thì lớp này vẫn chặn được. Bảo mật tốt là nhiều lớp chồng lên nhau, không phải một bức tường hoàn hảo.

Danh sách kiểm tra cuối seri

Mật mã

  • [ ] Mọi số ngẫu nhiên liên quan tới bảo mật dùng secrets / os.urandom, không bao giờ random
  • [ ] Không tự chế thuật toán; dùng thư viện đã kiểm chứng với tham số mặc định
  • [ ] "Chữ ký" bằng khóa chung là HMAC; so sánh bằng hmac.compare_digest
  • [ ] Mã hóa dùng AEAD (AES-GCM, ChaCha20-Poly1305), nonce không bao giờ lặp
  • [ ] Khóa công khai: Ed25519 / X25519; RSA chỉ với OAEP / PSS và ít nhất 2048 bit

Mật khẩu và đăng nhập

  • [ ] Argon2id (hoặc bcrypt, scrypt) với salt; không bao giờ SHA-256 trần
  • [ ] Cùng thông báo, cùng thời gian cho sai email và sai mật khẩu
  • [ ] Giới hạn số lần thử; chặn mật khẩu đã lộ; khuyến khích MFA / passkey
  • [ ] Cookie HttpOnly, Secure, SameSite; token ngắn hạn; quyền đọc từ database
  • [ ] OAuth: authorization code + PKCE, kiểm state, scope tối thiểu, liên kết bằng sub

Đường truyền và dữ liệu

  • [ ] Chỉ TLS 1.2+ (tốt nhất 1.3), chứng chỉ tự gia hạn, HSTS
  • [ ] Biết rõ mọi đoạn TLS, kể cả sau CDN
  • [ ] Biết khóa mã hóa nằm ở đâu; bản sao lưu được mã hóa và đã thử khôi phục
  • [ ] Không để dữ liệu nhạy cảm lọt vào log

Vận hành

  • [ ] Secret không vào git, log hay bài viết; có thể đổi secret mà không sửa code
  • [ ] SSH chỉ bằng khóa, không cho root đăng nhập, tường lửa chỉ mở những cổng cần
  • [ ] Phụ thuộc được ghim phiên bản và được quét lỗ hổng
  • [ ] Cập nhật bảo mật được cài đều đặn

Tóm lại cả seri

Bài Một câu
1 An toàn nằm ở khóa và ở số ngẫu nhiên; random bị đoán hết sau 624 số
2 Hàm băm cho dấu vân tay; cần khóa thì dùng HMAC, so sánh bằng compare_digest
3 Mật khẩu cần băm chậm có salt: Argon2id chậm hơn SHA-256 khoảng 100.000 lần
4 Khóa công khai giải bài toán trao khóa; Ed25519 nhanh, ML-KEM chống lượng tử
5 TLS 1.3 ghép mọi thứ; chứng chỉ, ACME, CT; forward secrecy
6 Mã hóa lúc lưu là câu chuyện về chỗ để khóa; nó không chống được lỗi ứng dụng
7 OAuth ủy quyền, OIDC xác thực; PKCE và state chặn code bị lộ và đăng nhập giả
8 SSH tin máy chủ bằng TOFU, đăng nhập bằng chữ ký; tắt mật khẩu
9 Bảo mật là nhiều lớp, bắt đầu từ "đang chống ai", và ghi trung thực những gì còn thiếu

Bảo mật không bao giờ "xong". Thuật toán mới ra đời (ML-KEM mới được chuẩn hóa năm 2024), lỗ hổng mới được tìm thấy trong thư viện đang dùng, và mỗi tính năng mới là một bề mặt tấn công mới. Thứ còn lại lâu dài là cách nghĩ: hỏi đang chống ai, dùng công cụ đã được kiểm chứng đúng như thiết kế của nó, xếp nhiều lớp phòng thủ, và định kỳ tự kiểm tra như bài này.