Глубоко в пучинах спецификации HTML дремлет давно забытый ужасный зверь. Представьте себе узел DOM настолько могучий, что он может изменять тип содержимого разных частей документа. HTML-элемент, который заставляет парсер трепетать и замолкать, и которого не остановить даже его собственным тегом.

Мудрецы из W3C стараются держать информацию об этом ужасе подальше от взора простых смертных, чтобы избавить их от угрозы его безумия. Они советуют нам не использовать имя магического тега, призывающего это древнее зло.

Но мы сегодня, естественно, поступим наоборот и заглянем в глубины элемента <plaintext>, чтобы узнать, какие занятные вещи с его помощью можно делать.

Предостережение

Но сразу отмечу один важный момент — не используйте этот элемент в продакшене. В стандарте HTML это чётко прописано:

Элементы в списке ниже полностью устарели и не должны использоваться: [...] plaintext

Так чем же таким <plaintext> заслужил место в списке устаревших элементов? Если коротко, то он завершает работу HTML-парсера и говорит браузеру, что всё последующее содержимое страницы нужно интерпретировать как простой текст.

И это буквально. То есть реально всё, и даже любые закрывающие теги </plaintext> или </html> будут выводиться так, будто скрытный, незакрытый <pre> обезумел и поглотил оставшуюся часть страницы. Кстати, это делает <plaintext> единственным непустым элементом, вообще лишённым завершающего тега.

И для чего нам эта мощь?

На первый взгляд, такая суперсила выглядит как какая-то глупость. На второй….тоже. Ниже мы рассмотрим, как вообще этот элемент стал частью HTML, а пока используем его для одной конкретной цели: отладки кода сервера.

Естественно, основную работу на себя здесь возьмут специальные отладчики вроде XDebug для PHP или встроенные страницы ошибок во фреймворках вроде Django. И даже старый-добрый print "<script>console.log('here!')</script>" часто окажется кстати. Эти инструменты должны всегда быть у вас под рукой.

Но представьте себе следующий кейс: вы залезли глубоко в код в поиске неуловимого бага, который влияет только на часть вывода HTML, и хотите найти, где конкретно на отрисованной странице он всплывает. Самый быстрый способ — это поместить <plaintext> рядом с проблемным местом, перезагрузить страницу и, вуаля! Просто прокручиваем вывод до места, где начнётся разметка.

Это особенно полезно для просмотра отформатированного отладочного вывода, например при использовании var_dump() в PHP или error.stack в NodeJS. Добавьте тег <plaintext> перед этим выводом, прежде чем обрабатывать его как HTML, и нужная строка станет очевидной.

<?php
 # TODO delme!
 echo '<plaintext>'; var_dump($strange_variable);
A screenshot of the HTMHell website where the lower part shows a PHP variable output followed by the site’s markup instead of the rendered HTML

При работе над приложением expressJS аналогичное решение может выглядеть так:

try {
     some_method();
 } catch (error) {
     response.send(<plaintext>${error.stack});
 }
A screenshot of the same HTMHell website as above with the lower part showing a JS error stack followed by the site’s markup instead of the rendered HTML

История этого зла

Как так случилось, что эта, казалось бы, маргинальная фича попала во все популярные браузеры? По правде говоря, она была там с самого зарождения HTML, что подтверждается этим историческим документом W3C от 1992 года:

Plaintext

Этот тег указывает, что весь последующий текст должен восприниматься буквально [!] вплоть до конца файла. Смысл в том, чтобы отображать простой текст также, как примеры XMP-текста — моноширинным шрифтом и с использованием явных переносов строк. Записывается он так:

<PLAINTEXT>

Он позволяет эффективно считывать оставшуюся часть файла, не прибегая к парсингу. Используется этот тег для оптимизации, и закрывающей части у него нет.

Эта особенность также говорит о причине его изобретения. В те времена мощность передового NeXT PC, на котором Тим Бернерс-Ли писал первый веб-браузер, составляла четверть от уставшего смартфона 2009 года. Тогда было очень важно применять оптимизацию везде, где только можно.

Учитывая, что изначально WWW создавалась как место для обмена научной информацией, наличие больших кусков простого текста в составе вашей новёхонькой и навороченной HTML-страницы было распространённым явлением. Возможность завершать работу дорогостоящего HTML-парсера и возвращаться к стандартному выводу оставшейся части файла в виде простого текста играла очень важную роль.

partial screenshot of a browser window. The address bar is shown, and the content of the page looks like a window of an old desktop OS.
Отрисовка примера использования элемента <plaintext> в эмуляции первого браузера в CERN в 1992 году. (Но мне тут пришлось немного схитрить, так как при обращении эмулятора к этому файлу возвращалась ошибка 404).

Хотя такая функциональность не уникальна для HTML. К примеру, в языке Perl используется особый маркер, который сообщает парсеру, что оставшуюся часть файла анализировать не надо:

print 'this is Perl code';
END
cout << 'this isn’t anymore';

Только Perl-программисты используют эту возможность не из соображений производительности, а для встраивания в свои программы дополнительных данных.

Естественно, сегодня в свете многомегабайтных полезных нагрузок JS такая оптимизация стала полностью излишней.

Насколько это опасно?

Но этот элемент по-прежнему маячит во всех браузерах, так что будет нелишним иметь его особенности ввиду.

Чтобы понять, как эта фича может быть использована во вред, представьте себе функцию обработки комментариев в блоге, где комментирующий мог бы тайком встраивать в строку <plaintext>. Как умные разработчики, мы знаем, что пользовательскому вводу доверять нельзя, поэтому прогоняем комментарий через санитайзер. А теперь взглянем, где после этого всё может пойти не так.

Для теста мы используем строку <p><b>hello<plaintext>world!</plaintext></b></p> и проверим, как на неё среагирует несколько библиотек для очистки.

Неожиданный санитайзинг

Каждый санитайзер мы выполняем в самой минимальной конфигурации, которая даёт хоть какой-то вывод. В этом суть — санитайзеры являются инструментами безопасности, и они должны по умолчанию давать безопасный вывод.

Но результат получается довольно удивительный (кликните по названию библиотеки ниже, чтобы увидеть пугающие подробности). Вы заметите, что среди проверенных библиотек не найдётся и двух (кроме Sanitizer API и DOMPurify, первая из которых является прямым последователем второй), которые бы согласились по части правильной очистки строки.

Новый HTML Sanitizer API из Firefox

developer.mozilla.org/en-US/docs/Web/API/Document/parseHTML_static

Код:

console.log(Document.parseHTML(TEST_STRING).body.innerHTML);

Смысл этого API в том, чтобы каким-то образом привести вложение тегов обратно в соответствие со спецификацией парсера HTML5, то есть закрыть теги <p> и <b>, после чего заново открыть <b>. Однако этот API никак не обрабатывает особую семантику <plaintext>.

Результат:

В итоге мы получаем строку, из которой элемент <plaintext> удалён вместе со своим содержимым:

<p><b>hello</b></p>
Санитайзинг для бедных с помощью DOM

Код:

const div = document.createElement('div');
 div.innerHTML = TEST_STRING;
 console.log(div.innerHTML);

Для этого теста мы устанавливаем тестовую строку с помощью HTMLElement.innerHTML = test_string и считываем её снова через .innerHTML. Браузеры Chrome и Firefox показывают один и тот же результат. Никакой очистки здесь по факту не происходит. Но я включил этот пример в список, поскольку он показывает, как движок JS реализует тестирование строки при её интерпретации в виде HTML.

Результат:

На выходе получаем исковерканную версию оригинала, которая содержит дважды закодированное содержимое в сохранившемся элементе <plaintext>.

<p><b>hello</b></p><plaintext><b>world!&lt;/plaintext&gt;&lt;/b&gt;&lt;/p&gt;</b></plaintext>

Примечание: аналогичный результат можно получить, если допустить оплошность в настройке санитайзера:

Document.parseHTML(TEST_STRING, { sanitizer: { removeElements: []}}).body.innerHTML
HTML Tidy

www.html-tidy.org/

Код:

echo -n "$TEST_STRING" | tidy

Результат:

Почтенный Tidy заменяет <plaintext> на <pre>— очень креативно.

<p><b>hello</b></p>
<pre><b>world!</b></pre>
xss

jsxss.com/

Код:

import xss from 'xss';
console.log(xss(TEST_STRING));

Результат:

Известный санитайзер на JavaScript с акцентом на предотвращении атак по типу XSS (cross-site scripting) экранирует только теги <plaintext>, оставляя всё остальное нетронутым.

<p><b>hello&lt;plaintext&gt;world!&lt;/plaintext&gt;</b></p>
DOMPurify

github.com/cure53/DOMPurify

Код:

import { JSDOM } from 'jsdom';
import DOMPurify from 'dompurify';
 
const purify = DOMPurify(new JSDOM('').window);
console.log(purify.sanitize(TEST_STRING));

Результат:

Классический санитайзер на базе JS решает удалить <plaintext> со всем его «содержимым». (Я взял «содержимое» в кавычки, так как технически всё после начала тега будет содержимым <plaintext>). DOMPurify следит за тем, чтобы элементы были правильно закрыты тегами. Результат получается тот же, что и в случае Sanitizer API.

<p><b>hello</b></p>
HTML Purifier

htmlpurifier.org/

Код:

<?php
 $config = HTMLPurifier_Config::createDefault();
 $purifier = new HTMLPurifier($config);
 printf($purifier->purify(TEST_STRING));

Результат:

Звезда в мире PHP использует несколько иной подход. Эта библиотека удаляет только сам элемент. (Заметьте, что “world!” остаётся нетронутым).

<p><b>helloworld!</b></p>
Symfony HtmlSanitizer

symfony.com/html-sanitizer

Код:

<?php
 use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
 use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
 
 $config = (new HtmlSanitizerConfig())->allowSafeElements();
 $sanitizer = new HTMLSanitizer($config);
 printf($sanitizer->sanitize(TEST_STRING));

Результат:

Санитайзер Symfony обладает уникальным умением перемещать теги. Ну, в этом случае у нас хотя бы все элементы оказались правильно закрыты, включая незакрываемый <plaintext>.

<p><b>hello</b></p><plaintext>world!</plaintext>
xmllint

gnome.pages.gitlab.gnome.org/libxml2/xmllint.html

Код:

echo -n "$TEST_STRING" | xmllint --html -

Результат:

Инструмент на базе libxml выдаёт предупреждение “invalid tag plaintext”, но разметку никак не меняет:

<p><b>hello<plaintext>world!</plaintext></b></p>
Mozilla Bleach

github.com/mozilla/bleach

Код:

import bleach
print(bleach.clean(TEST_STRING))

Результат:

Обратившись к этой библиотеке, Python-разработчики получат экранирование всего за исключением <b>.

&lt;p&gt;<b>hello&lt;plaintext&gt;world!&lt;/plaintext&gt;</b>&lt;/p&gt;
OWASP Java HTML Sanitizer

github.com/OWASP/java-html-sanitizer/

Код:

import org.owasp.html.PolicyFactory;
 import org.owasp.html.Sanitizers;
 
 public class Sanitize {
     public static void main(String[] args) {
         PolicyFactory policy = Sanitizers.FORMATTING.and(Sanitizers.LINKS);
         String safe = policy.sanitize(TEST_STRING);
         System.out.println(safe);
     }
 }

Результат:

Штатный HTML-санитайзер в мире Java экранирует всё и делает что-то странное с закрывающими тегами. Но здесь хотя бы исчез <plaintext>.

<b>helloworld!&lt;/plaintext&gt;&lt;/b&gt;&lt;/p&gt;</b>
Ammonia при использовании через nh3

github.com/rust-ammonia/ammonianh3.readthedocs.io/

Код:

import nh3
print(nh3.clean(TEST_STRING))

Этот санитайзер Rust заявляет о высокой скорости и соответствии спецификации HTML.

Результат:

Результат получился близким к результату браузеров, но всё же несколько иным. В этом случае действие тега <b> не распространяется на содержимое элемента<plaintext>.

<p><b>hello</b></p><b>world!&lt;/plaintext&gt;&lt;/b&gt;&lt;/p&gt;</b>

Итого, при использовании 11 методов очистки, мы получили 10 разных результатов!

Но сразу оговорюсь — я здесь не пытаюсь раскритиковать какие-либо библиотеки. Каждая из них имеет веские основания для своего поведения, и все они служат несколько разным целям.

Но этот эксперимент подчёркивает, что нужно чётко понимать предполагаемые задачи выбранного санитайзера и то, каким образом он изменит входные данные. Возможно, эта конкретная библиотека предназначена для удаления только потенциально опасных элементов с максимальным сохранением остального HTML без изменений? А может, её задача — соскрейпить из строки весь HTML или только экранировать спецсимволы HTML? Это будет сильно влиять на конечный результат.

Смешивая несколько инструментов без должного предварительного анализа, мы ступаем на тонкий лёд.

Возьмём, например DOMPurify и HTML Purifier, взаимодействие которых может оказаться весьма опасным. DOMPurify будет удалять весь <plaintext>, включая его содержимое, и любая последующая проверка на присутствие вредоносной начинки даст отрицательный результат. HTML Purifier же, напротив, просто будет обрезать тег <plaintext>, сохраняя его содержимое на странице. Если мы доверимся предыдущему результату DOMPurify, то удивимся внезапному появлению в HTML-коде нового содержимого в буквальном виде.

Если одна библиотека используется для валидации ввода, а другая — для экранирования вывода, это создаёт угрозу межсайтового скриптинга, если только мы не идём на это сознательно, преследуя конкретные цели.

Пусть древнее зло дремлет дальше

В случае самого <plaintext> особой угрозы нет. Поскольку этот тег имеет встроенное экранирование HTML, его потенциальная опасность сильно ограничена. Потребуется весьма редкая комбинация ошибок и недочётов, чтобы выполнить в нём вредоносный код.

Ну и для подкрепления этого утверждения предлагаю создать такую комбинацию. Предположим, что вы на своём сайте встраиваете Content-Security Policy (CSP) в элемент <meta>, а не HTTP-заголовок:

<meta http-equiv="Content-Security-Policy" content="script-src 'self'">

Это достаточно защитит вас от загрузки сторонних скриптов. Если атакующий найдёт возможность загрузить HTML-код до этого элемента, то сможет обнулить вашу CSP:

<script src="https://example.com/malicious.js"></script>
<plaintext>
<meta http-equiv="Content-Security-Policy" content="script-src 'self'">

Но, опять же, чтобы это сработало, должно совпасть ещё два момента:

  • атакующий должен иметь возможность разместить HTML-код в <head> (так как метатеги CSP можно использовать только там);

  • CSP не должна быть установлена через HTTP-заголовок.

Причём оставшаяся часть страницы в итоге преобразуется в text/plain, что лишает атаку какой-либо скрытности.

Так что сделаем вывод: знать особенности <plaintext> важно. Но если следовать проверенным практикам безопасности (например, стандарту OWASP Application Security Verification Standard), то это древнее зло будет нам не страшно.

Комментарии (0)