Versies van PDF Library for Delphi (PDFlibPas) vóór v3.539.47 konden geëscapete tekst dubbel decoderen bij het tekenen van HTML of Markdown naar een PDF. DrawHTMLText en DrawHTMLTextBox parsen de HTML, normaliseren die terug naar HTML en parsen die opnieuw, dus tekst geschreven als <unsafe> bereikte de tweede parse als een echte tag. Sinds v3.539.47 wordt elke entity exact één keer gedecodeerd en wordt tekst opnieuw ge-escaped zodra ze weer in HTML verandert
Het scenario dat dit blootlegt is alledaags. Een helpdesk exporteert tickets naar PDF, en de opmerking van de klant gaat een HTML-template in. De ontwikkelaar deed het juiste en escapte de opmerking, dus <b> werd <b>. Binnen de renderer werd die escaping stilletjes teruggedraaid: de opmerking kwam er vet uit, een onbekende tagnaam verdween simpelweg van de pagina, en een ge-escape anchor werd een klikbare linkannotatie. Geen exception, geen waarschuwing, een prima geldige PDF die iets anders zegt dan de data
Waarom wordt geëscapete tekst een echte tag in de PDF?
Geëscapete tekst werd markup doordat de renderer twee parse-passes draait, en de normalisatiestap ertussen al gedecodeerde tekst zonder opnieuw te escapen terug naar HTML wegschreef. Elke decode die de eerste parse had uitgevoerd stond de tweede parse daarna als levende syntaxis ter beschikking
Die twee passes bestaan met een goede reden. De eerste parse bouwt een lijst van tag- en wordelementen. NormalizeParsedHTML lost daarna de stylesheet-cascade op: hij matcht de regels uit <style>-blokken tegen elke tag, voegt ze samen met inline style-attributen, slaat het resultaat op de tag op en serialiseert de hele elementenlijst terug naar een HTML-string. De layout-pass parst die genormaliseerde string. Het is dezelfde machinerie die flexbox, CSS grid en voetnotenlayout in de HTML-rendering van PDFlibPas aandrijft
De gebreken zaten in hoe woorden werden geserialiseerd. Tags werden vanuit hun originele bronvorm teruggeschreven, terwijl woorden in hun gedecodeerde vorm teruggeschreven werden. Een woord dat de eerste parse van <unsafe> naar <unsafe> had gedecodeerd, kwam in de genormaliseerde HTML terecht als kale hoekhaken, en de tweede parse las het als een element. Rond die kernbug zaten drie kleinere lekken die dezelfde kant op wezen:
&zat niet in de ondersteunde entity-set, dusR&Dwerd letterlijk afgedrukt en er was geen manier om een letterlijke entity-spelling zoals<als tekst te schrijven- De tekenfase verving
een tweede keer, nadat het parsen al klaar was, dus een letterlijke entity-spelling kon aan het eind nog steeds verdwijnen - Markdown-code-escaping sloeg de ampersand over, en de dataset-exporteur escapte alleen hoekhaken, dus entity-spellingen binnen code of celwaarden werden als markup gedecodeerd
| Invoer die de renderer bereikt | Vóór v3.539.47 | Sinds v3.539.47 |
|---|---|---|
<unsafe> | Geparseerd als tag, de tekst bereikt de pagina nooit | <unsafe> als tekst getekend |
<b>x</b> | x vet afgedrukt | <b>x</b> als tekst getekend |
R&D | R&D letterlijk afgedrukt | R&D |
&lt; | &lt; letterlijk afgedrukt | < |
Markdown-codespan met | Werd een non-breaking space | als tekst getekend |
Celwaarde < in een dataset | < | < |
Hoe v3.539.47 het decoderen van HTML-entities single-pass maakt
PDFlibPas v3.539.47 maakt het decoderen van entities single-pass met drie samenhangende wijzigingen: de parser decodeert & als laatste, de tekenfase decodeert niets meer, en elke plek die gedecodeerde woorden weer in HTML omzet, escape ze eerst opnieuw
De ondersteunde entity-set voor tekstinhoud is nu <, >, & en . Al het andere, inclusief numerieke verwijzingen zoals A en named entities zoals ", blijft letterlijke tekst. Die grens doet ertoe voor de vraag hoe u uw eigen invoer escaped, zoals hieronder blijkt
De volgorde binnen de decoder is de eerste fix. Als & eerst werd gedecodeerd, zou de invoer &lt; < worden en zou de volgende vervanging het omzetten in <, een dubbele decode binnen één enkele pass. Het ANSI-woordpad vervangt daarom <, > en eerst en & als laatste, dus de ampersand die het produceert wordt nooit meer bekeken. Het UTF-16-woordpad is één enkele scan van links naar rechts in stappen van twee bytes die elke match ter plekke herschrijft en er voorbij loopt, wat dezelfde garantie structureel geeft
De tweede fix haalt de late -vervanging uit de tekenfase. Decoderen is het werk van de parser en van niemand anders, dus een woord dat de line breaker bereikt is definitieve tekst
De derde fix is de grensregel. NormalizeParsedHTML escaped nu &, < en > in elk gedecodeerd woord voordat het aan de genormaliseerde HTML wordt toegevoegd. De tweede parse decodeert het terug naar exact dezelfde tekst, dus het netto-effect over de hele pijplijn is één decode. De continuation-string volgt dezelfde regel: woorden die niet in de box pasten worden ge-escaped voordat ze aan LeftOverText worden toegevoegd, en de rest van het restant wordt gekopieerd uit de genormaliseerde HTML, die al in ge-escape vorm staat. De lus die die overgebleven woorden verzamelt is nu bovendien begrensd door het aantal woorden, waar de oude repeat-lus voorbij het laatste woord kon stappen
Waarom kan UTF-16BE-escaping geen replace op byteniveau gebruiken?
UTF-16BE-escaping kan geen replace op byteniveau gebruiken omdat het patroon van twee bytes voor een ampersand over twee ongerelateerde tekens heen kan vallen. De enige correcte werkeenheid is de hele 16-bit code unit
De renderer slaat Unicode-woorden op als big-endian UTF-16 verpakt in bytestrings, hoge byte eerst. Een ampersand is 00 26. Neem nu U+0100 (Latin capital A with macron, bytes 01 00) gevolgd door U+2603 (de snowman, bytes 26 03). De bytesequentie is 01 00 26 03, en byte twee en drie lezen als 00 26. Een bytezoektocht naar #0'&' vindt een ampersand die niet bestaat, plakt de bytes voor & midden tussen twee tekens in en schuift elk volgend teken één byte op
Dat is geen exotisch randgeval. Elk teken waarvan de lage byte nul is, kan de eerste helft leveren; U+4E00, een van de meest voorkomende CJK-ideogrammen, voldeed. De hoekhaken hebben dezelfde blootstelling: 00 3C en 00 3E verschijnen zodra zo'n teken wordt gevolgd door een uit U+3C00 tot U+3EFF in CJK Extension A. De fix in EscapeHTMLWord pakt de bytes uit naar een WideString, escaped teken voor teken en pakt het resultaat weer in. De decoderkant was al veilig omdat hij patronen alleen op even code unit-grenzen test
Dezelfde regel geldt voor uw eigen code. Als u UTF-16-tekst ooit als TBytes vasthoudt, bijvoorbeeld na TEncoding.BigEndianUnicode.GetBytes, doorzoek het dan niet op bytepatronen. Converteer terug naar een string en werk op tekens
Markdown-codeblokken en dataset-exporten: eerst de ampersand escapen
Sinds v3.539.47 escapen beide HTML-producenten binnen PDFlibPas, de Markdown-converter en de dataset-exporteur, de ampersand vóór de hoekhaken, zodat de enkele decode in de renderer exact de originele tekst teruggeeft
In MarkdownToHTML mappen inline codespans en fenced of ingesprongen codeblokken nu & naar &, < naar < en > naar >, terwijl spaties worden en een tab er vier wordt om de inspringing te houden. Gewone Markdown-proza escaped alleen de hoekhaken, dus rauwe HTML in proza kan geen tags injecteren terwijl een auteur & nog steeds met opzet kan schrijven, precies zoals Markdown-auteurs verwachten. DrawMarkdownText en DrawMarkdownTextBox gebruiken dezelfde conversie, dus code verschijnt exact zoals getypt in de 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
// Bekijk de HTML: in code wordt '&' '&' en wordt '<' '<'
Html := Lib.MarkdownToHTML(Md);
Lib.SetOrigin(1); // linkerbovenhoek als origine, Y groeit naar beneden
Lib.SetMeasurementUnits(0); // punten
// De pagina toont de code exact zoals getypt, entity-spellingen inbegrepen
Lib.DrawMarkdownText(50, 50, 495, Md);
Lib.SaveToFile('code-sample.pdf');
finally
Lib.Free;
end;
end;
De dataset-exporteur is het leerzame geval. Vóór v3.539.47 escapte hij alleen hoekhaken, en met opzet: de renderer decodeerde & niet, dus de ampersand escapen zou in elke cel die er een bevat & hebben afgedrukt. De workaround was correct voor de oude renderer en in het algemeen fout, want een celwaarde die toevallig < bevatte werd gedecodeerd naar <. Nu de renderer is opgelost, escaped de exporteur & eerst, en een waarde zoals R&D < & komt ongewijzigd in de PDF terecht. Als u rapporten op die manier bouwt, behandelt de walkthrough over een TDataSet naar een PDF-rapport exporteren in Delphi de rest van de exporteur
Waarom de ampersand eerst moet, is het uitschrijven waard. Escape eerst < en u krijgt <; escape daarna & en dat wordt &lt;, wat een correcte enkele decode toont als < in plaats van <. Een sequentiële replace-keten is alleen correct als het escape-teken zelf vóór alles wat het introduceert wordt behandeld
Hoe escapet u niet-vertrouwde tekst voor DrawHTMLTextBox?
Voor HTML-rendering in PDFlibPas escape u niet-vertrouwde tekstinhoud door eerst &, dan <, dan > te vervangen, precies één keer, en houdt u niet-vertrouwde data volledig uit attribuutwaarden
uses
System.SysUtils, PDFlibrary;
// Escape niet-vertrouwde tekst voor PDFlibPas HTML-tekstinhoud.
// '&' moet eerst worden vervangen, anders wordt de ampersand in een
// al geproduceerde '<' een tweede keer ge-escaped
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;
Op v3.539.47 verschijnt een opmerking zoals Try <a href="https://example.com">this</a> & <b> teken voor teken op de pagina. Vóór v3.539.47 kon dezelfde ge-escape invoer een levende linkannotatie opleveren, en dat is het deel dat een weergavefout tot een beveiligingsprobleem maakt: een ticketopmerking mag nooit een klikbare URL kunnen planten in een document waar uw medewerkers op vertrouwen
Kijk ook naar wat de functie niet escaped. Algemene HTML-escapers zetten bovendien " om naar " en ' naar ', wat voor een browser juist is. De tekstdecodering van PDFlibPas herkent alleen de vier eerder genoemde entities, dus die twee zouden letterlijk worden afgedrukt als " en '. Aanhalingstekens zijn onschadelijk in tekstinhoud; ze doen er alleen toe binnen attribuutwaarden, en de renderer decodeert helemaal geen entities in attributen. Het veilige ontwerp is daarom geen betere escaper maar een regel: niet-vertrouwde data gaat nooit in href, src of style. Als een linkdoel echt uit gebruikersdata moet komen, valideert u het zelf tegen een allow-list van schemes en tekens en weigert u alles met aanhalingstekens of hoekhaken
Twee upgradepunten volgen rechtstreeks uit de fix:
- Als uw code was gestopt met het escapen van
&omdat oudere versies&letterlijk afdrukten, voeg dat terug toe. Zonder dat wordt gebruikerstekst met<nu getoond als<, nog steeds onschadelijke tekst maar niet meer wat de gebruiker typte - Escape niet dubbel. Tekst die door twee escapers gaat rendert
<als de zichtbare spelling<, dus zoek de ene grens waar uw data de HTML binnenkomt en escape daar alleen
Pagineren met LeftOverText zonder escapes te breken
DrawHTMLTextBox geeft de HTML terug die niet paste, doorgaans LeftOverText genoemd, en sinds v3.539.47 behoudt dat restant letterlijke entity-spellingen en ge-escape hoekhaken wanneer u het aan de volgende box doorgeeft. De regel voor aanroepers is simpel: geef het ongewijzigd terug
const
BoxLeft = 50;
BoxTop = 50;
BoxWidth = 495; // gematen op een A4-pagina in punten
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 is al ge-escape engine-HTML: nooit escapen of unescapen
Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Rest);
end;
if Rest <> '' then
raise Exception.CreateFmt('Content still left after %d pages', [MaxPages]);
end;
Behandel het restant als opaak. Het is de genormaliseerde HTML van de engine, met styles al opgelost, dus haal het niet door uw eigen escaper, decodeer het niet en plak geen gebruikerstekst erin. De paginalimiet is goedkope verzekering: als een element nooit in de box past, heeft een onbegrensde lus geen natuurlijke uitgang
Markdown heeft zijn eigen continuation. DrawMarkdownTextBox geeft een token terug dat met een interne marker begint zodat de volgende aanroep de conversie kan overslaan; geef het terug aan DrawMarkdownTextBox of DrawMarkdownText, niet aan de HTML-ingangspunten, die de marker als tekst zouden tekenen
De algemene les: één keer decoderen, op elke grens opnieuw encoderen
Elke pijplijn die tekst parst, het resultaat terug serializeert in dezelfde syntaxis en het opnieuw parst, moet decoderen behandelen als een operatie die op precies één plek gebeurt, en moet op elke grens waar gedecodeerde tekst weer syntaxis wordt opnieuw encoderen. Template-engines, HTML-sanitizers en Markdown-naar-HTML-naar-PDF-ketens hebben deze vorm en falen op dezelfde manier zodra een serializer vergeet dat hij markup produceert
De symptomen zijn voorspelbaar zodra u de vorm kent. Te weinig her-encoderen maakt van data syntaxis, dat is de injectierichting. Te veel encoderen, of een decoder die twee keer draait, toont entity-spellingen aan de lezer of vreet ze op, dat is de weergaverichting. Eén richting alleen fixen breekt meestal de andere, en daarom moest de PDFlibPas-fix in dezelfde release &-decodering toevoegen, herordenen, de late decode verwijderen en opnieuw escapen bijvoegen. Hetzelfde principe loopt de andere kant op wanneer PDF-content als gestructureerde tekst wordt geëxporteerd, zoals in PDF naar Markdown en DOCX semantische export vanuit Delphi, waar elk letterlijk teken exact één keer voor de doelsyntaxis moet worden ge-escaped
Snelnaslag: checklist
- Upgrade naar PDFlibPas v3.539.47 of later als u HTML of Markdown rendert die gebruikersdata bevat
- Escape tekstinhoud met
&eerst, dan<en>; converteer geen aanhalingstekens voor PDFlibPas-tekst - Escape één keer, op het ene punt waar data de HTML-string binnenkomt
- Houd niet-vertrouwde waarden uit
href,srcenstyle, of valideer ze tegen een allow-list - Reken er alleen op dat
<,>,&en in tekst worden gedecodeerd; andere entities blijven letterlijk - Geef
LeftOverTextongewijzigd terug aanDrawHTMLTextBoxen begrens de paginalus - Geef Markdown-continuation-tokens alleen aan
DrawMarkdownTextBoxofDrawMarkdownText - Doorzoek UTF-16-bytebuffers nooit op bytepatronen; werk op hele code units
HTML- en Markdown-rendering, dataset-rapportexport en de rest van de layout-engine worden geleverd in de native Pascal-broncode van PDF Library for Delphi, voor Delphi en Free Pascal. Zie de productpagina van PDFlibPas voor edities, platformondersteuning en een proefdownload