Bài viết kỹ thuật

PDFlibPas HTML sang PDF: sửa lỗi entity bị giải mã hai lần

Các bản PDF Library for Delphi (PDFlibPas) trước v3.539.47 có thể giải mã văn bản đã escape tới hai lần khi vẽ HTML hoặc Markdown vào PDF. DrawHTMLText và DrawHTMLTextBox parse HTML, chuẩn hóa ngược lại thành HTML, rồi parse thêm lần nữa, nên văn bản viết dạng <unsafe> tới được lượt parse thứ hai như một tag thật. Kể từ v3.539.47, mỗi entity chỉ được giải mã đúng một lần và văn bản được escape lại ở mọi chỗ nó biến trở lại thành HTML

Tình huống làm lộ lỗi này rất đời thường. Một help desk export ticket ra PDF, và comment của khách hàng được nhét vào một HTML template. Developer đã làm đúng khi escape comment, nên <b> trở thành &lt;b&gt;. Bên trong renderer, phép escape ấy bị lặng lẽ hoàn tác: comment in đậm, một tag name lạ thì biến mất khỏi trang, còn một anchor đã escape hoá thành link annotation bấm được. Không exception, không warning, một tệp PDF hợp lệ hoàn hảo nhưng nói một điều khác với dữ liệu

Vì sao văn bản đã escape trở thành tag thật trong PDF?

Văn bản đã escape hoá thành markup vì renderer chạy hai lượt parse, và bước chuẩn hóa ở giữa ghi văn bản đã giải mã ngược về HTML mà không escape lại. Mọi phép giải mã mà lượt parse đầu thực hiện đều sẵn sàng cho lượt parse thứ hai dùng như cú pháp sống

Hai lượt parse ấy tồn tại có lý do cả. Lượt parse đầu dựng một danh sách các phần tử tag và từ. NormalizeParsedHTML rồi phân giải cascade của stylesheet: nó đối chiếu các rule từ khối <style> với từng tag, trộn chúng với thuộc tính inline style, lưu kết quả lên tag, và serialize cả danh sách phần tử ngược thành một chuỗi HTML. Lượt layout parse chuỗi đã chuẩn hóa ấy. Chính là cỗ máy đang thúc flexbox, CSS grid và layout footnote trong HTML rendering của PDFlibPas

Lỗi nằm ở cách các từ được serialize. Tag được ghi lại từ dạng nguồn gốc của nó, còn từ thì được ghi lại ở dạng đã giải mã. Một từ mà lượt parse đầu đã giải mã từ &lt;unsafe&gt; thành <unsafe> rơi vào HTML chuẩn hóa dưới dạng ngoặc nhọn thô, và lượt parse thứ hai đọc nó như một phần tử. Xoay quanh bug lõi ấy là ba chỗ rò nhỏ hơn cùng chỉ về một hướng:

  • &amp; không nằm trong tập entity được hỗ trợ, nên R&amp;D in nguyên văn và không có cách nào viết một chính tả entity dạng chữ như &lt; làm văn bản
  • Giai đoạn vẽ thay &nbsp; lần thứ hai, sau khi parsing đã xong, nên một chính tả entity dạng chữ vẫn có thể biến mất ngay ở bước cuối
  • Escape code Markdown bỏ qua dấu ampersand, còn dataset exporter chỉ escape ngoặc nhọn, nên các chính tả entity bên trong code hay giá trị ô bị giải mã thành markup
Pipeline HTML của PDFlibPas cho DrawHTMLText, nơi lượt parse một dựng các phần tử, NormalizeParsedHTML serialize chúng ngược thành HTML và lượt parse hai dàn kết quả; trước v3.539.47 các từ đã giải mã được ghi lại mà không escape và trở thành tag sống, kể từ v3.539.47 mọi từ đều được escape lại tại ranh giới
Các từ đã giải mã quay lại parser như cú pháp khi bộ chuẩn hóa quên mất mình đang sinh ra markup, đó là cách một comment đã escape ra đậm hoặc nảy ra một link
Input tới tay rendererTrước v3.539.47Kể từ v3.539.47
&lt;unsafe&gt;Bị parse thành tag, văn bản không bao giờ tới trang<unsafe> được vẽ làm văn bản
&lt;b&gt;x&lt;/b&gt;x được in đậm<b>x</b> được vẽ làm văn bản
R&amp;DR&amp;D in nguyên vănR&D
&amp;lt;&amp;lt; in nguyên văn&lt;
Code span Markdown chứa &nbsp;Trở thành dấu cách không ngắt&nbsp; được vẽ làm văn bản
Giá trị ô dataset &lt;<&lt;

Cách v3.539.47 đưa giải mã entity HTML về một lượt

PDFlibPas v3.539.47 đưa giải mã entity về một lượt bằng ba thay đổi phối hợp với nhau: parser giải mã &amp; sau cùng, giai đoạn vẽ không còn giải mã bất cứ thứ gì, và mọi chỗ biến các từ đã giải mã ngược về HTML đều escape chúng trước

Tập entity được hỗ trợ cho nội dung văn bản giờ là &lt;, &gt;, &amp; và &nbsp;. Bất cứ thứ gì khác, gồm cả tham chiếu số như &#65; và entity có tên như &quot;, đều giữ nguyên là văn bản. Ranh giới ấy quan trọng với cách bạn escape input của chính mình, như trình bày dưới đây

Thứ tự bên trong decoder là bản sửa đầu tiên. Nếu &amp; bị giải mã trước, input &amp;lt; sẽ thành &lt; và phép thay kế tiếp biến nó thành < — một double decode xảy ra ngay trong một lượt. Vì thế đường từ ANSI thay &lt;, &gt; và &nbsp; trước, &amp; sau cùng, để dấu ampersand nó sinh ra không bao giờ bị nhìn lại lần nữa. Đường từ UTF-16 là một lần quét trái-sang-phải theo bước hai byte, viết lại từng chỗ khớp tại chỗ rồi vượt qua, thứ bảo đảm điều tương tự bằng chính cấu trúc của nó

Thứ tự decoder của PDFlibPas cho một entity chuỗi như &amp;lt;: giải mã ampersand trước sẽ gập nó thành ngoặc nhọn thật ngay trong một lượt, trong khi giải mã lt, gt và nbsp trước ampersand giữ nguyên chính tả dạng chữ để văn bản tới trang đúng một lần giải mã
Ampersand là ký tự escape, nên nó phải được giải mã sau cùng và escape trước tiên, nếu không một lượt có thể giải mã hai lần

Bản sửa thứ hai bỏ phép thay &nbsp; muộn ra khỏi giai đoạn vẽ. Giải mã là việc của parser và của không đâu khác, nên một từ chạm tới bộ ngắt dòng là văn bản cuối cùng

Bản sửa thứ ba là quy tắc ranh giới. NormalizeParsedHTML giờ escape &, < và > trong mọi từ đã giải mã trước khi nối nó vào HTML chuẩn hóa. Lượt parse thứ hai giải mã ngược ra đúng chính văn bản ấy, nên hiệu ứng ròng trên toàn pipeline là một lần giải mã. Chuỗi phần tiếp theo theo cùng quy tắc: các từ không vừa box được escape trước khi nối vào LeftOverText, còn phần còn lại của phần thừa được chép từ HTML chuẩn hóa, vốn đã ở dạng escaped. Vòng lặp gom các từ thừa ấy giờ cũng bị chặn bởi số từ, trong khi vòng repeat cũ có thể bước vượt qua từ cuối

Vì sao escape UTF-16BE không thể dùng replace mức byte?

Escape UTF-16BE không thể dùng replace mức byte vì mẫu hai byte của một ampersand có thể vắt qua hai ký tự chẳng liên quan gì đến nhau. Đơn vị làm việc đúng duy nhất là cả code unit 16-bit

Renderer lưu các từ Unicode dưới dạng UTF-16 big-endian đóng gói vào chuỗi byte, byte cao trước. Một ampersand là 00 26. Giờ lấy U+0100 (chữ A hoa Latin có macron, bytes 01 00) đứng trước U+2603 (người tuyết, bytes 26 03). Chuỗi byte là 01 00 26 03, và byte thứ hai với thứ ba đọc thành 00 26. Một phép tìm kiếm byte theo #0'&' tìm thấy một ampersand không tồn tại, ghép các byte của &amp; vào giữa hai ký tự, và cắt xén mọi ký tự phía sau đi một byte

Rủi ro escape UTF-16BE trong PDFlibPas, nơi các byte 01 00 26 03 của U+0100 và U+2603 chứa mẫu 00 26 vắt qua hai ký tự, nên phép tìm ampersand mức byte ghép một entity vào giữa một code point; phép quét code unit chỉ thử các offset chẵn
Phép tìm theo byte tìm thấy một ampersand mà chẳng ký tự nào từng chứa; hãy làm việc trên cả code unit, đừng bao giờ trên buffer byte UTF-16 thô

Đó không phải trường hợp hiếm gì xa xôi. Bất kỳ ký tự nào có byte thấp bằng 0 đều có thể cung cấp nửa đầu; U+4E00, một trong các chữ Hán thường gặp nhất, đủ điều kiện. Các ngoặc nhọn cũng dính chường như vậy: 00 3C và 00 3E xuất hiện mỗi khi ký tự như thế đứng trước một ký tự từ U+3C00 tới U+3EFF trong CJK Extension A. Bản sửa trong EscapeHTMLWord bung các byte ra WideString, escape từng ký tự một rồi đóng gói lại. Phía decoder vốn đã an toàn vì nó chỉ thử mẫu tại các biên code unit chẵn

Quy tắc tương tự áp dụng cho code của chính bạn. Nếu lỡ giữ văn bản UTF-16 dưới dạng TBytes, ví dụ sau TEncoding.BigEndianUnicode.GetBytes, đừng tìm kiếm trên nó bằng các mẫu byte. Chuyển ngược về string và làm việc trên ký tự

Code block Markdown và export dataset: escape ampersand trước tiên

Kể từ v3.539.47, cả hai nơi sinh HTML bên trong PDFlibPas — bộ chuyển Markdown và dataset exporter — đều escape ampersand trước ngoặc nhọn, để lần giải mã duy nhất trong renderer trả lại đúng nguyên văn bản gốc

Trong MarkdownToHTML, code span inline và code block fenced hay indent giờ ánh xạ & thành &amp;, < thành &lt; và > thành &gt;, dấu cách thành &nbsp; còn tab thành bốn dấu như vậy để giữ thụt lề. Văn xuôi Markdown thường chỉ escape ngoặc nhọn, nên HTML thô trong văn xuôi không thể inject tag trong khi tác giả vẫn viết chủ ý được &amp;, đúng như các tác giả Markdown vẫn mong. DrawMarkdownText và DrawMarkdownTextBox dùng cùng phép chuyển đổi, nên code hiện trong PDF đúng như đã gõ:

uses
  System.SysUtils, PDFlibrary;

procedure RenderCodeSample;
var
  Lib: TPDFlib;
  Md, Html: WideString;
begin
  Md := 'Comparison helper:' + sLineBreak + sLineBreak +
        '```' + sLineBreak +
        'if (A < B) and (Flags <> 0) then' + sLineBreak +
        '  WriteLn(''&lt;tag&gt; &amp; R&amp;D'');' + sLineBreak +
        '```';
  Lib := TPDFlib.Create;
  try
    // Xem thử HTML: trong code, '&' thành '&amp;' và '<' thành '&lt;'
    Html := Lib.MarkdownToHTML(Md);
    Lib.SetOrigin(1);            // gốc trên-trái, Y tăng xuống dưới
    Lib.SetMeasurementUnits(0);  // điểm
    // Trang hiển thị code đúng như đã gõ, gồm cả chính tả entity
    Lib.DrawMarkdownText(50, 50, 495, Md);
    Lib.SaveToFile('code-sample.pdf');
  finally
    Lib.Free;
  end;
end;

Dataset exporter là trường hợp đáng học. Trước v3.539.47 nó chỉ escape ngoặc nhọn, và là cố ý: renderer không giải mã &amp;, nên escape ampersand sẽ in &amp; vào mọi ô có chứa nó. Cái workaround ấy đúng với renderer cũ và sai về mặt chung, vì một giá trị ô tình cờ chứa &lt; đã bị giải mã thành <. Với renderer đã sửa, exporter escape & trước, và một giá trị như R&D &lt; &amp; &nbsp; rơi vào PDF nguyên xi. Nếu bạn dựng báo cáo theo cách đó, bài hướng dẫn export TDataSet ra báo cáo PDF trong Delphi sẽ đề cập phần còn lại của exporter

Vì sao ampersand phải đi trước đáng được nói rõ một lần. Escape < trước bạn được &lt;; escape & sau thì nó thành &amp;lt;, mà một lần giải mã đúng sẽ hiển thị thành &lt; thay vì <. Chuỗi replace tuần tự chỉ đúng khi ký tự escape tự nó được xử lý trước mọi thứ đưa nó vào

Bạn nên escape văn bản không đáng tin cho DrawHTMLTextBox thế nào?

Với HTML rendering của PDFlibPas, hãy escape nội dung văn bản không đáng tin bằng cách thay &, rồi <, rồi >, đúng một lần, và giữ dữ liệu không đáng tin khỏi các giá trị attribute hoàn toàn

uses
  System.SysUtils, PDFlibrary;

// Escape văn bản không đáng tin cho nội dung HTML text của PDFlibPas.
// '&' phải được thay trước, nếu không ampersand bên trong
// một '&lt;' đã sinh ra sẽ bị escape lần thứ hai
function EscapeHTMLText(const S: string): string;
begin
  Result := StringReplace(S, '&', '&amp;', [rfReplaceAll]);
  Result := StringReplace(Result, '<', '&lt;', [rfReplaceAll]);
  Result := StringReplace(Result, '>', '&gt;', [rfReplaceAll]);
end;

procedure RenderTicket(const CustomerComment: string);
var
  Lib: TPDFlib;
  Html: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);
    Lib.SetMeasurementUnits(0);
    Html := '<p><b>Customer comment</b></p>' +
            '<p>' + EscapeHTMLText(CustomerComment) + '</p>';
    Lib.DrawHTMLText(50, 50, 495, Html);
    Lib.SaveToFile('ticket.pdf');
  finally
    Lib.Free;
  end;
end;

Trên v3.539.47, một comment như Try <a href="https://example.com">this</a> & &lt;b&gt; hiện lên trang đúng từng ký tự. Trước v3.539.47, cùng input đã escape ấy có thể sinh ra một link annotation sống, chính là phần biến một lỗi hiển thị thành vấn đề bảo mật: comment ticket không bao giờ được phép cài một URL bấm được vào một tài liệu mà nhân viên bạn tin tưởng

Hãy để ý điều hàm này không escape. Các HTML escaper đa dụng còn chuyển " thành &quot; và ' thành &#39;, điều đúng với browser. Phần giải mã văn bản của PDFlibPas chỉ nhận bốn entity đã liệt kê ở trên, nên hai cái kia sẽ in nguyên văn thành &quot; và &#39;. Dấu ngoặc kép vô hại trong nội dung văn bản; chúng chỉ có ý nghĩa bên trong giá trị attribute, mà renderer lại không giải mã entity trong attribute chút nào. Thiết kế an toàn vì thế không phải một escaper tốt hơn mà là một quy tắc: dữ liệu không đáng tin không bao giờ đi vào href, src hay style. Nếu đích link thật sự phải đến từ dữ liệu người dùng, hãy tự kiểm chứng nó theo một allow-list scheme và ký tự, và từ chối bất cứ thứ gì chứa dấu ngoặc kép hay ngoặc nhọn

Hai lưu ý nâng cấp rút ra trực tiếp từ bản sửa:

  • Nếu code của bạn ngừng escape & vì các bản cũ in nguyên văn &amp;, hãy thêm lại. Thiếu nó, văn bản người dùng chứa &lt; giờ hiển thị thành <, vẫn là văn bản vô hại nhưng không còn là cái người dùng đã gõ
  • Đừng escape hai lần. Văn bản đi qua hai escaper sẽ render < thành chính tả nhìn thấy được &lt;, nên hãy tìm đúng một ranh giới nơi dữ liệu của bạn đi vào HTML và chỉ escape ở đó

Chia trang bằng LeftOverText mà không phá vỡ các escape

DrawHTMLTextBox trả về phần HTML không vừa, thường gọi là LeftOverText, và kể từ v3.539.47 phần thừa ấy giữ nguyên các chính tả entity dạng chữ cùng ngoặc nhọn đã escape khi bạn truyền nó sang box kế tiếp. Quy tắc cho người gọi rất đơn giản: truyền lại nguyên trạng

const
  BoxLeft = 50;
  BoxTop = 50;
  BoxWidth = 495;    // cỡ cho trang A4 tính theo point
  BoxHeight = 740;
  MaxPages = 500;

procedure RenderLongHTML(Lib: TPDFlib; const Html: WideString);
var
  Rest: WideString;
  Pages: Integer;
begin
  Lib.SetOrigin(1);
  Lib.SetMeasurementUnits(0);
  Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Html);
  Pages := 1;
  while (Rest <> '') and (Pages < MaxPages) do
  begin
    Lib.NewPage;
    Inc(Pages);
    // LeftOverText là HTML của engine đã escape sẵn: đừng escape hay unescape nó
    Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Rest);
  end;
  if Rest <> '' then
    raise Exception.CreateFmt('Content still left after %d pages', [MaxPages]);
end;

Hãy coi phần thừa như một hộp đen. Đó là HTML chuẩn hóa của engine, với style đã phân giải xong, nên đừng đẩy nó qua escaper của bạn, đừng giải mã nó, và đừng ghép văn bản người dùng vào đó. Chặn số trang là bảo hiểm rẻ: nếu một phần tử nào đó không bao giờ vừa box, vòng lặp không chặn sẽ không có lối thoát tự nhiên

Markdown có phần tiếp diễn riêng. DrawMarkdownTextBox trả về một token bắt đầu bằng một marker nội bộ để lời gọi kế tiếp có thể bỏ qua bước chuyển đổi; hãy trả nó về DrawMarkdownTextBox hoặc DrawMarkdownText, đừng đưa cho các điểm vào HTML, nơi sẽ vẽ marker ra thành văn bản

Bài học chung: giải mã một lần, mã hóa lại ở mọi ranh giới

Bất kỳ pipeline nào parse văn bản, serialize kết quả ngược về cùng cú pháp rồi parse thêm lần nữa đều phải coi giải mã là một phép toán chỉ xảy ra đúng một chỗ, và phải mã hóa lại ở mọi ranh giới nơi văn bản đã giải mã trở lại thành cú pháp. Template engine, HTML sanitizer và các chuỗi Markdown-sang-HTML-sang-PDF có chung hình dạng này và sập theo cùng một kiểu khi một serializer quên rằng mình đang sinh ra markup

Triệu chứng trở nên đoán được một khi bạn biết hình dạng của nó. Mã hóa lại quá ít biến dữ liệu thành cú pháp, là hướng injection. Mã hóa quá nhiều, hay decoder chạy hai lần, phơi chính tả entity ra trước người đọc hoặc nuốt chúng, là hướng hiển thị. Sửa một hướng thường làm gãy hướng kia, đó là lý do bản sửa PDFlibPas phải thêm giải mã &amp;, đổi thứ tự nó, bỏ phép giải mã muộn và thêm escape lại trong cùng một bản phát hành. Nguyên tắc ấy chạy theo chiều ngược lại khi nội dung PDF được export thành văn bản có cấu trúc, như trong export ngữ nghĩa PDF sang Markdown và DOCX từ Delphi, nơi mọi ký tự dạng chữ phải được escape cho cú pháp đích đúng một lần

Danh mục tra nhanh

  • Nâng lên PDFlibPas v3.539.47 trở lên nếu bạn render HTML hay Markdown chứa dữ liệu người dùng
  • Escape nội dung văn bản với & trước, rồi < và >; đừng chuyển dấu ngoặc kép cho văn bản PDFlibPas
  • Escape một lần, tại đúng một điểm dữ liệu đi vào chuỗi HTML
  • Giữ các giá trị không đáng tin khỏi href, src và style, hoặc kiểm chứng chúng theo một allow-list
  • Chỉ mong đợi &lt;, &gt;, &amp; và &nbsp; được giải mã trong văn bản; các entity khác giữ nguyên dạng chữ
  • Truyền LeftOverText ngược về DrawHTMLTextBox nguyên trạng và chặn vòng lặp trang
  • Chỉ truyền token tiếp diễn Markdown cho DrawMarkdownTextBox hoặc DrawMarkdownText
  • Đừng bao giờ tìm mẫu byte trên buffer byte UTF-16; hãy làm việc trên cả code unit

HTML và Markdown rendering, export báo cáo dataset cùng phần còn lại của layout engine đi kèm trong mã nguồn Pascal thuần của PDF Library for Delphi, cho Delphi và Free Pascal. Xem trang sản phẩm PDFlibPas để biết các phiên bản, hỗ trợ nền tảng và bản dùng thử