0

Proxy Network — Mạng Proxy toàn tập Phần 2

Tor

Không phải "một proxy" mà là mạng overlay định tuyến qua nhiều chặng, mỗi chặng chỉ biết chặng trước và chặng sau. Với ứng dụng, nó lộ ra dưới dạng một SOCKS5 endpoint cục bộ — nên về mặt tích hợp nó là SOCKS5, còn về mặt mô hình đe doạ thì hoàn toàn khác. Chi tiết ở §7.

Ba thứ hay bị gọi nhầm là proxy

Thứ Có phải proxy không Khác ở đâu
NAT Không Chỉ viết lại địa chỉ trong header gói tin; không kết thúc kết nối, không hiểu giao thức tầng trên
VPN Không Là đường hầm ở tầng mạng cho mọi gói tin của cả máy; proxy chỉ nhận traffic của ứng dụng/giao thức được trỏ tới nó
Load balancer L4 Có, một dạng Chỉ là reverse proxy ở tầng 4 — cùng trục, khác điểm

Phân biệt VPN với proxy quan trọng khi chọn giải pháp: cần mọi thứ trên máy đi qua một chỗ, kể cả DNS và giao thức lạ → VPN. Cần một tiến trình cụ thể đi qua một chỗ có kiểm soát và ghi log theo từng request → proxy.

2.7 Bảng quyết định — bạn đang cần gì, dùng loại nào, công cụ nào

Đọc từ cột trái sang. Cột "bẫy chính" là thứ làm hỏng việc nhiều nhất trong thực tế, đừng bỏ qua.

Bạn đang cần Loại proxy Công cụ Bẫy chính
Che giấu nhiều backend sau một domain Reverse proxy L7, vị trí edge nginx, Caddy Quên chuẩn hoá header danh tính client → §4
Cân bằng tải nhiều instance cmd/api Reverse proxy L7 nginx upstream, HAProxy, Envoy Health check chỉ kiểm TCP → gửi traffic vào tiến trình sống mà hỏng (§5)
Chứng chỉ TLS tự động, cấu hình tối thiểu TLS terminating reverse proxy Caddy Rate limit của CA khi thử nghiệm — dùng môi trường staging của CA
Kiểm soát traffic ra ngoài của cmd/worker Egress forward proxy Squid, Envoy http.Transport viết tay quên Proxy → đi vòng qua proxy, không ai biết
Có một IP tĩnh để đối tác allowlist Egress gateway Squid / NAT gateway Scale ngang làm đổi IP nếu không cố định địa chỉ đi ra
Proxy giao thức không phải HTTP (Postgres, SMTP, Redis) SOCKS5 hoặc L4 TCP proxy Dante / ssh -D, HAProxy mode tcp Mất khả năng cân bằng theo request; kết nối dài dính một backend
Chặn domain xấu mà không giải mã Transparent proxy passthrough theo SNI Envoy, HAProxy mode tcp + SNI Mã hoá phần đầu bắt tay TLS sẽ làm cách này yếu dần
Áp chính sách lên máy không cài được gì Transparent intercepting proxy Squid + policy routing Không MITM thì chỉ chặn được theo host; MITM thì phải phân phối CA
Xem app mobile thực sự gọi API gì MITM proxy trên thiết bị test mitmproxy Certificate pinning trong app → phải dùng build debug
Chặn payload độc hại trước khi tới ứng dụng WAF (reverse L7) Coraza, ModSecurity + OWASP CRS False positive chặn nội dung hợp lệ — chạy log-only trước
Xác thực + rate limit tập trung cho API API gateway Envoy, Traefik, Kong Nhét nghiệp vụ vào gateway → logic phân mảnh ngoài codebase (§9)
mTLS và retry giữa nhiều service nội bộ Service mesh sidecar Envoy + control plane Với monolith event-driven này thường là thừa
Cache asset tĩnh gần người dùng cuối CDN — reverse proxy toàn cầu CDN bất kỳ Cache key sai (quên Vary) → phục vụ nhầm nội dung cá nhân hoá (§8)
Thu thập dữ liệu quy mô, cần IP xoay vòng Backconnect gateway + hồ residential Nhà cung cấp thương mại Nguồn gốc IP, chi phí theo GB, tỷ lệ IP chết (§6)
Ẩn danh trước cả người vận hành đường truyền Mạng overlay nhiều chặng Tor Chậm hơn hẳn; nhiều dịch vụ chặn thẳng exit node (§7)
Hiểu proxy tới tận từng byte Tự viết net/http/httputil, net Bản 20 dòng bỏ qua timeout, header hop-by-hop, và huỷ theo context (§10)

Với community cụ thể, hai dòng đầu và hai dòng về egress là những dòng bạn sẽ dùng thật; phần còn lại là để nhận ra khi gặp. Kiến trúc đề xuất đầy đủ nằm ở §12.

2.8 Bốn câu hỏi để tự phân loại bất kỳ proxy nào

Gặp một proxy lạ — trong tài liệu, trong hệ thống kế thừa, trong một bài blog — hỏi đúng bốn câu này theo thứ tự, và bạn định vị được nó ngay:

  1. Nó được đặt ở đó vì lợi ích của ai — bên gọi hay bên nhận? → forward hay reverse.
  2. Nếu tôi xoá cấu hình proxy trên client, có gì đổi không? Đổi → explicit. Không đổi mà traffic vẫn qua proxy → transparent, và câu hỏi tiếp theo bắt buộc là "nó có giải mã TLS không, và CA nào đang được tin?".
  3. Nó có cần đọc nội dung request để làm việc của nó không? Không → L4 hoặc SOCKS. Có → L7, và bạn phải trả lời tiếp chuyện TLS termination.
  4. Nó canh ranh giới nào? Internet vào, cụm vào, một tiến trình, mạng ra, hay một hồ IP.

Bốn câu trả lời đó cho bạn biết proxy này thấy được gìsửa được gì — hai điều duy nhất thực sự quan trọng khi đánh giá rủi ro của một proxy. Cơ chế tầng dây làm nên những khả năng đó là nội dung của §3.


3. Giao thức và cơ chế ở tầng dây

Phần §2 phân loại proxy theo vai trò. Phần này đi xuống tận byte: khi bạn gõ curl -x http://proxy:3128 http://vi.du/a, chính xác những byte nào rời khỏi máy bạn, và chúng khác gì so với khi không có proxy. Lý do phải xuống tới mức này rất thực dụng: gần như mọi sự cố proxy khó chịu nhất đều nằm ở chỗ hai bên hiểu khác nhau về vài byte — proxy tưởng đang nói HTTP/1.1 còn upstream trả HTTP/2, header Upgrade bị một tầng trung gian xóa nên WebSocket chết ngay lúc bắt tay kèm một mã 200 chẳng liên quan gì, hoặc một listener tin tuyệt đối vào IP do bên kia tự khai. Nhìn ở mức "proxy chuyển tiếp request" thì không thấy gì cả. Nhìn ở mức byte thì lỗi lộ ra ngay. Mọi ví dụ raw HTTP dưới đây dùng \r\n kết dòng (RFC 9112 bắt buộc), và dòng trống cuối cùng là bắt buộc — tôi để nguyên nó trong khối code.

3.1 Bốn hình dạng request-target — chỗ mọi thứ bắt đầu

RFC 9112 §3.2 định nghĩa bốn hình dạng của request-target, mỗi dạng một tiểu mục riêng: origin-form (§3.2.1), absolute-form (§3.2.2), authority-form (§3.2.3), asterisk-form (§3.2.4). Hai dạng đầu là chỗ ranh giới "nói thẳng với server" và "nói với proxy" hiện ra rõ nhất, nên phần này soi kỹ chúng. Hai dạng còn lại hẹp và cố định: authority-form chỉ có host:port, dùng đúng cho CONNECT (§3.2); asterisk-form là dấu * trong OPTIONS * HTTP/1.1, hỏi năng lực của chính server hoặc của proxy chứ không nhắm vào tài nguyên nào.

Nói thẳng với origin server — origin-form:

GET /bai-viet/proxy-la-gi HTTP/1.1
Host: community.example.com
User-Agent: curl/8.5.0
Accept: */*

Dòng đầu chỉ có đường dẫn. Server không biết bạn định gọi tên miền nào cho tới khi đọc header Host. Đây là lý do Host thành bắt buộc từ HTTP/1.1: một IP phục vụ hàng trăm virtual host, Host là thứ duy nhất phân biệt.

Nói với forward proxy — absolute-form:

GET http://community.example.com/bai-viet/proxy-la-gi HTTP/1.1
Host: community.example.com
User-Agent: curl/8.5.0
Accept: */*
Proxy-Connection: keep-alive

Khác biệt duy nhất mà cũng là tất cả: request-target chứa cả scheme và authority. Proxy nhận gói này không phải đoán gì — nó đọc http://community.example.com, tự phân giải DNS, mở TCP tới đó, rồi viết lại request thành origin-form để gửi đi.

Vì sao forward proxy bắt buộc dùng absolute-form?

Vì proxy là đích TCP của bạn, không phải origin server. Nếu client gửi origin-form tới proxy, proxy chỉ còn Host để suy ra đích — mà Host là header do client tự khai, có thể thiếu, có thể sai, và về ngữ nghĩa nó mô tả tài nguyên, không phải nơi cần gửi tới. Absolute-form đưa đích lên request line, nơi nó không thể bị nhầm với thứ gì khác.

Ba hệ quả, đều có mặt trong đời thực. Một, khi origin server nhận absolute-form, RFC 9112 §3.2.2 yêu cầu nó bỏ qua Host và dùng authority trong request-target; server phải chấp nhận dạng này để tương thích với proxy cũ. Hai, client HTTP/1.1 vẫn phải gửi Host kể cả khi đã có authority trong request-target — trùng lặp là cố ý, để bên nhận cũ không vỡ. Ba, có hai nguồn authority nghĩa là có bề mặt tấn công: proxy định tuyến theo request-target còn upstream định tuyến theo Host là một cặp bất đồng ngữ nghĩa cổ điển, gốc của nhiều lỗi request smuggling — xem §13.

Proxy-Connection ở ví dụ trên không phải header chuẩn — nó là bản vá lịch sử của trình duyệt để không làm hỏng proxy HTTP/1.0 cũ. Đừng viết code phụ thuộc vào nó, nhưng phải xử lý nó như header hop-by-hop.

Header hop-by-hop — thứ proxy phải xóa. RFC 9110 §7.6.1 nói rõ: intermediary phải loại bỏ mọi field được liệt kê trong header Connection trước khi chuyển tiếp. Ngoài ra có một nhóm theo truyền thống chỉ có nghĩa giữa hai đầu của một kết nối TCP: Connection, Proxy-Connection, Keep-Alive, Proxy-Authenticate, Proxy-Authorization, TE, Trailer, Transfer-Encoding, Upgrade. Đây đúng là danh sách mà net/http/httputil.ReverseProxy của thư viện chuẩn Go xóa trước khi gửi lên upstream. Ghi nhớ nó, vì Upgrade nằm trong đó — và đó chính là lý do WebSocket qua proxy cần cấu hình riêng ở §3.9.

3.2 CONNECT — cái ống dẫn byte

Absolute-form giải quyết được HTTP. Nó không giải quyết được HTTPS: proxy muốn viết lại request thì phải đọc được request, mà request đã nằm trong TLS. Proxy đọc được nghĩa là TLS đã bị phá.

Lời giải là phương thức CONNECT (RFC 9110 §9.3.6), dùng authority-form — request-target chỉ có host:port, không scheme, không path:

CONNECT community.example.com:443 HTTP/1.1
Host: community.example.com:443
Proxy-Authorization: Basic dXNlcjpwYXNz
User-Agent: curl/8.5.0

Proxy mở TCP tới community.example.com:443. Nếu thành công, nó trả:

HTTP/1.1 200 Connection Established
Proxy-Agent: tinyproxy/1.11

Từ byte đầu tiên sau dòng trống đó, kết nối không còn là HTTP nữa. Proxy thành một ống dẫn byte hai chiều mù chữ:

   Client                     Proxy                    Origin
     ├─ CONNECT host:443 ─────►│─ TCP SYN → host:443 ────►│
     │◄─ 200 Connection Est. ──┤                          │
     │═══════ TLS ClientHello ... Application Data ══════►│
     │◄══════ TLS ServerHello ... Application Data ═══════│
     │                         ▲ chỉ copy byte, không giải mã được gì

TLS handshake diễn ra xuyên qua proxy, giữa client và origin; private key của community.example.com vẫn nằm ở origin. Proxy thấy: địa chỉ đích, thời điểm, số byte mỗi chiều, và SNI trong ClientHello (vì ClientHello chưa mã hóa — §3.7). Proxy không thấy: đường dẫn, header, cookie, body, mã trạng thái.

Vì sao "proxy HTTP" lại chuyển được HTTPS?

Vì nó không chuyển HTTPS. Nó dùng một request HTTP để xin mở đường, rồi rút lui thành tầng vận chuyển. Cụm "HTTP proxy hỗ trợ HTTPS" trong tài liệu nhà cung cấp luôn có nghĩa là "hỗ trợ CONNECT", không bao giờ có nghĩa là proxy hiểu nội dung HTTPS.

Ba hệ quả vận hành: (1) xác thực chỉ xảy ra một lần, ở request CONNECT qua Proxy-Authorization (RFC 9110 §11.7), sau đó proxy không còn cơ hội chen vào — đây là lý do proxy thương mại tính tiền theo GB chứ không theo request; (2) log rất nghèo — chỉ có host:443, thời gian, byte in/out, không URL, không status code, nên đi tìm "request nào lỗi" trong log CONNECT là việc gần như bất khả; (3) muốn đọc nội dung thì phải MITM — proxy tự phát chứng chỉ bằng một CA nội bộ mà client đã cài tin cậy, giải mã, đọc, mã hóa lại. Đó là cách Burp Suite, Charles và mọi hệ thống corporate TLS inspection hoạt động; chi tiết ở §7.

CONNECT trong Go, và cái bẫy hijack:

// Package tunnel minh họa phía server của CONNECT; bản đầy đủ nằm ở §10.
package tunnel

import (
	"io"
	"net"
	"net/http"
	"time"
)

// HandleConnect phục vụ một request "CONNECT host:port HTTP/1.1".
func HandleConnect(w http.ResponseWriter, r *http.Request) {
	// Với authority-form, net/http đặt "host:port" vào r.Host và để r.URL.Path rỗng.
	// Không có scheme để mà parse — đây là dạng request duy nhất như vậy.
	upstream, err := net.DialTimeout("tcp", r.Host, 10*time.Second)
	if err != nil {
		// Đích không mở được là lỗi của chặng sau, không phải lỗi của client.
		http.Error(w, "không mở được kết nối tới đích", http.StatusBadGateway)
		return
	}
	defer upstream.Close()
	// Hijack chỉ có trên HTTP/1.x. Nếu listener này chạy HTTP/2 thì assertion
	// thất bại — CONNECT của HTTP/2 là cơ chế khác hẳn (RFC 9113 §8.5).
	hj, ok := w.(http.Hijacker)
	if !ok {
		http.Error(w, "listener không hỗ trợ hijack", http.StatusInternalServerError)
		return
	}
	client, brw, err := hj.Hijack()
	if err != nil {
		return
	}
	defer client.Close()
	// Tự tay ghi status line thay vì WriteHeader: sau khi tunnel mở, kết nối
	// không còn là HTTP, nên không được để net/http chèn thêm Date/Content-Length.
	if _, err := brw.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil {
		return
	}
	if err := brw.Flush(); err != nil {
		return
	}
	// Đọc từ brw chứ KHÔNG từ client: bufio.Reader có thể đã nuốt sẵn vài byte
	// đầu của ClientHello khi parse request. Đọc thẳng client sẽ mất chúng.
	// Một chiều đóng là đủ: bên kia nhận EOF khi defer đóng socket.
	done := make(chan struct{}, 2)
	go func() { _, _ = io.Copy(upstream, brw); done <- struct{}{} }()
	go func() { _, _ = io.Copy(client, upstream); done <- struct{}{} }()
	<-done
}

Dòng comment về brw là chỗ dễ sai nhất trong cả đoạn code này. Hijack() trả về cả net.Conn lẫn *bufio.ReadWriter chính vì lý do đó: server đã đọc vượt quá dòng trống khi parse request. Bỏ qua buffer và io.Copy thẳng từ client, bạn có một tunnel chạy đúng 95% số lần — 5% còn lại là TLS handshake failure không lý do, chỉ xảy ra khi client gửi ClientHello dính liền gói với CONNECT. Lỗi này âm thầm và gần như không thể truy vết ngược.

3.3 SOCKS5 ở mức byte — RFC 1928 và RFC 1929

SOCKS5 tiếp cận từ hướng ngược lại với HTTP proxy: thay vì hiểu giao thức ứng dụng rồi viết lại, nó cố tình không hiểu gì cả. Vài byte nhị phân để thỏa thuận đích, rồi bỏ đi.

Bước 1 và 2 — greeting rồi method selection. Client khai các phương thức xác thực nó biết, server chọn đúng một.

Client → | VER | NMETHODS | METHODS  |      Server → | VER | METHOD |
         |  1  |    1     | 1 .. 255 |               |  1  |   1    |
Client gửi:  05 01 00        Server gửi:  05 00  → chấp nhận, không cần xác thực
             │  │  └── METHODS[0]=0x00                05 02  → đòi user/pass (RFC 1929)
             │  └───── NMETHODS = 1                   05 FF  → không phương thức nào
             └──────── VER = 0x05                              chấp nhận được ⇒ đóng

Biết cả no-auth lẫn username/password thì client gửi 05 02 00 02. RFC 1928 §3 định nghĩa: 0x00 không xác thực, 0x01 GSSAPI, 0x02 username/password, 0x030x7F dành cho IANA, 0x800xFE dành riêng, 0xFF không chấp nhận được.

Bước 3 — xác thực username/password (RFC 1929), chỉ khi METHOD = 0x02.

| VER | ULEN |  UNAME   | PLEN |  PASSWD  |      độ dài: 1, 1, 1..255, 1, 1..255

Client:  01 04 75 73 65 72 04 70 61 73 73
         │  │  └─ "user" ─┘ │  └─ "pass" ─┘
         │  └── ULEN = 4    └───── PLEN = 4
         └───── VER = 0x01  ⚠️ là 0x01, KHÔNG phải 0x05

Server:  01 00   → STATUS = 0x00 nghĩa là thành công
         01 01   → khác 0x00 là thất bại, server phải đóng kết nối

Cái bẫy VER = 0x01

Byte version của gói xác thực là phiên bản của thủ tục username/password, không phải của SOCKS. Nó là 0x01. Người tự viết client SOCKS5 sai chỗ này rất nhiều, và server chỉ đóng kết nối chứ không nói gì — bạn nhận được một EOF trần trụi.

Hệ quả thứ hai: username và password đi nguyên văn, không mã hóa. SOCKS5 auth chỉ an toàn khi bản thân kết nối tới proxy đã được bọc (SSH tunnel, WireGuard, hoặc SOCKS-over-TLS nếu phần mềm hỗ trợ).

Bước 4 và 5 — request rồi reply. Hai gói cùng bố cục, chỉ khác byte thứ hai (CMD ở request, REP ở reply) và ý nghĩa của trường địa chỉ.

Request:  | VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT |   độ dài: 1,1,1,1, biến đổi, 2
Reply:    | VER | REP | RSV | ATYP | BND.ADDR | BND.PORT |

CMD:  0x01 CONNECT       — mở kết nối TCP tới đích (gần như toàn bộ lưu lượng thật)
      0x02 BIND          — bảo proxy lắng nghe hộ, chờ đích gọi ngược vào
      0x03 UDP ASSOCIATE — xin một cổng UDP để relay datagram
ATYP: 0x01 IPv4 (4 byte) | 0x03 DOMAINNAME (byte đầu là độ dài, KHÔNG có NUL kết thúc)
      | 0x04 IPv6 (16 byte).  RSV luôn 0x00. Port big-endian (network byte order).
REP (RFC 1928 §6):
      0x00 succeeded                          0x05 Connection refused
      0x01 general SOCKS server failure       0x06 TTL expired
      0x02 connection not allowed by ruleset  0x07 Command not supported
      0x03 Network unreachable                0x08 Address type not supported
      0x04 Host unreachable                   0x09–0xFF chưa gán

Client → CONNECT tới community.example.com:443 bằng tên miền:
05 01 00 03 15 63 6f 6d 6d 75 6e 69 74 79 2e 65 78 61 6d 70 6c 65 2e 63 6f 6d 01 bb
│  │  │  │  │  └───────────── "community.example.com" (0x15 = 21 byte) ──────────┘ │
│  │  │  │  └── độ dài tên miền = 21                                               │
│  │  │  └───── ATYP = 0x03 (DOMAINNAME)                              0x01bb = 443 ┘
│  │  └──────── RSV = 0x00
│  └─────────── CMD = 0x01 (CONNECT)
└────────────── VER = 0x05

Server ← 05 00 00 01 00 00 00 00 00 00
         └── REP=0x00. BND.ADDR/BND.PORT là địa chỉ phía proxy dùng để nói với đích;
             với CMD=CONNECT nhiều server trả 0.0.0.0:0 và client bỏ qua — chỉ
             BIND / UDP ASSOCIATE mới thực sự cần giá trị này.

Sau reply 0x00, kết nối thành ống dẫn byte thuần túy, y hệt sau 200 Connection Established. REP 0x02 là mã bạn gặp nhiều nhất khi vận hành: proxy chạy, xác thực đúng, nhưng ruleset chặn đích hoặc chặn port. Phân biệt 0x02 với 0x05 (đích từ chối) tiết kiệm hàng giờ đổ lỗi nhầm chỗ.

Điểm cần để ý khi tự viết client: chọn ATYP là một quyết định về quyền riêng tư, không phải chi tiết mã hóa. Gửi 0x03 với tên miền nguyên vẹn thì proxy phân giải DNS hộ; tự net.ParseIP trước rồi gửi 0x01 thì truy vấn DNS đã rời máy bạn ngoài đường hầm — §3.4 nói kỹ hậu quả. Trường độ dài tên miền chỉ có 1 byte, nên phải chặn len(host) > 255 trước khi ghi, và port luôn big-endian bất kể endianness của máy.

SOCKS5 so với SOCKS4/SOCKS4a. SOCKS4 không có RFC — nó là đặc tả de facto có trước, còn SOCKS4a là bản mở rộng cho phép gửi tên miền thay vì IP.

Khả năng SOCKS4 SOCKS4a SOCKS5 (RFC 1928)
Đích bằng tên miền (remote DNS) ❌ chỉ IPv4 ✅ ATYP 0x03
IPv6 ✅ ATYP 0x04
UDP UDP ASSOCIATE
Xác thực chuẩn hóa ❌ chỉ có trường USERID tự do, không mật khẩu ✅ RFC 1929 user/pass, RFC 1961 GSSAPI
BIND (kết nối ngược)

Trường USERID của SOCKS4 là chuỗi kết thúc bằng NUL do client tự điền, không có cơ chế kiểm chứng nào — gọi nó là "xác thực" là nói quá. Nếu hôm nay có ai đề nghị bạn dùng SOCKS4, câu trả lời đúng là hỏi vì sao không phải SOCKS5.

Hai lệnh còn lại ít dùng nhưng cần biết. BIND (0x02) sinh ra cho FTP active mode: client bảo proxy lắng nghe một port, proxy trả reply hai lần — lần đầu báo địa chỉ đang nghe, lần hai khi đích thực sự kết nối vào. Ngày nay gần như không dùng và phần lớn proxy thương mại tắt hẳn (trả REP 0x07). UDP ASSOCIATE (0x03) là lý do người ta chọn SOCKS5 cho game, VoIP, DNS và QUIC. Kết nối TCP vẫn giữ để làm "sổ đăng ký" — TCP đứt thì UDP relay dừng. Mỗi datagram được bọc thêm một header nhỏ:

| RSV | FRAG | ATYP | DST.ADDR | DST.PORT |   DATA   |   độ dài: 2,1,1, biến đổi, 2, ...
RSV = 0x0000. FRAG = 0x00 nghĩa là không phân mảnh — trên thực tế hầu hết
implementation chỉ hỗ trợ FRAG = 0x00 và từ chối phần còn lại.

Lưu ý vận hành: rất nhiều proxy quảng cáo "SOCKS5" nhưng chỉ hiện thực CONNECT. Cách kiểm chứng đáng tin duy nhất là thử thật, không phải đọc trang bán hàng.

3.4 SOCKS5 so với HTTP CONNECT — bảng quyết định

Cả hai đều cho ra một ống dẫn byte. Chọn cái nào phụ thuộc vào việc bạn cần gì ở phần trước khi ống mở ra.

Tiêu chí HTTP CONNECT SOCKS5
Tầng hoạt động L7 dùng một request HTTP để mở đường L4 L5, không biết gì về HTTP
Giao thức chở được Mọi thứ trên TCP — nhưng phần lớn proxy giới hạn port (thường chỉ 443) Mọi thứ trên TCP, cộng UDP qua UDP ASSOCIATE
Kích thước bắt tay Một request HTTP text, hàng trăm byte Vài chục byte nhị phân
Round-trip trước khi mở 1 2 (greeting + request), 3 nếu có auth
Metadata proxy nhìn thấy Header HTTP: User-Agent, Proxy-Authorization, header tùy biến ⇒ log và policy phong phú Chỉ đích và (nếu dùng) username — không có chỗ nhét metadata
Xác thực Proxy-Authorization Basic/Digest/Negotiate (RFC 9110 §11.7), có mã 407 để thử lại RFC 1929 user/pass hoặc RFC 1961 GSSAPI
DNS phân giải ở đâu Tên miền nằm trong request-target ⇒ luôn là proxy (remote DNS) Tùy ATYP: 0x03 ⇒ proxy phân giải; 0x01/0x04 ⇒ client đã phân giải rồi
Hạ tầng doanh nghiệp có sẵn Phổ biến — mọi corporate proxy đều nói CONNECT Ít hơn, thường phải tự dựng

Dòng in đậm là dòng quan trọng nhất của cả bảng. Với SOCKS5, quyền quyết định DNS nằm ở client, và client mặc định hay chọn sai.

Rò rỉ DNS — thất bại thầm lặng của SOCKS5

Nếu client tự phân giải community.example.com rồi gửi ATYP 0x01 với IP kết quả, truy vấn DNS đó đã rời máy bạn ngoài đường hầm: ISP thấy, resolver thấy, ai ngồi giữa cũng thấy. Lưu lượng TCP đi qua proxy nhưng ý định thì đã lộ từ trước đó.

curl phơi bày đúng ranh giới này bằng hai cờ khác nhau đúng một chữ, hành vi riêng tư khác nhau hoàn toàn.

curl --socks5          127.0.0.1:1080 https://community.example.com/  # ❌ DNS rời máy bạn
curl --socks5-hostname 127.0.0.1:1080 https://community.example.com/  # ✅ proxy phân giải hộ

Trong Go, golang.org/x/net/proxy cung cấp proxy.SOCKS5(...) trả về một proxy.Dialer; truyền vào hostname:port chứ không phải IP thì nó gửi ATYP 0x03, tức remote DNS. Mặc định này đúng — nhưng đừng tự phân giải trước rồi mới gọi dialer, vì làm vậy là bạn tự tay chọn local DNS.

3.5 PROXY protocol — trả lại IP thật cho L4 proxy

Reverse proxy tầng 7 có X-Forwarded-For để ghi IP client (§4). Một L4 proxy — TCP load balancer, TLS passthrough, SOCKS relay — không có chỗ nào để nhét header vào, vì nó không được phép hiểu payload. Nó chỉ copy byte. Kết quả: mọi kết nối tới upstream đều mang IP nguồn là IP của load balancer. Rate limit theo IP hỏng, geo-routing hỏng, audit log vô nghĩa, và fail2ban chặn nhầm chính load balancer của bạn.

PROXY protocol (đặc tả của HAProxy, không phải RFC) chèn một khối metadata trước byte đầu tiên của payload — tức trước cả ClientHello. Upstream đọc khối đó, biết IP thật, rồi coi phần còn lại của kết nối như bình thường.

v1 — text, đọc bằng mắt được:

PROXY TCP4 203.0.113.7 10.0.1.5 51234 8443\r\n
  │     │        │         │       │     └── port đích
  │     │        │         │       └──────── port nguồn
  │     │        │         └──────────────── IP đích, tức mặt trong của LB
  │     │        └────────────────────────── IP nguồn, tức client thật
  │     └─────────────────────────────────── TCP4 | TCP6 | UNKNOWN
  └───────────────────────────────────────── chữ ký cố định "PROXY" ở đầu dòng

Đặc tả giới hạn dòng v1 tối đa 107 byte kể cả CRLF. Con số này cố ý: đủ cho IPv6 dài nhất, và đủ nhỏ để bên nhận cấp phát buffer cố định mà không sợ bị bơm dữ liệu.

// Package proxyproto bóc header PROXY protocol v1 ra khỏi đầu kết nối.
package proxyproto

import (
	"bufio"
	"errors"
	"fmt"
	"net"
	"net/netip"
	"strings"
)

// ErrNotV1 để caller phân biệt "không phải header v1" với "header hỏng".
var ErrNotV1 = errors.New("proxyproto: không phải header v1")

// Src là IP:port thật của client; Dst là IP:port mặt ngoài của LB mà client đã gõ.
type Header struct{ Src, Dst netip.AddrPort }

// ParseV1 phải được gọi TRƯỚC khi trao conn cho bất kỳ tầng nào khác —
// TLS handshake bắt đầu ngay sau CRLF này.
func ParseV1(br *bufio.Reader) (*Header, error) {
	line, err := br.ReadString('\n')
	if err != nil {
		return nil, fmt.Errorf("proxyproto: không đọc được dòng header: %w", err)
	}
	// Chặn độ dài TRƯỚC khi parse: đặc tả v1 giới hạn 107 byte kể cả CRLF,
	// nên dòng dài hơn nghĩa là bên gửi không nói PROXY protocol.
	fields := strings.Split(strings.TrimSuffix(line, "\r\n"), " ")
	if len(line) > 107 || len(fields) != 6 || fields[0] != "PROXY" {
		return nil, ErrNotV1
	}
	src, err := netip.ParseAddrPort(net.JoinHostPort(fields[2], fields[4]))
	if err != nil {
		return nil, fmt.Errorf("proxyproto: địa chỉ nguồn hỏng: %w", err)
	}
	dst, err := netip.ParseAddrPort(net.JoinHostPort(fields[3], fields[5]))
	if err != nil {
		return nil, fmt.Errorf("proxyproto: địa chỉ đích hỏng: %w", err)
	}
	return &Header{Src: src, Dst: dst}, nil
}

v2 — binary, và vì sao nó tồn tại. v1 có ba nhược điểm: parse text tốn CPU khi có rất nhiều kết nối mới mỗi giây, khó phân biệt dứt khoát với payload thật, và không mở rộng được. v2 sửa cả ba bằng một khối nhị phân độ dài xác định, mở đầu bằng chữ ký 12 byte cố ý chọn để không thể là mở đầu hợp lệ của HTTP hay TLS:

byte 0..11   0D 0A 0D 0A 00 0D 0A 51 55 49 54 0A     ← chữ ký, chứa "QUIT\n"
byte 12      version (4 bit cao) + command (4 bit thấp) — 0x21 = v2 + PROXY (được
             proxy hộ ⇒ đọc địa chỉ bên dưới); 0x20 = v2 + LOCAL (LB tự mở, ví dụ
             health check ⇒ BỎ QUA địa chỉ)
byte 13      address family (4 bit cao) + transport (4 bit thấp)
               0x11 = AF_INET + STREAM   0x21 = AF_INET6 + STREAM   0x31 = AF_UNIX
byte 14..15  độ dài phần còn lại, big-endian (khối địa chỉ + TLV)
byte 16..    src addr, dst addr, src port, dst port — rồi TLV tùy chọn

Chữ ký bắt đầu bằng \r\n\r\n\0 là chi tiết thiết kế đáng học: nó khiến một HTTP server không hiểu PROXY protocol phải từ chối thay vì diễn giải nhầm thành request rỗng. Phần TLV của v2 chở thêm những gì v1 không chở được — đáng chú ý nhất là ALPN đã thỏa thuận và SNI mà LB đọc được từ ClientHello, nhờ đó upstream biết client xin h2 hay http/1.1 mà không cần tự làm TLS. Đây là lý do service mesh dùng v2 chứ không dùng v1.

Phần mềm Nhận (accept) Gửi (send)
HAProxy bind :443 accept-proxy server app 10.0.1.10:8443 send-proxy (v1) hoặc send-proxy-v2
Nginx listen 443 proxy_protocol; (cả http lẫn stream) proxy_protocol on;chỉ trong stream, module http không gửi được
Envoy listener filter envoy.filters.listener.proxy_protocol transport socket envoy.transport_sockets.upstream_proxy_protocol
AWS NLB / GCP TCP LB bật bằng thuộc tính của target group / backend service
http {
    set_real_ip_from  10.0.1.0/24;   # chỉ tin PROXY protocol từ đúng dải mạng của LB
    real_ip_header    proxy_protocol;
    server {
        # proxy_protocol trên listen: nginx sẽ ĐÒI header này ở mọi kết nối.
        # Kết nối không có header ⇒ nginx đóng ngay, không trả HTTP response nào.
        listen 443 ssl proxy_protocol;
        server_name community.example.com;
        location / {
            proxy_pass http://api_upstream;
            # $proxy_protocol_addr là IP lấy từ header; $remote_addr đã được
            # real_ip ghi đè nên hai biến này bằng nhau khi cấu hình đúng.
            proxy_set_header X-Forwarded-For $proxy_protocol_addr;
        }
    }
}

⚠️ Bẫy bảo mật nghiêm trọng — và rất dễ mắc

proxy_protocol trên một listener nghĩa là: "tin tuyệt đối vào IP mà bên kết nối tự khai ở byte đầu tiên". Không chữ ký, không xác thực, không gì cả.

Nếu listener đó người ngoài gọi thẳng vào được — LB đặt sai security group, container publish nhầm port, ai đó thêm một listen 0.0.0.0 để debug — thì bất kỳ ai cũng ghi được một dòng text và trở thành bất kỳ IP nào. Rate limit theo IP: vô hiệu. IP allowlist cho trang admin: vô hiệu. Audit log: trỏ vào người vô tội.

# Toàn bộ "cuộc tấn công" là đây. Không cần công cụ gì đặc biệt.
printf 'PROXY TCP4 127.0.0.1 10.0.1.5 12345 8443\r\nGET /admin HTTP/1.1\r\nHost: x\r\n\r\n' \
  | nc upstream.noi.bo 8443

Hai luật, không ngoại lệ: (1) listener bật PROXY protocol phải nằm sau firewall chỉ cho IP của LB đi vào — kiểm chứng bằng cách quét từ ngoài, không phải bằng cách đọc file cấu hình; (2) không bao giờ bật proxy_protocol trên cùng port với lưu lượng thường, vì nginx đòi header ở mọi kết nối nên không có chuyện "vừa bật vừa tắt". Cần cả hai thì mở hai port riêng.

3.6 TLS: termination, passthrough, re-encrypt

Termination        Client ══TLS══► Proxy ──plain──► App
Passthrough        Client ══════════TLS══════════► App   (proxy chỉ copy byte)
Re-encrypt         Client ══TLS══► Proxy ══TLS══► App     (hai phiên độc lập)
Tiêu chí Termination Passthrough Re-encrypt
Proxy đọc được nội dung? ✅ toàn bộ ❌ không gì ngoài SNI + kích thước gói ✅ toàn bộ
Ai giữ private key của domain Proxy Chỉ app Proxy (phía client) + app giữ key riêng
WAF, cache, rewrite header, chèn X-Forwarded-For ❌ hoàn toàn không
Định tuyến theo path / header ❌ chỉ theo SNI hoặc IP:port
Chi phí CPU 1 lần mã hóa/giải mã ~0 ở proxy (chỉ copy byte) 2 lần — đắt nhất
Lưu lượng mạng nội bộ Plaintext ⇒ ai ở trong mạng cũng đọc được Mã hóa suốt tuyến Mã hóa suốt tuyến
Quản lý chứng chỉ Một chỗ, dễ tự động hóa Rải ra mọi app Hai tầng, phức tạp nhất
Hợp với Web app thông thường Yêu cầu tuân thủ cấm giải mã ở giữa Zero-trust nội bộ, mTLS giữa các service

Khi chọn termination, đoạn từ proxy tới app là plaintext. Chấp nhận được nếu — và chỉ nếu — đoạn đó nằm trong một biên giới bạn thực sự kiểm soát: "cùng một VPC" thường đủ với đa số dự án, "cùng một datacenter thuê chung" thì không. Với dự án community, bố trí mặc định là termination tại reverse proxy, plaintext tới cmd/api trong mạng Docker nội bộ — chi tiết ở §12. Nhưng phải nhớ hệ quả ở tầng ứng dụng: cmd/api nhận request http://, nên mọi chỗ sinh URL tuyệt đối hoặc set cookie Secure phải dựa vào X-Forwarded-Proto chứ không dựa vào r.TLS.

3.7 SNI-based routing và cái hạn của nó

Passthrough có một điểm mù may mắn: TLS ClientHello đi trước khi mã hóa bắt đầu, và trong đó có phần mở rộng SNI — tên miền client muốn nói chuyện, ở dạng plaintext. Một L4 proxy có thể đọc lén byte này để định tuyến mà không cần private key.

# Bối cảnh stream, KHÔNG phải http. Cần module ngx_stream_ssl_preread_module.
stream {
    # ssl_preread chỉ nhìn ClientHello rồi dừng — không hề giải mã.
    map $ssl_preread_server_name $backend {
        community.example.com  app_tls;
        api.example.com        api_tls;
        default                app_tls;
    }
    upstream app_tls { server 10.0.1.10:8443; }
    upstream api_tls { server 10.0.1.20:8443; }
    server {
        listen 443;
        ssl_preread on;
        proxy_pass $backend;
    }
}
frontend tls_in
    bind :443
    mode tcp
    # Phải chờ đủ byte ClientHello trước khi quyết định, nếu không req.ssl_sni còn rỗng.
    tcp-request inspect-delay 5s
    tcp-request content accept if { req.ssl_hello_type 1 }
    use_backend app_tls if { req.ssl_sni -i community.example.com }
    default_backend api_tls

Ba giới hạn phải nói thẳng:

  1. Chỉ định tuyến được theo tên miền. Không path, không header, không cookie — chúng nằm sau lớp mã hóa. Muốn /api/ đi hai nơi thì bắt buộc phải terminate.
  2. SNI do client tự khai và proxy không kiểm chứng được. Client hoàn toàn có thể gửi SNI a.example.com rồi bên trong đặt Host: b.example.com — kỹ thuật domain fronting. Nếu upstream định tuyến theo Host còn LB định tuyến theo SNI, bạn lại có một cặp bất đồng ngữ nghĩa nữa.
  3. SNI đang trên đường biến mất. ECH (Encrypted Client Hello) mã hóa chính ClientHello, kể cả SNI, bằng khóa công khai lấy từ bản ghi DNS của đích. Với ECH, L4 proxy chỉ còn thấy IP đích — SNI routing hết tác dụng, kèm theo đó là mọi hệ thống lọc/kiểm duyệt/DLP dựa trên SNI.

Trạng thái của ECH

ECH là bản thảo trong IETF TLS working group, chưa được cấp số RFC tại thời điểm viết. Đã có triển khai thực tế ở một số CDN và trình duyệt, nhưng phụ thuộc vào DNS (bản ghi mang khóa công khai) nên phổ cập chậm.

Ý nghĩa thiết kế: đừng xây kiến trúc dài hạn dựa trên giả định "luôn đọc được SNI". Cần điều khiển theo tên miền một cách bền vững thì hoặc terminate TLS (bạn có key), hoặc dùng mTLS/định danh ở tầng ứng dụng thay vì nhìn lén ClientHello.

3.8 HTTP/2 và HTTP/3 qua proxy

Nghe nghịch lý: client dùng HTTP/2 (RFC 9113) tới proxy, rồi proxy hạ xuống HTTP/1.1 để nói với app. Lý do là lợi ích của HTTP/2 gần như biến mất ở chặng trong.

Lợi ích của HTTP/2 Còn giá trị ở chặng proxy → upstream?
Multiplexing nhiều stream trên một kết nối Ít — proxy đã có connection pool giữ kết nối ấm sẵn
Nén header (HPACK) Ít — mạng nội bộ băng thông rộng, RTT rất thấp
Bỏ head-of-line blocking ở tầng HTTP Có, nhưng đổi lại một TCP đứt làm hỏng tất cả stream trên đó
Server push Đã bị bỏ trong thực tế

Thêm nữa, HTTP/1.1 với keep-alive cực kỳ dễ debug: tcpdump ra là đọc được bằng mắt, còn HTTP/2 là nhị phân phải giải khung. Với đa số dự án, HTTP/1.1 lên upstream là mặc định đúng, và nginx chọn như vậy không phải vì thiếu sót.

upstream api_upstream {
    server 10.0.1.10:8000;
    # Số kết nối rảnh giữ lại cho MỖI worker process, không phải tổng.
    # Bỏ dòng này thì proxy_http_version 1.1 bên dưới gần như vô nghĩa.
    keepalive 32;
}
server {
    location / {
        proxy_pass http://api_upstream;
        # Bắt buộc: mặc định của nginx khi nói với upstream là HTTP/1.0,
        # vốn không có keep-alive ⇒ mỗi request một kết nối TCP mới.
        proxy_http_version 1.1;
        # Xóa header Connection do client gửi. Nếu không, "Connection: close"
        # của client bị chuyển tiếp và giết luôn kết nối keep-alive tới upstream.
        proxy_set_header Connection "";
    }
}

Khi bạn thật sự cần h2c. h2c = HTTP/2 cleartext, tức HTTP/2 không TLS. Trường hợp bắt buộc: gRPC, vốn được định nghĩa trên HTTP/2 — hạ xuống HTTP/1.1 là hỏng hẳn, không phải chậm đi. Một điểm dễ nhầm: RFC 7540 từng định nghĩa cách nâng cấp lên h2c bằng header Upgrade: h2c; RFC 9113 đã loại bỏ cơ chế đó. Ngày nay h2c hoạt động theo kiểu "biết trước" (prior knowledge) — client nói HTTP/2 ngay từ byte đầu vì đã được cấu hình để biết upstream là HTTP/2.

  • nginx: grpc_pass nói HTTP/2 cleartext lên upstream; proxy_pass thì không — nginx không hỗ trợ HTTP/2 tới upstream cho lưu lượng HTTP thường.
  • Caddy: reverse_proxy h2c://backend:8080 — scheme h2c:// là cách khai báo trực tiếp.
  • Envoy / HAProxy: khai báo giao thức upstream ở cấp cluster/backend (Envoy qua http2_protocol_options trong protocol options của cluster; HAProxy qua proto h2 trên dòng server). Cú pháp đổi theo phiên bản — tra tài liệu đúng version, đừng chép từ blog cũ.
package main

import (
	"fmt"
	"net/http"

	"golang.org/x/net/http2"
	"golang.org/x/net/http2/h2c"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
		// In ra để kiểm chứng thật: "HTTP/2.0" nghĩa là h2c đang chạy.
		fmt.Fprintf(w, "proto=%s\n", r.Proto)
	})
	// net/http chỉ tự bật HTTP/2 khi có TLS. h2c.NewHandler dò "connection
	// preface" của HTTP/2 ở đầu kết nối plaintext rồi chuyển sang http2.Server.
	h2s := &http2.Server{}
	srv := &http.Server{
		Addr:    ":8080",
		Handler: h2c.NewHandler(mux, h2s),
	}
	_ = srv.ListenAndServe()
}
# --http2-prior-knowledge là bắt buộc: không TLS thì không có ALPN để thỏa thuận.
curl --http2-prior-knowledge -v http://localhost:8080/healthz

HTTP/3 — vì sao L4 proxy TCP của bạn không dùng lại được. HTTP/3 (RFC 9114) chạy trên QUIC (RFC 9000), mà QUIC chạy trên UDP. Đây không phải chi tiết nhỏ, nó phá vỡ mọi giả định của hạ tầng proxy hiện có:

Giả định của L4 proxy TCP Thực tế với QUIC
Có kết nối TCP để accept và relay Chỉ có datagram UDP, không có "kết nối" ở tầng dưới
net.Conn để io.Copy hai chiều Phải quản lý connection ID ở tầng QUIC
Nhìn được ClientHello plaintext để đọc SNI ClientHello của QUIC đã được mã hóa ngay từ đầu (RFC 9001)
Địa chỉ nguồn đổi ⇒ kết nối mới QUIC có connection migration: đổi IP/port vẫn là cùng một kết nối

Hệ quả thực tế: muốn phục vụ HTTP/3 thì proxy phải thật sự nói QUIC, không thể relay hộ ở tầng dưới — nginx và HAProxy đều đã có hỗ trợ, cần phiên bản đủ mới và thường phải bật lúc build, kiểm tra bằng nginx -V / haproxy -vv trước khi lên kế hoạch. Phải mở UDP/443 ở firewall, security group và cả ở nhà cung cấp load balancer; quên bước này là triệu chứng kinh điển "curl --http3 treo rồi rơi về HTTP/2 mà không báo lỗi rõ ràng". Client không tự đoán được có HTTP/3 — server quảng cáo bằng header Alt-Svc (RFC 7838) trên phản hồi HTTP/1.1 hoặc HTTP/2, ví dụ Alt-Svc: h3=":443"; ma=86400, lần sau client mới thử QUIC. Và luôn giữ đường HTTP/2 qua TCP: nhiều mạng doanh nghiệp chặn thẳng UDP/443, nên HTTP/3 phải là tối ưu hóa cộng thêm, không bao giờ là con đường duy nhất.

3.9 WebSocket qua proxy — Upgrade là hop-by-hop

WebSocket (RFC 6455) bắt đầu bằng một request HTTP/1.1 hoàn toàn bình thường:

GET /ws/notifications HTTP/1.1
Host: community.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://community.example.com

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Sau 101, kết nối chuyển sang khung WebSocket nhị phân — giống hệt mô hình "sau khi bắt tay thì chỉ còn byte" của CONNECT.

Cái bẫy nằm ở đúng hai header UpgradeConnection: cả hai đều hop-by-hop (§3.1). Một proxy tuân thủ chuẩn sẽ xóa chúng trước khi chuyển tiếp. Nếu proxy không xử lý Upgrade một cách có ý thức, upstream nhận được một GET /ws/notifications bình thường, trả 200 kèm HTML, và client báo lỗi kiểu "Unexpected response code: 200" — hoàn toàn không nhắc gì tới proxy.

http {
    # $connection_upgrade là "upgrade" khi client xin nâng cấp, "close" khi không.
    # Hard-code "Connection: upgrade" cho MỌI request sẽ phá keep-alive của các
    # request HTTP thường đi chung location.
    map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
    }
    server {
        listen 443 ssl;
        server_name community.example.com;
        location /ws/ {
            proxy_pass http://api_upstream;
            proxy_http_version 1.1;   # WebSocket không tồn tại trên HTTP/1.0
            proxy_set_header Upgrade    $http_upgrade;
            proxy_set_header Connection $connection_upgrade;
            proxy_set_header Host       $host;

            # ⚠️ Dòng quan trọng nhất của cả khối. proxy_read_timeout mặc định
            # là 60s và tính từ lần ĐỌC gần nhất. Một kênh thông báo im lặng 60
            # giây sẽ bị nginx cắt — phía app không thấy lỗi gì, chỉ thấy client
            # "tự nhiên" reconnect đều đặn.
            proxy_read_timeout  3600s;
            proxy_send_timeout  3600s;
        }
    }
}

Ba việc phải làm cùng lúc, thiếu cái nào cũng hỏng:

  1. Chuyển tiếp Upgrade/Connection có ý thức bằng map như trên.
  2. Nới timeout đọc/ghi ở mọi tầng proxy trên đường đi. Đây là lỗi bị chẩn đoán nhầm nhiều nhất: ai cũng đổ cho mạng hoặc cho thư viện WebSocket, trong khi thủ phạm là một proxy_read_timeout mặc định ở một tầng mà bạn quên mất là nó tồn tại — CDN, LB đám mây, ingress controller, mỗi tầng có timeout riêng.
  3. Vẫn gửi ping/pong ở tầng ứng dụng, chu kỳ ngắn hơn timeout ngắn nhất trên tuyến. Kể cả khi đã nới timeout: NAT gateway và stateful firewall có bảng trạng thái riêng và dọn entry rảnh mà không báo cho ai. Ping định kỳ là thứ duy nhất giữ mọi bảng trạng thái trên tuyến còn sống.

Phía Go, httputil.ReverseProxy của thư viện chuẩn đã xử lý phản hồi 101 và tự chuyển sang chế độ ống dẫn byte — bạn không phải tự hijack. Nhưng nó chỉ làm vậy khi request đến vẫn còn header Upgrade, nên nếu trước nó còn một tầng proxy nữa thì tầng đó phải chuyển tiếp đúng. Cách dựng ReverseProxy với trường Rewrite nằm ở §10.


4. Danh tính client qua proxy — header, tin cậy và cách lấy IP đúng

4.1 Vấn đề gốc: RemoteAddr không còn là người dùng

Khi cmd/api chạy trần, r.RemoteAddr là đầu bên kia của kết nối TCP — chính là máy người dùng. Đây là dữ liệu tầng vận chuyển: không giả mạo được qua một kết nối TCP hoàn chỉnh, vì kẻ tấn công phải nhận được gói trả về mới bắt tay xong.

Đặt một reverse proxy vào giữa, sự thật đó biến mất:

   Người dùng                Reverse proxy              cmd/api
   203.0.113.7  ──TCP #1──►  10.1.2.3       ──TCP #2──►  10.4.0.9:8000
                                                          │
                                          r.RemoteAddr = "10.1.2.3:52144"

hai kết nối TCP tách biệt. cmd/api chỉ thấy kết nối thứ hai, nên RemoteAddr của nó là IP của proxy. Thông tin về người dùng thật không bị mã hoá hay che giấu — nó đơn giản là không tồn tại ở tầng đó nữa. Muốn có, phải để proxy chép nó vào một header ở tầng ứng dụng.

Phép thử một câu

Nếu tôi triển khai hệ thống này sau một load balancer mới mà không đổi một dòng code nào, cái gì sẽ sai mà không có test nào đỏ?

Câu trả lời gần như luôn là: mọi thứ dựa trên IP.

Hậu quả trong dự án community không phải giả định — cả ba đều là lỗi sản xuất kinh điển:

Chỗ dùng IP Sai thành cái gì Mức nghiêm trọng
Rate limit POST /auth/login theo IP Toàn bộ người dùng chia chung một khoá duy nhất là IP proxy. Ngưỡng bị một người dùng bình thường đốt hết, cả thế giới nhận 429 Sập tính năng đăng nhập
Audit log của module identity Mọi bản ghi user.login cùng một IP nội bộ. Cần điều tra một vụ chiếm tài khoản thì log vô giá trị Không truy vết được
Geo-block, hiển thị múi giờ Proxy nằm ở một vùng duy nhất → mọi người dùng "đến từ" cùng một nước Chặn nhầm, hoặc không chặn được ai

Điều làm lỗi này nguy hiểm: nó không gây lỗi biên dịch, không ném exception, không xuất hiện trong test đơn vị. Ở máy dev không có proxy, mọi thứ đúng. Nó chỉ sai sau khi lên môi trường thật, và sai âm thầm.


4.2 X-Forwarded-For — danh sách mà ai cũng viết được

X-Forwarded-For (viết tắt XFF) không phải chuẩn chính thức. Nó ra đời từ proxy Squid rồi thành quy ước de-facto; bản chuẩn hoá của IETF cho việc này là header Forwarded trong RFC 7239 (§4.4). Vì không có chuẩn, mỗi phần mềm proxy tự quyết định chi tiết — và đó là gốc của phần lớn rắc rối. Định dạng là danh sách ngăn bằng dấu phẩy, trái → phải theo chiều đi của request:

X-Forwarded-For: 203.0.113.7 , 198.51.100.4 , 172.16.5.6
                      │              │             │
                phần tử TRÁI NHẤT   giữa      phần tử PHẢI NHẤT
                do CLIENT tự khai   do proxy   do proxy B ghi (nó
                (hoặc do proxy A    A ghi      thấy IP của proxy A)
                 ghi — không phân                    │
                 biệt được!)          r.RemoteAddr = IP của proxy B

Luật cốt lõi: mỗi hop chỉ thêm vào bên phải địa chỉ của kết nối mà nhận được. Hệ quả:

  • Phần tử phải nhất do proxy gần bạn nhất ghi → đáng tin đúng bằng mức bạn tin proxy đó.
  • Phần tử trái nhất là thứ client tự gửi lên, hoặc do proxy ngoài cùng ghi — và server không có cách nào phân biệt hai trường hợp đó.

Đó là lý do "lấy phần tử đầu tiên của X-Forwarded-For" là một lỗ hổng giả mạo có thật, không phải chuyện lý thuyết:

# Kẻ tấn công chỉ cần gõ đúng một dòng này.
curl -H "X-Forwarded-For: 1.2.3.4" https://community.example.com/auth/login

Proxy thấy header đã có sẵn, nối thêm IP thật vào bên phải rồi chuyển tiếp, thành X-Forwarded-For: 1.2.3.4, 203.0.113.7. Backend lấy phần tử đầu tiên là tin 1.2.3.4. Kẻ tấn công đổi giá trị đó mỗi request thì vượt rate limit hoàn toàn, ghi rác vào audit log, và bỏ qua mọi danh sách chặn theo IP. Chi phí tấn công: một tham số curl.

Vì sao proxy không xoá header client gửi lên?

Vì một proxy trung gian không thể biết mình có phải hop đầu tiên hay không. Nếu request thật sự đã đi qua một proxy hợp lệ ở trước (CDN chẳng hạn), xoá đi là mất thông tin. Nginx với biến $proxy_add_x_forwarded_for cũng nối thêm chứ không ghi đè, đúng theo tinh thần đó.

Chỉ hop ngoài cùng — cái trực tiếp nhận kết nối từ Internet — mới có quyền và nghĩa vụ ghi đè XFF. Đây là cấu hình bạn phải đặt tay, không phải mặc định.


4.3 X-Real-IP, X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port

Header Ai đặt Mang gì Khi nào lệch
X-Real-IP Proxy, phải cấu hình tay Một địa chỉ duy nhất, không phải danh sách Nếu proxy đặt nó bằng địa chỉ peer mà bản thân nó không phải hop ngoài cùng → nó ghi IP của proxy trước. Chuỗi nhiều hop thì header này vô nghĩa
X-Forwarded-Proto Hop terminate TLS http hoặc https — scheme mà client đã dùng Không cấu hình → backend tưởng mọi thứ là http. Nguồn của redirect loop ở §4.11
X-Forwarded-Host Hop ngoài cùng Giá trị Host gốc client gửi Proxy ghi đè Host thành tên nội bộ (api-internal:8000) mà quên set header này → link tuyệt đối sinh sai tên miền
X-Forwarded-Port Hop ngoài cùng Cổng client kết nối vào (443) Ít nơi đặt. Đừng dựa vào nó để suy ra scheme — dùng X-Forwarded-Proto

Ba lưu ý thực dụng:

  1. X-Real-IP chỉ đúng khi có đúng một proxy. Nó không có chỗ chứa lịch sử nên không diễn tả được chuỗi nhiều hop. Hôm nay hạ tầng là nginx → api, ngày mai thêm CDN ở trước, X-Real-IP im lặng trở thành IP của CDN. XFF ít nhất còn nhìn thấy được sự thay đổi.
  2. Nhà cung cấp lớn có header riêng. Cloudflare gửi CF-Connecting-IP; một số dịch vụ dùng True-Client-IP. Chúng chỉ đáng tin khi tường lửa chặn để chỉ dải IP của nhà cung cấp đó chạm được origin — nếu không, chúng cũng là header người lạ gửi như mọi header khác. Chi tiết ở §8.
  3. Một số proxy ghi kèm cả cổng vào phần tử XFF (203.0.113.7:36542), và IPv6 thì có dạng [2001:db8::1]:4711. Đây là lý do code ở §4.6 không dùng strings.Split(s, ":").

4.4 Forwarded của RFC 7239 — chuẩn thật sự

RFC 7239 định nghĩa một header thay cho cả họ X-Forwarded-*. Cú pháp: danh sách phần tử ngăn bằng dấu phẩy, mỗi phần tử gồm các tham số ngăn bằng dấu chấm phẩy.

Forwarded: for=203.0.113.7;proto=https;host=community.example.com, for=198.51.100.4;by=172.16.5.6
Tham số Nghĩa
for Client mà hop này nhận request từ đó
by Giao diện mà hop này nhận request vào — hữu ích khi proxy có nhiều địa chỉ
proto Giao thức client đã dùng (http / https)
host Giá trị Host gốc

Ba điểm cú pháp thường bị làm sai, và hai điểm cuối là lý do bộ phân tích không được giả định mọi phần tử đều là IP hợp lệ:

  • IPv6 bắt buộc nằm trong nháy kép: for="[2001:db8::1]:4711". Dấu ngoặc vuông và dấu hai chấm không hợp lệ trong một token HTTP, bỏ nháy là header hỏng.
  • Định danh che giấu bắt đầu bằng gạch dưới: for=_hidden, for=_secret. Chúng giữ được cấu trúc chuỗi mà không tiết lộ địa chỉ nội bộ.
  • unknown dùng khi hop không xác định được địa chỉ phía trước.
package clientip

import (
	"net/http"
	"strings"
)

// ForwardedNodes rút các giá trị `for=` trong header Forwarded (RFC 7239) theo
// đúng thứ tự trái→phải. Giá trị trả về là chuỗi thô: "192.0.2.60:1234",
// "[2001:db8::1]:4711", "unknown", hoặc định danh che giấu dạng "_hidden".
func ForwardedNodes(h http.Header) []string {
	var out []string
	for _, line := range h.Values("Forwarded") {
		// Dấu phẩy ngăn các hop — y hệt XFF, nên thuật toán đếm ngược
		// từ phải sang trái ở §4.5 áp dụng nguyên vẹn cho header này.
		for _, element := range strings.Split(line, ",") {
			// Trong một hop, dấu chấm phẩy ngăn các tham số:
			// for=192.0.2.60;proto=http;by=203.0.113.43
			for _, param := range strings.Split(element, ";") {
				key, value, found := strings.Cut(param, "=")
				if !found || !strings.EqualFold(strings.TrimSpace(key), "for") {
					continue
				}
				// IPv6 BẮT BUỘC được bọc trong nháy kép, vì "[::1]:4711"
				// chứa ký tự không hợp lệ với token của HTTP.
				if v := strings.Trim(strings.TrimSpace(value), `"`); v != "" {
					out = append(out, v)
				}
			}
		}
	}
	return out
}

Chuẩn hơn, nhưng vì sao ít dùng hơn?

X-Forwarded-For đã có mặt cả thập kỷ trước RFC 7239 — toàn bộ log parser, WAF rule và tài liệu trên mạng đều viết quanh nó; vì cú pháp Forwarded khó phân tích hơn hẳn (tham số, nháy kép, phân biệt token với quoted-string) trong khi với XFF thì một lời gọi strings.Split(",") là gần đủ; và vì hai bên phải cùng chuyển — proxy gửi Forwarded mà framework chỉ đọc XFF là hỏng, nên không ai muốn đi trước.

Khuyến nghị: đọc XFF làm nguồn chính, chấp nhận Forwarded nếu có, và khi tự viết proxy thì gửi cả hai. Đừng chọn Forwarded làm nguồn duy nhất trừ khi bạn kiểm soát toàn bộ chuỗi hop.


4.5 Thuật toán đúng: đếm ngược từ phải sang trái

Đây là phần quan trọng nhất của cả mục này. Nguyên tắc: phần tử càng bên phải càng do một hop gần bạn hơn ghi ra, nên càng đáng tin. Vì vậy đi từ phải sang trái, bỏ qua đúng những địa chỉ mà bạn biết là proxy của mình, và dừng ở địa chỉ đầu tiên không phải của bạn.

X-Forwarded-For: 1.2.3.4 , 203.0.113.7 , 172.16.5.6
                                                   ▲
    RemoteAddr = 10.1.2.3  ───────────────────► nối vào cuối chuỗi
                                                   │
    bước 1: 10.1.2.3    ∈ tin cậy → bỏ qua  ───────┘
    bước 2: 172.16.5.6  ∈ tin cậy → bỏ qua
    bước 3: 203.0.113.7 ∉ tin cậy → ✅ ĐÂY LÀ CLIENT, dừng
    bước 4: 1.2.3.4     ← không bao giờ đọc tới. Đây là phần client tự khai.

Có hai cách xác định "đâu là proxy của mình":

Cách Cấu hình Ưu Nhược
Đếm số hop tin cậy (trusted hop count) Một số nguyên: "bỏ qua đúng 2 phần tử từ phải" Đơn giản; không cần biết IP proxy, hợp với hạ tầng IP động (CDN, LB tự co giãn) Sai ngay khi số hop đổi. Thêm một CDN ở trước là mọi IP lệch một bậc, im lặng
Danh sách CIDR tin cậy Danh sách dải mạng của proxy Đúng cả khi số hop thay đổi; tự kiểm chứng được Phải cập nhật khi hạ tầng đổi dải IP

Dự án này dùng danh sách CIDR vì proxy nằm trong mạng nội bộ có dải cố định. Với hạ tầng dùng CDN có hàng nghìn IP, cách thực dụng là kết hợp: tường lửa chỉ cho dải CDN chạm origin, rồi khai chính dải đó làm CIDR tin cậy.

Ba luật kèm theo, vi phạm cái nào cũng đủ hỏng:

  1. Chỉ đọc XFF khi RemoteAddr nằm trong danh sách tin cậy. Kết nối đến thẳng từ Internet thì mọi header forwarding trên đó là đồ tự khai — bỏ hết.
  2. ❌ Không bao giờ lấy phần tử trái nhất, kể cả khi "hình như nó đúng" trong môi trường của bạn hôm nay.
  3. Nếu ứng dụng KHÔNG chạy sau proxy nào, phải bỏ qua XFF hoàn toàn — không phải "đọc nếu có". Ứng dụng phơi thẳng ra Internet mà vẫn tin XFF là đã tự mở sẵn lỗ hổng giả mạo.

Ở tầng cấu hình, Nginx làm đúng thuật toán này bằng ngx_http_realip_module:

# Trong ngữ cảnh http {} hoặc server {}
set_real_ip_from  10.0.0.0/8;
set_real_ip_from  172.16.0.0/12;
real_ip_header    X-Forwarded-For;

# on  = duyệt chuỗi từ PHẢI sang TRÁI, bỏ qua các địa chỉ đã khai ở trên.
# off = chỉ lấy phần tử phải nhất rồi dừng — sai khi có từ 2 hop trở lên.
real_ip_recursive on;

Cấu hình đầy đủ cho từng phần mềm proxy nằm ở §11. Điều cần nhớ ở đây: dù xử lý ở tầng proxy hay tầng ứng dụng, đúng một nơi được phép làm việc đó. Nginx đã viết lại địa chỉ rồi mà backend còn tự phân tích XFF lần nữa thì hai logic chồng lên nhau, và bạn sẽ mất một buổi chiều để hiểu vì sao.


4.6 Code: package clientip

Đặt ở internal/platform/clientip/clientip.go — thuần kỹ thuật, không biết gì về nghiệp vụ, đúng ranh giới của platform/ trong ARCHITECTURE.md §4.5. Dùng net/netip chứ không phải net.IP cũ, vì ba lý do: netip.Addrkiểu giá trị so sánh được (dùng thẳng làm khoá map — rất hợp cho rate limiter), nó không cấp phát trên heap như net.IP (vốn là một []byte), và netip.Prefix.Contains gọn hơn net.IPNet.Contains.

// Package clientip xác định địa chỉ IP thật của client khi ứng dụng chạy sau proxy.
// Package này thuần kỹ thuật: nó chỉ biết header HTTP và danh sách mạng tin cậy,
// không biết gì về user, bài viết hay module nào.
package clientip

import (
	"context"
	"fmt"
	"net/http"
	"net/netip"
	"strings"
)

// HeaderForwardedFor là tên chuẩn hoá của X-Forwarded-For. maxChainLen chặn độ
// dài chuỗi: header do người ngoài gửi, không giới hạn thì một request có thể
// ép server duyệt hàng vạn phần tử.
const (
	HeaderForwardedFor = "X-Forwarded-For"
	maxChainLen        = 50
)

// ctxKey là kiểu riêng tư — không package nào khác ghi đè được giá trị này.
type ctxKey struct{}

// NewContext gắn IP đã xác định vào context.
func NewContext(ctx context.Context, addr netip.Addr) context.Context {
	return context.WithValue(ctx, ctxKey{}, addr)
}

// From trả về IP client. ok=false nghĩa là KHÔNG xác định được — bên gọi phải
// tự chọn cách xử lý an toàn, đừng coi giá trị zero là một IP thật.
func From(ctx context.Context) (netip.Addr, bool) {
	addr, ok := ctx.Value(ctxKey{}).(netip.Addr)
	return addr, ok && addr.IsValid()
}

// ParsePrefixes đọc danh sách CIDR từ cấu hình.
func ParsePrefixes(cidrs []string) ([]netip.Prefix, error) {
	out := make([]netip.Prefix, 0, len(cidrs))
	for _, raw := range cidrs {
		raw = strings.TrimSpace(raw)
		if raw == "" {
			continue
		}
		p, err := netip.ParsePrefix(raw)
		if err != nil {
			return nil, fmt.Errorf("clientip: CIDR không hợp lệ %q: %w", raw, err)
		}
		// Masked() bỏ các bit thừa: "10.1.2.3/8" thành "10.0.0.0/8".
		out = append(out, p.Masked())
	}
	return out, nil
}

// FromRequest trả về IP client theo thuật toán đi ngược từ phải sang trái.
func FromRequest(r *http.Request, trusted []netip.Prefix) (netip.Addr, bool) {
	peer, ok := parseAddr(r.RemoteAddr)
	if !ok {
		return netip.Addr{}, false
	}
	// Không khai báo proxy tin cậy nào => ứng dụng nhận kết nối trực tiếp.
	// XFF lúc này hoàn toàn do người lạ gửi: bỏ qua, không đọc một chữ.
	if len(trusted) == 0 {
		return peer, true
	}
	// Peer không nằm trong danh sách tin cậy => nó chính là client, và mọi
	// header forwarding nó mang theo là đồ tự khai. Bỏ qua luôn.
	if !inTrusted(trusted, peer) {
		return peer, true
	}

	chain := forwardedChain(r.Header)
	for i := len(chain) - 1; i >= 0; i-- {
		if !chain[i].IsValid() {
			// Gặp phần tử không phân tích được ("unknown", "_hidden", rác).
			// Không đoán bừa: từ điểm này trở về trái không còn gì đáng tin.
			return netip.Addr{}, false
		}
		if !inTrusted(trusted, chain[i]) {
			return chain[i], true
		}
	}
	// Cả chuỗi đều là IP tin cậy: request sinh ra từ chính hạ tầng nội bộ
	// (health check, service gọi nhau). Trả về peer — đó là sự thật duy nhất.
	return peer, true
}

// forwardedChain trả về chuỗi XFF theo đúng thứ tự trái→phải.
func forwardedChain(h http.Header) []netip.Addr {
	out := make([]netip.Addr, 0, 4)
	// Client có thể gửi nhiều dòng X-Forwarded-For; net/http giữ chúng thành
	// nhiều phần tử. Phải nối theo đúng thứ tự nhận được, không đảo.
	for _, line := range h.Values(HeaderForwardedFor) {
		for _, item := range strings.Split(line, ",") {
			if len(out) >= maxChainLen {
				return out
			}
			addr, ok := parseAddr(item)
			if !ok {
				// Phần tử rác VẪN chiếm một vị trí. Nếu bỏ hẳn nó đi, kẻ tấn
				// công chỉ cần chèn rác để làm lệch phép đếm ngược.
				out = append(out, netip.Addr{})
				continue
			}
			out = append(out, addr)
		}
	}
	return out
}

// parseAddr chấp nhận cả bốn dạng gặp trong thực tế:
// "203.0.113.7", "203.0.113.7:54321", "2001:db8::1", "[2001:db8::1]:54321".
func parseAddr(s string) (netip.Addr, bool) {
	s = strings.TrimSpace(s)
	if s == "" {
		return netip.Addr{}, false
	}
	if addr, err := netip.ParseAddr(s); err == nil {
		return normalize(addr), true
	}
	if ap, err := netip.ParseAddrPort(s); err == nil {
		return normalize(ap.Addr()), true
	}
	return netip.Addr{}, false
}

// normalize gỡ dạng IPv4-mapped và zone: listener dual-stack báo client IPv4
// dưới dạng "::ffff:10.1.2.3", còn zone làm Prefix.Contains luôn trả về false —
// cả hai đều biến danh sách CIDR tin cậy thành vô dụng mà không báo lỗi nào.
func normalize(a netip.Addr) netip.Addr {
	return a.Unmap().WithZone("")
}

func inTrusted(trusted []netip.Prefix, addr netip.Addr) bool {
	for _, p := range trusted {
		if p.Contains(addr) {
			return true
		}
	}
	return false
}

Hai quyết định đáng giải thích thêm:

Vì sao phần tử rác vẫn chiếm một chỗ? Nếu lọc bỏ các phần tử không hợp lệ, kẻ tấn công gửi X-Forwarded-For: 1.2.3.4, rác, rác làm chuỗi ngắn lại và phép đếm ngược trượt sang phần tử của chính hắn. Giữ chỗ rồi coi phần tử đó là "không tin cậy và không đọc được" là cách duy nhất an toàn.

Vì sao trả về (netip.Addr, bool) chứ không trả IP rỗng?netip.Addr{} trông như một giá trị hợp lệ với code gọi bất cẩn, và rate limiter sẽ gom mọi request "không xác định" vào cùng một khoá — đúng cái bug mà cả mục này đang chống. Bắt bên gọi xử lý ok là ép nó nghĩ.


4.7 Middleware và cách lắp vào cmd/api

Đặt ở internal/platform/httpx/middleware/clientip.go, cùng chỗ với RequestIDRecover. Kiểu trả về func(http.Handler) http.Handler đúng chữ ký mà chi.Router.Use nhận, nên package middleware không phải import chi.

package middleware

import (
	"log/slog"
	"net/http"
	"net/netip"

	"github.com/chuongtd/community/internal/platform/clientip"
)

// ClientIP xác định IP thật của client và gắn vào context. trusted là danh sách
// CIDR của CÁC PROXY CỦA BẠN (đọc từ biến môi trường TRUSTED_PROXY_CIDRS), không
// phải của client. Danh sách rỗng nghĩa là không có proxy nào: XFF bị bỏ hoàn toàn.
func ClientIP(trusted []netip.Prefix, log *slog.Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			addr, ok := clientip.FromRequest(r, trusted)
			if !ok {
				// KHÔNG nhét đại một IP vào context. Rate limiter và audit log
				// thà biết "không xác định" còn hơn ghi một địa chỉ sai.
				log.WarnContext(r.Context(), "không xác định được IP client",
					"remote_addr", r.RemoteAddr,
					"xff", r.Header.Values(clientip.HeaderForwardedFor),
				)
				next.ServeHTTP(w, r)
				return
			}
			next.ServeHTTP(w, r.WithContext(clientip.NewContext(r.Context(), addr)))
		})
	}
}
package api

import (
	"log/slog"
	"net/http"

	"github.com/go-chi/chi/v5"

	"github.com/chuongtd/community/internal/platform/clientip"
	"github.com/chuongtd/community/internal/platform/httpx/middleware"
)

func NewRouter(trustedCIDRs []string, log *slog.Logger) (http.Handler, error) {
	trusted, err := clientip.ParsePrefixes(trustedCIDRs)
	if err != nil {
		return nil, err
	}

	r := chi.NewRouter()

	// ClientIP phải đứng TRƯỚC mọi middleware cần biết địa chỉ người gọi:
	// rate limit, audit log, geo-block. Đặt sau chúng thì chúng đọc phải
	// giá trị rỗng — và lỗi này im lặng, test đơn lẻ vẫn xanh.
	r.Use(middleware.ClientIP(trusted, log))

	r.Get("/whoami", func(w http.ResponseWriter, r *http.Request) {
		addr, _ := clientip.From(r.Context())
		_, _ = w.Write([]byte(addr.String()))
	})

	return r, nil
}

Cẩn thận với 0.0.0.0/0. Đặt dải này vào TRUSTED_PROXY_CIDRS là tuyên bố "mọi địa chỉ trên Internet đều là proxy của tôi" — tương đương tắt sạch cơ chế và quay về lấy phần tử trái nhất. Nó xuất hiện thường xuyên trong các file cấu hình mẫu trên mạng, kèm chú thích "để cho tiện lúc dev".


4.8 Test — bảng ca kiểm thử

Đây là loại code mà test không phải tuỳ chọn: một lỗi off-by-one trong phép đếm ngược không gây crash, nó chỉ lặng lẽ tin nhầm người. Ca "giả mạo 10 phần tử" đáng giá nhất trong bảng — nó là bài kiểm tra duy nhất phân biệt được code đúng với code chỉ có vẻ đúng.

Tình huống RemoteAddr X-Forwarded-For Kỳ vọng
Không proxy (danh sách rỗng) 203.0.113.7:54321 1.2.3.4 203.0.113.7 — XFF bị bỏ
Một proxy tin cậy 10.1.2.3:40000 203.0.113.7 203.0.113.7
Hai proxy nối tiếp 10.1.2.3:40000 203.0.113.7, 172.16.5.6 203.0.113.7
Client giả mạo 10 phần tử 10.1.2.3:40000 1.1.1.1, …, 203.0.113.7 203.0.113.7, không phải 1.1.1.1
IPv6 có ngoặc vuông và port [2001:db8:0:1::a]:40000 [2001:db8:ffff::5]:9999, [2001:db8:0:1::b]:8443 2001:db8:ffff::5
IPv4-mapped (listener dual-stack) [::ffff:10.1.2.3]:40000 203.0.113.7 203.0.113.7
Peer ngoài danh sách tin cậy 198.51.100.9:33333 1.2.3.4 198.51.100.9 — XFF bị bỏ
XFF gửi thành nhiều dòng header 10.1.2.3:40000 203.0.113.7 + 172.16.5.6 203.0.113.7
Cả chuỗi đều tin cậy 10.1.2.3:40000 10.9.9.9 10.1.2.3 (peer)
Rác ở vị trí quyết định 10.1.2.3:40000 unknown, 172.16.5.6 ok == false
RemoteAddr hỏng không-phải-địa-chỉ ok == false
package clientip

import (
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestFromRequest(t *testing.T) {
	// edge = dải mạng của CÁC PROXY CỦA BẠN, không phải của client.
	edge := []string{"10.0.0.0/8", "172.16.0.0/12", "2001:db8:0:1::/64"}
	spoof := "1.1.1.1, 2.2.2.2, 3.3.3.3, 4.4.4.4, 5.5.5.5, 6.6.6.6, 7.7.7.7, 8.8.8.8, 9.9.9.9, 203.0.113.7"

	tests := []struct {
		name       string
		trusted    []string
		remoteAddr string
		xff        []string // mỗi phần tử là MỘT dòng header X-Forwarded-For
		want       string
		wantOK     bool
	}{
		{"không proxy — bỏ XFF", nil, "203.0.113.7:54321", []string{"1.2.3.4"}, "203.0.113.7", true},
		{"một proxy", edge, "10.1.2.3:40000", []string{"203.0.113.7"}, "203.0.113.7", true},
		{"hai proxy", edge, "10.1.2.3:40000", []string{"203.0.113.7, 172.16.5.6"}, "203.0.113.7", true},
		// Ca quan trọng nhất: code lấy phần tử trái nhất sẽ trả về 1.1.1.1.
		{"giả mạo 10 phần tử", edge, "10.1.2.3:40000", []string{spoof}, "203.0.113.7", true},
		{"IPv6 ngoặc vuông + port", edge, "[2001:db8:0:1::a]:40000", []string{"[2001:db8:ffff::5]:9999, [2001:db8:0:1::b]:8443"}, "2001:db8:ffff::5", true},
		{"IPv4-mapped dual-stack", edge, "[::ffff:10.1.2.3]:40000", []string{"203.0.113.7"}, "203.0.113.7", true},
		{"peer không tin cậy — bỏ XFF", edge, "198.51.100.9:33333", []string{"1.2.3.4"}, "198.51.100.9", true},
		{"XFF nhiều dòng", edge, "10.1.2.3:40000", []string{"203.0.113.7", "172.16.5.6"}, "203.0.113.7", true},
		{"cả chuỗi đều tin cậy", edge, "10.1.2.3:40000", []string{"10.9.9.9"}, "10.1.2.3", true},
		{"rác ở vị trí quyết định", edge, "10.1.2.3:40000", []string{"unknown, 172.16.5.6"}, "", false},
		{"RemoteAddr hỏng", edge, "không-phải-địa-chỉ", nil, "", false},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			trusted, err := ParsePrefixes(tc.trusted)
			if err != nil {
				t.Fatalf("ParsePrefixes: %v", err)
			}
			r := httptest.NewRequest(http.MethodGet, "/posts", nil)
			r.RemoteAddr = tc.remoteAddr
			for _, line := range tc.xff {
				r.Header.Add(HeaderForwardedFor, line)
			}

			got, ok := FromRequest(r, trusted)
			if ok != tc.wantOK {
				t.Fatalf("ok = %v, muốn %v (got = %v)", ok, tc.wantOK, got)
			}
			if tc.wantOK && got.String() != tc.want {
				t.Fatalf("IP = %s, muốn %s", got, tc.want)
			}
		})
	}
}


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.