Hãy tưởng tượng hai người thanh tra phòng cháy đi qua cùng một toà nhà. Người thứ nhất đếm: "có 36 bình gas quá hạn trong toà nhà này." Đúng, nhưng rồi sao? Người thứ hai đi từ cửa chính theo từng hành lang, lần tới từng căn phòng, rồi nói: "trong 36 cái đó, 0 cái nằm trên lối đi mà người ta thật sự bước qua — số còn lại khoá kín trong các phòng không ai vào." Một cái bình gas hỏng trong căn phòng bạn có nhập về nhưng chẳng bao giờ mở cửa thì chưa phải mối nguy sống. Đó đúng là khác biệt giữa govulncheck và mọi máy quét lỗ hổng khác — và là lý do nó đáng một bài riêng. Áp chót của sê-ri, về công cụ bảo mật Go có sẵn mà các ngôn ngữ khác chưa có tương đương.

govulncheck và phân tích khả năng gọi tới

go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...

Tôi chạy trên một dự án ghim golang.org/x/text@v0.3.7 — bản có lỗ hổng đã biết:

  === Symbol Results ===
  No vulnerabilities found.

  Your code is affected by 0 vulnerabilities.
  This scan also found 4 vulnerabilities in packages you import and 36
  vulnerabilities in modules you require, but your code doesn't appear to call
  these vulnerabilities.

Ba tầng số liệu, và đó là điểm khác biệt:

36 lỗ hổng trong module bạn yêu cầu — con số mà mọi máy quét khác đưa ra.

4 lỗ hổng trong package bạn thật sự import.

0 lỗ hổng mà mã của bạn gọi tới hàm bị ảnh hưởng.

govulncheck phân tích đồ thị lời gọi từ main xuống — đúng người thanh tra đi từ cửa chính theo mọi hành lang — và chỉ báo động khi có đường đi thật tới hàm có lỗ hổng.

Đặt cạnh bài 99 sê-ri Java: OSV-Scanner báo 73 lỗ hổng trên một pom.xml năm dòng, không kèm thông tin nào về việc mã có gọi tới hay không. Danh sách đó đúng nhưng khó ưu tiên.

Với govulncheck, con số bạn phải xử lý ngay là số thứ ba — và nó thường nhỏ hơn nhiều.

Đưa vào CI:

- run: go install golang.org/x/vuln/cmd/govulncheck@latest
- run: govulncheck ./...

Nó trả mã thoát khác 0 khi tìm thấy lỗ hổng gọi tới được, nên build đỏ đúng lúc cần.

Lưu ý: nó chỉ phân tích tĩnh, nên mã gọi qua phản chiếu hoặc plugin sẽ không được theo dõi. Với những chỗ đó, vẫn cần nâng cấp phòng ngừa.

Năm chỗ khác cần kiểm

SQL injection. Bài 49 đã nói: luôn dùng $1, không nối chuỗi. Và tên bảng, tên cột không thay bằng tham số được — phải đối chiếu danh sách cho phép.

db.QueryContext(ctx, `select * from don where ma = $1`, ma)   // đúng

Duyệt đường dẫn. Bài 44 đã nói, và nó đáng nhắc lại:

dich := filepath.Join(goc, tenDoNguoiDungNhap)
if !strings.HasPrefix(filepath.Clean(dich), goc) {
	return errors.New("đường dẫn không hợp lệ")
}

Từ Go 1.24 có os.Root giới hạn mọi thao tác trong một thư mục, an toàn hơn hẳn — nhưng trên 1.23 thì mẫu trên là thứ bạn có.

Mật mã. math/rand không dùng cho token, khoá, mật khẩu — nó đoán được. Dùng crypto/rand:

b := make([]byte, 32)
if _, err := rand.Read(b); err != nil { return err }   // crypto/rand

Băm mật khẩu thì golang.org/x/crypto/bcrypt hoặc argon2, không dùng sha256 — nó quá nhanh.

So sánh chuỗi bí mật phải dùng subtle.ConstantTimeCompare để tránh tấn công theo thời gian.

Giới hạn đầu vào. Bài 45 và 46:

r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
io.LimitReader(r, 10<<20)

Không có nó, một request 10GB làm hết bộ nhớ máy chủ.

Timeout ở mọi nơi. Bài 46 đã đo: http.ListenAndServe không có timeout nào, và một kết nối gửi header dở dang giữ goroutine của bạn vĩnh viễn. Đây vừa là vấn đề hiệu năng vừa là lỗ hổng từ chối dịch vụ.

Ba thứ Go giúp sẵn

Đáng ghi nhận, vì chúng loại bỏ cả họ lỗ hổng:

Không có tràn bộ đệm. Truy cập slice ngoài biên là panic, không phải ghi đè bộ nhớ. Đây là khác biệt lớn nhất so với C.

html/template tự thoát ký tự theo ngữ cảnh:

t := template.Must(template.New("x").Parse(`<div>{{.}}</div>`))
t.Execute(w, "<script>alert(1)</script>")

Nó biết đang ở trong HTML, trong thuộc tính, trong JavaScript hay trong URL, và thoát cho đúng. Dùng html/template cho HTML — không dùng text/template, cái đó không thoát gì cả.

Nhưng nó không cứu được nếu bạn tự ghép chuỗi HTML rồi dùng template.HTML để bỏ qua kiểm tra.

Đua dữ liệu bắt được bằng -race — bài 36.

Ba thứ Go không giúp

Đọc quá phần tử của slice trong chỉ số tự tính vẫn panic lúc chạy — chết dịch vụ nếu không recover.

Tràn số im lặng — bài 5 đã đo: int8(300) cho 44 mà không cảnh báo. Với dữ liệu từ ngoài vào kiểu hẹp, phải kiểm biên tường minh.

Bí mật trong log. Bài 57 đã nói: cài slog.LogValuer để kiểu tự che.

Ba việc làm ngay

govulncheck ./...            # lỗ hổng gọi tới được
go vet ./...                 # lỗi mà trình biên dịch không bắt
gosec ./...                  # mẫu mã không an toàn

Cộng Dependabot hoặc Renovate để nâng cấp tự động — bài 99 sê-ri Java đã đo: 66 trong 73 lỗ hổng chỉ cần nâng phiên bản.

Nếu chỉ cài đúng một thứ sau khi đọc bài này, thì là dòng đầu tiên — và đọc ba con số ở cuối:

go install golang.org/x/vuln/cmd/govulncheck@latest && govulncheck ./...

Con số thứ ba khác 0 là phải xử lý hôm nay; hai con số đầu là danh sách nâng cấp dần. Cả ba gộp lại cho bạn biết ngay mức độ khẩn, thay vì một danh sách phẳng không biết bắt đầu từ đâu.

Mẫu số chung

Hai ý lớn của bài này — "chỉ báo lỗ hổng gọi tới được" và "ngôn ngữ xoá sẵn cả họ lỗi" — đều không phải chuyện riêng của Go, mà là hai xu hướng lớn của cả ngành bảo mật phần mềm.

  • Reachability (khả năng gọi tới) là thứ mọi hệ sinh thái đang chạy theo, vì máy quét đếm mù tạo ra "mệt vì cảnh báo". npm audit của Node là ví dụ răn đe kinh điển: nó báo hàng trăm lỗ hổng không phân biệt có gọi tới hay không, riết rồi cả đội bỏ qua — kể cả cái thật. Rust có cargo audit (cũng đếm mù theo RUSTSEC), còn phía Java/JS các công cụ thương mại (Snyk, Socket) thêm dần phân tích reachability. govulncheck đáng chú ý vì nó đưa phân tích đồ thị lời gọi vào công cụ chính chủ, miễn phí.
  • Xoá cả họ lỗi bằng mặc định an toàn cũng phổ quát: an-toàn-bộ-nhớ của Go, Rust, Java, JS xoá sổ họ tràn bộ đệm vốn ám ảnh C/C++; thoát-ký-tự-theo-ngữ-cảnh của html/template (và của React, của Rails) xoá phần lớn họ XSS. Bug class rẻ nhất là bug class ngôn ngữ khiến không thể viết ra.

Sợi chỉ chung đáng mang theo: giá trị của một máy quét nằm ở độ chính xác, không ở độ dài danh sách — một công cụ kêu cứu quá nhiều dạy bạn phớt lờ nó, và một danh sách 73 dòng không xếp hạng còn nguy hiểm hơn ba dòng có xếp hạng, vì nó khiến bạn tê liệt. Và tầng sâu hơn: an ninh rẻ nhất là thứ bạn không phải kiểm — vì stdlib hoặc ngôn ngữ đã khiến lỗi đó không tồn tại. Nên chiến lược đúng ở mọi ngôn ngữ là hai bước: chọn công cụ và thư viện an toàn theo mặc định để cả họ lỗi biến mất, rồi dành sức người cho đúng số ít lỗ hổng thật sự gọi tới được — chứ không phải quét ra một núi cảnh báo rồi chết chìm trong đó.

Ngày mai là bài cuối: nhìn lại sáu mươi bài và bản đồ đi tiếp.

Bài tập làm thử

Bài 1 (đọc hiểu). govulncheck báo: "4 vulnerabilities in packages you import and 36 vulnerabilities in modules you require, but your code doesn't appear to call these vulnerabilities" và "0 vulnerabilities found" cho mã của bạn. Con số nào là con số cần xử lý ngay hôm nay, và vì sao?

Đáp án

Con số cần xử lý ngay là con số thứ ba — số lỗ hổng mà mã của bạn thật sự gọi tới được (ở đây là 0). govulncheck phân tích đồ thị lời gọi từ main xuống và chỉ báo động khi có đường đi thật tới hàm có lỗ hổng. 36 và 4 vẫn đáng nâng cấp dần, nhưng chúng nằm trong package/module được yêu cầu mà mã không hề đụng tới, nên chưa phải nguy cơ sống ngay lúc này.

Bài 2 (sửa lỗi). Đoạn mã sau có hai lỗi bảo mật theo đúng những gì bài viết liệt kê. Tìm và sửa.

func TaoToken() string {
	b := make([]byte, 32)
	rand.Read(b)
	return fmt.Sprintf("%x", b)
}

func TruyVan(db *sql.DB, ten string) (*sql.Rows, error) {
	q := "select * from don where ten = '" + ten + "'"
	return db.Query(q)
}
Đáp án

Lỗi 1: nếu rand ở đây là math/rand thì token sinh ra đoán được — phải dùng crypto/rand cho token/khoá/mật khẩu.

Lỗi 2: TruyVan nối chuỗi trực tiếp vào câu SQL — mở cửa cho SQL injection. Phải dùng tham số hoá:

import "crypto/rand"

func TaoToken() string {
	b := make([]byte, 32)
	if _, err := rand.Read(b); err != nil {
		return ""
	}
	return fmt.Sprintf("%x", b)
}

func TruyVan(db *sql.DB, ten string) (*sql.Rows, error) {
	return db.Query(`select * from don where ten = $1`, ten)
}

Bài 3 (đọc hiểu). Vì sao html/template an toàn hơn text/template khi sinh trang HTML từ dữ liệu người dùng, và có trường hợp nào html/template vẫn không cứu được bạn?

Đáp án

html/template tự thoát ký tự theo ngữ cảnh — nó biết đang ở trong HTML, trong thuộc tính, trong JavaScript hay trong URL và thoát cho đúng, trong khi text/template không thoát gì cả. Tuy nhiên nó không cứu được nếu bạn tự ghép chuỗi HTML rồi dùng template.HTML để cố tình bỏ qua kiểm tra — lúc đó bạn đã tự tắt cơ chế bảo vệ.

Bài 4 (vận dụng thực tế). Bạn viết một handler nhận file upload từ người dùng nhưng chưa giới hạn kích thước, và server chạy http.ListenAndServe không có timeout. Viết hai dòng mã khắc phục hai vấn đề này theo đúng khuyến nghị bài viết.

Đáp án
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // giới hạn 1MB

srv := &http.Server{
	Addr:         ":8080",
	ReadTimeout:  5 * time.Second,
	WriteTimeout: 10 * time.Second,
}

Không giới hạn đầu vào thì một request khổng lồ làm hết bộ nhớ máy chủ; không có timeout thì một kết nối gửi header dở dang giữ goroutine vĩnh viễn — vừa là vấn đề hiệu năng vừa là lỗ hổng từ chối dịch vụ.

Bài 5 (bẫy/đánh đổi). Go loại bỏ được cả họ lỗi tràn bộ đệm nhờ panic khi truy cập slice ngoài biên. Nhưng bài viết cũng liệt kê ba thứ Go không giúp được. Nêu một trong số đó và giải thích hệ quả nếu bỏ qua.

Đáp án

Một trong ba: tràn số im lặng (ví dụ int8(300) cho ra 44 mà không cảnh báo gì). Nếu dữ liệu từ ngoài (mạng, tệp) được đọc vào một kiểu số hẹp như int32 hay uint16 mà không kiểm biên tường minh, giá trị có thể bị cuộn vòng âm thầm và gây lỗi logic nghiêm trọng — ví dụ số tiền hoặc số lượng bị sai lệch mà không có bất kỳ log hay panic nào báo hiệu.

(Hai thứ còn lại: đọc quá phần tử slice trong chỉ số tự tính vẫn panic lúc chạy — chết dịch vụ nếu không recover; và bí mật lọt vào log nếu không tự cài slog.LogValuer để che.)