Loạt bài phòng thủ: hiểu lỗ hổng để viết code an toàn, demo trong lab cô lập.
Rất nhiều vụ rò rỉ bí mật nghiêm trọng không đến từ hacker tinh vi, mà từ hai thói quen code vô tình: hardcode secret vào source và vô tình in secret ra log. Một API key nhét thẳng vào code rồi commit lên GitHub — kể cả repo private cũng rủi ro, và nếu public thì bot quét ra trong vài phút. Một dòng log.Printf("config: %+v", cfg) để debug — và mật khẩu database nằm trong log server, nơi nhiều người truy cập và giữ rất lâu.
Điều nguy hiểm là cả hai đều không gây lỗi — code chạy bình thường, secret cứ âm thầm lộ. Bài này (phần 10 loạt Bảo mật web) đo thật hai kiểu rò rỉ đó, và một kỹ thuật Go gọn: một kiểu dữ liệu Secret tự che khiến việc lộ secret qua log/JSON trở nên mặc định không xảy ra.
Cơ chế: đọc từ môi trường, và kiểu Secret tự redact

Hình 1: Sai — hardcode secret kiểu string thường, log.Printf("%+v", cfg) in cả struct làm secret lộ nguyên (log/error/stack trace/JSON đều dễ vô tình lộ). Đúng — đọc từ ENV (os.Getenv), và dùng kiểu Secret override String()/MarshalJSON() trả ***, chỉ lấy giá trị thật qua Reveal() khi cần.
Tái hiện thật trong go-lab
Mình chạy trong go-lab (golang 1.23): so một config dùng secret kiểu string thường với một config dùng kiểu Secret tự che.

Hình 2: Kết quả thật — ① secret kiểu string thường: lộ nguyên DBPass:SieuBiMat@2026 trong cả log (%+v) và JSON; ② kiểu Secret (đọc từ ENV): log/JSON đều ra ***, nhưng Reveal() vẫn trả giá trị thật (độ dài 14, khớp — code vẫn kết nối DB được).
Kết quả rõ ràng:
- String thường lộ ngay khi in cả struct. Config với
DBPass stringkhilog.Printf("%+v", cfg)in raDBPass:SieuBiMat@2026— nguyên văn.json.Marshalcũng cho"DBPass":"SieuBiMat@2026". Đây là cách secret lộ phổ biến nhất: không ai cố ý in secret, nhưng một dòng log debug "in cả config để xem", một error trả về cả object, một JSON response vô tình chứa field nhạy cảm — và secret vào log/response. Ai đọc được log là có secret. - Đọc từ ENV: secret không nằm trong code. Thay vì hardcode, đọc bằng
os.Getenv("DB_PASS")— secret được nạp từ bên ngoài lúc chạy, không bao giờ xuất hiện trong source để lọt vào git. - Kiểu Secret tự che, nhưng code vẫn dùng được. Định nghĩa
type Secret stringvớiString()vàMarshalJSON()trả***, thì mọi lần in/log/serialize config đều raDBPass:***— an toàn mặc định. Nhưng khi code thực sự cần giá trị (để kết nối DB), gọiReveal()lấy ra giá trị thật (độ dài 14, khớp secret). Đây là ý tưởng đẹp: thay vì phải nhớ che secret ở từng chỗ log (dễ sót), bạn làm nó mặc định an toàn ở tầng kiểu dữ liệu, chỉ chủ động lộ ở đúng một chỗ cần.
Đánh đổi cần cân nhắc
Secret qua ENV/secret manager, và .gitignore file .env. Mức cơ bản là đọc secret từ biến môi trường thay vì hardcode. Mức tốt hơn cho production là dùng secret manager (HashiCorp Vault, AWS/GCP Secrets Manager, cloud KMS) — chúng mã hoá secret khi lưu, kiểm soát ai truy cập, ghi log truy cập, và cho phép xoay (rotate) secret dễ dàng. Nếu dùng file .env cho dev, bắt buộc .gitignore nó (và commit một .env.example không chứa giá trị thật làm mẫu). Nguyên tắc: secret và code sống ở hai nơi khác nhau.
Secret lỡ commit = coi như LỘ, phải xoay chứ không chỉ xoá. Một hiểu lầm nguy hiểm: "lỡ commit secret, mình xoá commit đó là xong". Không. Git lưu toàn bộ lịch sử — secret vẫn nằm trong các commit cũ, trong bản clone của người khác, trong cache của GitHub, và có thể đã bị bot quét. Một khi secret vào git (dù đã "xoá"), coi như nó đã lộ: cách xử lý đúng là xoay secret ngay (tạo key mới, vô hiệu key cũ), không chỉ xoá khỏi lịch sử. Dùng công cụ quét secret (git-secrets, trufflehog, gitleaks) trong CI để chặn secret lọt vào git ngay từ đầu.
Cẩn thận các kênh rò rỉ phụ: URL, error, stack trace. Secret lộ không chỉ qua log cố ý. Vài kênh phụ hay quên: URL (https://api?token=xxx — token vào access log của server, proxy, trình duyệt history; luôn để secret trong header hay body, không trong query string); thông báo lỗi trả về client (như bài CLAUDE.md nhắc: đừng trả e.getMessage() của lỗi DB chứa thông tin nhạy cảm); stack trace in ra khi panic có thể chứa biến secret; crash dump (như core dump). Kiểu Secret tự redact giúp phòng phần lớn các kênh này — vì nó che ở mọi nơi giá trị được format thành text.
Ba ý mang về
- Hai cách rò rỉ phổ biến: hardcode và log secret: đo thật, secret kiểu
stringthường lộ nguyênSieuBiMat@2026trong cả log%+vlẫn JSON — không ai cố ý in secret, nhưng log debug/error/JSON response vô tình chứa nó; hardcode thì lộ qua git. - Kiểu Secret tự redact: mặc định an toàn: đo thật, kiểu
SecretoverrideString()/MarshalJSON()trả***nên mọi lần in/log/serialize đều ra***, nhưngReveal()vẫn trả giá trị thật (độ dài 14, kết nối DB được) — che mặc định ở tầng kiểu thay vì nhớ che từng chỗ. - Secret sống ngoài code, lộ là phải xoay: đọc từ ENV/secret manager,
.gitignorefile.env; secret lỡ commit coi như đã lộ (xoay ngay, không chỉ xoá lịch sử), dùng git-secrets/trufflehog chặn từ đầu; cẩn thận kênh phụ (URL/error/stack trace — đừng để secret trong query string).
Nguồn
- OWASP — Secrets Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
- The Twelve-Factor App — Config (store in environment): https://12factor.net/config
- GitHub — Removing sensitive data & rotating secrets: https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/removing-sensitive-data-from-a-repository
Phần sau ta bàn về broken access control — lỗ hổng xếp hạng số 1 OWASP: khi server xác thực "bạn là ai" nhưng quên kiểm "bạn có quyền với tài nguyên này không" (IDOR), và cách kiểm quyền đúng ở phía server.