Odborný článok

PDFlibPas HTML do PDF: oprava dvojito dekódovaných entít

Verzie PDF Library for Delphi (PDFlibPas) pred v3.539.47 mohli dekódovať escapovaný text dvakrát, keď sa HTML alebo Markdown kreslil do PDF. DrawHTMLText a DrawHTMLTextBox parsnú HTML, normalizujú ho naspäť do HTML a potom ho parsujú znova, takže text zapísaný ako <unsafe> sa do druhého parse dostal ako reálny tag. Od v3.539.47 sa každá entita dekóduje presne raz a text sa znovu escapuje všade, kde sa vracia do podoby HTML

Scenár, ktorý to odhalí, je úplne bežný. Help desk exportuje tickety do PDF a komentár zákazníka ide do HTML šablóny. Vývojár urobil správne a komentár escapoval, takže <b> sa stalo &lt;b&gt;. V rendereri sa toto escapovanie potichu odvolalo: komentár vyšiel tučne, neznáme meno tagu jednoducho zmizlo zo strany a escapovaný anchor sa stal klikateľnou link annotáciou. Žiadna výnimka, žiadne varovanie, úplne platné PDF, ktoré hovorí niečo iné, než hovoria dáta

Prečo sa escapovaný text stane reálnym tagom v PDF?

Escapovaný text sa stal markupom preto, lebo renderer beží v dvoch parse prechodoch a normalizačný krok medzi nimi zapisoval už dekódovaný text naspäť do HTML bez opätovného escapovania. Každé dekódovanie, ktoré vykonal prvý parse, bolo potom druhému parse k dispozícii ako živá syntax

Tie dva prechody majú dobrý dôvod. Prvý parse stavia zoznam elementov tagov a slov. NormalizeParsedHTML potom vyrieši kaskádu stylesheetu: porovná pravidlá z blokov <style> s každým tagom, zlúči ich s inline style atribútmi, uloží výsledok na tag a serializuje celý zoznam elementov naspäť do HTML reťazca. Layout prechod parsuje ten normalizovaný reťazec. Je to tie isté zariadenie, ktoré poháňa flexbox, CSS grid a footnote layout vo HTML renderingu PDFlibPas

Chyba bola v tom, ako sa serializovali slová. Tagy sa zapisovali naspäť z pôvodnej zdrojovej podoby, zatiaľ čo slová v dekódovanej podobe. Slovo, ktoré prvý parse dekódoval z &lt;unsafe&gt; na <unsafe>, pristalo v normalizovanom HTML ako holé uhlové zátvorky a druhý parse ho prečítal ako element. Okolo tejto jadrovej chyby sedeli tri menšie úniky smerujúce tým istým smerom:

  • &amp; nebolo v sade podporovaných entít, takže R&amp;D sa vytlačilo doslova a nebolo ako zapísať doslovné hláskovanie entity ako &lt; ako text
  • Kresliaca fáza nahradila &nbsp; znova, po skončení parsovania, takže doslovné hláskovanie entity mohlo zmiznúť až na samom konci
  • Escapovanie Markdown kódu preskočilo ampersand a dataset exporter escapoval len uhlové zátvorky, takže hláskovanie entít vnútri kódu alebo hodnôt buniek sa dekódovalo ako markup
PDFlibPas HTML pipeline pre DrawHTMLText, kde parse jedna stavia elementy, NormalizeParsedHTML ich serializuje naspäť do HTML a parse dva výsledok rozloží; pred v3.539.47 sa dekódované slová zapisovali naspäť unescaped a stali sa živými tagmi, od v3.539.47 sa každé slovo znovu escapuje na hranici
Dekódované slová sa vracajú do parsera ako syntax, keď normalizátor zabudne, že produkuje markup — presne takto sa escapovaný komentár stal tučným alebo mu vyrástol link
Vstup, ktorý sa dostane do rendereruPred v3.539.47Od v3.539.47
&lt;unsafe&gt;Parsované ako tag, text sa na stranu nikdy nedostane<unsafe> nakreslené ako text
&lt;b&gt;x&lt;/b&gt;x nakreslené tučne<b>x</b> nakreslené ako text
R&amp;DR&amp;D vytlačené doslovaR&D
&amp;lt;&amp;lt; vytlačené doslova&lt;
Markdown code span obsahujúci &nbsp;Stalo sa nezalomiteľnou medzerou&nbsp; nakreslené ako text
Hodnota bunky datasetu &lt;<&lt;

Ako v3.539.47 robí dekódovanie HTML entít jednopriebežným

PDFlibPas v3.539.47 robí dekódovanie entít jednopriebežným tromi zosúladenými zmenami: parser dekóduje &amp; posledný, kresliaca fáza už nedekóduje nič a každé miesto, ktoré vracia dekódované slová do HTML, ich predtým znovu escapuje

Sada podporovaných entít pre textový obsah je teraz &lt;, &gt;, &amp; a &nbsp;. Čokoľvek iné vrátane numerických referencií ako &#65; a pomenovaných entít ako &quot; ostáva doslovným textom. Na tej hranici záleží aj pri tom, ako escapujete vlastný vstup, ako ukazuje nižšie

Prvou opravou je poradie vnútri dekodéra. Keby sa &amp; dekódovalo ako prvé, vstup &amp;lt; by sa stal &lt; a ďalšia náhrada by z neho urobila < — dvojité dekódovanie, ktoré sa stane vnútri jediného priechodu. ANSI cesta slov preto nahrádza &lt;, &gt; a &nbsp; najprv a &amp; posledný, takže ampersand, ktorý sama vyrobí, už nikto znova nepreskúma. UTF-16 cesta slov je jediný prechod zľava doprava v dvojbajtových krokoch, ktorý každú zhodu prepíše na mieste a preskočí za ňu, čo dáva rovnakú garanciu konštrukčne

PDFlibPas poradie dekodéra pre reťazenú entitu ako &amp;lt;: dekódovanie ampersandu najprv ju zvinie na reálnu uhlovú zátvorku vnútri jediného priechodu, zatiaľ čo dekódovanie lt, gt a nbsp pred ampersandom ponechá doslovné hláskovanie intact, takže text sa dostane na stranu presne raz dekódovaný
Ampersand je escape znak, takže sa musí dekódovať posledný a escapovať najprv, inak jeden priechod dokáže dekódovať dvakrát

Druhá oprava sťahuje neskorú náhradu &nbsp; z kresliacej fázy. Dekódovanie patrí parseru a nikomu inému, takže slovo, ktoré sa dostane k line breakeru, je finálny text

Tretia oprava je pravidlo hranice. NormalizeParsedHTML teraz escapuje &, < a > v každom dekódovanom slove skôr, než ho pripojí k normalizovanému HTML. Druhý parse ho dekóduje naspäť na presne ten istý text, takže čistý efekt celej pipeline je jedno dekódovanie. Continuation reťazec sa drží toho istého pravidla: slová, ktoré sa nezmestili do boxu, sa escapujú skôr, než sa pripoja do LeftOverText, a zvyšok ostatku sa kopíruje z normalizovaného HTML, ktoré už v escapovanej podobe je. Slučka, ktorá zbiera tie zostatkové slová, je teraz ohraničená aj počtom slov, kde stará opakovaná slučka mohla prekročiť posledné slovo

Prečo nemôže UTF-16BE escapovanie použiť náhradu na úrovni bajtov?

UTF-16BE escapovanie nemôže použiť náhradu na úrovni bajtov, pretože dvojbajtový vzor ampersandu môže prebehnúť cez dva nesúvisiace znaky. Jediná správna pracovná jednotka je celý 16-bitový code unit

Renderer ukladá Unicode slová ako big-endian UTF-16 zabalené do bajtových reťazcov, prvý vysoký bajt. Ampersand je 00 26. Teraz vezmime U+0100 (Latin capital A with macron, bajty 01 00) nasledované U+2603 (snehuliak, bajty 26 03). Bajtová sekvencia je 01 00 26 03 a bajty dva a tri sa čítajú 00 26. Bajtové hľadanie #0'&' nájde ampersand, ktorý neexistuje, nalepí bajty pre &amp; doprostred dvoch znakov a každý nasledujúci znak posunie o jeden bajt

PDFlibPas riziko UTF-16BE escapovania, kde bajty 01 00 26 03 pre U+0100 a U+2603 obsahujú vzor 00 26 cez dva znaky, takže bajtové hľadanie ampersandu nalepí entitu doprostred code pointu; scan po code unitoch testuje len párne ofsety
Bajtové hľadanie nájde ampersand, ktorý žiadny znak nikdy neobsahoval; pracujte na celých code unitoch, nikdy na holých UTF-16 bajtových bufferoch

To nie je exotický kút. Akýkoľvek znak, ktorého nízky bajt je nula, dodá prvú polovicu; U+4E00, jeden z najčastejších CJK ideogramov, sa kvalifikuje. Uhlové zátvorky majú rovnakú expozíciu: 00 3C a 00 3E sa objavia vždy, keď za takýmto znakom nasleduje jeden z U+3C00 po U+3EFF v CJK Extension A. Oprava v EscapeHTMLWord rozbalí bajty do WideString, escapuje znak po znaku a výsledok znovu zabalí. Strana dekodéra bola bezpečná už predtým, lebo testuje vzory len na párnych hraniciach code unitov

Rovnaké pravidlo platí pre váš vlastný kód. Ak niekedy držíte UTF-16 text ako TBytes, napríklad po TEncoding.BigEndianUnicode.GetBytes, nehľadajte v ňom bajtové vzory. Preveďte naspäť na string a pracujte so znakmi

Markdown code bloky a dataset exporty: escapujte ampersand najprv

Od v3.539.47 obidva HTML producenty vnútri PDFlibPas, konvertor Markdown aj dataset exporter, escapujú ampersand pred uhlovými zátvorkami, takže jediné dekódovanie v rendereri obnoví presne pôvodný text

V MarkdownToHTML mapujú inline code spany aj fenced alebo odsadené code bloky teraz & na &amp;, < na &lt; a > na &gt;, zatiaľ čo medzery sa stanú &nbsp; a tab štyrmi z nich, aby odsadenie ostalo. Bežná Markdown próza escapuje len uhlové zátvorky, takže surové HTML v próze nedokáže vstrieť tagy, kým autor si naďalej môže &amp; napísať zámerne, do čoho sa Markdown autori vžili. DrawMarkdownText a DrawMarkdownTextBox používajú tú istú konverziu, takže kód sa v PDF ukáže presne ako napísaný:

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
    // Pozrite si HTML: v kóde sa '&' stane '&amp;' a '<' stane '&lt;'
    Html := Lib.MarkdownToHTML(Md);
    Lib.SetOrigin(1);            // počiatok v ľavom hornom rohu, Y rastie nadol
    Lib.SetMeasurementUnits(0);  // body (points)
    // Strana ukáže kód presne ako napísaný, vrátane hláskovania entít
    Lib.DrawMarkdownText(50, 50, 495, Md);
    Lib.SaveToFile('code-sample.pdf');
  finally
    Lib.Free;
  end;
end;

Dataset exporter je poučný prípad. Pred v3.539.47 escapoval len uhlové zátvorky a zámerne: renderer nedekódoval &amp;, takže escapovanie ampersandu by vytlačilo &amp; v každej bunke, ktorá ho obsahuje. Workaround bol pre starý renderer správny a všeobecne nesprávny, lebo hodnota bunky, ktorá náhodou obsahovala &lt;, sa dekódovala na <. S opraveným rendererom exporter escapuje & najprv a hodnota ako R&D &lt; &amp; &nbsp; pristane v PDF slovo od slova. Ak takto staviate reporty, priebežný rozbor na exporte TDataSet do PDF reportu v Delphi pokrýva zvyšok exportera

Prečo musí ampersand ísť najprv, stojí za to povedať raz. Escapujte < najprv a dostanete &lt;; escapujte & ako druhé a to sa stane &amp;lt;, čo korektné jediné dekódovanie zobrazí ako &lt; namiesto <. Sekvenčný reťazec náhrad je korektný len vtedy, keď sa escape znak sám obslúži skôr než čokoľvek, čo ho zavádza

Ako escapovať nedôveryhodný text pre DrawHTMLTextBox?

Pre HTML rendering PDFlibPas escapujte nedôveryhodný textový obsah náhradou &, potom <, potom >, presne raz, a nedôveryhodné dáta držte vonku z hodnôt atribútov úplne

uses
  System.SysUtils, PDFlibrary;

// Escapuje nedôveryhodný text pre HTML textový obsah PDFlibPas.
// '&' sa musí nahradiť najprv, inak by ampersand vnútri
// už vyrobeného '&lt;' bol escapovaný znova
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;

Na v3.539.47 sa komentár ako Try <a href="https://example.com">this</a> & &lt;b&gt; objaví na strane znak za znakom. Pred v3.539.47 mohol ten istý escapovaný vstup vyprodukovať živú link annotáciu, čo je tá časť, ktorá mení zobrazovaciu chybu na bezpečnostný problém: komentár v tickete by nikdy nemal vedieť vysadiť klikateľnú URL do dokumentu, ktorému váš personál verí

Všimnite si, čo tá funkcia neescapuje. Univerzálne HTML escapery menia aj " na &quot; a ' na &#39;, čo je správne pre prehliadač. Textové dekódovanie PDFlibPas pozná len štyri entity vypísané vyššie, takže by tie dve sa vytlačili doslova ako &quot; a &#39;. Úvodzky sú v textovom obsahu neškodné; záleží na nich len vnútri hodnôt atribútov a renderer entity v atribútoch nedekóduje vôbec. Bezpečný dizajn je preto nie lepší escaper, ale pravidlo: nedôveryhodné dáta nikdy nejdú do href, src ani style. Ak cieľ linku naozaj musí pochádzať z dát používateľa, validujte ho sami proti allow-listu schém a znakov a odmietnite čokoľvek s úvodzovkami alebo uhlovými zátvorkami

Z opravy priamo plynú dve poznámky k upgradu:

  • Ak váš kód prestal escapovať &, lebo staršie verzie vytlačili &amp; doslova, pridajte to naspäť. Bez toho sa teraz text od používateľa obsahujúci &lt; zobrazí ako < — stále neškodný text, ale už nie to, čo používateľ napísal
  • Nescapujte dvakrát. Text, ktorý prejde dvoma escapermi, vykreslí < ako viditeľné hláskovanie &lt;, takže nájdite tú jedinú hranicu, kde vaše dáta vstupujú do HTML, a escapujte len tam

Stránkovanie s LeftOverText bez rozbitia escapovania

DrawHTMLTextBox vracia HTML, ktoré sa nezmestilo, bežne volané LeftOverText a od v3.539.47 ten ostatok zachováva doslovné hláskovanie entít a escapované uhlové zátvorky, keď ho podáte do ďalšieho boxu. Pravidlo pre volajúcich je jednoduché: podávajte ho ďalej nezmenený

const
  BoxLeft = 50;
  BoxTop = 50;
  BoxWidth = 495;    // dimenzované pre A4 stranu v bodoch
  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 je už escapované engine HTML: nikdy neescapujte ani nedekódujte
    Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Rest);
  end;
  if Rest <> '' then
    raise Exception.CreateFmt('Content still left after %d pages', [MaxPages]);
end;

Berte ostatok ako nepriehľadný. Je to normalizované HTML enginu s už vyriešenými stylmi, takže nepúšťajte cezeň vlastný escaper, nedekódujte ho a nevstrieajte do neho text od používateľa. Kap strán je lacné poistenie: ak nejaký element nikdy nezmestí do boxu, neohraničená slučka nemá prirodzený východ

Markdown má vlastný continuation. DrawMarkdownTextBox vracia token začínajúci vnútorným markerom, aby ďalšie volanie mohlo preskočiť konverziu; odovzdávajte ho DrawMarkdownTextBox alebo DrawMarkdownText, nie HTML vstupným bodom, ktoré by marker nakreslili ako text

Všeobecná lekcia: dekóduj raz, re-enkóduj na každej hranici

Každá pipeline, ktorá parsuje text, serializuje výsledok naspäť do tej istej syntaxe a parsuje ho znova, musí brať dekódovanie ako operáciu, ktorá sa stane presne na jednom mieste, a musí re-enkódovať na každej hranici, kde sa dekódovaný text znova stáva syntaxou. Template enginy, HTML sanitizery a reťazce Markdown-do-HTML-do-PDF zdieľajú tento tvar a zlyhávajú rovnako, keď serializátor zabudne, že produkuje markup

Príznaky sú predvídateľné, keď poznáte tvar. Príliš málo re-enkódovania mení dáta na syntax, to je smer injekcie. Príliš veľa enkódovania alebo dekodér, ktorý beží dvakrát, ukazuje čitateľovi hláskovanie entít alebo ich zje, to je smer zobrazenia. Opraviť jeden smer samotný zvyčajne rozbije druhý, prečo oprava PDFlibPas musela v tom istom release pridať dekódovanie &amp;, preusporiadať ho, odstrániť neskoré dekódovanie a pridať re-escapovanie. Ten istý princíp beží opačným smerom, keď sa PDF obsah exportuje ako štruktúrovaný text, ako v PDF do Markdown a DOCX semantickom exporte z Delphi, kde sa každý doslovný znak musí escapovať pre cieľovú syntax presne raz

Rýchly referenčný checklist

  • Upgradujte na PDFlibPas v3.539.47 alebo novšiu, ak renderujete HTML alebo Markdown obsahujúce dáta od používateľov
  • Escapujte textový obsah s & najprv, potom < a >; pre text PDFlibPas nemenite úvodzky
  • Escapujte raz, na tom jedinom mieste, kde dáta vstupujú do HTML reťazca
  • Držte nedôveryhodné hodnoty vonku z href, src a style alebo ich validujte proti allow-listu
  • Očakávajte, že v texte sa dekóduje len &lt;, &gt;, &amp; a &nbsp;; ďalšie entity ostanú doslovné
  • Podávajte LeftOverText naspäť do DrawHTMLTextBox nezmenený a kapujte slučku strán
  • Markdown continuation tokeny odovzdávajte len DrawMarkdownTextBox alebo DrawMarkdownText
  • Nikdy nehľadajte bajtové vzory v UTF-16 bajtových bufferoch; pracujte na celých code unitoch

HTML a Markdown rendering, dataset report export a zvyšok layout enginu dodáva natívny Pascal zdroj PDF Library for Delphi, pre Delphi aj Free Pascal. Pozrite produktovú stránku PDFlibPas pre edície, podporu platforiem a skúšobné stiahnutie