HTTP header có chứa được tiếng Việt không? ASCII, obs-text và chỗ .NET vạch ranh giới
Trả lời ngắn: không, nếu bạn muốn nó đi qua mọi tầng an toàn. Đặc tả HTTP định nghĩa giá trị header bằng ký tự ASCII in được, cộng thêm một nhánh lỗi thời tên là obs-text cho phép byte 0x80–0xFF nhưng không quy định bảng mã nào để diễn giải chúng. Hệ quả là một chuỗi tiếng Việt có thể đi lọt một phần: í nằm trong Latin-1 nên biểu diễn được, còn ồ thì không, vì nó là U+1ED3 — vượt ngoài 0xFF. .NET chọn cách dứt khoát: HttpClient từ chối gửi mọi giá trị header non-ASCII. Muốn mang dữ liệu có dấu thì phải mã hoá, bằng percent-encoding, RFC 8187 hoặc Base64.
Bài này tách ra từ một sự cố production, nơi header cf-ipcity: Hồ Chí Minh do Cloudflare chèn làm HttpClient ném exception. Ở đó tôi dừng lại ở hiện tượng. Ở đây tôi trả lời câu hỏi mà hiện tượng ấy đặt ra: rốt cuộc HTTP header được phép chứa những gì?
Tóm tắt nhanh (TL;DR)
- Giá trị header HTTP về bản chất là chuỗi byte, không phải chuỗi Unicode.
- RFC 9110 cho phép
0x80–0xFFqua nhánh obs-text, nhưng không định nghĩa charset — nên không bên nào biết chắc phải giải mã thế nào. - Trong thực tế mỗi bên chọn một bảng mã khác nhau, và bạn nhận về mojibake.
- Tiếng Việt lọt một phần qua Latin-1:
íđược,ồkhông. - .NET không chơi trò may rủi:
HttpClientném lỗi thay vì gửi byte mà nó không đảm bảo được. - Cách đúng: percent-encoding, RFC 8187, hoặc Base64.
Ngữ pháp thật sự của một giá trị header
Mở RFC 9110 ra, phần định nghĩa giá trị của một trường header rút gọn lại còn:
field-value = *field-content
field-content = field-vchar [ 1*( SP / HTAB / field-vchar ) field-vchar ]
field-vchar = VCHAR / obs-text
VCHAR = %x21-7E ; ký tự ASCII in được
obs-text = %x80-FF ; byte ngoài ASCII, đã lỗi thời
Hai dòng cuối là toàn bộ câu chuyện.
VCHAR là ASCII in được, từ 0x21 tới 0x7E. Đây là phần ai cũng đồng ý.
obs-text là byte từ 0x80 tới 0xFF. Chữ obs là viết tắt của obsolete — lỗi thời. Nó tồn tại trong đặc tả không phải để bạn dùng, mà để mô tả những thứ đã trót tồn tại trên Internet và không thể xoá đi được.
Và đây là điểm quyết định: đặc tả không nói những byte đó thuộc bảng mã nào. Không phải "nó là UTF-8", cũng không phải "nó là Latin-1". Chỉ là byte, không kèm cách diễn giải.
Một định dạng cho phép bạn gửi byte nhưng không nói người nhận phải hiểu chúng ra sao thì không phải một kênh truyền dữ liệu, nó là một lời mời gọi hiểu nhầm.
Vì sao í đi lọt mà ồ thì không
Đây là chi tiết tôi thấy thú vị nhất, và nó giải thích vì sao loại lỗi này hay xuất hiện không đều — có khách thì hỏng, có khách thì không.
Lấy đúng chuỗi trong sự cố và nhìn byte của nó ở hai bảng mã:
Chuỗi : Hồ Chí Minh
UTF-8 : 48 E1 BB 93 20 43 68 C3 AD 20 4D 69 6E 68
Latin-1: 48 3F 20 43 68 ED 20 4D 69 6E 68
^^ ^^
'ồ' → 0x3F 'í' → 0xED
Đọc bảng này theo từng ký tự:
| Ký tự | Điểm mã Unicode | UTF-8 | Latin-1 |
|---|---|---|---|
H | U+0048 | 48 | 48 |
ồ | U+1ED3 | E1 BB 93 | không biểu diễn được → 3F (?) |
í | U+00ED | C3 AD | ED |
í là U+00ED, nằm gọn trong 0x00–0xFF, nên Latin-1 mang được nó bằng đúng một byte. ồ là U+1ED3 — vượt xa 0xFF — nên Latin-1 không có chỗ cho nó và phải thay bằng dấu ?.
Tiếng Việt vì vậy nằm vắt ngang ranh giới: các nguyên âm có dấu sắc, huyền, hỏi ở dạng đơn giản như á à í ò ú thì rơi vào Latin-1, còn các tổ hợp như ồ ậ ữ ợ ẩ thì không. Một hệ thống chuyền tay nhau header bằng Latin-1 sẽ chạy có vẻ ổn với Hải và vỡ với Hồ — cùng một đoạn code, khác mỗi dữ liệu.
Đó là kiểu bug tệ nhất: nó phụ thuộc vào nội dung, nên nó vượt qua mọi bài test mà bạn không cố tình nghĩ tới nó.
Chuyện gì xảy ra khi hai bên chọn khác bảng mã
Giả sử một bên ghi header bằng UTF-8 còn bên kia đọc bằng Latin-1 — tình huống mặc định trong rất nhiều stack.
Bên gửi ghi ồ thành ba byte E1 BB 93. Bên nhận đọc từng byte theo Latin-1 và được ba ký tự: á, », “. Chuỗi Hồ biến thành Hồ.
Đây chính là mojibake, và nó không phải lỗi của bên nào. Cả hai đều làm đúng theo bảng mã mình chọn. Vấn đề là đặc tả không bắt họ chọn giống nhau.
Đáng nói là mojibake im lặng. Không exception, không cảnh báo, chỉ là dữ liệu sai lặng lẽ chảy tiếp qua hệ thống, vào log, vào database, vào báo cáo. So với cách .NET ném thẳng một exception thì việc lặng lẽ hỏng dữ liệu tệ hơn nhiều.