Các HTML entity như &, é và — ra đời từ thời trang web thường không lưu được ký tự ngoài ASCII. Ngày nay gần như mọi trang đều dùng UTF-8, nên phần lớn entity là không bắt buộc, nhưng vài phép thoát ký tự là thiết yếu, và làm sai chúng là gốc rễ của lỗ hổng cross-site scripting (XSS). Bài này tách bạch hai chuyện đó, cho thấy trình duyệt thật sự giải mã tham chiếu ra sao (với vài quy tắc bất ngờ), và chỉ ra những nơi mà thoát HTML không đủ. Mọi ví dụ đều có thể dán vào công cụ mã hóa HTML entity.
Entity từng là cách lách giới hạn mã hóa
Một tham chiếu ký tự gọi tên ký tự bằng điểm mã Unicode (é hoặc é) hoặc bằng tên trong bảng HTML (é). Trên trang lưu dạng ISO-8859-1, "é" lưu trực tiếp được nhưng "—" hay "€" thì không, nên người viết dùng — và €. Với UTF-8, khai báo bằng <meta charset="utf-8">, mọi ký tự Unicode đều lưu trực tiếp được: é, —, €, ạ và 😀 hoàn toàn không cần entity. Tiếng Việt có dấu cũng vậy.
Vì thế quy tắc đầu tiên rất đơn giản: lưu tệp dạng UTF-8 và viết ký tự như chính nó. Mã nguồn dễ đọc hơn và tệp nhỏ hơn (é tốn 8 byte, é chỉ 2).
Những phép thoát không thể bỏ
Một số ký tự có nghĩa với bộ phân tích HTML và phải được thoát khi bạn muốn chúng hiện dưới dạng chữ:
| Ký tự | Ở đâu | Viết |
|---|---|---|
& | mọi nơi trong văn bản và giá trị thuộc tính | & |
< | trong văn bản | < |
" | trong giá trị thuộc tính đặt trong nháy kép | " |
' | trong giá trị thuộc tính đặt trong nháy đơn | ' |
> | không bắt buộc nhưng theo thông lệ | > |
Ví dụ: chuỗi Tom & Jerry <3 thành Tom & Jerry <3. Nếu không thoát, <3 mở đầu thứ mà bộ phân tích tưởng là một thẻ và phần còn lại của dòng có thể biến mất; & Jerry chỉ sống sót nhờ may mắn vì nó không phải tham chiếu.
Trình duyệt giải mã tham chiếu thế nào
Đặc tả HTML mô tả việc giải mã rất chính xác, kể cả vài quy tắc tương thích cũ:
- Khớp dài nhất.
∉là ∉.¬it;không phải tên, nên bộ phân tích lấy tên dài nhất khớp được, tên cũ¬, và cho ra¬it;. - Tên cũ thiếu dấu chấm phẩy. 106 tên từ HTML 4 (bộ Latin-1 cùng
&,©,>,<,",®) vẫn được nhận dù thiếu;. Vì vậy©2024trong văn bản hiện thành©2024. Các tên thêm vào sau, như&helliphay&euro, cần dấu chấm phẩy. - Ngoại lệ trong thuộc tính. Trong giá trị thuộc tính, tên cũ thiếu dấu chấm phẩy được giữ nguyên khi ký tự tiếp theo là
=hoặc chữ, số. Quy tắc này tồn tại để liên kết nhưhref="?a=1©=2"vẫn chạy; trong nội dung trang, cùng các ký tự đó sẽ hiện thành©=2. Đó cũng là lý do bạn vẫn nên viết&giữa các tham số URL trong HTML. - Số đặc biệt.
�, surrogate (�) và số trên U+10FFFF thành U+FFFD, ký tự thay thế.€đếnŸđược ánh xạ sang ký tự Windows-1252 mà các trang cũ muốn nói:–là gạch ngang ngắn,€là ký hiệu euro.
Bộ giải mã của công cụ theo đúng các quy tắc này, và tùy chọn "Giá trị thuộc tính" cho thấy sự khác biệt.
Thoát ký tự và XSS
Cross-site scripting xảy ra khi văn bản từ người dùng được chèn vào trang và trình duyệt coi một phần của nó là mã HTML hay mã chạy. Thoát các ký tự trên khi đưa văn bản vào nội dung HTML hoặc giá trị thuộc tính có nháy ngăn được điều đó: <script>alert(1)</script> thành chữ hiển thị thay vì một đoạn script.
Nó không bảo vệ bạn ở các ngữ cảnh khác:
- Bên trong `<script>`: bộ phân tích HTML không giải mã entity ở đó, và JavaScript có quy tắc chuỗi riêng. Hãy dùng tuần tự hóa JSON (và thoát
</). - Trong URL (
href,src):javascript:alert(1)không chứa ký tự HTML đặc biệt nào. Hãy kiểm tra scheme (chỉ chohttps:,mailto:), rồi mã hóa phần trăm bằng công cụ mã hóa URL. - Trong thuộc tính xử lý sự kiện (
onclick) và `style`: giá trị được giải mã rồi chạy như JavaScript hay CSS. Tránh đưa dữ liệu người dùng vào đó. - Thuộc tính không có nháy: một dấu cách kết thúc giá trị, nên
class=x onmouseover=...chèn được thuộc tính mới. Luôn đặt thuộc tính trong nháy.
Trong mã ứng dụng, hãy dựa vào cơ chế tự động thoát của template engine (React, Vue, Jinja, Razor và nhiều công cụ khác thoát mặc định) và dùng Content Security Policy làm lớp thứ hai. Chỉ mã hóa bằng tay cho nội dung dùng một lần, email và khi gỡ lỗi.
Những lỗi hay gặp
- Thoát hai lần. Thoát văn bản đã thoát biến
&thành&amp;, rồi hiện trên trang thành&. Chỉ thoát một lần, ngay lúc xuất ra. - Thoát lúc nhận dữ liệu. Hãy lưu văn bản gốc; chỉ thoát khi chèn vào HTML. Cùng văn bản đó có thể đi tiếp sang JSON, CSV hay email.
- Mã hóa emoji thành hai tham chiếu.
��mã hóa riêng hai nửa UTF-16 của 😀; trình duyệt hiện hai ký tự thay thế. Hãy dùng một tham chiếu cho cả điểm mã:😀. - Tin `'` ở mọi nơi. Nó hợp lệ trong HTML5 nhưng không có trong HTML 4, và không phải hệ thống cũ nào cũng biết.
'an toàn ở mọi nơi. - Dán từ trình soạn thảo văn bản rồi nhận về
hay ký tự vô hình trong tên và URL. Giải mã sẽ cho thấy thứ thật sự nằm ở đó.
Thử ngay
Dán một đoạn mã vào công cụ mã hóa HTML entity, chuyển giữa "Nội dung trang" và "Giá trị thuộc tính" khi giải mã một URL có ©=, và mã hóa đoạn văn có emoji để thấy mỗi điểm mã chỉ một tham chiếu, cùng chuỗi thoát JavaScript và CSS tương ứng.