10 tháng 12, 2025
Ký tự đặc biệt trong header Content-Disposition
Hôm nay mình gặp một lỗi khá thú vị với dự án của công ty. Đó là người dùng không tải file về được trong khi bình thường chức năng này hoạt động ngon ơ. Hệ thống cho phép tải lên báo cáo từ máy tính người dùng. Tên file gốc sẽ được lưu lại. Web UI hiển thị danh sách file có thể tải về, ví dụ như file ABC nằm mơ đ .xlsx. Các bạn có nhận thấy điều gì đặc biệt không? Hãy cùng nhau khám phá nhé.
Vấn đề
Vào một ngày đẹp trời PM nhắn “Ê mày, khách hàng mail tao là nó không tải file này được?”. Trong đầu mình liền nghĩ “Ngon, có người cần đến mình rồi đây”. Lần theo ảnh chụp màn hình của hắn ta tôi mở web và nhấp vào link với tên file là ABC nằm mơ đ .xlsx thì đúng là không tải được thật. Trình duyệt hiện lỗi như này.
This XML file does not appear to have any style information associated with it. The document tree is shown below.
<Error>
<Code>InvalidArgument</Code>
<Message>
Header value cannot be represented using ISO-8859-1.
</Message>
<ArgumentName>response-content-disposition</ArgumentName>
<ArgumentValue>attachment; filename="ABC nằm mơ đ .xlsx"</ArgumentValue>
<RequestId>TF836QAD934JN03H</RequestId>
<HostId>
mOe4EBxKBkYSu3Mr2oRZ4t0DOW9FjjnpLR9+ssWoieZa5MEk8gsDsMJjl7B9/YGf/TJH8+qD1+yJAtlmctj1eJetP2J+XRlz5zzAogsV6Mo=
</HostId>
</Error>Link trên là từ AWS S3 nhé. Nội dung lỗi là giá trị (attachment; filename=”ABC nằm mơ đ .xlsx) của Header (Content-Disposition) không thể được đại diện bằng ISO-8859-1.
Quá trình điều tra
Đã nhận ra điều đặc biệt mình hỏi từ đầu chưa? Ai lại đặt tên file tào lao thế đúng không? Gì mà ABC nằm mơ đái dầm… Ủa mà ái dầm đâu rồi? Sao có cái lỗ toang hoác trong tên file thế này? Đó, điều bất thường đó. Tên file có một ký tự lạ. Mình liền copy bỏ vào trình soạn thảo và phóng to gấp 10 lần xem nó là gì. Hoá ra người dùng lúc tải file lên vì muốn cool ngầu hoặc chơi khăm thằng dev quèn mà đã nhét thêm cái ký tự đặc biệt này vào cố tình làm cho KPI tháng này của mình tan tành trong khi cũng là tháng review lên lương. Ối dồi ôi, mày được lắm thằng khách hàng. Nhưng mà mình lại thích làm khách hàng vui nên thôi kệ nó chửi đủ rồi, giờ fix thôi.
Ký tự đặc biệt này chính là dấu cách được gõ bằng bàn phím tiếng Nhật, Hàn hoặc Trung thường được biết đến là full-width space. Full-width là vì các ký tự này có chiều dài gấp đôi ký tự latin thông thường. Vậy tại sao lại có kí tự full-width?
Ký tự đặc biệt này chính là dấu cách được gõ bằng bàn phím tiếng Nhật, Hàn hoặc Trung thường được biết đến là full-width space. Full-width là vì các ký tự này có chiều dài gấp đôi ký tự latin thông thường. Vậy tại sao lại có kí tự full-width?
Ngày xa xưa, thuở khai máy lập tính ở phương Tây, họ tạo một bảng mã dành riêng cho ngôn ngữ la tinh là ASCII. Rồi sau đó là tới ISO-8859-1 với 8 bit đại diện cho một ký tự. Nó có 256 ký tự, 128 thằng đầu tiên giống với ASCII, 128 thằng còn lại được mở rộng để đại diện cho những chữ cái có dấu hoặc ký hiệu đặc biệt như ©, ®, ±, ×, ÷, … Nhưng lưu ý là vẫn không hỗ trợ tiếng Việt nhé. Mỗi chữ cái có độ rộng khác nhau như chữ i, l sẽ khác với m, n. Rồi sau này khi mạng mẽo về tới miền đông địa cầu với các quốc gia có bảng chữ cái riêng như Hiragana, Kanji,… thì các chữ đa phần sẽ rộng hơn chữ la tinh, vì nó có nhiều nét, ví dụ như 祿. Hãy thử bôi đen nó bạn sẽ thấy rộng hơn các chữ liền kề. Và khi họ kết hợp chữ cái la tinh với chữ rộng này thì cảm giác rất lộn xộn lúc rộng lúc ngắn gây khó đọc và xấu. Nên người ta làm luôn một phiên bản chữ mới có chiều rộng gấp đôi hay full-width. Thế là ngoài dấu cách hẹp còn có thêm một dấu cách rộng!
À mà bạn không cần đọc đoạn trên cũng được!
Tiếp theo, trong <Message> có nhắc đến ISO-8859-1 (Latin-1). Nó là tên của bảng mã ký tự dùng để mã hoá các ký tự la tinh. Ngày xưa header dùng bảng mã này vì lúc đó chỉ dùng cho phương Tây, nhưng sau này khi toàn cầu hoá rồi thì không dùng cho các ký tự nước khác được. Ví dụ nó không chứa các chữ cái tiếng Việt như đ, ế, ở,…
Để khắc phục tình trạng đó, người ta hỗ trợ thêm bảng ký tự UTF-8 mà ngày nay được dùng rộng rãi và nhờ nó mà các chữ cái Việt Nam, hay full-width của Nhật, Hàn,… đều dùng được cho Header.
Vậy làm cách nào để Header (cụ thể là Content-Disposition) có thể chứa kí tự UTF-8 đây?
Response header Content-Disposition biểu thị cách mà trình duyệt hiển thị nội dung, là inline, một phần hay đính kèm để tải về. Dưới đây là vài ví dụ về giá trị của header này:
Response header Content-Disposition biểu thị cách mà trình duyệt hiển thị nội dung, là inline, một phần hay đính kèm để tải về. Dưới đây là vài ví dụ về giá trị của header này:
Content-Disposition: inline Content-Disposition: attachment Content-Disposition: attachment; filename="file name.jpg" Content-Disposition: attachment; filename*=UTF-8''file%20name.jpg
Khi muốn tải file về, ta dùng attachment kèm theo filename để khi tải xuống trình duyệt dùng đặt tên file. Trường hợp đang xem xét là dạng
Content-Disposition: attachment; filename="ABC nằm mơ đ .xlsx"
với filename có chứa dấu cách full-width, ký tự không hợp lệ, trình duyệt không hiểu được nên gây ra lỗi.
Vậy làm cách nào để trình duyệt hiểu được ký tự này? Hãy nhìn vào ví dụ thứ 4 ở trên với filename*=UTF-8''file%20name.jpg. Đây chính là cách mà RFC 5897 đã thêm vào nhằm hỗ trợ kiểu mã hoá UTF-8.
Giá trị của filename* gồm 3 phần ngăn cách bởi dấu ', Kiểu mã hoá (UTF-8 hoặc ISO-8859-1), ngôn ngữ (en, vi,… có thể bỏ qua) và nội dung được mã hoá URL (mã hoá phần trăm).
Vì dự án đang dùng Ruby on Rails, nó đã hỗ trợ sẵn công cụ mã hoá URL đó là URL::Util.url_encode(content).
Vì dự án đang dùng Ruby on Rails, nó đã hỗ trợ sẵn công cụ mã hoá URL đó là URL::Util.url_encode(content).
Tiến hành sửa lỗi
Với tất cả công cụ trong tay chúng ta hãy tiến hành sửa lỗi bằng cách mã hoá tên file trong header theo UTF-8 nào. (Ở đây có nhiều cách sửa ví dụ như bạn có thể thay thế các kí tự đặc biệt khi người dùng tải file lên chẳng hạn. Và nhiều cách nữa để các bạn tự suy nghĩ nhé. Mình chọn cách này vì thấy nó cool, thế thôi, hehe.)
Dự án dùng gem aws-s3 hỗ trợ làm việc với S3. Khi tạo presigned URL cho file cần tải về ta thêm option response_content_disposition với giá trị "attachment; filename*=UTF-8''#{ERB::Util.url_encode(filename)}" để server hiểu được filename chứa ký tự đặc biệt.
opts = { expires_in: expires_in }
opts[:response_content_disposition] = "attachment; filename*=UTF-8''#{ERB::Util.url_encode(filename)}"
s3.url key, optsXong vậy là có thể tải file về bình thường được rồi. Sửa có vẻ khá đơn giản nhưng qua đó lại học được nhiều thứ thú vị ha.
Hành trình tới đây là kết thúc mong rằng các bạn học được điều gì có ích. 🍻