Версії PDF Library for Delphi (PDFlibPas) до v3.539.47 могли декодувати екранований текст двічі під час малювання HTML чи Markdown у PDF. DrawHTMLText і DrawHTMLTextBox розбирають HTML, нормалізують його назад у HTML, а потім розбирають знову, тож текст, записаний як <unsafe>, доходив до другого розбору вже як справжній тег. З v3.539.47 кожна сутність декодується рівно один раз, а текст повторно екранується скрізь, де він знову стає HTML
Сценарій, який це викриває, найзвичайніший. Help desk експортує тікети в PDF, і коментар клієнта потрапляє в HTML-шаблон. Розробник зробив усе правильно і екранував коментар, тож <b> став <b>. Усередині рендерера те екранування тихо скасувалося: коментар вийшов жирним, невідоме ім'я тега просто зникло зі сторінки, а екранований якір перетворився на клікабельну анотацію-посилання. Жодного виключення, жодного попередження, цілком валідний PDF, що каже не те, що кажуть дані
Чому екранований текст стає справжнім тегом у PDF?
Екранований текст ставав розміткою тому, що рендерер виконує два проходи розбору, а крок нормалізації між ними писав уже декодований текст назад у HTML без повторного екранування. Кожне декодування, яке зробив перший прохід, тоді було доступне другому проходу як живий синтаксис
Два проходи існують не просто так. Перший розбудовує список елементів тегів і слів. NormalizeParsedHTML потім розрішує каскад таблиць стилів: зіставляє правила з блоків <style> із кожним тегом, зливає їх з інлайн-атрибутами 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; надрукований буквально | < |
Code span у 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-бітовий code unit
Рендерер зберігає 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, екранує посимвольно і знову запаковує результат. Декодерна сторона вже була безпечною, бо тестує шаблони лише на парних межах code unit
Те саме правило стосується вашого власного коду. Якщо ви тримаєте UTF-16 текст як TBytes, скажімо після TEncoding.BigEndianUnicode.GetBytes, не шукайте в ньому байтові шаблони. Перетворіть назад на рядок і працюйте з символами
Кодові блоки Markdown і експорт датасетів: спершу екрануйте амперсанд
З v3.539.47 обидва виробники HTML усередині PDFlibPas — конвертер Markdown і експортер датасетів — екранують амперсанд перед кутовими дужками, тож одне декодування в рендерері відновлює точно оригінальний текст
У MarkdownToHTML інлайн code span'и та блоки коду в 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 той самий екранований вхід міг породити живу анотацію-посилання, і саме це перетворює зсув відображення на проблему безпеки: коментар тікета не повинен мати змоги посадити клікабельний URL у документ, якому ваш персонал довіряє
Зверніть увагу, чого функція не екранує. Універсальні HTML-екранувальники перетворюють ще й " на " та ' на ', що правильно для браузера. Текстове декодування PDFlibPas розпізнає лише чотири сутності, перелічені вище, тож ці дві надрукувалися б буквально як " і '. Лапки в текстовому вмісті нешкідливі; вони важать лише всередині значень атрибутів, а рендерер узагалі не декодує сутності в атрибутах. Тож безпечна конструкція — не кращий екранувальник, а правило: недовірені дані ніколи не потрапляють у href, src чи style. Якщо ціль посилання справді має прийти з даних користувача, валідуйте її самі проти allow-list схем і символів та відхиляйте все, що містить лапки чи кутові дужки
Дві нотатки про оновлення випливають із виправлення безпосередньо:
- Якщо ваш код перестав екранувати
&, бо старіші версії друкували&буквально, поверніть це. Без нього текст користувача, що містить<, тепер показується як<— досі нешкідливий текст, але вже не те, що набрав користувач - Не екрануйте двічі. Текст, що проходить через два екранувальники, рендерить
<як видиме написання<, тож знайдіть ту єдину межу, де ваші дані входять у 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-to-HTML-to-PDF мають цю саму форму і падають так само, коли серіалізатор забуває, що виробляє розмітку
Симптоми передбачувані, як тільки знаєш форму. Замало перекодування — і дані стають синтаксисом, це напрямок інжекції. Забагато кодування, або декодер, що ганяється двічі, — і читачеві показуються написання сутностей чи з'їдаються, це напрямок відображення. Полагодити один напрямок на самоті зазвичай ламає інший, саме тому виправлення PDFlibPas мало додати декодування &, перепорядкувати його, прибрати пізнє декодування та додати повторне екранування в одному релізі. Той самий принцип працює й у зворотний бік, коли вміст PDF експортується як структурований текст, як у семантичному експорті PDF у Markdown і DOCX з Delphi, де кожен буквальний символ мусить бути екранований для цільового синтаксису рівно один раз
Шпаргалка-чекліст
- Оновіться до PDFlibPas v3.539.47 чи новішої, якщо рендерите HTML чи Markdown із даними користувача
- Екрануйте текстовий вміст спершу
&, потім<і>; не перетворюйте лапки для тексту PDFlibPas - Екрануйте один раз, у єдиній точці, де дані входять у HTML-рядок
- Тримайте недовірені значення поза
href,srcіstyle, або валідуйте їх проти allow-list - Очікуйте, що в тексті декодуються лише
<,>,&і ; інші сутності лишаються буквальними - Передавайте
LeftOverTextназад уDrawHTMLTextBoxбез змін і обмежуйте цикл сторінок - Передавайте токени продовження Markdown лише в
DrawMarkdownTextBoxчиDrawMarkdownText - Ніколи не шукайте в байтових буферах UTF-16 байтові шаблони; працюйте з цілими code unit
HTML- і Markdown-рендеринг, експорт датасетних звітів та решта рушія верстки постачаються в нативних Pascal-джерелах PDF Library for Delphi, для Delphi та Free Pascal. Видання, підтримку платформ і пробне завантаження дивіться на сторінці продукту PDFlibPas