Загрузка...

Мой первый контрибьют в OpenSource: как я чинил XSS в MoonShine

В предыдущей статье я разобрал MoonShine как он есть в 2026 году: взвесил плюсы, минусы, сравнил с Filament и набросал план, что хотелось бы улучшить. В списке «хотелок» была интеграция со Spatie Activity Log — пакетом для логирования действий пользователей. Красивые логи, история изменений, кто и когда что нажал — всё это казалось отличным следующим шагом.

Но в процессе разработки я вспомнил об одной давней занозе…

Как всё началось: баг, который я видел ещё при первой установке

Когда я впервые ставил MoonShine, я заметил странную вещь. Если в подсказке к полю (hint) написать что-то вроде <strong>Важно!</strong>, то в интерфейсе это отображается как жирный текст, а не как буквальная строка <strong>Важно!</strong>. То есть HTML просто рендерится без экранирования.

Тогда я подумал «ну и ладно, наверное так задумано», поставил галочку в мозге и пошёл дальше. Но теперь, погрузившись в кодовую базу глубже, я понял: это не фича, это XSS-уязвимость. И не маленькая — она присутствует в подписях к полям (labels), в подсказках (hints), в карточках, в элементах UI вроде бейджей, дропдаунов и кнопок действий.

Если пользователь может влиять на содержимое этих полей — например, через импорт данных или API — атакующий может подсунуть туда произвольный JavaScript. Классический XSS во всей красе.

Moonshine XSS
Подписи к полям рендерят HTML как есть — теги интерпретируются вместо того, чтобы отображаться текстом

Четыре PR вместо одного: логика разбивки

Проблема оказалась разбросана по всей кодовой базе. Я нашёл неэкранированные {!! !!} в Blade-шаблонах в нескольких местах сразу: подсказки к полям, метки полей, значения карточек, различные UI-компоненты. Исправить всё в одном PR — значит сделать монстра из 40+ изменённых файлов, в котором сложно разобраться стороннему ревьюеру.

Поэтому я разбил задачу на 4 отдельных PR:

  • PR #1989 — подсказки к полям (hints)
  • PR #1990 — метки полей (labels)
  • PR #1991 — значения карточек (card values)
  • PR #1992 — прочие UI-компоненты (Badge, Dropdown, ActionButton, Popover и другие)

Принцип простой: один PR — одна логическая единица. Ревьюер видит изменение, понимает зачем оно, проверяет, идёт дальше. Никакого когнитивного перегруза.

Комментарий от Данила: обратная совместимость важнее?

Несколько дней я ждал фидбека. И вот пришёл ответ от lee-to (Данил, один из мейнтейнеров MoonShine):

«Мы намеренно убрали экранирование из всех этих мест давным-давно. Это было решение разработчиков/пользователей MoonShine — делегировать ответственность самому разработчику. Но проблема сейчас не в этом. Вы предлагаете изменения, которые сломают совместимость. Тот, кто использует HTML в этих местах, получит непредсказуемое поведение…»

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

Моя позиция: безопасность должна быть дефолтом. Да, возможно 10% проектов реально используют HTML в этих местах намеренно. Но остальные 90% просто не знают, что они уязвимы. Это и есть классическая ловушка «security by obscurity».

Данил, впрочем, не закрыл PR и не сказал «нет». Он предложил подумать, как сделать это не ломая старый код.

Решение: opt-in экранирование

Я подумал. Классический способ решить такую дилемму — сделать новое безопасное поведение опциональным, а не обязательным. В мире Laravel это называется opt-in: хочешь — включаешь, не хочешь — ничего не меняется.

Конкретно я предложил следующее решение:

1. Конфигурационные флаги

В файл config/moonshine.php добавляется новая секция:

'html_escaping' => [
    'hints'       => false, // opt-in
    'labels'      => false, // opt-in
    'card_values' => false, // opt-in
    'ui_elements' => false, // opt-in
],

По умолчанию все флаги выключены — поведение как раньше, ничего не ломается. Хочешь безопасности — ставишь true и получаешь экранирование.

2. Явные *Html()-методы для доверенного контента

Для тех, кто намеренно хочет вставить HTML (например, иконку SVG в подпись к полю), добавлены методы-близнецы с суффиксом Html:

// Безопасный текст — экранируется при включённом флаге
Text::make('Название')->hint('Обычный текст подсказки')

// Доверенный HTML — никогда не экранируется
Text::make('Название')->hintHtml('<strong>Жирная</strong> подсказка')

Полная таблица новых методов:

Трейт / Класс Безопасный метод HTML-метод
WithHint hint() hintHtml()
WithLabel setLabel() / make() labelHtml()
Card values() valuesHtml()
Badge make($value) valueHtml()
Dropdown make($title) / items() titleHtml() / itemsHtml()
ActionButton make($label) labelHtml()
Popover trigger() triggerHtml()
ValueMetric make($label) / valueFormat() labelHtml() / valueFormatHtml()
MenuItem make($label) labelHtml()
TabsTab make($label) labelHtml()

3. Логика в Blade-шаблонах

В шаблонах добавилась простая условная логика:

@if(! $escapeHints || $rawFlag)
    {!! $value !!}  {{-- дефолт: как раньше, совместимо с v4 --}}
@else
    {{ $value }}    {{-- opt-in: экранируем --}}
@endif

4. Исправление storage invariant

Попутно выяснилось, что экранирование было ещё и неправильно вставлено в pipeline обработки данных — в методах prepareRequestValue и resolveValue. Это грубое нарушение принципа: данные должны храниться «как есть», а экранирование — это задача слоя отображения. Я это тоже исправил.

5. Путь к v5.x

В документации к PR прописан план: в версии 5.x все флаги по умолчанию станут true. То есть со временем экранирование станет дефолтным поведением, но у разработчиков будет время подготовиться заранее.

Git-рабочий процесс: как я объединял PR

После того как Данил одобрил концепцию, мне нужно было переписать все четыре PR в один согласно новому подходу. Для этого я работал в ветке fix/hint-html-escaping и использовал следующие команды:

# Переключаемся на нашу ветку
git checkout fix/hint-html-escaping

# Сбрасываем к последнему коммиту в upstream (убираем старые коммиты)
git fetch upstream
git reset --hard upstream/4.x

# Пишем весь новый код заново (или переносим изменения)
# ... редактирование файлов ...

# Создаём один единственный коммит со всеми изменениями
git add -A
git commit -m "feat: add opt-in HTML escaping for security (v4.x compatible)"

# Принудительно пушим, так как история изменилась (force push)
git push origin fix/hint-html-escaping --force

Force push в публичный PR — это немного неловко, но когда вы переписываете историю коммитов по просьбе мейнтейнера, это нормальная практика. Главное — предупредить в комментарии, что вы это сделали.

Роль ИИ в написании кода

Честно говоря, весь код PR я писал с использованием ИИ. В первую очередь — Claude Sonnet 4.6 в режиме «думающий» (extended thinking) и Codex 5.3 среднее думание. Связка работает хорошо: Claude отлично понимает контекст и архитектурные решения, Codex уверенно генерирует boilerplate-код.

Но несколько раз пришлось серьёзно ревьювить результаты. Первые версии кода имели характерные проблемы:

  • Лишние конструкции, которые «просто на всякий случай»
  • Дублирование логики в разных местах
  • Чрезмерно сложные условия там, где нужен простой флаг

После нескольких итераций правок код стал чистым и понятным. Комментарий к PR тоже сгенерировал ИИ — английский у меня, мягко говоря, не родной, а писать подробные технические описания на нём руками было бы мучением.

Итог: PR ждёт мержа, но я доволен

На момент написания статьи PR #1989 всё ещё открыт и ждёт мержа. Мейнтейнеры заняты, это OpenSource — так бывает. Но сам опыт оказался очень полезным.

До этого я никогда не делал контрибьют в чужие OpenSource-проекты. Всегда казалось, что это что-то для «настоящих» программистов с огромным опытом. Оказалось, что нет: нужно просто найти реальную проблему, предложить разумное решение и быть готовым к диалогу с мейнтейнерами. Они обычные люди с обычными приоритетами.

Что я вынес из этого опыта:

  • Дробите PR — маленькие понятные изменения мержат охотнее
  • Будьте готовы к компромиссу — ваше видение может расходиться с видением мейнтейнеров, и это нормально
  • ИИ — хороший инструмент, но не замена голове — первые версии кода нужно ревьювить
  • Английский комментарий может написать ИИ — и это совершенно нормально

Что дальше: пакет с Activity Log

Параллельно с PR я всё-таки продолжил работу над интеграцией Spatie Activity Log для MoonShine. Большая часть кода уже сгенерирована, осталось отполировать детали.

Пакет будет уметь:

  • Визуальный diff — наглядное сравнение «было/стало» для каждого поля
  • Версионирование записей — полная история изменений каждой модели
  • Откат изменений — возможность вернуться к любой предыдущей версии
  • UI для просмотра истории — удобный интерфейс прямо в MoonShine

Moonshine TrailТак выглядит интеграция Activity Log — история изменений, статистика событий, фильтрация
Как только отполирую — выпущу в OpenSource. Следите за обновлениями.

Кстати, о пакетах: тема Polaris

Раз уж мы говорим о пакетах для MoonShine, не могу не упомянуть мою тему Polaris (Полярная) — альтернативное оформление для MoonShine-админки.

Если вам надоел стандартный вид и хочется чего-то свежего — попробуйте. Установка стандартная через Composer, документация в репозитории. Буду рад фидбеку и звёздочкам на GitHub.

Итого

Контрибьютить в OpenSource оказалось не страшно, а местами даже интересно. Особенно когда мейнтейнеры отвечают конструктивно, а не просто закрывают PR без объяснений. MoonShine в этом смысле приятно удивил.

PR с opt-in экранированием — это не просто «починить баг». Это небольшой архитектурный паттерн, который даёт разработчикам выбор между безопасностью и гибкостью, не ломая существующий код. Надеюсь, он попадёт в основную ветку и поможет сделать MoonShine чуть безопаснее.

А пока — жду мержа и полирую Activity Log пакет. Не переключайтесь.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *