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

Ảnh chụp đoạn mã nền tối minh hoạ quản lý bí mật đừng hardcode đừng log secret, khối sai hardcode cộng secret kiểu string thường lộ khi log type Config struct Host string DBPass string DBPass thường cfg bằng Config DBPass SieuBiMat hardcode lộ qua git log.Printf phần trăm cộng v cfg in cả struct secret hiện nguyên trong log log error stack trace JSON response đều dễ vô tình lộ secret, khối đúng đọc từ ENV cộng kiểu Secret tự redact một đọc từ ENV secret manager không hardcode pass bằng os.Getenv DB_PASS hai kiểu Secret tự che khi in log JSON type Secret string func Secret String string return ba sao v s phần trăm s ba sao func Secret MarshalJSON byte error return byte ba sao nil func s Secret Reveal string return string s chỉ lấy giá trị khi cần

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.

Ảnh chụp bảng kết quả tái hiện thật trong go-lab output thật golang 1.23 log JSON trước vs sau redact, mục một secret kiểu string thường lộ trong log và JSON log config Host db.internal DBPass SieuBiMat@2026 JSON config Host db.internal DBPass SieuBiMat@2026 mật khẩu DB hiện nguyên ai đọc log response cũng thấy, mục hai kiểu Secret đọc từ ENV tự che nhưng code vẫn dùng được log config Host db.internal DBPass ba sao JSON config Host db.internal DBPass ba sao Reveal độ dài bằng 14 khớp secret thật code vẫn kết nối DB được log JSON ra ba sao chỉ nơi cần mới gọi Reveal không lộ vô tình, Secret kiểu string thường lộ ngay khi in cả struct log debug error JSON response kiểu Secret override String MarshalJSON trả ba sao nên mọi lần in log serialize đều an toàn mặc định giá trị thật chỉ lấy ra qua Reveal ở đúng chỗ cần kết nối DB mặc định an toàn thay vì nhớ che từng chỗ

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 string khi log.Printf("%+v", cfg) in ra DBPass:SieuBiMat@2026 — nguyên văn. json.Marshal cũ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 string với String() và MarshalJSON() trả ***, thì mọi lần in/log/serialize config đều ra DBPass:*** — an toàn mặc định. Nhưng khi code thực sự cần giá trị (để kết nối DB), gọi Reveal() 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ề

  1. Hai cách rò rỉ phổ biến: hardcode và log secret: đo thật, secret kiểu string thường lộ nguyên SieuBiMat@2026 trong cả log %+v lẫ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.
  2. Kiểu Secret tự redact: mặc định an toàn: đo thật, kiểu Secret override String()/MarshalJSON() trả *** nên mọi lần in/log/serialize đều ra ***, nhưng Reveal() 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ỗ.
  3. Secret sống ngoài code, lộ là phải xoay: đọc từ ENV/secret manager, .gitignore file .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

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.