Artykuł techniczny

PDFlibPas: naprawa podwójnie dekodowanych encji w HTML

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ę &lt;b&gt;. 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 &lt;unsafe&gt; 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ę:

  • &amp; nie było w zestawie obsługiwanych encji, więc R&amp;D drukowało się dosłownie i nie dało się zapisać dosłownego zapisu encji takiego jak &lt; jako tekstu
  • Etap rysowania zastępował &nbsp; 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
Potok HTML w PDFlibPas dla DrawHTMLText, gdzie parsowanie pierwsze buduje elementy, NormalizeParsedHTML serializuje je z powrotem do HTML-a, a parsowanie drugie składa wynik; przed v3.539.47 zdekodowane słowa były zapisywane bez ponownego escapowania i stawały się żywymi tagami, od v3.539.47 każde słowo jest escapowane ponownie na granicy
Zdekodowane słowa wracają do parsera jako składnia, gdy normalizator zapomina, że produkuje znaczniki — tak escapowany komentarz wychodził pogrubiony albo wypuszczał link
Wejście docierające do rendereraPrzed v3.539.47Od v3.539.47
&lt;unsafe&gt;Sparsowany jako tag, tekst nigdy nie dociera na stronę<unsafe> narysowany jako tekst
&lt;b&gt;x&lt;/b&gt;x narysowany pogrubieniem<b>x</b> narysowany jako tekst
R&amp;DR&amp;D wydrukowane dosłownieR&D
&amp;lt;&amp;lt; wydrukowane dosłownie&lt;
Span kodu Markdown zawierający &nbsp;Stawał się spacją niełamliwą&nbsp; narysowany jako tekst
Wartość komórki datasetu &lt;<&lt;

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 &amp; 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 &lt;, &gt;, &amp; i &nbsp;. Wszystko inne, łącznie z referencjami numerycznymi takimi jak &#65; i encjami nazwanymi takimi jak &quot;, 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 &amp; było dekodowane najpierw, wejście &amp;lt; stałoby się &lt;, 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 &lt;, &gt; i &nbsp;, a &amp; 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

Kolejność dekodera w PDFlibPas dla łańcuchowej encji takiej jak &amp;lt;: dekodowanie ampersanda najpierw zwija go do prawdziwego nawiasu kątowego wewnątrz jednego przebiegu, a dekodowanie lt, gt i nbsp przed ampersandem zachowuje dosłowny zapis w nienaruszonym stanie, więc tekst trafia na stronę zdekodowany dokładnie raz
Ampersand to znak escape, więc musi być dekodowany jako ostatni i escapowany jako pierwszy, bo jeden przebieg potrafi zdekodować dwa razy

Druga poprawka usuwa późne podstawianie &nbsp; 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 &amp; w środek dwóch znaków i ścina każdy dalszy znak o jeden bajt

Zagrożenie escapowania UTF-16BE w PDFlibPas: bajty 01 00 26 03 dla U+0100 i U+2603 zawierają wzorzec 00 26 rozpięty na dwa znaki, więc wyszukiwanie ampersanda na poziomie bajtów wszywa encję w środek punktu kodowego; skan code unitów testuje wyłącznie parzyste przesunięcia
Wyszukiwanie bajtowe znajduje ampersand, którego żaden znak nigdy nie zawierał; pracuj na całych code unitach, nigdy na surowych buforach bajtów UTF-16

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 &amp;, < na &lt; i > na &gt;, spacje stają się &nbsp;, 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ć &amp; 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(''&lt;tag&gt; &amp; R&amp;D'');' + sLineBreak +
        '```';
  Lib := TPDFlib.Create;
  try
    // Obejrzyj HTML: w kodzie '&' staje się '&amp;', a '<' staje się '&lt;'
    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ł &amp;, więc escapowanie ampersanda wydrukowałoby &amp; 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ć &lt;, była dekodowana do <. Z naprawionym rendererem eksporter escapuje najpierw &, a wartość taka jak R&D &lt; &amp; &nbsp; 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 &lt;; escapuj & w drugiej kolejności, a to stanie się &amp;lt;, co poprawne pojedyncze dekodowanie pokaże jako &lt; 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 '&lt;' zostałby escapowany drugi raz
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 komentarz taki jak Try <a href="https://example.com">this</a> & &lt;b&gt; 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 &quot; i ' na &#39;, 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 &quot; i &#39;. 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 &amp; dosłownie, dodaj to z powrotem. Bez tego tekst użytkownika zawierający &lt; 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 &lt;, 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 &amp;, 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, src i style albo waliduj je względem listy dozwolonych
  • Oczekuj dekodowania w tekście tylko &lt;, &gt;, &amp; i &nbsp;; inne encje zostają dosłowne
  • Przekazuj LeftOverText z powrotem do DrawHTMLTextBox bez zmian i limituj pętlę stron
  • Tokeny kontynuacji Markdown przekazuj wyłącznie do DrawMarkdownTextBox albo DrawMarkdownText
  • 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