Proxy Network — Mạng Proxy toàn tập Phần 8
// internal/platform/health/shutdown.go
package health
import (
"context"
"log/slog"
"net/http"
"time"
)
// Shutdown thực hiện đúng thứ tự rút lui. Tách thành hàm riêng để thứ tự này
// nhìn thấy được, thay vì lẫn giữa 80 dòng khác trong run().
func Shutdown(log *slog.Logger, srv *http.Server, c *Checker, drainDelay, timeout time.Duration) error {
// [1] Thông báo, chưa phải hành động: server vẫn nhận và xử lý request
// bình thường trong suốt bước này.
c.Drain()
log.Info("bắt đầu drain", "drain_delay", drainDelay)
// [2] Khoảng chờ này phải LỚN HƠN (chu kỳ health check × số lần fail cho
// phép) của proxy, cộng biên an toàn. Cấu hình proxy đổi thì con số này
// phải đổi theo — đó là lý do nó là biến môi trường chứ không phải hằng số.
time.Sleep(drainDelay)
// [3] Context MỚI: context của signal đã bị huỷ từ lúc nhận SIGTERM. Dùng
// lại nó thì Shutdown thấy ctx.Done() và trả về NGAY với ctx.Err() — nó
// không giết handler đang chạy, nó chỉ thôi chờ; ngay sau đó run() trả về,
// tiến trình thoát, và request dở dang chết cùng tiến trình. Kết quả thấy
// từ ngoài giống hệt việc cắt ngang, nên rất dễ tưởng là bug của net/http.
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return srv.Shutdown(ctx)
}
Ba điều kiện để chuỗi này thật sự cho zero-downtime:
- Health check của proxy phải trỏ vào
/readyz, không phải/healthzvà không phải "cổng TCP có mở không". Kiểm tra TCP luôn thành công cho tới đúng mili-giây listener đóng — nó không cho bạn khe hở nào để drain. ShutdownDrainDelay> chu kỳ × ngưỡng fail của proxy. Proxy hỏi mỗi 2 giây và cần 2 lần fail → cần ít nhất 4 giây, cộng biên. Đặt 5s là hợp lý cho cấu hình đó; đổi cấu hình proxy thì đổi con số này.- Node mới phải sẵn sàng trước khi node cũ nghỉ. Bật instance mới, chờ
/readyzcủa nó trả 200, rồi mới gửi SIGTERM cho instance cũ. Với một node duy nhất thì không có "zero-downtime" — chỉ có "downtime ngắn". Muốn thật sự không gián đoạn thì phải có node thứ hai, và đó là §12.8.
cmd/worker và cmd/relay không có phần [1] và [2] — không ai gửi traffic tới chúng. Việc của chúng khi nhận SIGTERM là xử lý nốt lô hiện tại rồi commit offset, không phải chờ proxy.
12.8 Đường đi tương lai khi dự án lớn lên
Đừng làm những việc dưới đây bây giờ. Hãy biết dấu hiệu để nhận ra lúc cần.
| Việc | Dấu hiệu nên làm | Cái giá phải trả |
|---|---|---|
| Thêm CDN | Người dùng ở xa chịu độ trễ cao; băng thông cho ảnh/JS/CSS vượt xa băng thông cho JSON | Thêm một tầng cache có thể phục vụ nội dung cũ hoặc nội dung của người khác nếu Cache-Control/Vary sai — xem §8 |
| Tách reverse proxy sang máy riêng | TLS + nén tranh CPU với cmd/api; cần deploy cấu hình proxy độc lập với app; cần nhiều node app |
Thêm một chặng mạng, thêm một thứ phải giám sát và vá lỗi |
Nhiều instance cmd/api |
Một node đã chạm trần CPU, hoặc bạn cần deploy không gián đoạn thật sự | Vỡ bốn thứ trong code — xem ngay dưới |
| Thêm PgBouncer | Tổng MaxConns của mọi tiến trình chạm max_connections |
Ba pool mode và cái bẫy prepared statement ở §12.2 |
Bốn thứ vỡ khi cmd/api chạy nhiều hơn một instance
Đây là danh sách cần thuộc, vì mỗi thứ đều không có triệu chứng khi chạy một instance:
1. Biến đếm trong bộ nhớ. Rate limit theo user, bộ đếm lượt xem gom lô, cache trong RAM — tất cả đều là cục bộ của một tiến trình. Ba instance nghĩa là giới hạn "10 request/phút" trở thành 30, và ba bộ cache có ba câu trả lời khác nhau cho cùng một câu hỏi.
Cách xử lý: chuyển trạng thái ra ngoài (Redis), hoặc chấp nhận sai số và ghi rõ (giới hạn thành 10 × số instance — chấp nhận được với chống lạm dụng, không chấp nhận được với hạn mức tính tiền).
2. Cron/ticker chạy trùng. Bất kỳ time.Ticker nào trong cmd/api sẽ chạy N lần mỗi chu kỳ. Job dọn dẹp bảng outbox ở ARCHITECTURE.md §7.6 chạy ba lần đồng thời là ba transaction tranh nhau cùng những dòng đó.
Cách xử lý: advisory lock của Postgres (pg_try_advisory_lock — instance nào lấy được thì chạy, còn lại bỏ qua vòng này), hoặc tách hẳn thành binary riêng chạy đúng một bản. Đây chính là lý do cmd/relay được quy định chỉ một instance ngay từ ARCHITECTURE.md §4.1.
3. WebSocket/SSE không có sticky session. Một WebSocket là kết nối dài tới một instance cụ thể. User A nối vào instance 1, user B nối vào instance 2; B đăng bài, instance 2 muốn đẩy thông báo cho A — nhưng nó không giữ socket của A.
Cách xử lý: sticky session không giải quyết được vấn đề này — nó chỉ đảm bảo A nối lại đúng instance cũ, không giúp instance 2 với tới A. Cần một kênh fan-out: mỗi instance subscribe một topic Kafka (hoặc Redis pub/sub) và tự đẩy xuống socket nó đang giữ. Ngoài ra proxy phải được cấu hình cho kết nối dài — chuyển tiếp Upgrade/Connection, tắt buffering, nới timeout đọc; mặc định của proxy sẽ cắt kết nối im lặng, và bạn sẽ đi tìm bug trong Go (§5 nói kỹ về timeout).
4. File ghi xuống đĩa cục bộ. Ảnh upload lưu vào ./uploads sẽ chỉ tồn tại trên node nhận request đó. Lần đọc sau rơi vào node khác → 404 ngẫu nhiên — nếu proxy rải đều thì xấp xỉ (N-1)/N số lần đọc, tức là càng scale càng hỏng nặng. Chữ "ngẫu nhiên" mới là phần đắt: bug tái hiện được một lần trên ba thì không ai tin nó có thật.
Cách xử lý: object storage. Không có cách chữa nào khác đáng làm.
Một thứ không vỡ, và đó là phần thưởng cho quyết định ở bước 4: xác thực bằng JWT không cần session affinity. Token tự mang đủ thông tin, mọi instance kiểm được độc lập, không có bảng session nào phải chia sẻ. Nếu dự án dùng session lưu trong RAM thì mục này đã dài thêm một gạch đầu dòng nữa.
Thứ tự nên làm khi cần scale. Đầu tiên: đo. Trần CPU của
cmd/apihay của Postgres? Rất thường là Postgres — và lúc đó thêm instancecmd/apichỉ làm mọi thứ tệ hơn, vì mỗi instance mang theo một pool kết nối mới. Thêm node là câu trả lời cho "CPU của app đã đầy", không phải cho "hệ thống chậm".
12.9 Liên hệ ba luật kiến trúc
Ba luật ở ARCHITECTURE.md §2 được viết cho code Go, nhưng cả ba đều có bản dịch sang tầng proxy.
Luật 1 — module không import module khác. Bản dịch: hạ tầng không được biết gì về domain. Proxy biết host, path, method, header, IP. Nó không được biết posts là gì, author_id là gì, hay ai được sửa bài của ai.
Phép thử một câu:
Xoá sạch file cấu hình proxy và chạy
cmd/apitrần trên cổng 8000 — hệ thống còn đúng nghiệp vụ không?
- Còn (chỉ mất TLS, mất nén, mất chặn flood) → ranh giới sạch.
- Không còn (có endpoint đáng lẽ phải chặn nay mở toang, có luật phân quyền biến mất) → nghiệp vụ đã rò ra hạ tầng.
Cụ thể, nếu bạn thấy mình đang gõ những dòng như thế này vào file cấu hình thì hãy dừng lại:
# ❌ Nghiệp vụ đã rò ra hạ tầng
location /posts {
if ($http_x_role != "admin") { return 403; } # phân quyền ở proxy
}
location /users/premium { ... } # proxy biết "premium" là gì
Ba lý do khiến việc này tệ hơn nó trông:
- Không test được. Bộ test Go của bạn chạy
chirouter trực tiếp, không qua nginx. Luật phân quyền đó không có một dòng test nào bảo vệ, và cũng không thể có. - Không thấy được khi đọc code. Người mới đọc
internal/modules/post/sẽ kết luận endpoint này ai gọi cũng được. Họ đúng — theo code. Sự thật nằm trong một file mà họ không biết là có. - Nó sẽ lệch. Thêm
/posts/{id}/commentstrong Go là chuyện 5 phút; nhớ ra phải sửa cả nginx là chuyện của người đã bị lỗi này cắn một lần rồi.
Luật 2 — event là hợp đồng, không phải lời gọi hàm. Bản dịch: proxy chỉ đứng trên đường đồng bộ. Không có proxy nào trên đường outbox → Kafka → consumer, và điều đó là cố ý. Nếu có ngày bạn thấy mình muốn dựng một HTTP endpoint để "gọi worker rồi chờ kết quả" — có lẽ qua một gateway cho tiện — thì bạn đang biến một event ngược trở lại thành RPC, và Luật 2 đã bị phá trước cả khi dòng cấu hình đầu tiên được viết.
Luật 3 — ghi DB và phát event trong cùng một transaction. Proxy chạm vào luật này ở một chỗ bất ngờ: retry. Khi upstream timeout, proxy có thể gửi lại request sang node khác. Với GET thì vô hại. Với POST /posts thì request đầu có thể đã commit xong transaction (bài viết + dòng outbox) rồi mới chết trên đường trả lời — và lần retry tạo bài thứ hai.
Nginx mặc định không retry request có method không idempotent (POST, PATCH, LOCK — phân loại idempotent là của RFC 9110 §9.2.2, không phải quy ước riêng của nginx), và đừng bật: tham số non_idempotent của proxy_next_upstream tồn tại đúng để tắt lớp bảo vệ đó, nên nó chỉ nên xuất hiện khi bạn biết chính xác mình đang làm gì. Nhưng ngay cả khi proxy ngoan, client vẫn tự retry được — nút "Đăng bài" bấm hai lần là đủ. Bài học ghép với bước 6: dự án này đã có idempotency ở phía consumer vì Kafka là at-least-once. Nhiều instance + proxy retry nghĩa là phía ghi cũng cần một lớp chống trùng — khoá idempotency do client gửi kèm, hoặc ràng buộc UNIQUE tự nhiên (chẳng hạn slug) biến lần ghi thứ hai thành lỗi trùng thay vì thành bài viết thứ hai. Ràng buộc UNIQUE là lớp phòng thủ rẻ nhất và đáng tin nhất, vì nó do database bảo đảm chứ không do thứ tự các dòng code.
13. Bảo mật — proxy là bề mặt tấn công, không chỉ là lá chắn
Mọi tài liệu về proxy đều bán cho bạn một câu chuyện: đặt reverse proxy ra trước, hệ thống an toàn hơn. Câu đó đúng một nửa. Nửa còn lại: bạn vừa thêm vào hệ thống một tiến trình chạy code của người khác, nhìn thấy toàn bộ traffic ở dạng rõ, và đi tới được những nơi Internet không đi tới được.
| Mặt của proxy | Nó làm gì | Rủi ro sinh ra |
|---|---|---|
| Hướng vào | Nhận request từ Internet, diễn giải, chuyển tiếp | Smuggling, cache poisoning, Host header, header giả mạo |
| Hướng ra | Mở kết nối tới nơi khác thay cho ai đó | SSRF, open relay, rò rỉ credential |
Phần lớn kỹ sư chỉ nghĩ tới mặt thứ nhất. Lỗ hổng đắt nhất trong thực tế lại nằm ở mặt thứ hai — và nó không đến từ nginx, nó đến từ chính code Go của bạn, ngay dòng http.Get(userURL).
Câu hỏi thứ ba trong ba câu hỏi mang theo suốt tài liệu — "nó tin cái gì bạn gửi tới?" — giải thích gần như mọi mục dưới đây. Mỗi lỗ hổng ở đây đều quy về một chuyện: hai thành phần bất đồng về việc ai được tin, hoặc dòng byte này nghĩa là gì.
13.1 Vẽ lại vùng tin cậy trước khi nói chuyện phòng thủ
Internet Vùng biên Vùng nội bộ (VPC)
──────────┬────────────────────────┬──────────────────────────────
attacker │ CDN ──► nginx │ cmd/api ──► Postgres, Kafka, Redis
│ (TLS, ACL) │ └──► HTTP đi RA ngoài ◄── (!)
─ ─┼─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┼─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
│ ranh giới 1 │ ranh giới 2
│ (header là lời khai) │ (header đã được xác thực)
| Trước ranh giới 1 | Giữa 1 và 2 | Sau ranh giới 2 | |
|---|---|---|---|
X-Forwarded-For |
Client tự bịa được | Do nginx đặt | Tin được (nếu 1 và 2 đúng) |
Host |
Client tự bịa được | Đã qua allowlist | Tin được |
| Đích của kết nối đi ra | — | — | ⚠️ Không ai kiểm |
Ô ⚠️ là chỗ SSRF sống. Không proxy nào trong sơ đồ đứng ở chiều đi ra: cmd/api mở kết nối trực tiếp, từ bên trong VPC, tới những địa chỉ mà kẻ tấn công không tới được. Đó chính xác là định nghĩa của proxy — một thứ đi tới nơi khác thay cho người khác. Bạn vừa xây một cái mà không biết.
13.2 SSRF — khi tính năng "dán link vào đây" biến server thành proxy
13.2.1 Cơ chế và mục tiêu
Dự án này có ít nhất ba chỗ nhận URL từ người dùng, cả ba đều rất tự nhiên về mặt sản phẩm: ảnh đại diện lấy từ URL, link preview cho bài viết (đọc thẻ OpenGraph/oEmbed), và webhook đi ra. Điểm chung: người dùng chọn đích, server thực hiện kết nối. Kẻ tấn công không cần vào được VPC — họ chỉ cần nhờ bạn vào hộ.
Điều làm SSRF nguy hiểm hơn nó có vẻ: không cần đọc được response vẫn gây hại. Một POST tới http://redis:6379/ với body nhồi khéo vẫn ghi được dữ liệu vào Redis, dù client nhận về lỗi phân tích. Đó là blind SSRF, và bộ lọc "chỉ chấp nhận Content-Type: image/*" không cứu được gì.
| Đích kinh điển | Vì sao là mục tiêu |
|---|---|
169.254.169.254 |
Metadata service của hầu hết cloud — nơi cấp credential tạm cho instance |
metadata.google.internal, tên .internal |
Cùng thứ đó qua DNS nội bộ, nên vượt được bộ lọc theo IP |
127.0.0.1, ::1, 0.0.0.0 |
Admin endpoint chỉ bind loopback vì "đã an toàn rồi" |
10/8, 172.16/12, 192.168/16 |
Postgres, Redis, Kafka, Kafka UI, bảng điều khiển nội bộ |
100.64.0.0/10 |
CGNAT — nhiều môi trường dùng cho mạng nội bộ, và IsPrivate() không bắt |
Metadata service của các cloud lớn đã có bản vá kiến trúc: bản mới bắt lấy token bằng một request PUT kèm header riêng (hoặc bắt buộc có header dạng Metadata-Flavor), và giới hạn số hop IP. Bật chế độ đó là việc bắt buộc, nhưng nó chỉ chặn GET đơn thuần và không bảo vệ Redis hay Kafka của bạn.
13.2.2 Sáu cách vượt bộ lọc ngây thơ
// ❌ SAI. Cả sáu kỹ thuật trong bảng dưới đều phá được bộ lọc này.
if strings.Contains(u.Host, "127.0.0.1") || strings.HasPrefix(u.Host, "10.") {
return errors.New("cấm")
}
| # | Kỹ thuật | URL minh hoạ | Vì sao qua được |
|---|---|---|---|
| 1 | Redirect 302 | https://evil.com/r → Location: http://169.254.169.254/ |
Bộ lọc kiểm URL đầu, client HTTP đi theo hop sau |
| 2 | DNS rebinding | http://rebind.evil.com/ (TTL 0) |
Lúc kiểm trả IP công cộng, lúc dial trả 127.0.0.1 |
| 3 | IPv4-mapped IPv6 | http://[::ffff:127.0.0.1]/ |
So chuỗi không khớp; prefix IPv4 cũng không khớp nếu quên Unmap() |
| 4 | Thập phân / octal | http://2130706433/, http://0177.0.0.1/ |
Cả hai là 127.0.0.1 với getaddrinfo và với curl |
| 5 | Credential trong URL | https://api.example.com@169.254.169.254/ |
Phần trước @ là userinfo (RFC 3986); host thật nằm sau @ |
| 6 | Bọc IPv4 trong IPv6 | http://[64:ff9b::7f00:1]/, http://[2002:7f00:1::]/ |
NAT64 và 6to4 mang một địa chỉ IPv4 bên trong |
Về #4 có một chi tiết đo được và hay bị hiểu nhầm: netip.ParseAddr từ chối cả "2130706433", "0177.0.0.1", "0x7f.0.0.1" lẫn "127.1" — với thư viện chuẩn của Go, dạng thập phân rơi vào nhánh tra cứu DNS và thường thất bại. Nhưng khoảnh khắc URL đó sang tay một công cụ khác (curl, một sidecar, một thư viện đi qua getaddrinfo bằng cgo), hành vi đổi. Đừng dựa vào việc thư viện của bạn tình cờ nghiêm khắc.
13.2.3 Phòng thủ đúng — internal/platform/safefetch
Package này thuộc platform/ chứ không thuộc modules/post, vì nó qua được phép thử một câu ở 02-nen-mong-platform.md §1: copy sang dự án khác vẫn chạy.
// Package safefetch bọc mọi lần server tự đi lấy một URL do người dùng nhập.
package safefetch
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"syscall"
"time"
)
// Vì sao cần danh sách riêng thay vì chỉ dựa vào netip.Addr.IsPrivate(): IsPrivate()
// chỉ phủ RFC 1918 (IPv4) và fc00::/7 (IPv6 ULA). Nó KHÔNG phủ CGNAT 100.64.0.0/10 —
// dải mà nhiều nhà mạng và một số cloud dùng cho mạng nội bộ — cũng không phủ các
// dải IPv6 bọc IPv4 bên trong.
var blockedPrefixes = []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/8"), // nhiều OS coi 0.0.0.0 là localhost
netip.MustParsePrefix("100.64.0.0/10"), // CGNAT
netip.MustParsePrefix("192.0.0.0/24"), // IETF protocol assignments
netip.MustParsePrefix("198.18.0.0/15"), // benchmark
netip.MustParsePrefix("240.0.0.0/4"), // reserved
netip.MustParsePrefix("::/128"),
netip.MustParsePrefix("64:ff9b::/96"), // NAT64 — bọc một địa chỉ IPv4
netip.MustParsePrefix("2002::/16"), // 6to4 — cũng bọc một địa chỉ IPv4
}
// isBlocked là hàm quyết định DUY NHẤT. Mọi chỗ khác chỉ gọi nó.
func isBlocked(addr netip.Addr) bool {
// Unmap trước tiên: ::ffff:127.0.0.1 về mặt gói tin đi tới 127.0.0.1, nhưng
// netip.Prefix.Contains so sánh theo họ địa chỉ nên prefix IPv4 sẽ KHÔNG khớp
// nếu để nguyên dạng IPv6. Đây đúng là kỹ thuật #3 ở bảng trên.
addr = addr.Unmap()
switch {
case !addr.IsValid(),
addr.IsLoopback(), // 127.0.0.0/8, ::1
addr.IsPrivate(), // RFC 1918 + fc00::/7
addr.IsLinkLocalUnicast(), // 169.254.0.0/16 (metadata cloud), fe80::/10
addr.IsMulticast(),
addr.IsUnspecified():
return true
}
for _, p := range blockedPrefixes {
if p.Contains(addr) {
return true
}
}
return false
}
// Cố ý KHÔNG kèm địa chỉ đã phân giải vào thông báo lỗi: chi tiết hơn nghĩa là biến
// chính endpoint này thành công cụ quét mạng nội bộ cho kẻ tấn công.
var (
ErrBlocked = errors.New("safefetch: URL không được phép")
ErrTooLarge = errors.New("safefetch: nội dung quá lớn")
)
// checkURL chạy ở cả request đầu LẪN mỗi lần redirect.
func checkURL(u *url.URL) error {
// Không chặn scheme thì file://, gopher://, dict:// mở ra cả một lớp tấn công khác.
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("%w: scheme %q", ErrBlocked, u.Scheme)
}
// https://api.example.com@169.254.169.254/ — mắt người đọc "api.example.com",
// trình phân tích URL đọc "169.254.169.254". Bộ lọc bằng strings.Contains thua ở đây.
if u.User != nil {
return fmt.Errorf("%w: URL chứa credential", ErrBlocked)
}
if u.Hostname() == "" {
return fmt.Errorf("%w: thiếu host", ErrBlocked)
}
return nil
}
func NewClient(timeout time.Duration) *http.Client {
dialer := &net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
// ★ Chốt chặn THẬT nằm ở đây, không phải ở bước phân giải DNS. Control chạy
// sau khi đã có địa chỉ cụ thể và ngay TRƯỚC connect(), trên đúng địa chỉ sắp
// nối tới. Nhờ vậy nó bịt cùng lúc hai lỗ: DNS rebinding (không còn khe hở
// giữa lúc kiểm và lúc dial) và redirect sang IP nội bộ (mỗi hop là kết nối
// mới, lại qua đây).
Control: func(network, address string, _ syscall.RawConn) error {
// Chặn luôn "unix": một Transport bị cấu hình sai có thể nối tới socket nội bộ.
if network != "tcp4" && network != "tcp6" {
return fmt.Errorf("%w: network %q", ErrBlocked, network)
}
ap, err := netip.ParseAddrPort(address)
if err != nil {
return fmt.Errorf("%w: địa chỉ không hợp lệ", ErrBlocked)
}
// Giới hạn cổng chặn 22, 6379 (Redis), 5432 (Postgres), 9092 (Kafka)...
if p := ap.Port(); p != 80 && p != 443 {
return fmt.Errorf("%w: cổng %d", ErrBlocked, p)
}
if isBlocked(ap.Addr()) {
return ErrBlocked
}
return nil
},
}
transport := &http.Transport{
// Proxy: nil — cố ý KHÔNG dùng http.ProxyFromEnvironment. Nếu môi trường có
// HTTP_PROXY, mọi kết nối đi tới proxy đó và Control chỉ thấy địa chỉ CỦA PROXY
// (luôn hợp lệ); đích thật nằm trong dòng CONNECT mà ta không kiểm được nữa.
Proxy: nil,
DialContext: dialer.DialContext,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
MaxIdleConns: 32,
IdleConnTimeout: 30 * time.Second,
}
return &http.Client{
Transport: transport,
Timeout: timeout, // trần tuyệt đối cho cả vòng đời request
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// Chuỗi redirect dài là cách giữ goroutine + kết nối rất rẻ tiền.
if len(via) >= 3 {
return fmt.Errorf("%w: quá nhiều redirect", ErrBlocked)
}
// Kiểm scheme lại ở TỪNG hop: hop đầu https không có nghĩa hop sau cũng vậy.
return checkURL(req.URL)
},
}
}
// Không có trần đọc, một URL trỏ tới file rất lớn đủ để giết tiến trình — DoS mà
// không cần lỗ hổng nào khác.
const MaxBodyBytes = 2 << 20 // 2 MiB
func Fetch(ctx context.Context, client *http.Client, raw string) ([]byte, error) {
u, err := url.Parse(raw)
if err != nil {
return nil, fmt.Errorf("%w: không phân tích được", ErrBlocked)
}
if err := checkURL(u); err != nil {
return nil, err
}
// Phân giải DNS trước KHÔNG phải là lớp bảo vệ — Control mới là. Nó ở đây để từ
// chối sớm, khỏi tốn một kết nối, và để trả lỗi sạch sẽ. Nếu CHỈ có bước này thì
// thua DNS rebinding.
addrs, err := net.DefaultResolver.LookupNetIP(ctx, "ip", u.Hostname())
if err != nil || len(addrs) == 0 {
return nil, fmt.Errorf("%w: không phân giải được host", ErrBlocked)
}
for _, a := range addrs {
// Chặn nếu BẤT KỲ bản ghi nào là nội bộ, không phải "chỉ khi tất cả":
// [1.2.3.4, 127.0.0.1] sẽ được dial theo thứ tự không đoán trước được.
if isBlocked(a) {
return nil, ErrBlocked
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "community-linkpreview/1.0")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("safefetch: %w", err)
}
defer resp.Body.Close()
// Content-Length là lời khai của phía bên kia, có thể nói dối — nhưng nếu nó đã tự
// khai quá trần thì cắt sớm, khỏi tải về byte nào.
if resp.ContentLength > MaxBodyBytes {
return nil, ErrTooLarge
}
// Đọc MaxBodyBytes+1 để phân biệt "vừa đủ trần" với "vượt trần".
body, err := io.ReadAll(io.LimitReader(resp.Body, MaxBodyBytes+1))
if err != nil {
return nil, fmt.Errorf("safefetch: đọc body: %w", err)
}
if len(body) > MaxBodyBytes {
return nil, ErrTooLarge
}
return body, nil
}
13.2.4 Vì sao Dialer.Control mới là chốt chặn thật
Đây là ý quan trọng nhất của mục 13.2, và là chỗ gần như mọi bản "SSRF protection" trên mạng làm sai.
Cách SAI — kiểm rồi mới đi:
parse URL ──► lookup DNS ──► [ KIỂM IP ] ──► ... ──► connect()
▲ ▲
└───── khe hở ────────┘
kẻ tấn công đổi bản ghi DNS ở đây
Cách ĐÚNG — kiểm ở đúng khoảnh khắc dial:
parse URL ──► lookup ──► kiểm sớm ──► connect()
│
[ Dialer.Control ] ◄── không có khe hở
Vì mọi kết nối mới đều đi qua Control, nó chặn cùng lúc DNS rebinding (không còn khoảng thời gian để bản ghi kịp đổi), redirect (hop sau là kết nối mới), và trường hợp nhiều bản ghi A — Go thử lần lượt, mỗi lần thử là một lần gọi Control. CheckRedirect vẫn cần, nhưng vai trò khác: chặn scheme lạ ở hop sau và giới hạn số hop, không phải chặn IP nội bộ.
Viết test cho bộ lọc này là bắt buộc, và dòng test đáng tiền nhất là {"100.64.0.1", true}: chạy netip.MustParseAddr("100.64.0.1").IsPrivate() sẽ ra false — bộ lọc chỉ có IsPrivate() || IsLoopback() để lọt toàn bộ dải CGNAT. Các ca còn lại phải có: ::ffff:127.0.0.1, 169.254.169.254, fd00::1, 64:ff9b::7f00:1, 2002:7f00:1::.
Cái bẫy phá hỏng toàn bộ lớp phòng thủ này:
Proxy: http.ProxyFromEnvironment.Nếu môi trường có
HTTP_PROXY/HTTPS_PROXY— rất hay gặp trong container doanh nghiệp —Transportnối tới proxy đó, vàControlchỉ thấy địa chỉ của proxy, luôn hợp lệ. Đích thật nằm trong dòngCONNECTmàControlkhông đọc được. ĐặtProxy: nilcó chủ đích kèm comment giải thích, nếu không sẽ có người "sửa lại cho đúng chuẩn" ở lần review sau.
13.2.5 Hai lớp còn lại
| Lớp | Biện pháp | Chặn được cái mà code không chặn |
|---|---|---|
| Mạng | Security group: tiến trình fetch chỉ được ra 80/443 tới Internet, cấm tới subnet nội bộ | Bug trong chính bộ lọc, hoặc thư viện thứ ba tự gọi HTTP |
| Kiến trúc | Tách việc fetch sang tiến trình riêng, ở subnet không có đường tới DB | Toàn bộ lớp "leo thang sau khi thủng" |
Cách tách hợp rất tự nhiên với kiến trúc event-driven của dự án: post phát PostCreated, một consumer chuyên trách đi lấy link preview rồi ghi kết quả về. Consumer đó không cần quyền gì ngoài Kafka và một bảng.
13.3 HTTP Request Smuggling — hai cái hộp đọc cùng một dòng byte theo hai cách
13.3.1 Nguyên nhân: ranh giới request là thoả thuận, không phải sự thật
Trên kết nối keep-alive, các request nối nhau thành một dòng byte liên tục — không có ký tự "hết request". Chỗ kết thúc được suy ra từ header, và HTTP/1.1 cho hai cách: Content-Length: 13 (sau đúng 13 byte) hoặc Transfer-Encoding: chunked (sau khối kích thước 0).
RFC 9112 nói rõ: có Transfer-Encoding thì Content-Length phải bị bỏ qua, và message mang cả hai nên bị coi là lỗi. Nhưng "nên bị coi là lỗi" không phải "bắt buộc từ chối", và các hộp cũ diễn giải khác nhau. Điều kiện đủ để có lỗ hổng chỉ có một: proxy và upstream không đồng ý với nhau, trên một kết nối được dùng lại.
13.3.2 CL.TE — proxy đếm byte, upstream chia khối
POST / HTTP/1.1
Host: community.example.com
Content-Length: 13
Transfer-Encoding: chunked
0
SMUGGLED
Body dài đúng 13 byte, khớp Content-Length:
byte: 1 2 3 4 5 6 7 8 9 10 11 12 13
0 \r \n \r \n S M U G G L E D
└──┬──┘ └──┬──┘ └───────┬──────────┘
khối 0 hết khối 8 byte "thừa"
(TE: hết) (TE: chưa đọc tới)
Proxy (theo CL=13): "13 byte" → chuyển tiếp cả 13 byte
Upstream (theo TE) : "khối 0 → request hết ở byte 5"
⇒ "SMUGGLED" NẰM LẠI trong bộ đệm kết nối
Request kế tiếp của nạn nhân: GET /me HTTP/1.1\r\nCookie: session=...
Upstream thực sự đọc được: SMUGGLEDGET /me HTTP/1.1\r\nCookie: session=...
└──────┘ tiền tố do kẻ tấn công dán vào
Request kế tiếp đó thuộc về một người dùng khác, vì proxy gom nhiều client vào chung một pool kết nối tới upstream.
13.3.3 TE.CL và TE.TE
TE.CL là hình ảnh gương: Content-Length: 3 cùng với Transfer-Encoding: chunked, body là 8\r\nSMUGGLED\r\n0\r\n\r\n. Proxy theo TE nên đọc trọn khối 8 byte rồi khối 0 và chuyển tiếp toàn bộ; upstream theo CL chỉ đọc đúng 3 byte 8\r\n rồi coi request đã hết, để lại SMUGGLED\r\n0\r\n\r\n làm tiền tố.
TE.TE thì không cần hai header khác loại — nó dựa vào việc một hộp phân tích Transfer-Encoding lỏng hơn hộp kia:
Transfer-Encoding: xchunked
Transfer-Encoding : chunked
Transfer-Encoding: chunked, identity
Transfer-Encoding: chunked
Transfer-Encoding: x
Nếu hộp A coi những dòng đó là "không phải chunked" (rơi về Content-Length) còn hộp B vẫn nhận ra chunked, bạn có lại đúng kịch bản CL.TE.
13.3.4 net/http của Go đứng về phe nào — số đo, không phải phỏng đoán
Gửi request thô tới một httptest.NewServer rồi in r.ContentLength và r.TransferEncoding. Đây là loại việc đáng bỏ ra mười phút đo trên đúng bản Go bạn đang build, thay vì tin một bài blog:
| Request gửi vào | net/http trả về |
|---|---|
Content-Length: 13 + Transfer-Encoding: chunked |
⚠️ 200, CL=-1, TE=[chunked], header Content-Length bị xoá |
Content-Length: 5 + Content-Length: 6 |
400 Bad Request |
Content-Length: 5 + Content-Length: 5 |
200 (trùng nhưng nhất quán) |
Transfer-Encoding: xchunked |
501 — "Unsupported transfer encoding" |
Transfer-Encoding: chunked + Transfer-Encoding: x |
501 |
Transfer-Encoding: chunked, identity |
501 |
Transfer-Encoding : chunked (cách trước dấu :) |
400 — "invalid header name" |
Content-Length: -1 |
400 Bad Request |
- Tin tốt: Go nghiêm khắc với mọi biến thể TE.TE ở 13.3.3 — chặn ngay ở
net/http, không cần bạn viết gì. - Tin xấu, và là điểm phải nhớ: với
CL + TE, Go không trả 400. Nó chọn phechunkedvà xoáContent-Lengthkhỏir.Header. Hệ quả: bạn không thể phát hiện xung đột này từ trong mộthttp.Handler— thông tin đã bị chuẩn hoá mất trước khi middleware chạy. Mọi middleware "chống smuggling" viết ở tầng handler đều là placebo.
Chỗ duy nhất chặn được là hộp đứng trước. Nếu hộp đó cũng viết bằng Go thì có tin tốt tiếp:
Đo với httputil.ReverseProxy (Rewrite + SetXForwarded) trước một backend Go:
gửi vào proxy : Content-Length: 13 + Transfer-Encoding: chunked
backend nhận : CL=-1 TE=[chunked] header Content-Length=[] Transfer-Encoding=[]
ReverseProxy không chuyển tiếp nguyên xi phần framing: nó phân tích request thành http.Request rồi để Transport tự sinh lại framing cho chặng đi ra. Sự mập mờ không sống sót qua hop. Đây là một lập luận thật cho việc viết reverse proxy bằng Go (§10), không phải khẩu hiệu.
13.3.5 Hậu quả và cách chặn
| Hậu quả | Cách khai thác |
|---|---|
| Cướp request của người khác | Tiền tố smuggle là POST /comments thiếu body; request của nạn nhân kèm Cookie trở thành body ấy và được lưu công khai |
| Vượt kiểm soát truy cập | Proxy chặn /admin từ ngoài, nhưng request smuggle sinh ra bên trong upstream, không qua luật của proxy |
| Đầu độc cache | Kết hợp 13.4: response của request smuggle được cache và trả cho người khác |
| Vượt WAF | WAF chỉ nhìn thấy request "vỏ" hợp lệ |
Điểm chung: nạn nhân không làm gì sai và không thể tự bảo vệ.
| Biện pháp | Chặn được gì | Cái giá |
|---|---|---|
Từ chối ở biên request có cả CL và TE |
CL.TE, TE.CL | Gần như không |
| Đồng bộ phiên bản HTTP giữa các hộp | Bất đồng do bản cũ vá khác nhau | Phải kiểm soát cả chuỗi |
| HTTP/2 tới upstream | Gần như toàn bộ lớp này | Không phải proxy nào cũng làm được |
| Tắt dùng lại kết nối tới upstream | Toàn bộ (không có "request kế tiếp") | Đắt: mất keep-alive, độ trễ tăng rõ |
| Cập nhật proxy | Các biến thể đã biết | Việc phải làm định kỳ |
Vì sao HTTP/2 diệt tận gốc: ranh giới message không suy ra từ header mà nằm trong khung nhị phân có độ dài tường minh, và mỗi request là một stream riêng có ID — không còn "dòng byte chung" để chèn tiền tố. Lưu ý: chỉ đúng nếu chặng proxy → upstream là HTTP/2, không phải chặng client → proxy. Nginx nhận HTTP/2 từ client nhưng proxy_http_version chỉ nhận 1.0 và 1.1 — chặng trong vẫn là HTTP/1.1. HAProxy (proto h2 trên dòng server) và Envoy thì làm được; chi tiết ở §11.
# Ngữ cảnh http {} — từ chối request khai báo ranh giới theo hai cách mâu thuẫn.
map "$http_transfer_encoding:$http_content_length" $ambiguous_framing {
default 0;
"~^.+:.+$" 1; # cả hai header cùng có mặt và cùng khác rỗng
}
server {
listen 443 ssl;
server_name community.example.com;
location / {
# 400 chứ không 444: đây là request sai chuẩn, không phải quét độc hại.
if ($ambiguous_framing) { return 400; }
proxy_pass http://api_upstream;
proxy_http_version 1.1; # 1.0 tắt keep-alive tới upstream
proxy_set_header Connection ""; # bắt buộc khi upstream có keepalive
}
}
frontend fe_public
bind :443 ssl crt /etc/haproxy/certs/community.pem
# Đếm số lần mỗi header xuất hiện; có cả hai là mập mờ về ranh giới.
http-request deny deny_status 400 if { req.hdr_cnt(content-length) gt 0 } { req.hdr_cnt(transfer-encoding) gt 0 }
default_backend be_api
Cái bẫy là tổ chức, không phải kỹ thuật. Smuggling gần như luôn xuất hiện ở chỗ hai đội khác nhau quản hai cái hộp — đội hạ tầng quản LB, đội backend quản
cmd/api— và không ai đọc changelog của bên kia. Phòng thủ bền nhất: một bài test tự động gửi các request thô ở 13.3.4 vào staging qua đúng chuỗi proxy thật, chạy mỗi lần deploy hạ tầng.
13.4 Cache poisoning — đầu vào không nằm trong khoá
Cache tìm entry bằng cache key — thường là scheme + method + host + đường dẫn + query. Nhưng response được sinh từ toàn bộ request, kể cả header không nằm trong khoá. Header nào ảnh hưởng tới response mà không nằm trong khoá thì gọi là unkeyed input.
Kẻ tấn công gửi: Cache lưu dưới khoá:
GET / GET https://community.example.com/
Host: community.example.com (X-Forwarded-Host KHÔNG nằm trong khoá)
X-Forwarded-Host: evil.com
│ upstream sinh: <script src="https://evil.com/app.js">
▼
Nạn nhân gửi request BÌNH THƯỜNG, trúng khoá, nhận đúng response độc hại đó.
Kẻ tấn công gửi một request; sau đó mọi người dùng chạm vào node cache ấy đều dính cho tới khi entry hết hạn. Đây là điểm khiến cache poisoning khác hẳn XSS phản xạ: nó không cần dụ ai bấm link.
X-Forwarded-Host là thủ phạm quen mặt vì rất nhiều framework dùng nó để "biết mình đang được truy cập bằng tên miền nào" rồi sinh URL tuyệt đối từ đó: canonical link, <base href>, đường dẫn file JS/CSS, link trong email. Nó vừa là unkeyed input vừa điều khiển việc sinh URL — đúng hai tính chất cần thiết. Cùng nhóm: X-Forwarded-Scheme, X-Original-URL, X-Rewrite-URL, X-Forwarded-Port.
Biện pháp 1 — không sinh URL tuyệt đối từ header, sinh từ cấu hình. Đây là biện pháp diệt tận gốc; hai biện pháp sau chỉ là lưới an toàn.
// URLBuilder sinh MỌI URL tuyệt đối của hệ thống — canonical URL của bài viết,
// link xác thực email, link đặt lại mật khẩu.
//
// ★ Luật: BaseURL đến từ cấu hình (biến môi trường), KHÔNG BAO GIỜ từ r.Host hay
// X-Forwarded-Host. Đây là toàn bộ cách chặn cache poisoning lẫn password reset
// poisoning ở 13.5.
type URLBuilder struct{ BaseURL *url.URL }
// Phân tích base URL lúc khởi động — sai cấu hình thì hỏng ngay lúc boot, không
// phải lúc người dùng đầu tiên bấm "quên mật khẩu".
func NewURLBuilder(raw string) (*URLBuilder, error) {
u, err := url.Parse(raw)
if err != nil {
return nil, err
}
return &URLBuilder{BaseURL: u}, nil
}
func (b *URLBuilder) PasswordReset(token string) string {
u := *b.BaseURL // copy, không sửa base dùng chung
u.Path = "/auth/reset-password"
q := url.Values{}
q.Set("token", token)
u.RawQuery = q.Encode()
return u.String()
}
Biện pháp 2 — xoá header ở hop biên. Nếu nginx là hop đầu tiên, không header X-Forwarded-* nào có lý do tồn tại sẵn khi request đến:
location / {
proxy_pass http://api_upstream;
# Gán chuỗi rỗng cho một header nghĩa là KHÔNG chuyển tiếp nó — nên bốn dòng
# dưới vừa xoá bản giả mạo của client vừa đặt lại bản đúng từ dữ liệu THẬT.
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
# Hai header định tuyến mà một số framework tin theo -> vượt ACL.
proxy_set_header X-Original-URL "";
proxy_set_header X-Rewrite-URL "";
absolute_redirect off; # trả Location tương đối, không tự ghép host vào
}
Bản tương đương khi proxy viết bằng Go — chú ý thứ tự:
var spoofable = []string{
"X-Forwarded-For", "X-Forwarded-Host", "X-Forwarded-Proto", "X-Forwarded-Port",
"X-Real-IP", "Forwarded",
"X-Original-URL", "X-Rewrite-URL", // vài framework tin để định tuyến -> vượt ACL
}
func New(upstream *url.URL) *httputil.ReverseProxy {
return &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
// ★ Xoá TRƯỚC, đặt SAU. Đảo thứ tự thì SetXForwarded chỉ nối thêm vào
// chuỗi giả mạo của client, và tầng trong đọc phần tử ĐẦU chuỗi sẽ nhận
// đúng IP mà kẻ tấn công muốn nó nhận.
for _, h := range spoofable {
pr.Out.Header.Del(h)
}
pr.SetURL(upstream)
// SetXForwarded đặt lại dựa trên kết nối THẬT tới proxy này. Đây là lý do
// dùng Rewrite thay cho Director: Director không có bước này, nên mọi bản
// copy-paste Director trên mạng đều quên xoá header giả mạo.
pr.SetXForwarded()
pr.Out.Host = pr.In.Host
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
// Không in err ra response: nó chứa host/cổng nội bộ.
http.Error(w, "upstream không phản hồi", http.StatusBadGateway)
},
}
}
Biện pháp 3 — Vary đúng và cache key tường minh. Vary (RFC 9111) là cách response nói với cache: "tôi phụ thuộc vào những header này, hãy đưa chúng vào khoá". Khác nhau theo nén thì Vary: Accept-Encoding; khác nhau theo ngôn ngữ thì Vary: Accept-Language và phải đưa vào cache key; response phụ thuộc một header nội bộ nào đó thì sửa thiết kế cho hết phụ thuộc, đừng thêm Vary.
Vary: Cookielà cái bẫy, không phải giải pháp. Nó có vẻ đúng — mỗi cookie một entry. Nhưng cookie đổi liên tục (analytics, A/B test) nên tỉ lệ cache hit tụt gần bằng 0: bạn trả tiền cho một cache không cache gì cả. Và chỉ cần một đường khiến response cá nhân lọt vào entry công khai là rò rỉ dữ liệu. Nội dung cá nhân thìCache-Control: private, no-store, chấm hết.
location /articles/ {
proxy_cache api_cache;
# Khoá tường minh: đọc một dòng là biết cái gì được tính vào khoá.
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_valid 200 5m;
# Không bao giờ cache response của request có kèm danh tính.
proxy_cache_bypass $http_authorization $cookie_session;
proxy_no_cache $http_authorization $cookie_session;
proxy_pass http://api_upstream;
}
Cùng vấn đề này ở quy mô CDN — nơi hậu quả nhân lên theo số node biên — nằm ở §8.
13.5 Host header attack và password reset poisoning
Cùng gốc rễ với 13.4 nhưng hậu quả trực tiếp hơn nhiều:
1. Kẻ tấn công POST /auth/forgot-password
Host: evil.com ← hoặc X-Forwarded-Host: evil.com
body: { "email": "victim@example.com" }
2. cmd/api sinh link từ r.Host:
https://evil.com/auth/reset-password?token=<TOKEN THẬT CỦA NẠN NHÂN>
3. Email gửi ĐÚNG tới hộp thư nạn nhân, ĐÚNG mẫu chuẩn, ĐÚNG thời điểm.
4. Nạn nhân bấm → token đi thẳng vào log của evil.com.
Không bước nào trông đáng ngờ với nạn nhân. Chặn ở hai tầng, vì tầng nào cũng có ngày bị bỏ qua:
# Tầng 1 — server block mặc định nuốt mọi Host lạ. Không có block này,
# request "Host: evil.com" rơi vào server block đầu tiên và được phục vụ.
server {
listen 80 default_server;
listen 443 ssl default_server;
server_name _;
ssl_certificate /etc/nginx/certs/fallback.pem;
ssl_certificate_key /etc/nginx/certs/fallback.key;
return 444; # mã riêng của nginx: đóng kết nối, không trả gì cả
}
// Tầng 2 — trong cmd/api. Vì sao lặp lại việc nginx đã làm: phòng thủ theo tầng.
// Ngày ai đó thêm một LB mới, hoặc container bị lộ thẳng ra ngoài vì một rule
// security group sai, middleware này vẫn đứng đó.
func AllowedHosts(hosts []string, next http.Handler) http.Handler {
allow := make(map[string]struct{}, len(hosts))
for _, h := range hosts {
allow[strings.ToLower(h)] = struct{}{}
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// r.Host còn kèm cổng. Điều kiện "]" để không cắt nhầm IPv6 [::1]:8443.
host := strings.ToLower(r.Host)
if i := strings.LastIndex(host, ":"); i != -1 && !strings.Contains(host[i:], "]") {
host = host[:i]
}
if _, ok := allow[host]; !ok {
// 421 Misdirected Request: "anh gửi nhầm server rồi" — đúng ngữ nghĩa hơn
// 400, và không tiết lộ host nào mới là hợp lệ.
http.Error(w, "host không được phục vụ", http.StatusMisdirectedRequest)
return
}
next.ServeHTTP(w, r)
})
}
Và trong module identity, link đặt lại mật khẩu phải đi qua URLBuilder ở 13.4. Nếu trong toàn bộ codebase không có chỗ nào ghép chuỗi từ r.Host, lớp lỗ hổng này biến mất theo kiểu cấu trúc — không phụ thuộc vào việc ai đó nhớ kiểm tra.
13.6 Open proxy và open relay — sai lầm rẻ nhất, hậu quả đắt nhất
Open proxy nhận yêu cầu chuyển tiếp từ bất kỳ ai tới bất kỳ đâu. Nó xuất hiện theo hai đường, cả hai đều là tai nạn: dựng forward proxy để test rồi bind 0.0.0.0 và quên auth; hoặc reverse proxy có route kiểu proxy_pass http://$arg_target; — đích lấy từ query, tức là SSRF đặt ngay ở biên. Máy quét Internet tìm ra nó trong vài giờ.
| Hậu quả | Diễn ra thế nào |
|---|---|
| IP vào danh sách đen | Traffic lạm dụng mang IP của bạn; email và API gọi ra bắt đầu bị từ chối, gỡ khỏi blocklist mất hàng tuần |
| Trách nhiệm pháp lý | Log của bên bị hại chỉ ghi IP của bạn — bạn là bị đơn cho tới khi chứng minh ngược lại |
| Hoá đơn băng thông | Kẻ khác dùng, bạn trả tiền |
| Bàn đạp vào nội bộ | Kết hợp 13.2: proxy đứng trong VPC nên tới được mọi thứ trong đó |
Hai luật, không ngoại lệ. Xác thực trước, luôn luôn — không có "tạm thời mở để test", vì cái tạm thời đó sống lâu hơn bạn tưởng. Và allowlist đích đến, không phải blocklist: blocklist là danh sách những gì bạn đã nghĩ ra, allowlist là danh sách những gì bạn thực sự cần. Với dự án này danh sách đó rất ngắn — nhà cung cấp email, nơi lưu ảnh, endpoint OAuth. Kèm theo: rate limit ngay trên proxy (13.9), và ghi log đích đến, vì không có log đích thì ngày bị lạm dụng bạn không có gì để trả lời.
13.7 Header injection và CRLF — Go che cho bạn, nginx thì không
Nếu giá trị header được ghép từ input người dùng và input đó chứa \r\n, kẻ tấn công chèn được header mới hoặc cả một response mới (response splitting). Hành vi thật của Go, đo chứ không đoán:
| Vị trí | Đầu vào | Kết quả đo được |
|---|---|---|
Server: w.Header().Set("X-Echo", "abc\r\nX-Injected: yes") |
CRLF trong giá trị | Ghi ra dây thành X-Echo: abc X-Injected: yes — \r và \n bị thay bằng khoảng trắng |
Client: req.Header.Set("X-Bad", "abc\r\n...") |
CRLF trong giá trị | client.Do trả lỗi net/http: invalid header field value for "X-Bad" |
Ở tầng Go lớp lỗ hổng này đã đóng: phía server khử ký tự, phía client từ chối thẳng. Đừng vì thế mà kết luận hệ thống an toàn — hop trước Go không có bảo vệ đó. Ví dụ kinh điển ở nginx, tinh vi vì hai biến chỉ khác nhau một chữ:
# ❌ SAI — $uri là đường dẫn ĐÃ giải mã %XX. Request tới
# /%0d%0aX-Injected:%20yes biến thành CRLF thật trong Location.
location / { return 302 https://$host$uri; }
# ✅ ĐÚNG — $request_uri là nguyên bản, chưa giải mã.
location / { return 302 https://$host$request_uri; }
Hai điều cần thuộc. Không bao giờ ghép input người dùng vào giá trị header ở tầng cấu hình proxy — ở đó không có ai khử ký tự cho bạn. Và: mặc định nginx bỏ header có dấu gạch dưới trong tên (underscores_in_headers off); nghe như tính năng bảo mật nhưng nó là nguồn bug — X_Custom_Auth do một client Java gửi lên biến mất im lặng ở nginx, và bạn đi tìm nguyên nhân trong code Go. Cần header như thế thì đổi tên dùng dấu gạch ngang.
13.8 Xác thực lên proxy — giữ credential khỏi đi trên dây ở dạng rõ
Khi cmd/api phải ra Internet qua một forward proxy (rất phổ biến trong môi trường doanh nghiệp), proxy đòi Proxy-Authorization (RFC 9110). Cơ chế ở §3; đây chỉ nói phần bảo mật.
| Cách | Bản chất | Rủi ro |
|---|---|---|
Basic (RFC 7617) |
base64(user:pass) — mã hoá 0%, chỉ là mã hoá ký tự |
Ai đọc được gói tin là có mật khẩu |
Digest |
Thách thức–đáp ứng, không gửi mật khẩu | Phức tạp; thuật toán băm mặc định thường đã cũ |
| Theo IP nguồn | Proxy tin một dải IP | Không có bí mật để lộ, nhưng thua mọi thứ giả mạo được IP nguồn |
| mTLS tới proxy | Client certificate | Mạnh nhất; cần hạ tầng cấp phát chứng chỉ |
Điểm quan trọng nhất: Basic chỉ chấp nhận được khi chặng tới proxy đã mã hoá — tức URL proxy dùng scheme https, không phải http. Nhiều người nhầm rằng "đằng nào đích cũng là HTTPS thì an toàn". Không: khi bạn CONNECT tới proxy qua http://, chính dòng CONNECT và header Proxy-Authorization đi trên kết nối chưa mã hoá; TLS chỉ dựng lên sau đó, bên trong đường hầm.
// ★ Scheme "https", không phải "http": chặng tới proxy được mã hoá, nên
// Proxy-Authorization không đi trên dây ở dạng rõ.
// Go tự sinh Proxy-Authorization Basic từ userinfo trong URL, kể cả trên CONNECT.
proxyURL, err := url.Parse("https://" + user + ":" + url.QueryEscape(pass) + "@proxy.internal:3128")
if err != nil {
return nil, fmt.Errorf("proxy URL không hợp lệ: %w", err)
}
tr := &http.Transport{
Proxy: http.ProxyURL(proxyURL),
// Header dành riêng cho chặng CONNECT phải đặt ở đây, KHÔNG đặt vào req.Header:
// header của request nằm bên trong đường hầm, proxy không đọc được.
ProxyConnectHeader: http.Header{"X-Proxy-Pool": []string{"vn-residential"}},
TLSHandshakeTimeout: 10 * time.Second,
}
Ba luật vận hành đi kèm: không hard-code credential (đọc qua platform/config); không log URL proxy — nó chứa mật khẩu, nên với log/slog hãy log các trường đã chọn lọc, đừng bao giờ log nguyên *url.URL hay *http.Request; và xoay được credential — nếu đổi mật khẩu proxy phải deploy lại thì bạn sẽ không đổi.
13.9 Rate limit và DDoS — một tầng là không đủ
13.9.1 Ba tầng, ba loại tấn công khác nhau
| Tầng | Chặn được | Không chặn được |
|---|---|---|
| Mạng / CDN | Flood L3–L4, băng thông bão hoà | Request hợp lệ về hình thức nhưng đắt về xử lý |
| Proxy | Request/giây theo IP, số kết nối đồng thời, body quá lớn | Logic nghiệp vụ ("một tài khoản không được tạo 100 bài/phút") |
| Ứng dụng | Giới hạn theo user / API key / endpoint, quota nghiệp vụ | Mọi thứ trước khi request tới được Go, kể cả chi phí TLS handshake |
Điểm mấu chốt: request bị chặn ở tầng ứng dụng thì đã tốn TLS handshake, một goroutine, một lần phân tích JWT. Nếu chỉ có tầng này, một flood đủ lớn vẫn giết được bạn dù không request nào chạm tới database. Ngược lại, tầng proxy không biết "người dùng này đã tạo bao nhiêu bài hôm nay". Cần cả ba.
# Ngữ cảnh http {} — zone chia sẻ giữa mọi worker process. $binary_remote_addr
# tiết kiệm hơn $remote_addr vì lưu dạng nhị phân thay vì chuỗi.
limit_req_zone $binary_remote_addr zone=api_rps:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=api_conn:10m;
limit_req_status 429;
limit_conn_status 429;
server {
location /api/ {
# burst=20 nodelay: cho phép cụm 20 request đi ngay (trang thật tải nhiều
# tài nguyên cùng lúc) nhưng trung bình vẫn kẹp ở 10r/s. Bỏ nodelay thì
# nginx xếp hàng và làm chậm — thường không phải điều bạn muốn.
limit_req zone=api_rps burst=20 nodelay;
limit_conn api_conn 20;
# Chặn body khổng lồ TRƯỚC khi nó tới Go và chiếm bộ nhớ.
client_max_body_size 2m;
client_body_timeout 10s;
proxy_pass http://api_upstream;
}
}
13.9.2 Token bucket hay sliding window, và khoá theo cái gì
| Token bucket | Sliding window | |
|---|---|---|
| Mô hình | Xô chứa token, nạp đều theo thời gian, mỗi request tiêu 1 | Đếm số request trong N giây gần nhất |
| Bùng phát ngắn | ✅ Cho phép, đúng bằng sức chứa xô | ❌ Chặn ngay khi chạm ngưỡng |
| Bộ nhớ mỗi khoá | Hằng số nhỏ (số token + một mốc thời gian) | Lớn hơn: cần dấu thời gian, hoặc xấp xỉ bằng hai cửa sổ |
| Biên cửa sổ | Không có khái niệm biên | ⚠️ Bản "fixed window" cho phép gấp đôi ngưỡng quanh biên |
| Có sẵn trong Go | golang.org/x/time/rate |
Phải tự viết, hoặc dùng Redis |
| Hợp với | API thường, traffic trình duyệt | Quota nghiệp vụ ("100 bài/ngày") |
Với cmd/api, token bucket là mặc định đúng: trang web thật gửi cụm request cùng lúc, và sliding window chặt sẽ chặn nhầm người dùng thật.
| Khoá | Ưu | Nhược |
|---|---|---|
| IP | Có sẵn, không cần đăng nhập | ⚠️ CGNAT: cả một toà nhà, một trường học, một nhà mạng di động dùng chung vài IP. Chặn một người là chặn hàng nghìn |
| User ID | Công bằng, đúng ngữ nghĩa | Chỉ có sau khi đăng nhập; kẻ tấn công tạo tài khoản mới |
| API key | Công bằng, gắn với hạn mức trả tiền | Chỉ áp dụng cho client máy |
| IP + endpoint | Chặn brute force /auth/login mà không đụng phần còn lại |
Nhiều khoá hơn, tốn bộ nhớ hơn |
Thực tế dùng khoá tổ hợp theo ngữ cảnh: /auth/login khoá theo IP + email (chặn brute force mà không khoá chết cả dải CGNAT), phần còn lại theo user_id khi đã đăng nhập và theo IP khi chưa. Và luôn nhớ: khoá theo IP chỉ có nghĩa nếu bạn lấy đúng IP — toàn bộ phần trusted proxy ở §4 phải đúng trước. Sai chỗ đó thì mọi request đều mang IP của nginx, và bạn vừa tự dựng một công tắc tắt toàn hệ thống.
13.9.3 Token bucket trong cmd/api
package ratelimit
import (
"net/http"
"strconv"
"sync"
"time"
"golang.org/x/time/rate"
)
// KeyFunc quyết định "đếm theo cái gì" — lựa chọn quan trọng nhất, xem 13.9.2.
type KeyFunc func(*http.Request) string
type Limiter struct {
mu sync.Mutex
buckets map[string]*entry
r rate.Limit // tốc độ nạp token (token/giây)
burst int // sức chứa xô — mức bùng phát chấp nhận được
ttl time.Duration
key KeyFunc
}
type entry struct {
lim *rate.Limiter
seen time.Time
}
func New(perSecond float64, burst int, key KeyFunc) *Limiter {
return &Limiter{
buckets: make(map[string]*entry),
r: rate.Limit(perSecond),
burst: burst,
ttl: 10 * time.Minute,
key: key,
}
}
func (l *Limiter) allow(k string) (bool, time.Duration) {
l.mu.Lock()
e, ok := l.buckets[k]
if !ok {
e = &entry{lim: rate.NewLimiter(l.r, l.burst)}
l.buckets[k] = e
}
e.seen = time.Now()
l.mu.Unlock()
// Reserve thay vì Allow: khi bị từ chối ta vẫn biết còn phải đợi bao lâu, để trả
// Retry-After tử tế thay vì bắt client tự đoán.
res := e.lim.Reserve()
if !res.OK() {
return false, time.Second
}
if d := res.Delay(); d > 0 {
// Không đợi — trả token về xô ngay. Không Cancel thì lần gọi bị từ chối này
// vẫn "chiếm chỗ" của các request hợp lệ đến sau.
res.Cancel()
return false, d
}
return true, 0
}
// Sweep xoá bucket lâu không dùng; gọi định kỳ từ một goroutine có ticker.
//
// ⚠️ Không có hàm này, map là memory leak có điều khiển: mỗi IP lạ ghé qua một lần
// đều để lại một entry vĩnh viễn. Một vòng quét đủ rộng là đủ giết tiến trình —
// chính lớp chống DoS trở thành đường DoS.
func (l *Limiter) Sweep() {
cutoff := time.Now().Add(-l.ttl)
l.mu.Lock()
defer l.mu.Unlock()
for k, e := range l.buckets {
if e.seen.Before(cutoff) {
delete(l.buckets, k)
}
}
}
func (l *Limiter) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
k := l.key(r)
if k == "" {
// Không xác định được khoá thì TỪ CHỐI, không phải cho qua: fail-open ở
// đây nghĩa là kẻ tấn công chỉ cần làm cho khoá rỗng.
http.Error(w, "không xác định được nguồn gọi", http.StatusBadRequest)
return
}
ok, wait := l.allow(k)
if !ok {
// Retry-After tính bằng giây, làm tròn LÊN để client không quay lại sớm.
secs := int(wait.Seconds())
if secs < 1 {
secs = 1
}
w.Header().Set("Retry-After", strconv.Itoa(secs))
http.Error(w, "quá nhiều request", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
Với New(1, 3, ...), sáu request liên tiếp cho ra [200 200 200 429 429 429] kèm Retry-After: 1 — ba token trong xô tiêu ngay, sau đó phải chờ nạp lại. Lắp vào chi bằng r.Use(loginLimiter.Middleware) trong r.Route("/auth", ...), với ratelimit.New(0.2, 5, ...) khoá theo "login:" + httpx.ClientIP(r). Con số 0.2 req/s (≈ một lần mỗi 5 giây) với burst 5 là điểm khởi đầu, không phải khuyến nghị — hãy chỉnh theo số liệu đo được của chính bạn.
13.9.4 Vì sao rate limit trong bộ nhớ vô hiệu khi chạy nhiều instance
Đây là cái bẫy im lặng nhất của cả mục 13.9, vì nó không gây lỗi — nó chỉ âm thầm nới lỏng giới hạn.
Cấu hình: 10 req/s. Thực tế với 3 instance sau load balancer:
┌──► api #1 ─ xô riêng ─► 10 r/s
LB (round robin) ├──► api #2 ─ xô riêng ─► 10 r/s
└──► api #3 ─ xô riêng ─► 10 r/s
────────
tổng thật: 30 r/s
Giới hạn thật bằng ngưỡng × số instance, và nó đổi mỗi lần autoscale: hôm nay 30 r/s, giờ cao điểm 100 r/s — đúng lúc bạn cần giới hạn nhất thì nó lỏng nhất, và không log nào nói cho bạn biết.
| Cách | Chính xác | Độ trễ thêm | Hạ tầng thêm | Khi nào chọn |
|---|---|---|---|---|
| In-memory (code trên) | ❌ Nhân theo số instance | ~0 | Không | 1 instance, hoặc lớp chặn thô bên cạnh lớp chính |
| Chia ngưỡng cho số instance | ⚠️ Sai khi autoscale hoặc một node chết | ~0 | Không | Số instance cố định, chấp nhận sai số |
| Redis dùng chung | ✅ | Một round-trip mạng nội bộ mỗi request | Redis + xử lý khi Redis chết | Nhiều instance, cần đúng theo user/API key |
Ở proxy (limit_req) |
✅ với một nginx; ❌ với nhiều nginx | ~0 | Không | Giới hạn theo IP — nên là tuyến đầu |
Bản Redis ở mức nguyên lý — điều quan trọng là toàn bộ phép đọc–sửa–ghi phải nguyên tử, nếu không hai instance chạm cùng khoá cùng lúc sẽ đếm hụt:
-- Fixed window, chạy như một script Lua trên Redis nên nguyên tử.
-- KEYS[1] = khoá (ví dụ "rl:user:42:1725400000")
-- ARGV[1] = ngưỡng, ARGV[2] = độ dài cửa sổ tính bằng mili-giây
local n = redis.call('INCR', KEYS[1])
if n == 1 then
-- Chỉ đặt TTL ở lần tăng ĐẦU TIÊN. Đặt mỗi lần thì cửa sổ bị đẩy lùi liên tục
-- và khoá không bao giờ hết hạn.
redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
if n > tonumber(ARGV[1]) then return 0 end
return 1
Redis không trả lời thì cho qua hay chặn? Đây là quyết định phải ra trước khi Redis chết, không phải lúc nó đang chết.
Fail-open (cho qua): mất bảo vệ đúng lúc hệ thống đang yếu — mà Redis chết thường xảy ra vì đang bị tấn công. Fail-closed (chặn): một sự cố Redis biến thành sự cố toàn hệ thống.
Lựa chọn thực dụng: fail-open, nhưng đồng thời rơi về limiter in-memory của chính instance đó. Bạn mất độ chính xác toàn cục, giữ được một trần thô ở mỗi node, và không biến sự cố cache thành sự cố ngừng dịch vụ. Ghi log
WARNmỗi lần rơi về chế độ này — xem §14.
13.10 Checklist bề mặt tấn công — năm câu hỏi cho mỗi proxy
Mỗi lần thêm một proxy vào hệ thống — CDN mới, sidecar mới, một httputil.ReverseProxy viết vội để test — hỏi đúng năm câu này. Chúng bao trọn mọi thứ ở trên.
| # | Câu hỏi | Trả lời sai nghĩa là | Mục |
|---|---|---|---|
| 1 | Nó tin header nào? Header nào từ client được nó đọc và hành động theo? | Cache poisoning, Host header attack, IP giả mạo | 13.4, 13.5, §4 |
| 2 | Nó tin ai gọi tới nó? Ai mở được kết nối tới cổng của nó — cả Internet, hay chỉ hop trước? | Vượt ACL, dùng proxy làm bàn đạp | 13.6 |
| 3 | Nó có thể gọi tới đâu? Tập đích tối đa của nó là gì? | SSRF, open relay | 13.2, 13.6 |
| 4 | Nó giữ key gì? Khoá riêng TLS, credential upstream, token API | Thủng nó là thủng mọi thứ phía sau | 13.8 |
| 5 | Log của nó có PII không? URL đầy đủ, header, body, IP | Rò rỉ dữ liệu qua đúng cái bạn dựng lên để bảo vệ | §14 |
Ba câu trả lời mặc định đúng cho gần như mọi trường hợp:
- Câu 1 — ở hop biên thì không tin gì cả: xoá sạch
X-Forwarded-*vàForwardedrồi tự đặt lại. Chỉ tin ở các hop sau hop biên. - Câu 3 — allowlist chứ không blocklist, và ép nó xuống tận
Dialer.Control, đừng dừng ở kiểm URL. - Câu 5 — mặc định không log query string và không log header; bật riêng từng trường khi cần điều tra, có thời hạn.
Câu hỏi thứ sáu, dành cho lúc chọn phần mềm: ai vá nó, và bạn biết tin vá bằng cách nào? Một proxy không được vá là lỗ hổng có hẹn trước — smuggling ở 13.3 gần như luôn là lỗ hổng của một bản proxy cụ thể, không phải của HTTP.
14. Vận hành — hiệu năng, quan sát và gỡ lỗi
Ba phần trước nói về việc dựng proxy. Phần này nói về việc sống chung với nó lúc 2 giờ sáng: khi p99 tăng gấp ba mà không ai đổi code, khi 5% request trả 502 và cả proxy lẫn app đều khai là mình vô tội.
Nguyên tắc xuyên suốt: proxy là chỗ duy nhất nhìn thấy cả hai phía. Nó biết client chờ bao lâu và upstream trả lời bao lâu. Không ghi lại hai con số đó tách nhau là vứt đi lợi thế lớn nhất mà kiến trúc này cho không.
14.1 Ngân sách độ trễ — thời gian đi đâu
Client CDN edge LB Reverse proxy cmd/api Postgres
├─ DNS ──────────────►│ │ │ │ │
├─ TCP + TLS ────────►│ │ │ │ │
├─ HTTP request ─────►├─ (miss) ──►├─────────────►├────────────►│ │
│ │ │ │ ├─ SQL ─────►│
│ │◄───────────┤◄─────────────┤◄────────────┤◄───────────┤
│◄────────────────────┤ │ │ │ │
└── mỗi mũi tên đi-về là 1 RTT của chặng đó, và chúng cộng dồn tuyến tính
All rights reserved