HTML entity, UTF-8 và khi nào thoát ký tự chặn được XSS

Ký tự nào bắt buộc phải thoát trong HTML, vì sao không còn cần é, trình duyệt giải mã &copy thiếu dấu chấm phẩy ra sao, và chỗ nào thoát ký tự không bảo vệ được bạn.

Trong bài này

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 (&#233; hoặc &#xE9;) hoặc bằng tên trong bảng HTML (&eacute;). 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 &mdash; và &euro;. 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 (&eacute; 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ựỞ đâuViết
&mọi nơi trong văn bản và giá trị thuộc tính&amp;
<trong văn bản&lt;
"trong giá trị thuộc tính đặt trong nháy kép&quot;
'trong giá trị thuộc tính đặt trong nháy đơn&#39;
>không bắt buộc nhưng theo thông lệ&gt;

Ví dụ: chuỗi Tom & Jerry <3 thành Tom &amp; Jerry &lt;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. &notin; là ∉. &notit; 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ũ &not, 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 &AMP, &COPY, &GT, &LT, &QUOT, &REG) vẫn được nhận dù thiếu ;. Vì vậy &copy2024 trong văn bản hiện thành ©2024. Các tên thêm vào sau, như &hellip hay &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&copy=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 &amp; giữa các tham số URL trong HTML.
  • Số đặc biệt. &#0;, surrogate (&#xD800;) và số trên U+10FFFF thành U+FFFD, ký tự thay thế. &#128; đến &#159; được ánh xạ sang ký tự Windows-1252 mà các trang cũ muốn nói: &#150; là gạch ngang ngắn, &#128; 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ỉ cho https:, 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 &amp; thành &amp;amp;, rồi hiện trên trang thành &amp;. 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. &#55357;&#56832; 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ã: &#128512;.
  • Tin `&apos;` ở 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. &#39; 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ề &nbsp; 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ó &copy=, 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.

Công cụ được nhắc trong bài

Về trang blog