Verze PDF Library for Delphi (PDFlibPas) před v3.539.47 mohly dekódovat escapovaný text dvakrát, když se HTML nebo Markdown kreslil do PDF. DrawHTMLText a DrawHTMLTextBox naparsují HTML, normalizují ho zpátky do HTML a pak ho parsují znovu, takže text zapsaný jako <unsafe> dorazil do druhého průchodu jako skutečný tag. Od v3.539.47 se každá entita dekóduje přesně jednou a text se tam, kde se vrací do HTML, znovu escapuje
Scénář, který tohle odhalí, je úplně obyčejný. Help desk exportuje tickety do PDF a zákaznický komentář jde do HTML šablony. Vývojář udělal správnou věc a komentář escapoval, takže se <b> změnilo na <b>. Uvnitř rendereru se tohle escapování potichu zrušilo: komentář vyšel tučný, neznámý název tagu prostě zmizel ze stránky a escapovaný anchor se proměnil v klikatelnou link annotaci. Žádná výjimka, žádné varování, dokonale validní PDF, které říká něco jiného, než říkají data
Proč se z escapovaného textu v PDF stane skutečný tag?
Escapovaný text se stal markupem proto, že renderer běží ve dvou parsovacích průchodech a normalizační krok mezi nimi zapsal už dekódovaný text zpátky do HTML bez opětovného escapování. Každé dekódování, které první průchod provedl, pak bylo druhému průchodu k dispozici jako živá syntaxe
Ty dva průchody existují ze solidního důvodu. První průchod postaví seznam elementů tagů a slov. NormalizeParsedHTML pak vyřeší kaskádu stylesheetů: přiřadí pravidla z bloků <style> ke každému tagu, sloučí je s inline atributy style, uloží výsledek do tagu a serializuje celý seznam elementů zpátky do HTML řetězce. Layout průchod tenhle normalizovaný řetězec parsuje. Je to tentýž stroj, který pohání flexbox, CSS grid a layout poznámek pod čarou v HTML renderování PDFlibPas
Chyba byla v tom, jak se serializovala slova. Tagy se zapisovaly zpátky v původní zdrojové podobě, ale slova v podobě dekódované. Slovo, které první průchod dekódoval z <unsafe> na <unsafe>, dopadlo do normalizovaného HTML jako syrové lomené závorky a druhý průchod si ho přečetl jako element. Kolem toho hlavního bugu seděly tři menší díry směřující stejným směrem:
&nebylo v podporované sadě entit, takžeR&Dse vytisklo doslovně a neexistovala cesta, jak zapsat doslovný zápis entity jako<jako text- Kreslicí fáze nahrazovala
podruhé, už po skončení parsování, takže doslovný zápis entity mohl zmizet úplně na konci - Escapování kódu v Markdown přeskočilo ampersand a dataset exporter escapoval jen lomené závorky, takže zápisy entit uvnitř kódu nebo hodnot buněk se dekódovaly jako markup
| Vstup, který dorazí do rendereru | Před v3.539.47 | Od v3.539.47 |
|---|---|---|
<unsafe> | Naparsováno jako tag, text se na stránku nikdy nedostane | <unsafe> vykresleno jako text |
<b>x</b> | x vykresleno tučně | <b>x</b> vykresleno jako text |
R&D | R&D vytištěno doslovně | R&D |
&lt; | &lt; vytištěno doslovně | < |
Markdown code span obsahující | Stalo se nezalomitelnou mezerou | vykresleno jako text |
Hodnota buňky datasetu < | < | < |
Jak v3.539.47 dělá dekódování HTML entit jednoprůchodovým
PDFlibPas v3.539.47 dělá dekódování entit jednoprůchodovým pomocí tří koordinovaných změn: parser dekóduje & poslední, kreslicí fáze už nic nedekóduje a každé místo, které vrací dekódovaná slova do HTML, je nejdřív znovu escapuje
Podporovaná sada entit pro textový obsah je teď <, >, & a . Cokoli jiného, včetně numerických referencí jako A a pojmenovaných entit jako ", zůstává doslovným textem. Tahle hranice má význam pro to, jak escapujete vlastní vstup, jak je ukázáno níže
Pořadí uvnitř dekodéru je první oprava. Kdyby se & dekódovalo první, vstup &lt; by se změnil na < a další náhrada by z něj udělala < — dvojité dekódování, které se stane uvnitř jediného průchodu. ANSI cesta slov proto nahrazuje <, > a první a & poslední, takže ampersand, který vyprodukuje, už se nikdy znovu nezkoumá. UTF-16 cesta slov je jediný průchod zleva doprava po dvoubajtových krocích, který každý match přepíše na místě a přeskočí ho — ta dá tutéž garanci strukturálně
Druhá oprava odstraňuje pozdní náhradu z kreslicí fáze. Dekódování patří parseru a nikomu jinému, takže slovo, které dorazí do lámání řádků, je finální text
Třetí oprava je pravidlo hranice. NormalizeParsedHTML teď escapuje &, < a > v každém dekódovaném slově, než ho připojí k normalizovanému HTML. Druhý průchod ho dekóduje zpátky na přesně tentýž text, takže čistý efekt přes celou pipeline je jedno dekódování. Pokračovací řetězec drží totéž pravidlo: slova, která se nevešla do boxu, se escapují, než se připojí k LeftOverTextu, a zbytek pozůstatku se kopíruje z normalizovaného HTML, které je už v escapované podobě. Smyčka, která ty zbylá slova sbírá, je teď navíc ohraničená počtem slov, kde starý repeat cyklus mohl šlápnout za poslední slovo
Proč escapování UTF-16BE nemůže použít náhradu na úrovni bajtů?
Escapování UTF-16BE nemůže použít náhradu na úrovni bajtů, protože dvoubajtový vzorek ampersandu může mostit přes dva nesouvisející znaky. Jediná správná pracovní jednotka je celá 16bitová code unit
Renderer ukládá Unicode slova jako big-endian UTF-16 zabalené do bajtových řetězců, vysoký bajt první. Ampersand je 00 26. Vezměte teď U+0100 (velké latinské A s makronem, bajty 01 00) následované U+2603 (sněhulák, bajty 26 03). Bajtová sekvence je 01 00 26 03 a bajty dva a tři čtou 00 26. Bajtové hledání #0'&' najde ampersand, který neexistuje, vloží bajty pro & doprostřed dvou znaků a každý následující znak zastřihne o jeden bajt
To není exotický okrajový případ. Jakýkoli znak, jehož nízký bajt je nula, může dodat první polovinu; U+4E00, jeden z nejčastějších CJK ideogramů, se kvalifikuje. Lomené závorky mají stejnou expozici: 00 3C a 00 3E se objeví vždy, když takový znak následuje za jedním z rozsahu U+3C00 až U+3EFF v CJK Extension A. Oprava v EscapeHTMLWord rozbalí bajty do WideStringu, escapuje znak po znaku a výsledek zase zabalí. Strana dekodéru byla už bezpečná, protože testuje vzorky jen na sudých hranicích code unit
Totéž pravidlo platí pro váš vlastní kód. Pokud kdykoli držíte UTF-16 text jako TBytes, například po TEncoding.BigEndianUnicode.GetBytes, nehledejte v něm bajtové vzorky. Převeďte zpátky na string a pracujte na znacích
Bloky kódu Markdown a exporty datasetů: escapujte nejdřív ampersand
Od v3.539.47 oba producenti HTML uvnitř PDFlibPas, konvertor Markdown i dataset exporter, escapují ampersand před lomenými závorkami, takže jediné dekódování v rendereru obnoví přesně původní text
V MarkdownToHTML mapují inline code spany a fenced či odsazené bloky kódu teď & na &, < na < a > na >, mezery se mění na a tabulátor na čtyři z nich, aby odsazení zůstalo. Obyčejná Markdown próza escapuje jen lomené závorky, takže syrové HTML v próze nemůže vstříknout tagy, zatímco autor může & pořád napsat záměrně, přesně jak autoři Markdown čekají. DrawMarkdownText a DrawMarkdownTextBox používají tutéž konverzi, takže kód se v PDF ukáže přesně tak, jak byl napsaný:
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
// Zkontrolujte HTML: v kódu se '&' mění na '&' a '<' na '<'
Html := Lib.MarkdownToHTML(Md);
Lib.SetOrigin(1); // počátek vlevo nahoře, Y roste dolů
Lib.SetMeasurementUnits(0); // body (points)
// Stránka ukazuje kód přesně tak, jak byl napsaný, včetně zápisů entit
Lib.DrawMarkdownText(50, 50, 495, Md);
Lib.SaveToFile('code-sample.pdf');
finally
Lib.Free;
end;
end;
Dataset exporter je poučný případ. Před v3.539.47 escapoval jen lomené závorky, a to záměrně: renderer nedekódoval &, takže escapování ampersandu by vytisklo & v každé buňce, která ho obsahovala. Tenhle workaround byl správný pro starý renderer a obecně špatný, protože hodnota buňky, která náhodou obsahovala <, se dekódovala na <. S opraveným rendererem exporter escapuje & první a hodnota jako R&D < & dopadne do PDF slovo od slova. Pokud takhle stavíte reporty, návod export TDataSetu do PDF reportu v Delphi pokrývá zbytek exporteru
Proč musí jít ampersand první, stojí za to říct jednou jasně. Escapujte < první a dostanete <; escapujte & druhý a z toho se stane &lt;, což správné jediné dekódování zobrazí jako < místo <. Sekvenční řetěz náhrad je správný jen tehdy, když se escape znak obslouží dřív než cokoli, co ho zavádí
Jak escapovat nedůvěryhodný text pro DrawHTMLTextBox?
Pro HTML renderování PDFlibPas escapujte nedůvěryhodný textový obsah nahrazením &, pak <, pak >, přesně jednou, a nedůvěryhodná data držte zcela mimo hodnoty atributů
uses
System.SysUtils, PDFlibrary;
// Escapuje nedůvěryhodný text pro HTML textový obsah PDFlibPas.
// '&' se musí nahradit první, jinak by ampersand uvnitř
// už vyrobeného '<' byl escapován podruhé
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;
Na v3.539.47 se komentář jako Try <a href="https://example.com">this</a> & <b> objeví na stránce znak za znakem. Před v3.539.47 mohl tentýž escapovaný vstup vyrobit živou link annotaci, a to je ta část, která z chyby zobrazení dělá bezpečnostní problém: komentář ticketu by nikdy neměl umět zasadit klikatelné URL do dokumentu, kterému váš tým věří
Všimněte si, co funkce neescapuje. Univerzální HTML escapery převádějí navíc " na " a ' na ', což je správné pro prohlížeč. Dekódování textu v PDFlibPas rozezná jen čtyři entity uvedené výš, takže by se ty dva vytiskly doslovně jako " a '. Uvozovky jsou v textovém obsahu neškodné; záleží jen uvnitř hodnot atributů a renderer entity v atributech vůbec nedekóduje. Bezpečný design proto není lepší escaper, ale pravidlo: nedůvěryhodná data nikdy nejdou do href, src ani style. Pokud má cílový odkaz opravdu přijít z uživatelských dat, validujte ho sami proti allow-listu schémat a znaků a odmítněte cokoli obsahujícího uvozovky nebo lomené závorky
Z opravy přímo plynou dvě poznámky k upgradu:
- Pokud váš kód přestal escapovat
&, protože starší verze tiskly&doslovně, přidejte to zpátky. Bez toho se uživatelský text obsahující<teď zobrazí jako<— pořád neškodný text, ale už ne to, co uživatel napsal - Neescapujte dvakrát. Text, který projde dvěma escapery, vykreslí
<jako viditelný zápis<, takže najděte tu jedinou hranici, kde vaše data vstupují do HTML, a escapujte jen tam
Stránkování přes LeftOverText bez rozbitého escapování
DrawHTMLTextBox vrací HTML, které se nevešlo, obvykle zvané LeftOverText, a od v3.539.47 tenhle pozůstatek zachovává doslovné zápisy entit a escapované lomené závorky, když ho pošlete do dalšího boxu. Pravidlo pro volající je jednoduché: pošlete ho zpátky nezměněný
const
BoxLeft = 50;
BoxTop = 50;
BoxWidth = 495; // rozměr na stránku A4 v bodech
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é HTML engine: 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 pozůstatek jako neprůhledný. Je to normalizované HTML engine, se styly už vyřešenými, takže ho nepouštějte vlastním escaperem, nedekódujte ho a nevkládejte do něj uživatelský text. Cap na stránky je levné pojištění: pokud se nějaký element do boxu nikdy nevejde, neohraničená smyčka nemá přirozený východ
Markdown má vlastní pokračování. DrawMarkdownTextBox vrací token začínající interním markerem, aby další volání mohlo konverzi přeskočit; vraťte ho DrawMarkdownTextBox nebo DrawMarkdownText, ne HTML vstupním bodům, které by marker vykreslily jako text
Obecná lekce: dekódujte jednou, kódujte znovu na každé hranici
Každá pipeline, která parsuje text, serializuje výsledek zpátky do téže syntaxe a parsuje ho znovu, musí brát dekódování jako operaci, která se stane přesně na jednom místě, a musí se na každé hranici, kde se dekódovaný text znovu stává syntaxí, znovu kódovat. Template enginy, HTML sanitizery a řetězy Markdown do HTML do PDF mají tentýž tvar a selhávají stejně, když serializér zapomene, že vyrábí markup
Příznaky jsou předvídatelné, jakmile tvar znáte. Málo reenkódování mění data na syntaxi — to je směr injekce. Příliš mnoho kódování nebo dekodér, který běží dvakrát, ukazuje čtenáři zápisy entit nebo je spolkne — to je směr zobrazení. Oprava jen jednoho směru obvykle rozbije ten druhý, a proto musela oprava v PDFlibPas ve stejném vydání přidat dekódování &, změnit jeho pořadí, odstranit pozdní dekódování a přidat opětovné escapování. Tentýž princip běží opačným směrem, když se obsah PDF exportuje jako strukturovaný text, jako v sémantickém exportu PDF do Markdown a DOCX z Delphi, kde se každý literální znak musí pro cílovou syntaxi escapovat přesně jednou
Přehledový kontrolní seznam
- Přejděte na PDFlibPas v3.539.47 a novější, pokud renderujete HTML nebo Markdown obsahující uživatelská data
- Escapujte textový obsah s
&první, pak<a>; uvozovky u textu pro PDFlibPas nepřevádějte - Escapujte jednou, na jediném místě, kde data vstupují do HTML řetězce
- Nedůvěryhodné hodnoty držte mimo
href,srcastyle, nebo je validujte proti allow-listu - Očekávejte, že se v textu dekóduje jen
<,>,&a ; ostatní entity zůstávají doslovné - Vraťte
LeftOverTextnezměněnýDrawHTMLTextBoxu a omezte stránkovou smyčku - Pokračovací tokeny Markdown předávejte jen
DrawMarkdownTextBoxneboDrawMarkdownText - Nikdy nehledejte v UTF-16 bajtových bufferech bajtové vzorky; pracujte na celých code unitách
HTML a Markdown renderování, export dataset reportů a zbytek layout enginu lodí se v nativním Pascal zdrojovém kódu PDF Library for Delphi, pro Delphi a Free Pascal. Edice, podporu platforem a trial ke stažení najdete na produktové stránce PDFlibPas