В предыдущей статье я разобрал MoonShine как он есть в 2026 году: взвесил плюсы, минусы, сравнил с Filament и набросал план, что хотелось бы улучшить. В списке «хотелок» была интеграция со Spatie Activity Log — пакетом для логирования действий пользователей. Красивые логи, история изменений, кто и когда что нажал — всё это казалось отличным следующим шагом.
Но в процессе разработки я вспомнил об одной давней занозе…
Как всё началось: баг, который я видел ещё при первой установке
Когда я впервые ставил MoonShine, я заметил странную вещь. Если в подсказке к полю (hint) написать что-то вроде <strong>Важно!</strong>, то в интерфейсе это отображается как жирный текст, а не как буквальная строка <strong>Важно!</strong>. То есть HTML просто рендерится без экранирования.
Тогда я подумал «ну и ладно, наверное так задумано», поставил галочку в мозге и пошёл дальше. Но теперь, погрузившись в кодовую базу глубже, я понял: это не фича, это XSS-уязвимость. И не маленькая — она присутствует в подписях к полям (labels), в подсказках (hints), в карточках, в элементах UI вроде бейджей, дропдаунов и кнопок действий.
Если пользователь может влиять на содержимое этих полей — например, через импорт данных или API — атакующий может подсунуть туда произвольный JavaScript. Классический XSS во всей красе.

Четыре 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
Так выглядит интеграция Activity Log — история изменений, статистика событий, фильтрация
Как только отполирую — выпущу в OpenSource. Следите за обновлениями.
Кстати, о пакетах: тема Polaris
Раз уж мы говорим о пакетах для MoonShine, не могу не упомянуть мою тему Polaris (Полярная) — альтернативное оформление для MoonShine-админки.
Если вам надоел стандартный вид и хочется чего-то свежего — попробуйте. Установка стандартная через Composer, документация в репозитории. Буду рад фидбеку и звёздочкам на GitHub.
Итого
Контрибьютить в OpenSource оказалось не страшно, а местами даже интересно. Особенно когда мейнтейнеры отвечают конструктивно, а не просто закрывают PR без объяснений. MoonShine в этом смысле приятно удивил.
PR с opt-in экранированием — это не просто «починить баг». Это небольшой архитектурный паттерн, который даёт разработчикам выбор между безопасностью и гибкостью, не ломая существующий код. Надеюсь, он попадёт в основную ветку и поможет сделать MoonShine чуть безопаснее.
А пока — жду мержа и полирую Activity Log пакет. Не переключайтесь.