В версиях PDF Library for Delphi (PDFlibPas) до v3.539.47 экранированный текст мог декодироваться дважды при отрисовке HTML или Markdown в PDF. DrawHTMLText и DrawHTMLTextBox разбирают HTML, нормализуют его обратно в HTML, затем разбирают снова, так что текст, записанный как <unsafe>, доезжал до второго разбора уже настоящим тегом. Начиная с v3.539.47 каждая сущность декодируется ровно один раз, а текст повторно экранируется всюду, где он снова превращается в HTML
Сценарий, в котором это вылезает, — самый обычный. Хелпдеск экспортирует тикеты в PDF, и комментарий клиента идёт в HTML-шаблон. Разработчик сделал как надо и экранировал комментарий, так что <b> превратился в <b>. Внутри рендерера это экранирование тихо отменялось: комментарий выходил жирным, неизвестное имя тега просто исчезало со страницы, а экранированный якорь становился кликабельной link annotation. Ни исключения, ни предупреждения — совершенно валидный PDF, который говорит не то, что в данных
Почему экранированный текст становится настоящим тегом в PDF?
Экранированный текст становился разметкой потому, что рендерер делает два прохода разбора, а шаг нормализации между ними записывал уже декодированный текст обратно в HTML, не экранируя его снова. Каждая декодировка, выполненная первым проходом, доставалась второму проходу как живой синтаксис
Два прохода существуют не зря. Первый разбор строит список элементов-тегов и слов. Затем NormalizeParsedHTML разрешает каскад стилей: сопоставляет правила из блоков <style> с каждым тегом, вливает их с inline-атрибутами style, сохраняет результат на теге и сериализует весь список элементов обратно в строку HTML. Проход вёрстки разбирает эту нормализованную строку. Это тот же механизм, что гоняет flexbox, CSS grid и вёрстку сносок в HTML-рендеринге PDFlibPas
Дефект сидел в сериализации слов. Теги записывались обратно в исходной форме, а слова — в декодированной. Слово, которое первый разбор декодировал из <unsafe> в <unsafe>, попадало в нормализованный HTML голыми угловыми скобками, и второй разбор читал его как элемент. Вокруг этой центральной баги теснились три утечки поменьше, смотревшие в ту же сторону:
&не входил в набор поддерживаемых сущностей, поэтомуR&Dпечатался буквально, а записать литеральную сущность вроде<как текст не было никакой возможности- Стадия рисования заменяла
второй раз, уже после того как разбор закончился, так что литеральная сущность могла исчезнуть в самый последний момент - Экранирование кода в Markdown пропускало амперсанд, а экспортёр датасетов экранировал только угловые скобки, так что сущности внутри кода или значений ячеек декодировались как разметка
| Ввод, дошедший до рендерера | До v3.539.47 | С v3.539.47 |
|---|---|---|
<unsafe> | Разобран как тег, текст на страницу не попадает | <unsafe> нарисован как текст |
<b>x</b> | x нарисован жирным | <b>x</b> нарисован как текст |
R&D | R&D напечатан буквально | R&D |
&lt; | &lt; напечатан буквально | < |
Кодовый спан Markdown, содержащий | Становился неразрывным пробелом | нарисован как текст |
Значение ячейки датасета < | < | < |
Как v3.539.47 делает декодирование HTML-сущностей однопроходным
PDFlibPas v3.539.47 делает декодирование сущностей однопроходным за счёт трёх согласованных изменений: парсер декодирует & последним, стадия рисования больше ничего не декодирует, а каждое место, превращающее декодированные слова обратно в HTML, сперва экранирует их заново
Набор поддерживаемых сущностей для текстового содержимого теперь — <, >, & и . Всё прочее, включая числовые ссылки вроде A и именованные сущности вроде ", остаётся буквальным текстом. Эта граница важна для того, как вы экранируете собственный ввод, — см. ниже
Порядок внутри декодера — первая правка. Декодируй & первым, и ввод &lt; превратился бы в <, а следующая замена сделала бы из него < — двойное декодирование внутри одного прохода. Поэтому ANSI-путь слов заменяет <, > и сначала, а & — последним, так что порождённый им амперсанд больше никем не рассматривается. UTF-16-путь слов — одиночное сканирование слева направо двухбайтовыми шагами, переписывающее каждое совпадение на месте и перепрыгивающее через него, что даёт ту же гарантию структурно
Вторая правка убирает позднюю замену из стадии рисования. Декодирование — вотчина парсера и никого больше, так что слово, дошедшее до разбивателя строк, — финальный текст
Третья правка — правило границы. NormalizeParsedHTML теперь экранирует &, < и > в каждом декодированном слове, прежде чем дописать его к нормализованному HTML. Второй разбор декодирует его обратно в ровно тот же текст, так что суммарный эффект по всему конвейеру — одна декодировка. Строка продолжения следует тому же правилу: слова, не влезшие в бокс, экранируются перед дописыванием в LeftOverText, а остаток копируется из нормализованного HTML, который уже в экранированной форме. Цикл, собирающий эти остаточные слова, отныне ограничен их количеством — старый repeat-цикл мог шагнуть за последнее слово
Почему экранирование UTF-16BE не может работать побайтово?
Побайтовая замена для экранирования UTF-16BE не годится, потому что двухбайтовый паттерн амперсанда может расползтись на два несвязанных символа. Единственная корректная единица работы — целый 16-битный кодовый юнит
Рендерер хранит Unicode-слова как big-endian UTF-16, упакованный в байтовые строки, старшим байтом вперёд. Амперсанд — это 00 26. Возьмём U+0100 (латинская A с макроном, байты 01 00) и следом U+2603 (снеговик, байты 26 03). Байтовая последовательность — 01 00 26 03, а байты два и три читаются как 00 26. Побайтовый поиск #0'&' находит несуществующий амперсанд, вшивает байты & в середину двух символов и сдвигает каждый последующий символ на один байт
Это не экзотический уголок. Любой символ с нулевым младшим байтом годится на первую половину; U+4E00, один из самых частых CJK-иероглифов, вполне подходит. У угловых скобок та же уязвимость: 00 3C и 00 3E всплывают всякий раз, когда такой символ стоит перед символом из диапазона U+3C00–U+3EFF в CJK Extension A. Исправление в EscapeHTMLWord распаковывает байты в WideString, экранирует посимвольно и упаковывает результат обратно. Сторона декодера была в безопасности изначально: она проверяет паттерны только на чётных границах кодовых юнитов
То же правило касается и вашего кода. Если вы держите UTF-16-текст как TBytes, скажем после TEncoding.BigEndianUnicode.GetBytes, не ищите в нём байтовые паттерны. Сконвертируйте обратно в строку и работайте с символами
Кодовые блоки Markdown и экспорт датасетов: сначала экранируем амперсанд
Начиная с v3.539.47 оба HTML-продюсера внутри PDFlibPas — конвертер Markdown и экспортёр датасетов — экранируют амперсанд до угловых скобок, так что единственная декодировка в рендерере восстанавливает ровно исходный текст
В MarkdownToHTML инлайн-кодовые спаны и блочные кодовые блоки (fenced или с отступом) теперь мапят & в &, < в <, > в >, а пробелы становятся , причём таб — четырьмя сразу, чтобы сохранить отступ. Обычная проза Markdown экранирует только угловые скобки: сырой HTML в прозе не может инжектить теги, но автор по-прежнему может нарочно написать & — примерно так, как авторы Markdown и ожидают. DrawMarkdownText и DrawMarkdownTextBox используют то же преобразование, так что код появляется в PDF ровно как набран:
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
// Смотрим HTML: в коде '&' становится '&', а '<' становится '<'
Html := Lib.MarkdownToHTML(Md);
Lib.SetOrigin(1); // начало в левом верхнем углу, Y растёт вниз
Lib.SetMeasurementUnits(0); // пункты
// Страница показывает код ровно как набран, включая написания сущностей
Lib.DrawMarkdownText(50, 50, 495, Md);
Lib.SaveToFile('code-sample.pdf');
finally
Lib.Free;
end;
end;
Экспортёр датасетов — самый поучительный случай. До v3.539.47 он экранировал только угловые скобки, и намеренно: рендерер не декодировал &, так что экранирование амперсанда напечатало бы & в каждой ячейке, где он есть. Обход был верен для старого рендерера и неверен в общем случае: значение ячейки, в котором случайно оказывалось <, декодировалось в <. С починенным рендерером экспортёр экранирует & первым, и значение вроде R&D < & попадает в PDF дословно. Если вы строите отчёты так, остальное про экспортёр разобрано в обзоре экспорта TDataSet в PDF-отчёт на Delphi
Почему амперсанд обязан идти первым, стоит проговорить один раз. Экранируйте сперва < — получите <; экранируйте затем & — он станет &lt;, что корректная одиночная декодировка покажет как < вместо <. Последовательная цепочка замен корректна лишь тогда, когда экранирующий символ обрабатывается раньше всего, что его вводит
Как экранировать недоверенный текст для DrawHTMLTextBox?
Для HTML-рендеринга PDFlibPas экранируйте недоверенный текст заменой &, затем <, затем > — ровно один раз — и держите недоверенные данные подальше от значений атрибутов
uses
System.SysUtils, PDFlibrary;
// Экранирует недоверенный текст для текстового содержимого HTML в PDFlibPas.
// '&' заменяется первым, иначе амперсанд внутри уже
// порождённого '<' был бы экранирован второй раз
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;
На v3.539.47 комментарий вроде Try <a href="https://example.com">this</a> & <b> появляется на странице символ в символ. До v3.539.47 тот же экранированный ввод мог породить живую link annotation — и именно это превращает глитч отображения в проблему безопасности: комментарий тикета не должен уметь сажать кликабельный URL в документ, которому ваш персонал доверяет
Обратите внимание, чего функция не экранирует. Универсальные HTML-экранщики вдобавок конвертируют " в " и ' в ' — для браузера это правильно. Текстовый декодер PDFlibPas узнаёт только четыре сущности, перечисленные выше, так что эти две напечатались бы буквально как " и '. Кавычки в текстовом содержимом безвредны; они важны лишь внутри значений атрибутов, а сущности в атрибутах рендерер не декодирует вовсе. Безопасный дизайн поэтому — не экранщик получше, а правило: недоверенные данные никогда не попадают в href, src или style. Если цель ссылки действительно должна приходить из пользовательских данных, валидируйте её сами по белому списку схем и символов и отвергайте всё, что содержит кавычки или угловые скобки
Из правки напрямую следуют два замечания к обновлению:
- Если ваш код перестал экранировать
&из-за того, что старые версии печатали&буквально, верните экранирование. Без него пользовательский текст с<теперь отображается как<— по-прежнему безвредный текст, но уже не то, что набрал пользователь - Не экранируйте дважды. Текст, прошедший через два экранщика, отрисует
<как видимое написание<, так что найдите ту единственную границу, где ваши данные входят в HTML, и экранируйте только там
Пагинация через LeftOverText без поломки экранирования
DrawHTMLTextBox возвращает не влезший HTML — обычно называемый LeftOverText, — и с v3.539.47 этот остаток сохраняет литеральные написания сущностей и экранированные угловые скобки, когда вы передаёте его следующему боксу. Правило для вызывающих простое: передавайте обратно без изменений
const
BoxLeft = 50;
BoxTop = 50;
BoxWidth = 495; // размер под страницу A4 в пунктах
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 — уже экранированный HTML движка: не экранируйте и не разэкранируйте его
Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Rest);
end;
if Rest <> '' then
raise Exception.CreateFmt('Content still left after %d pages', [MaxPages]);
end;
Относитесь к остатку как к непрозрачному. Это нормализованный HTML движка, у которого уже разрешены стили, так что не гоняйте его через собственный экранщик, не декодируйте и не вшивайте в него пользовательский текст. Лимит страниц — дешёвая страховка: если какой-то элемент никогда не влезет в бокс, у цикла без лимита нет естественного выхода
У Markdown своё продолжение. DrawMarkdownTextBox возвращает токен, начинающийся с внутреннего маркера, чтобы следующий вызов мог пропустить конверсию; передавайте его обратно в DrawMarkdownTextBox или DrawMarkdownText, а не в HTML-точки входа — те нарисуют маркер как текст
Общий урок: декодируй один раз, перекодируй на каждой границе
Любой конвейер, который разбирает текст, сериализует результат обратно в тот же синтаксис и разбирает снова, обязан трактовать декодирование как операцию, происходящую ровно в одном месте, и обязан перекодировать на каждой границе, где декодированный текст снова становится синтаксисом. Шаблонные движки, HTML-санитайзеры и цепочки Markdown→HTML→PDF имеют ту же форму и падают так же, когда сериализатор забывает, что производит разметку
Симптомы предсказуемы, как только вы узнали форму. Недостаток перекодирования превращает данные в синтаксис — это направление инъекции. Избыток кодирования или декодер, отработавший дважды, показывает читателю написания сущностей или съедает их — это направление отображения. Починка одного направления в одиночку обычно ломает другое, поэтому исправлению в PDFlibPas пришлось в одном релизе и добавить декодирование &, и поменять его порядок, и убрать позднюю декодировку, и добавить повторное экранирование. Тот же принцип работает и в обратную сторону, когда содержимое PDF экспортируется как структурированный текст, как в семантическом экспорте PDF в Markdown и DOCX на Delphi, где каждый литеральный символ обязан быть экранирован для целевого синтаксиса ровно один раз
Краткая памятка
- Обновитесь до PDFlibPas v3.539.47 или новее, если рендерите HTML или Markdown с пользовательскими данными
- Экранируйте текстовое содержимое: сначала
&, затем<и>; кавычки для текста PDFlibPas не конвертируйте - Экранируйте один раз, в той единственной точке, где данные входят в строку HTML
- Держите недоверенные значения подальше от
href,srcиstyleили валидируйте их по белому списку - Ожидайте декодирования в тексте только для
<,>,&и ; прочие сущности остаются литеральными - Передавайте
LeftOverTextобратно вDrawHTMLTextBoxбез изменений и ограничивайте цикл страниц - Токены продолжения Markdown передавайте только в
DrawMarkdownTextBoxилиDrawMarkdownText - Никогда не ищите байтовые паттерны в байтовых буферах UTF-16; работайте с целыми кодовыми юнитами
HTML- и Markdown-рендеринг, экспорт отчётов из датасетов и остальная движковая вёрстка поставляются в нативных Pascal-исходниках PDF Library for Delphi, для Delphi и Free Pascal. Издания, поддержка платформ и триальная загрузка — на странице продукта PDFlibPas