PDF Library for Delphi (PDFlibPas) w wersjach sprzed v3.539.47 potrafił zdekodować escapowany tekst dwa razy przy rysowaniu HTML-a albo Markdowna do PDF. DrawHTMLText i DrawHTMLTextBox parsują HTML, normalizują go z powrotem do HTML-a, po czym parsują jeszcze raz, więc tekst zapisany jako <unsafe> docierał do drugiego parsowania jako prawdziwy tag. Od v3.539.47 każda encja jest dekodowana dokładnie raz, a tekst jest escapowany ponownie wszędzie tam, gdzie zamienia się z powrotem w HTML
Scenariusz, który to obnaża, jest zwyczajny. Help desk eksportuje zgłoszenia do PDF, a komentarz klienta trafia do szablonu HTML. Programista zrobił właściwie i escapował komentarz, więc <b> stało się <b>. Wewnątrz renderera to escapowanie zostało po cichu cofnięte: komentarz wychodził pogrubiony, nieznana nazwa tagu po prostu znikała ze strony, a escapowana kotwica stawała się klikalną adnotacją linku. Żadnego wyjątku, żadnego ostrzeżenia — poprawny w każdym calu PDF, który mówi co innego niż dane
Dlaczego escapowany tekst staje się w PDF prawdziwym tagiem?
Escapowany tekst stawał się znacznikami, bo renderer wykonuje dwa przebiegi parsowania, a krok normalizacji pomiędzy nimi zapisywał już zdekodowany tekst z powrotem do HTML-a bez ponownego escapowania. Każde dekodowanie wykonane przez pierwszy przebieg było wtedy dostępne dla drugiego jako żywa składnia
Dwa przebiegi istnieją nie bez powodu. Pierwsze parsowanie buduje listę elementów tagów i słów. NormalizeParsedHTML rozwiązuje potem kaskadę arkusza stylów: dopasowuje reguły z bloków <style> do każdego tagu, scala je z atrybutami style zapisanymi inline, przechowuje wynik na tagu i serializuje całą listę elementów z powrotem do napisu HTML. Przebieg layoutu parsuje ten znormalizowany napis. To ta sama maszyneria, która napędza flexbox, CSS grid i składanie przypisów w renderingu HTML w PDFlibPas
Wada siedziała w sposobie serializacji słów. Tagi były zapisywane z powrotem w swojej pierwotnej formie źródłowej, a słowa w formie zdekodowanej. Słowo, które pierwszy przebieg zdekodował z <unsafe> na <unsafe>, lądowało w znormalizowanym HTML-u jako gołe nawiasy kątowe, a drugie parsowanie czytało je jako element. Wokół tego błędu rdzenia siedziały trzy mniejsze przecieki wskazujące w tę samą stronę:
&nie było w zestawie obsługiwanych encji, więcR&Ddrukowało się dosłownie i nie dało się zapisać dosłownego zapisu encji takiego jak<jako tekstu- Etap rysowania zastępował
drugi raz, już po zakończeniu parsowania, więc dosłowny zapis encji mógł jeszcze zniknąć na samym końcu - Escapowanie kodu w Markdown pomijało ampersand, a eksporter datasetów escapował tylko nawiasy kątowe, więc zapisy encji wewnątrz kodu albo wartości komórek były dekodowane jako znaczniki
| Wejście docierające do renderera | Przed v3.539.47 | Od v3.539.47 |
|---|---|---|
<unsafe> | Sparsowany jako tag, tekst nigdy nie dociera na stronę | <unsafe> narysowany jako tekst |
<b>x</b> | x narysowany pogrubieniem | <b>x</b> narysowany jako tekst |
R&D | R&D wydrukowane dosłownie | R&D |
&lt; | &lt; wydrukowane dosłownie | < |
Span kodu Markdown zawierający | Stawał się spacją niełamliwą | narysowany jako tekst |
Wartość komórki datasetu < | < | < |
Jak v3.539.47 robi z dekodowaniem encji HTML jeden przebieg
PDFlibPas v3.539.47 robi z dekodowaniem encji jeden przebieg trzema skoordynowanymi zmianami: parser dekoduje & na końcu, etap rysowania już niczego nie dekoduje, a każde miejsce, które zamienia zdekodowane słowa z powrotem w HTML, escapuje je najpierw ponownie
Zestaw obsługiwanych encji dla treści tekstowej to teraz <, >, & i . Wszystko inne, łącznie z referencjami numerycznymi takimi jak A i encjami nazwanymi takimi jak ", zostaje tekstem dosłownym. Ta granica ma znaczenie dla tego, jak escapujesz własne dane wejściowe, co pokazano niżej
Kolejność w dekoderze to pierwsza poprawka. Gdyby & było dekodowane najpierw, wejście &lt; stałoby się <, a następne podstawienie zamieniłoby to w < — podwójne dekodowanie wewnątrz jednego przebiegu. Ścieżka słów ANSI zastępuje więc najpierw <, > i , a & na końcu, dzięki czemu wyprodukowany ampersand nie jest już nigdy badany. Ścieżka słów UTF-16 to pojedynczy skan od lewej do prawej w krokach dwubajtowych, który przepisuje każde trafienie w miejscu i przesuwa się za nie — ta sama gwarancja wynika tu wprost z konstrukcji
Druga poprawka usuwa późne podstawianie z etapu rysowania. Dekodowanie należy do parsera i nikogo innego, więc słowo, które dociera do łamacza linii, to tekst ostateczny
Trzecia poprawka to reguła graniczna. NormalizeParsedHTML escapuje teraz &, < i > w każdym zdekodowanym słowie, zanim dołączy je do znormalizowanego HTML-a. Drugie parsowanie dekoduje to z powrotem do dokładnie tego samego tekstu, więc efekt netto całego potoku to jedno dekodowanie. Napis kontynuacji trzyma się tej samej reguły: słowa, które nie zmieściły się w ramce, są escapowane przed dołączeniem do LeftOverText, a reszta pozostałości jest kopiowana ze znormalizowanego HTML-a, który już jest w postaci escapowanej. Pętla zbierająca te pozostałe słowa jest teraz dodatkowo ograniczona liczbą słów, podczas gdy stara pętla repeat mogła nadepnąć za ostatnie słowo
Dlaczego escapowanie UTF-16BE nie może używać podstawienia na poziomie bajtów?
Escapowanie UTF-16BE nie może używać podstawienia na poziomie bajtów, bo dwubajtowy wzorzec ampersanda potrafi rozpiąć się na dwa niespokrewnione znaki. Jedyną poprawną jednostką pracy jest cały 16-bitowy code unit
Renderer trzyma słowa Unicode jako big-endian UTF-16 spakowane w napisy bajtowe, starszy bajt najpierw. Ampersand to 00 26. Weźmy teraz U+0100 (wielka latina A z makronem, bajty 01 00) i za nią U+2603 (bałwanek, bajty 26 03). Sekwencja bajtów to 01 00 26 03, a bajty drugi i trzeci czytają się 00 26. Wyszukiwanie bajtowe #0'&' znajduje ampersand, który nie istnieje, wszywa bajty & w środek dwóch znaków i ścina każdy dalszy znak o jeden bajt
To nie jest egzotyczny narożnik. Każdy znak, którego młodszy bajt jest zerem, może dostarczyć pierwszej połowy; U+4E00, jeden z najczęstszych ideogramów CJK, kwalifikuje się bez pytań. Nawiasy kątowe mają tę samą ekspozycję: 00 3C i 00 3E pojawiają się zawsze, gdy za takim znakiem idzie jakiś z zakresu U+3C00 do U+3EFF w CJK Extension A. Poprawka w EscapeHTMLWord rozpakowuje bajty do WideString, escapuje znak po znaku i pakuje wynik z powrotem. Strona dekodująca była już bezpieczna, bo testuje wzorce wyłącznie na parzystych granicach code unitów
Ta sama reguła dotyczy twojego kodu. Jeśli kiedykolwiek trzymasz tekst UTF-16 jako TBytes, na przykład po TEncoding.BigEndianUnicode.GetBytes, nie szukaj w nim wzorców bajtowych. Skonwertuj z powrotem na napis i pracuj na znakach
Bloki kodu Markdown i eksporty datasetów: najpierw escapuj ampersand
Od v3.539.47 oba producenty HTML-a wewnątrz PDFlibPas, konwerter Markdown i eksporter datasetów, escapują ampersand przed nawiasami kątowymi, więc pojedyncze dekodowanie w rendererze odtwarza dokładnie oryginalny tekst
W MarkdownToHTML inline code spany i bloki kodu w ogrodzeniach albo wcięciach mapują teraz & na &, < na < i > na >, spacje stają się , a tab czterema z nich, żeby zachować wcięcie. Zwyczajna proza Markdown escapuje tylko nawiasy kątowe, więc surowy HTML w prozie nie wstrzyknie tagów, a autor wciąż może napisać & celowo, mniej więcej tak, jak oczekują autorzy Markdown. DrawMarkdownText i DrawMarkdownTextBox używają tej samej konwersji, więc kod pojawia się w PDF dokładnie tak, jak został wpisany:
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
// Obejrzyj HTML: w kodzie '&' staje się '&', a '<' staje się '<'
Html := Lib.MarkdownToHTML(Md);
Lib.SetOrigin(1); // początek w lewym górnym rogu, Y rośnie w dół
Lib.SetMeasurementUnits(0); // punkty
// Strona pokazuje kod dokładnie tak, jak wpisano, łącznie z zapisami encji
Lib.DrawMarkdownText(50, 50, 495, Md);
Lib.SaveToFile('code-sample.pdf');
finally
Lib.Free;
end;
end;
Eksporter datasetów to pouczający przypadek. Przed v3.539.47 escapował tylko nawiasy kątowe — i to celowo: renderer nie dekodował &, więc escapowanie ampersanda wydrukowałoby & w każdej komórce, która go zawiera. Obejście było poprawne dla starego renderera i błędne ogólnie, bo wartość komórki, która trafiła zawierać <, była dekodowana do <. Z naprawionym rendererem eksporter escapuje najpierw &, a wartość taka jak R&D < & ląduje w PDF dosłownie. Jeśli budujesz raporty w ten sposób, walkthrough na eksporcie TDataSet do raportu PDF w Delphi omawia resztę eksportera
Dlaczego ampersand musi iść pierwszy, warto raz napisać wprost. Escapuj najpierw < i dostajesz <; escapuj & w drugiej kolejności, a to stanie się &lt;, co poprawne pojedyncze dekodowanie pokaże jako < zamiast <. Sekwencyjny łańcuch podstawień jest poprawny tylko wtedy, gdy znak escape jest obkładany przed wszystkim, co go wprowadza
Jak escapować niezaufany tekst dla DrawHTMLTextBox?
W renderingu HTML w PDFlibPas escapuj niezaufaną treść tekstową, zastępując &, potem <, potem >, dokładnie raz, a dane niezaufane trzymaj z dala od wartości atrybutów w ogóle
uses
System.SysUtils, PDFlibrary;
// Escapuje niezaufany tekst dla treści tekstowej HTML w PDFlibPas.
// '&' trzeba zastąpić najpierw, bo inaczej ampersand w środku
// już wyprodukowanego '<' zostałby escapowany drugi raz
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 komentarz taki jak Try <a href="https://example.com">this</a> & <b> pojawia się na stronie znak w znak. Przed v3.539.47 to samo escapowane wejście mogło wyprodukować żywą adnotację linku i to jest ten element, który zamienia usterkę wyświetlania w problem bezpieczeństwa: komentarz w zgłoszeniu nie powinien móc zasadzić klikalnego URL-a w dokumencie, któremu twój personel ufa
Zwróć uwagę, czego funkcja nie escapuje. Escapery HTML ogólnego przeznaczenia konwertują dodatkowo " na " i ' na ', co dla przeglądarki jest słuszne. Dekodowanie tekstu w PDFlibPas rozpoznaje tylko cztery encje wymienione wcześniej, więc te dwa wydrukują się dosłownie jako " i '. Cudzysłowy w treści tekstowej są nieszkodliwe; mają znaczenie tylko wewnątrz wartości atrybutów, a renderer w ogóle nie dekoduje encji w atrybutach. Bezpieczna konstrukcja to więc nie lepszy escaper, tylko reguła: dane niezaufane nigdy nie trafiają do href, src ani style. Jeśli cel linku naprawdę musi pochodzić od danych użytkownika, zwaliduj go sam względem listy dozwolonych schematów i znaków i odrzucaj wszystko, co zawiera cudzysłowy albo nawiasy kątowe
Z poprawki wprost wynikają dwie uwagi aktualizacyjne:
- Jeśli twój kod przestał escapować
&, bo starsze wersje drukowały&dosłownie, dodaj to z powrotem. Bez tego tekst użytkownika zawierający<wyświetli się teraz jako<— wciąż nieszkodliwy tekst, ale już nie to, co użytkownik wpisał - Nie escapuj dwa razy. Tekst przepuszczony przez dwa escapery wyrenderuje
<jako widoczny zapis<, więc znajdź tę jedną granicę, na której twoje dane wchodzą do HTML-a, i escapuj tylko tam
Paginacja z LeftOverText bez psucia escapowania
DrawHTMLTextBox zwraca HTML, który się nie zmieścił, zwyczajowo zwany LeftOverText, i od v3.539.47 ta pozostałość zachowuje dosłowne zapisy encji i escapowane nawiasy kątowe, gdy przekazujesz ją do następnej ramki. Reguła dla wołających jest prosta: przekazuj dalej bez zmian
const
BoxLeft = 50;
BoxTop = 50;
BoxWidth = 495; // rozmiar pod stronę A4 w punktach
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 to już escapowany HTML silnika: nigdy nie escapuj i nie dekoduj
Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Rest);
end;
if Rest <> '' then
raise Exception.CreateFmt('Content still left after %d pages', [MaxPages]);
end;
Traktuj pozostałość jak nieprzezroczystą. To znormalizowany HTML silnika, ze stylami już rozwiązanymi, więc nie przepuszczaj go przez własny escaper, nie dekoduj go i nie wszywaj w niego tekstu użytkownika. Limit stron to tania polisa: jeśli jakiś element nigdy nie zmieści się w ramce, pętla bez limitu nie ma naturalnego wyjścia
Markdown ma własną kontynuację. DrawMarkdownTextBox zwraca token zaczynający się od wewnętrznego znacznika, dzięki któremu następne wywołanie może pominąć konwersję; oddawaj go DrawMarkdownTextBox albo DrawMarkdownText, a nie punktom wejściowym HTML, które narysowałyby znacznik jako tekst
Ogólna lekcja: dekoduj raz, koduj ponownie na każdej granicy
Każdy potok, który parsuje tekst, serializuje wynik z powrotem do tej samej składni i parsuje go ponownie, musi traktować dekodowanie jako operację dziejącą się w dokładnie jednym miejscu i musi kodować ponownie na każdej granicy, gdzie zdekodowany tekst znów staje się składnią. Silniki szablonów, sanitizery HTML i łańcuchy Markdown–HTML–PDF mają ten sam kształt i zawodzą tak samo, gdy serializator zapomina, że produkuje znaczniki
Objawy są przewidywalne, gdy znasz kształt. Za mało rekodowania zamienia dane w składnię, czyli kierunek iniekcji. Za dużo kodowania albo dekoder wykonany dwa razy pokazuje czytelnikowi zapisy encji albo je zjada, czyli kierunek wyświetlania. Naprawa jednego kierunku z osobna zwykle psuje drugi, dlatego poprawka w PDFlibPas musiała w jednym wydaniu dodać dekodowanie &, przestawić jego kolejność, usunąć późne dekodowanie i dodać ponowne escapowanie. Ta sama zasada biegnie w drugą stronę, gdy treść PDF jest eksportowana jako tekst strukturalny, jak w semantycznym eksporcie PDF do Markdown i DOCX z Delphi, gdzie każdy dosłowny znak musi być escapowany dla składni docelowej dokładnie raz
Ściąga kontrolna
- Zaktualizuj do PDFlibPas v3.539.47 lub nowszego, jeśli renderujesz HTML albo Markdown zawierające dane użytkownika
- Escapuj treść tekstową najpierw
&, potem<i>; nie konwertuj cudzysłowów dla tekstu w PDFlibPas - Escapuj raz, w jedynym punkcie, w którym dane wchodzą do napisu HTML
- Trzymaj niezaufane wartości z dala od
href,srcistylealbo waliduj je względem listy dozwolonych - Oczekuj dekodowania w tekście tylko
<,>,&i ; inne encje zostają dosłowne - Przekazuj
LeftOverTextz powrotem doDrawHTMLTextBoxbez zmian i limituj pętlę stron - Tokeny kontynuacji Markdown przekazuj wyłącznie do
DrawMarkdownTextBoxalboDrawMarkdownText - Nigdy nie szukaj wzorców bajtowych w buforach bajtów UTF-16; pracuj na całych code unitach
Rendering HTML i Markdown, eksport raportów z datasetów i reszta silnika layoutu jadą w natywnym kodzie źródłowym Pascal PDF Library for Delphi, dla Delphi i Free Pascal. Wydania, obsługiwane platformy i wersję próbną znajdziesz na stronie produktu PDFlibPas