RFC 9839 và Unicode có vấn đề
(tbray.org)- RFC 9839 định nghĩa rõ ràng các ký tự Unicode có vấn đề có thể xuất hiện trong các trường văn bản khi phát triển phần mềm
- RFC này đề cập đến những vấn đề phát sinh do thiếu tính nhất quán trong cách xử lý các ký tự đó giữa những ngôn ngữ và thư viện khác nhau
- RFC 9839 đề xuất ba tập con ít gây vấn đề hơn để có thể lựa chọn sử dụng tùy trường hợp
- So với khung PRECIS hiện có, RFC này đơn giản hơn và dễ áp dụng hơn
- Một thư viện Go cho RFC 9839 cũng đã được công bố để hỗ trợ triển khai thực tế
Bối cảnh và tổng quan về RFC 9839
- Unicode được dùng làm tiêu chuẩn trong gần như mọi xử lý dữ liệu văn bản
- Tuy nhiên, khi thiết kế cấu trúc dữ liệu hoặc giao thức thực tế, việc cho phép mọi ký tự Unicode đều có thể gây ra vấn đề
- Paul Hoffman và tác giả đã gửi bản nháp cá nhân lên IETF nhằm đưa ra tiêu chí rõ ràng cho các vấn đề Unicode lặp đi lặp lại này
- Sau 2 năm thảo luận, tài liệu được chấp nhận làm tiêu chuẩn chính thức và công bố thành RFC 9839
- Tài liệu này giải thích chi tiết về các loại ký tự có vấn đề, lý do chúng gây rắc rối (về mặt kỹ thuật và tiêu chuẩn), cùng ba tập con để người dùng có thể lựa chọn sử dụng
Nội dung chính của RFC 9839
- Đây là tài liệu cần được tham khảo khi thiết kế các trường văn bản trong môi trường phần mềm và mạng
- RFC 9839 dài 10 trang, thuộc nhóm khá ngắn trong các tài liệu tiêu chuẩn của IETF
- Nội dung được trình bày dễ hiểu, chủ yếu dành cho các nhà phát triển phần mềm và kỹ sư mạng
Ví dụ về các ký tự Unicode có vấn đề
- Ví dụ, trường
usernametrong JSON có thể nhận chuỗi như sau{ "username": "\u0000\u0089\uDEAD\uD9BF\uDFFF" } - Vấn đề của từng code point
U+0000: ký tự NULL vô nghĩa, có thể làm gián đoạn hoạt động của một số ngôn ngữ lập trìnhU+0089: mã điều khiển C1 (CHARACTER TABULATION WITH JUSTIFICATION), có hành vi phức tạp và thiếu nhất quánU+DEAD: ký tự surrogate không có cặp, phát sinh từ giới hạn của UTF-16. Điều này dẫn đến dữ liệu không lý tưởng\uD9BF\uDFFF(thực tế làU+7FFFF) : noncharacter, bị tiêu chuẩn cấm trao đổi
- Những code point như trên có thể gây ra khả năng xử lý không nhất quán trong cấu trúc dữ liệu và giao thức, cũng như tạo ra lỗi khó lường
- RFC 9839 chính thức định nghĩa các ký tự có vấn đề như vậy và nêu rõ những loại cần loại trừ
Thiết kế và giới hạn của JSON
- Đây không phải lỗi của người tạo ra JSON là Doug Crockford
- JSON được thiết kế trong giai đoạn Unicode chưa đủ trưởng thành, nên không thể siết chặt tập ký tự một cách nghiêm ngặt
- Vì giờ không thể thay đổi tiêu chuẩn, nên cần một cách loại trừ các ký tự có vấn đề dựa trên kinh nghiệm thực tế
Khác biệt với khung PRECIS của IETF
- Trước RFC 9839 của năm 2025, IETF đã có nhiều tiêu chuẩn như RFC 8264 (PRECIS Framework)
- Khung này mô tả chi tiết cách tinh lọc, áp dụng và so sánh các chuỗi đã quốc tế hóa
- Tài liệu dài 43 trang, với phần nền tảng và giải pháp rất toàn diện
- PRECIS phụ thuộc mạnh vào phiên bản Unicode, đồng thời khá phức tạp và khó áp dụng
- RFC 9839 ngắn gọn hơn, tập trung vào tính thực tiễn, và dễ được chấp nhận nhanh khi định nghĩa giao thức mới
Các tập con của RFC 9839 và ví dụ ứng dụng
- RFC 9839 đưa ra ba tập con thực tế (scalars, XML, assignables)
- Mỗi tập con khác nhau đôi chút ở phạm vi các ký tự có vấn đề bị loại trừ
- Dưới đây là tóm tắt bảng về cách các định dạng dữ liệu chính và các tập con của RFC 9839 xử lý ký tự có vấn đề
- Một số định dạng như CBOR, TOML, XML, YAML loại trừ một phần surrogate hoặc ký tự điều khiển
- I-JSON loại trừ surrogate và noncharacter
- JSON thông thường, Protobufs không loại trừ
- XML, YAML do đặc tính charset nên chỉ loại trừ một phần noncharacter/mã điều khiển
- Lưu ý: XML và YAML không loại trừ các noncharacter nằm ngoài Basic Multilingual Pane
Thư viện RFC 9839 cho Go
- Một thư viện Go nhỏ hỗ trợ kiểm tra ký tự theo ba tập con của RFC 9839 đã được công bố
- Thư viện đã được kiểm thử đầy đủ, nhưng việc tối ưu hóa vẫn đang tiếp tục
- Tác giả hoan nghênh việc thử nghiệm và phản hồi từ môi trường làm việc thực tế
Ý nghĩa và quá trình xây dựng RFC 9839
- RFC 9839 được công bố chính thức sau hơn 15 lần chỉnh sửa bản nháp, cùng với nhiều vòng phản hồi từ các đồng tác giả
- Nhờ thảo luận và đóng góp từ nhiều chuyên gia trong cộng đồng, tài liệu đã phát triển thành một văn bản hoàn thiện hơn rất nhiều so với bản đầu
- Các cộng tác viên được nêu tên trong phần “Acknowledgements”
Trải nghiệm gửi RFC theo diện cá nhân
- RFC 9839 được thực hiện dưới hình thức individual submission
- So với cách làm truyền thống thông qua Working Group, hình thức này tốn nhiều công sức và gánh nặng thủ tục hơn
- So với trải nghiệm tham gia Working Group, cách làm truyền thống hiệu quả hơn và đáng được khuyến nghị hơn
1 bình luận
Ý kiến Hacker News
Tôi nghĩ rõ ràng có những ký tự gây ra vấn đề, nhưng cảm giác kịch bản tệ nhất là khi người thiết kế cấu trúc dữ liệu hay giao thức có xu hướng không muốn cho phép tùy ý mọi loại ký tự, kể cả những ký tự đã được escape đúng cách. Ví dụ, tôi cho rằng việc kiểm tra tính hợp lệ của tên người dùng nên được xử lý ở một tầng khác. Tức là kiểm tra tên người dùng ngắn hơn 60 ký tự, cấm emoji hay ký tự zalgo, cấm byte null, v.v. và trả về lỗi thích hợp ở API. Tôi không muốn việc này thất bại ở bước parse JSON vì những vấn đề như vậy thay vì xác thực trước. Dĩ nhiên có những lớp ký tự rõ ràng là không phù hợp cho tên người dùng. Nhưng nếu tôi đang gửi một tệp văn bản thực sự dùng tab v.v., thì tôi kỳ vọng những gì kiểu
stringutf8 của ngôn ngữ tôi xử lý được cũng phải mã hóa được. Đặc biệt là byte null có rất nhiều trường hợp sử dụng và thực tế cũng thường xuất hiện trong JSON. Tuy vậy, nếu bắt buộc chỉ được dùng một tập Unicode “bình thường” bị giới hạn, thì tôi nghĩ có một tiêu chuẩn vẫn tốt hơn là mỗi bên tự làm một tiêu chuẩn nhỏ riêng. Tóm lại ý tưởng tự thân có vẻ ổn, nhưng lập luận nêu trong bài blog thì tôi không thấy thuyết phục lắmNói theo cách nghiêm túc, tính đến năm 2025, tôi nghĩ các cách biểu diễn chuỗi có thể thực sự bảo vệ được ở giao thức wire cấp thấp về thực tế chỉ còn một trong các lựa chọn sau
Nói thật, tôi ước các tệp văn bản thuần không dùng ký tự C0 (ngoại trừ xuống dòng và miễn cưỡng thì HT) và ký tự C1. Tôi hiểu người ta muốn lưu những thứ như ANSI color markup, nhưng trong trường hợp đó thì thực ra nó không còn là văn bản thuần mà là một dạng định dạng markup văn bản. Nó giống Markdown, chỉ khác là dùng mã hóa trong dải C0. Chỉ vì dữ liệu trông đẹp khi chạy lệnh như
catkhông có nghĩa là có thể gọi nó là văn bản thuần. Tôi cũng hiểu rằng có rất nhiều định dạng markup được mã hóa dưới dạng plain text vì lý do tương tácTôi cho rằng chính quan điểm “điều tệ nhất là bắt đầu cấm các nhóm ký tự tùy ý trong cấu trúc dữ liệu và giao thức” mới là cách nghĩ xa rời thực tế. Điều tệ nhất thực sự là phần mềm như parser có lỗi dẫn đến vi phạm an ninh
Tôi nghi ngờ liệu có hệ thống nào cho phép UTF-8 trong tên người dùng không. Hiển nhiên mọi định danh được thao tác hay đánh giá bằng lập trình (tên đăng nhập, mật khẩu, v.v.) đều phải là ASCII. Không phải ISO-8859-1, mà chỉ ASCII. Unicode không phù hợp cho mục đích này. Nếu chỉ là chỗ hiển thị tên người dùng thì không sao, nhưng với vai trò định danh cho đăng nhập hệ thống thì mã hóa không phải ASCII phải bị cấm tuyệt đối. Ngay cả phần mềm bàn phím cũng không thể tự đảm bảo tính nhất quán của UTF-8 về mặt biểu diễn trực quan khi vượt ra ngoài ASCII, và còn rối hơn tùy hệ điều hành và cấu hình. Cũng không có gì đảm bảo các tệp nhị phân còn sót lại sau này và AI diễn giải Unicode sẽ thống nhất với nhau. Ngoài ra, về tính nhất quán, cả RFC 9839 lẫn bài viết đều không làm rõ việc các tình huống IVS hay vấn đề chuẩn hóa (NFC/NFD/NFKC/NFKD) có được tính là trong hay ngoài phạm vi hay không. Có vẻ như phần mục đích bị thiếu hẳn. Chỉ có những nhắc đến mơ hồ kiểu như có các “code point không phải ký tự”
Tôi thắc mắc tại sao lại phải cấm emoji trong tên người dùng
Tôi muốn nói rằng IETF không phải đến năm 2025 mới chờ để hỗ trợ Bad Unicode. Từ trước đó lâu rồi, RFC 8264: PRECIS Framework đã xử lý khá toàn diện nhiều vấn đề Bad Unicode khác nhau. Cũng nên tham khảo các RFC liên quan như RFC 8265(liên kết), 8266(liên kết). Nói chung, những thứ như mật khẩu có thể đổi hướng văn bản hoặc bị mã hóa khác nhau tùy thiết bị nhập thì không nên dùng trong username/password. Có thể xử lý an toàn qua các profile RFC này. Với mục đích như vậy, "failing closed" (chặn nghiêm ngặt hơn) an toàn hơn. Dù có emoji mới xuất hiện, tôi vẫn thích cấm và đi theo hướng bảo thủ hơn là cho phép trong username rồi ảnh hưởng đến mọi trang
Unicode rõ ràng có những phần “tốt”, nhưng thật đáng thất vọng khi phải biết rằng có những ký tự phải loại trừ ngoại lệ. Đó là kết quả của việc cố gắng bao quát toàn diện cách ghi chép ngôn ngữ nên trở nên quá phức tạp. Lúc nào cũng phải nghĩ xem ký tự nào cần được đối xử đặc biệt, thật mệt mỏi. Vì vậy tôi coi chuỗi Unicode là một đơn vị dữ liệu riêng biệt. Tôi nhận đầu vào, lưu trữ, render, so sánh tính tương đương dữ liệu, nhưng không cố diễn giải nội dung của nó. Thậm chí việc nối chuỗi hay thao tác với chuỗi cũng khiến tôi không yên tâm
Unicode giống như một vực sâu vô tận của trivia và những quyết định tồi. Ví dụ, các RFC liên quan có cảnh báo về ký tự điều khiển ASCII cũ (vì có thể gây nhầm lẫn khi hiển thị), nhưng lại không hề nhắc tới các ký tự đổi hướng có vấn đề an ninh nghiêm trọng như Explicit Directional Overrides
Một ví dụ đơn giản là nếu chuỗi thứ nhất kết thúc bằng một emoji modifier mồ côi và chuỗi thứ hai bắt đầu bằng một emoji có thể sửa đổi thì đã phát sinh vấn đề rồi. Càng thêm các trường hợp phức tạp thì vấn đề chỉ càng lớn hơn
Đúng là độ phức tạp rất lớn, nhưng trong số đó thì surrogate và control code là kết quả của những thiết kế kỳ quặc còn sót lại vì quá khứ, chứ không phải để phục vụ mục đích ghi chép ngôn ngữ
Unicode tuy bất tiện nhưng theo tôi vẫn ít bất tiện hơn các tiêu chuẩn mã hóa cũ khác
Tôi nghĩ phần lớn vấn đề chỉ cần xử lý bằng cách từ chối các chuỗi byte UTF-8 không hợp lệ hoặc trả lỗi toàn cục là được. Ví dụ như surrogate vốn đã là bất hợp pháp trong UTF-8, nên nếu ngôn ngữ dùng utf-8 thì phải trả lỗi cho các chuỗi như vậy. Điều thực sự gây vấn đề theo tôi là các “code point” có tính chất rắc rối (không in được, v.v.). Tách chúng ra như một khái niệm khác với chuỗi byte bất hợp pháp sẽ hữu ích hơn
Unicode vốn đã định nghĩa các nhóm (General Category) cho từng code point để phân loại các loại ký tự kỳ lạ. Có thể tham khảo bài Wikipedia liên quan. Ví dụ, trong Python,
unicodedata.category(chr(0))trả về "Cc" (control), cònunicodedata.category(chr(0xdead))trả về "Cs" (surrogate)Tôi nghĩ việc loại bỏ mọi ký tự "legacy control" không chỉ ở dạng literal mà cả trong chuỗi escape (ví dụ "\u0027") là quá tay. C1 thì ít dùng nên không sao, nhưng một số ký tự C0 có ví dụ sử dụng thực tế. escape, EOF, NUL v.v. theo tôi vẫn còn công dụng rõ ràng
Tôi nghĩ các ký tự C0 hơi lạ (như U+001E Record Separator) rất hữu ích trong luồng dữ liệu. Có thể chặn trong tài liệu, nhưng với dữ liệu dạng stream thì lại hữu dụng
Tôi từng thấy ký tự form feed (U+000C) được dùng trong mã nguồn chương trình. Emacs vốn hỗ trợ điều hướng theo trang nên thỉnh thoảng có những ký tự như vậy
Tôi không nghĩ Unicode là tốt. Dù bộ ký tự là gì đi nữa thì rốt cuộc từng ứng dụng vẫn phải tự quyết định loại ký tự nào thực sự được dùng (ký tự điều khiển, ký tự đồ họa, độ dài tối đa, v.v.). Cố thêm/bớt ở JSON hay nơi tương tự cũng không mang lại nhiều hiệu quả. Với Unicode, ASCII hay bộ ký tự khác, đôi khi việc đặt tên cho một tập con cụ thể (hoặc superset) có thể hữu ích, nhưng đừng tưởng đó là lựa chọn tốt cho tất cả mọi người. RFC 9839 có đặt tên cho một vài tập con Unicode, nhưng không đảm bảo rằng nó mặc nhiên đúng cho dịch vụ tôi xây. Kết luận của tôi là cũng nên cân nhắc việc hoàn toàn không dùng hoặc không ép buộc Unicode
Tôi đang phân vân giữa việc kiểm soát đầu vào, hoặc bọc nó trong một kiểu dữ liệu có thể xuất ra an toàn với đầu vào không đáng tin cậy (cho web+log+debug)
Tôi ước tiêu chuẩn có giới hạn về số lượng giá trị vô hướng Unicode có thể nằm trong một đơn vị đồ họa. Lần cuối tôi xem thì (dù cũng là vài năm trước) tiêu chuẩn không có giới hạn như vậy, mà chỉ khuyến nghị trong ứng dụng streaming thì giới hạn một đơn vị đồ họa ở 128 byte. Nếu đặt rõ giới hạn như vậy trong tiêu chuẩn thì việc triển khai sẽ dễ hơn nhiều và cũng không tạo ra ràng buộc thừa
Tôi thực sự đã gặp trường hợp chương trình bị hỏng vì giả định rằng “không có ký tự điều khiển” (trong khi form feed dùng để phân trang, ký tự escape thì thường dùng cho terminal, v.v.). Giả định “mọi thứ đều là UTF-8” cũng hay sai (tệp dữ liệu cũ, log, v.v.). Nếu không xử lý gì có ý nghĩa về mặt văn bản, thì cách tốt nhất là cứ truyền nội dung đi dưới dạng chuỗi byte mà không thay đổi. Nhưng vì Microsoft Windows, đôi khi lại buộc phải truyền chuỗi
char16_t. UTF-16 về cơ bản khác UTF-8 ở đầu vào/đầu ra. Khi chuyển đổi, nên dùng WTF-8 (UTF-16) hoặc surrogate escape (UTF-8) tương ứng ở bước dữ liệu ngoài → dạng nội bộ. Không thể trộn hai cách này với nhau.