Как нам удалось создать безопасный и функциональный HTML email-рендерер
Как сейчас везде плохо и почему
В мире электронной почты качество отображения HTML-писем оставляет желать лучшего. Основные проблемы связаны с различиями в поддержке HTML и CSS между почтовыми клиентами и веб-интерфейсами. Часто письма, идеально выглядящие в одном почтовом сервисе, могут отображаться совершенно некорректно в другом. Пользователи разочаровываются, конверсия снижается, а имидж бренда ухудшается.
Во многом это обусловлено тем, что в мире есть всего несколько сервисов-монополий, у которых есть веб-интерфейс отображения писем, и всего несколько популярных почтовых клиентов. Gmail или Yahoo ничего не меняли в своих движках отображения годами, несмотря на то, что HTML и CSS за эти годы значительно продвинулись вперёд по возможностям отображения контента. К примеру, адаптивность появилась с внедрением медиа-запросов около 10–12 лет назад, а поддержка тёмной темы в CSS — около 4–5 лет назад, но до сих пор ни один крупный почтовый сервис не поддерживает эти функции. Ему просто незачем что-то менять — это веб-мастера должны приспосабливаться к весьма причудливым и нигде не описанным ограничениям сервиса. К примеру, кто-то поддерживает анимации, кто-то — нет. Gmail не поддерживает даже свой (изобретённый и популяризированный компанией Google) формат изображений webp, iCloud поддерживает, а Yandex — нет. И таких несовместимостей буквально сотни. Веб-мастера вынуждены словно окунуться в атмосферу раннего интернета, когда шла война браузеров и приходилось идти на страшные ухищрения, чтобы добиться схожего отображения веб-страниц в разных браузерах.
Да, конечно, зачастую те или иные ограничения (к примеру, отсутствие скриптов) сделаны исходя из соображений безопасности (вряд ли бы мы хотели получить письмо с интерактивным баннером, который сливает наши данные рекламной сети, верно?), но зачастую набор ограничений выглядит действительно безумно — ну чем Gmail помешали простые фоновые изображения, задаваемые с помощью background-image
?
Что мы придумали
Когда мы делали первые пробы пера на рынке электронной почты с Временной почтой AdGuard, мы потратили много времени, изучая возможности по отображению писем. Мы определённо не хотели превращаться во второй Gmail, изобретая собственное подмножество HTML/CSS, урезав все компоненты, которые нам показались лишними. Но и оставлять пользователей один на один с веб-угрозами мы не собирались.
Часть 1. 7 строк JavaScript-кода, что решили проблему
К счастью, мир веб-разработки не стоит на месте и разработчики браузеров давно работают над тем, чем мы в итоге и воспользовались — над браузерными песочницами.
Что это такое? Это набор техник ограничения возможностей страницы — буквально список возможностей, очень похожий на CSP (впрочем, его мы тоже используем, об этом чуть позже): «не используй cookie родительской страницы», «не исполняй JavaScript», «не передавай HTTP Referrer, когда кто-то переходит по ссылке из фрейма» и ряд подобных директив. Звучит интересно, не так ли? Давайте рассмотрим технические детали реализации.
Из API мы получаем тело письма как строку, в которой находится HTML-разметка, нам нужно эту разметку отобразить в браузере, используя возможности песочницы:
const html = '<html><body>....</body></html>';
const iframe = document.createElement('iframe');
iframe.credentialless = true;
iframe.sandbox = 'allow-popups allow-popups-to-escape-sandbox';
iframe.referrerpolicy = 'no-referrer';
iframe.srcdoc = html;
document.body.appendChild(iframe);
Стоит теперь добавить немного стилей, спозиционировать фрейм на родительской странице — и вы прекрасны, вы только что сделали довольно безопасный движок отображения HTML-писем без дурацких ограничений!
В идеальном мире, пожалуй, на этом можно было бы и остановиться, ведь основную опасность — JavaScript — мы отключили, но, к сожалению, современные письма — это просто гора всевозможных трекеров, которые пытаются собрать как можно больше данных о вас. Это zero-pixels — невидимые изображения, которые передают на сервер ваш IP-адрес и данные о вашем девайсе, и специальные отслеживающие ссылки, которые позволяют сохранить каждое ваше действие с письмом. И мы должны были с этим что-то сделать.
Часть 2. Анонимизация пользователей
В процессе разработки мы наткнулись на великолепный инструмент оценки безопасности нашего движка — emailprivacytester.com.
Этот сервис генерирует HTML-письмо, в котором есть ряд специально сформированных элементов, что тестируют те или иные уязвимости. Запускаем диагностику, отправляем себе письмо на ящик AdGuard TempMail и… получаем множество утечек IP-адреса.
Так как с JavaScript и ему подобными вещами мы разобрались, давайте сконцентрируемся на утечках IP-адреса. Такая утечка — это когда отправитель письма может узнать ваш адрес, просто отправив вам письмо. Конечно, если вы пользуетесь AdGuard VPN, то он узнает адрес VPN-сервера, но стоит открыть один раз письмо без VPN — и злоумышленник сможет узнать ваше местонахождение с точностью до города. Разумеется, мы не могли позволить такому случиться с нашими пользователями. Что делать?
Image proxy
На самом деле, всё уже было придумано до нас — и все «большие» почтовики давно это делают: они оборачивают все картинки в свою прокси.
Как? Например, если в вашем письме было вот такое изображение:
<img src="http://my.website/image/cat.jpg" />
то при каждом открытии письма владелец сервера my.website
будет получать HTTP-запрос. В его метаданных будет находиться ваш IP-адрес, а также немного сведений о вашем устройстве (операционная система, версия браузера и некоторые другие данные, позволяющие вас деанонимизировать).
Как работает прокси: он подменяет все src изображений на свой URL, делая что-то вроде этого:
<img src="https://email.provider/image?url=http://my.website/image/cat.jpg" />
Веб-сервер email.provider
устроен так, что скачивает исходную картинку он лишь однажды, после чего сохраняет её к себе, и при повторном запросе владелец сервера my.website
запрос уже не получает. И даже при первом запросе my.website
получает запрос от имени email.provider
— с IP-адресом его сервера и без каких-либо данных, указывающих на вас. Всё отлично, правда, за исключением того, что эти данные о визитах теперь собирает сам email.provider
(а мы помним, что, как правило, это большая корпорация — Google, Yandex или Yahoo).
В общем, мы сделали аналогичное решение, за исключением того, что наша image proxy, как и другие продукты AdGuard, не собирает и не хранит данные пользователей, а полностью анонимна.
Так как прокси готов, нужно как-то заменить все изображения в письме на «обёрнутые» в прокси, для этого нужно разобрать наш исходный HTML уже как DOM-документ. Работать с DOM в песочнице мы не можем — в песочнице нет JavaScript, и мы не можем его там выполнить даже для нашего кода, так как это автоматически разрешит выполнение JavaScript-кода, пришедшего в письме. А если мы работаем с DOM вне контекста песочницы, то хотелось бы получить чуть больше гарантий того, что наш HTML чист, после чего его можно было бы обрабатывать.
К счастью, мы нашли потрясающую библиотеку DOMPurify — она умеет чистить и приводить в безопасный и структурированный вид HTML-контент. Она отлично протестирована и по достоинству очень высоко оценена экспертами по безопасности. Начнём её использовать:
const html = '<html><body>....</body></html>';
const clean = DOMPurify.sanitize(html, {
// восстановить частичный HTML до полноценного документа, если это необходимо
WHOLE_DOCUMENT: true,
// эти теги не имеют смысла в контексте HTML-письма, поэтому мы их удалим
FORBID_TAGS: ['audio', 'video', 'button', 'input', 'form'],
});
const iframe = document.createElement('iframe');
// набор действий с песочницей из первого шага
iframe.srcdoc = clean;
document.body.appendChild(iframe);
Отлично, мы избавились от мусора, теперь можно вернуться к обработке изображений. DOMPurify поможет нам и в этом! У неё есть отличный механизм hooks — мы сможем решить вторую задачу, не меняя контекст:
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'IMG' && node.hasAttribute('src')) {
// заменить оригинальный src ссылкой на ImageProxy
const src = wrapWithImageProxy(node.getAttribute('src');
node.setAttribute('src', src);
}
});
Запускаем диагностику и видим, что количество IP-утечек действительно сократилось, но не до нуля. Как же так? Пришло время поговорить о CSS.
Утечки IP-адресов через CSS
CSS обладает поистине потрясающими возможностями по стилизации контента. Ни одна другая система к нему даже не приблизилась — лёгкость, простота и множество возможностей делают CSS одним из лучших изобретений программной инженерии. Но, как это часто водится с очень мощными инструментами, часть их возможностей можно использовать во вред. В нашей задаче «вредной» оказалась функция url(), которая позволяет загружать контент по ссылке и использовать его для оформления страницы. Самый часто встречающийся пример — фоновые изображения. Код их загрузки выглядит примерно так:
background-image: url(http://my.website/image/cat.jpg);
Проблемы здесь ровно те же, что и в прошлом разделе — утечка IP-адреса. Помимо фоновых изображений, функция url() может быть использована в следующих правилах — их довольно много, и синтаксис CSS-селектора позволяет задавать достаточно сложные правила вроде селектора из примера по ссылке выше:
background-image: cross-fade(20% url(http://my.website/image/first.png), url(http://my.website/image/second.png));
Подобное правило не так просто безопасно распарсить — для этого нужны подходящие инструменты. И нам вновь повезло его найти! csstree делает ровно то, что нам нужно — позволяет анализировать CSS и заменять некоторые типы его конструкций. В виде кода это выглядит так:
csstree.walk(ast, (node) => {
if (node.type === 'Url') {
node.value = wrapWithImageProxy(node.value);
}
});
что позволило получить на выходе
background-image: cross-fade(20% url(https://img.agrd.eu/image?url=http://my.website/image/first.png), url(https://img.agrd.eu/image?url=http://my.website/image/second.png));
И вуаля, больше нет утечек IP-адресов!
CSP
Приватность данных — это такая область, где не может быть исключений. Никаких, ни единого. Поэтому нужно что-то придумать на тот случай, если обнаружится уязвимость в библиотеках или в нашем коде, который неправильно интерпретировал данные. Защиту последнего рубежа — когда все другие средства отказали. В нашем случае в этом качестве используется великий и ужасный CSP — набор директив, позволяющий гранулярно управлять доступом страницы к тем или иным ресурсам. Часто веб-мастера страдают из-за него, забывая, что какой-то новый ресурс, который будет нужен странице, в CSP не разрешён (да чего греха таить, мы и сами так ошибались несколько раз). Сайт ломается со включённым CSP, но нам, кажется, удалось его укротить. Получилось что-то вроде того:
# Разрешим изображения только с нашей image proxy
img-src data: https://img.agrd.eu/image;
# и запретим все остальные возможности куда-либо подключиться
script-src 'none';
style-src 'none';
font-src 'none';
connect-src 'none';
media-src 'none';
object-src 'none';
prefetch-src 'none';
child-src 'none';
frame-src 'none';
worker-src 'none';
frame-ancestors 'none';
...
# что можно заменить на
default-src 'none';
Теперь мы в полной безопасности: даже если сломается одна из библиотек или наш код в функции преобразования адресов изображений, CSP не позволит браузеру совершить запрос к серверу злоумышленника и тем самым передать ваш IP-адрес.
Итоги работы с конфиденциальностью данных
Благодаря возможностям современных браузеров и нескольким отличным библиотекам мы получили безопасное и качественное отображение HTML-писем без единой утечки IP-адресов, без JavaScript или какого-то иного потенциально опасного контента — всё находится полностью под нашим контролем.
Почему это здорово
Мы немного углубились в тему безопасности, сместив акцент с наиболее важного факта: нам удалось сделать на 100% HTML5/CSS3-совместимые HTML-письма, что не удавалось никому в мире! А значит, вам могут быть доступны все возможности современных HTML-страниц — и анимации, и адаптивность, и современные форматы изображений, и тёмные темы в том виде, который задумал автор письма (вам ведь тоже не нравится уродливая смена цветов с помощью примитивных алгоритмов?). Больше не нужно верстать сложные сетки таблицами как в 90-х, а свободно сверстать прекрасное адаптивное письмо для мобильных устройств — можно.
Очень странно, что в середине 2020 годов где-то в интернете может наблюдаться настолько дикое поведение, как отображение вашей почты почтовыми гигантами со всеми присущими им недостатками и уязвимостями.
Мы очень рады, что нам удалось создать безопасную, современную альтернативу, которая отвечает всем требованиям, стоящим перед современным почтовым клиентом.
Если что-то пошло не так
Мы гордимся тем, чего нам уже удалось достичь с Временной почтой AdGuard, но мы также понимаем, что всё ещё находимся в начале пути. Создавая новый продукт, почти невозможно полностью избежать ошибок и недочётов. Если вы обнаружили уязвимость, связанную с отображением писем, пожалуйста, напишите нам на security@adguard.com. Мы устраним уязвимость в максимально короткий срок, а вы сможете претендовать на вознаграждение в рамках нашей программы Bug Bounty.
Наша команда разработчиков очень серьёзно подходит к обратной связи от пользователей. Поэтому, если вы заметите, что ваше HTML-письмо отображается неверно, также отправьте нам сообщение на почту support@adguard.com, и мы обязательно исправим проблему.