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 <b>. 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ừ <unsafe> 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:
&không nằm trong tập entity được hỗ trợ, nênR&Din nguyên văn và không có cách nào viết một chính tả entity dạng chữ như<làm văn bản- Giai đoạn vẽ thay
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
| Input tới tay renderer | Trước v3.539.47 | Kể từ v3.539.47 |
|---|---|---|
<unsafe> | 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 |
<b>x</b> | x được in đậm | <b>x</b> được vẽ làm văn bản |
R&D | R&D in nguyên văn | R&D |
&lt; | &lt; in nguyên văn | < |
Code span Markdown chứa | Trở thành dấu cách không ngắt | được vẽ làm văn bản |
Giá trị ô dataset < | < | < |
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ã & 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à <, >, & và . Bất cứ thứ gì khác, gồm cả tham chiếu số như A và entity có tên như ", đề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 & bị giải mã trước, input &lt; sẽ thành < 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 <, > và trước, & 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ó
Bản sửa thứ hai bỏ phép thay 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 & vào giữa hai ký tự, và cắt xén mọi ký tự phía sau đi một byte
Đó 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 &, < thành < và > thành >, dấu cách thành 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 &, đú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(''<tag> & R&D'');' + sLineBreak +
'```';
Lib := TPDFlib.Create;
try
// Xem thử HTML: trong code, '&' thành '&' và '<' thành '<'
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ã &, nên escape ampersand sẽ in & 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 < đã bị giải mã thành <. Với renderer đã sửa, exporter escape & trước, và một giá trị như R&D < & 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 <; escape & sau thì nó thành &lt;, mà một lần giải mã đúng sẽ hiển thị thành < 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 '<' đã sinh ra sẽ bị escape lần thứ hai
function EscapeHTMLText(const S: string): string;
begin
Result := StringReplace(S, '&', '&', [rfReplaceAll]);
Result := StringReplace(Result, '<', '<', [rfReplaceAll]);
Result := StringReplace(Result, '>', '>', [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> & <b> 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 " và ' thành ', đ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 " và '. 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&, hãy thêm lại. Thiếu nó, văn bản người dùng chứa<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<, 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ã &, đổ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,srcvàstyle, hoặc kiểm chứng chúng theo một allow-list - Chỉ mong đợi
<,>,&và được giải mã trong văn bản; các entity khác giữ nguyên dạng chữ - Truyền
LeftOverTextngược vềDrawHTMLTextBoxnguyên trạng và chặn vòng lặp trang - Chỉ truyền token tiếp diễn Markdown cho
DrawMarkdownTextBoxhoặcDrawMarkdownText - Đừ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ử