| 2.0.0 (15.09.2026) |
Версия 2.0.0 — рефакторинг, безопасность, Яндекс SmartCaptcha и поддержка PHP 8.5
Внимание. В версии есть несовместимые изменения (см. раздел «Несовместимые изменения»). Перед обновлением рабочего сайта сделайте резервную копию.
1. Исправление уязвимостей
- Анонимный вызов произвольных событий ядра (submit.php). Эндпоинт формы выполнял GetModuleEvents("main", $_POST['EVENT_ID'], true) и ExecuteModuleEventEx($arEvent, array($_POST)) — любой посетитель мог анонимно вызвать любой обработчик события модуля main с полностью управляемыми аргументами. Теперь имя события берётся только из подписанных настроек компонента, область поиска обработчиков ограничена модулем logicloud.health, а в обработчик передаётся отфильтрованный по белому списку набор полей, а не весь $_POST.
- Неаутентифицированная запись в произвольный инфоблок (mass assignment, submit.php). IBLOCK_ID, SECTION_ID, ACTIVE, IBLOCK_ITEM_NAME принимались из запроса, а в PROPERTY_VALUES попадали все остальные ключи $_POST. Введена подпись серверных параметров формы (\Logicloud\Health\Security\FormSigner на базе Bitrix\Main\Security\Sign\Signer): браузер возвращает подписанную строку, подменить её нельзя. Инфоблок дополнительно проверяется на принадлежность текущему сайту, свойства принимаются только из списка полей, объявленных в настройках компонента, и только из числа реально существующих свойств инфоблока.
- Проверка капчи только на клиенте. Ранее JS обращался к ajax/captcha.php и, получив «OK», отправлял данные в submit.php — прямой POST капчу не проходил вообще. Проверка токена перенесена на сервер и выполняется в том же хите, в котором обрабатываются данные формы (класс \Logicloud\Health\Security\Captcha).
- Неаутентифицированная загрузка файлов (submit.php). Файлы принимались без ограничений и сохранялись в корень /upload/. Теперь загрузка возможна только в поля типа «Файл», объявленные в настройках компонента, действует белый список расширений (jpg, jpeg, png, gif, webp, bmp, pdf, doc, docx, xls, xlsx, odt, ods, rtf, txt, csv, zip), ограничение размера 10 МБ, проверка is_uploaded_file(); файлы складываются в отдельный каталог /upload/lcld.forms/ и привязываются к модулю.
- Неаутентифицированная запись в произвольный инфоблок (making-accept.php). IBLOCK_ID брался из $_POST['iblockId']. Инфоблок теперь определяется на сервере по символьному коду (lcld_online_form) в рамках текущего сайта, свойства принимаются только из списка свойств этого инфоблока, врач проверяется на принадлежность инфоблоку врачей и на активность. Устранено обращение к explode('-', $key)[1] без проверки наличия элемента, дававшее warning и раскрытие пути в логах.
- Раскрытие данных чужих инфоблоков (making-form.php). По $_POST['doctorId'] можно было получить название и изображение любого элемента любого инфоблока. Выборка ограничена инфоблоком врачей и активными элементами, добавлена обработка отсутствующего элемента.
- Сохранённая XSS в отзывах (news.list/reviews и news.list/mainpage.reviews). Свойства REVIEW и FIO выводились без экранирования, а источником данных была публичная форма. Значение REVIEW пропускается через CBXSanitizer (уровень SECURE_LEVEL_MIDDLE), ФИО и дата экранируются полностью.
- CSRF на странице настроек модуля (options.php). Обработчик $_REQUEST['LCLD_SETTINGS'] сохранял настройки без проверки sessid и без проверки прав. Добавлены check_bitrix_sessid(), bitrix_sessid_post() в форме и проверка права «W» на модуль через $APPLICATION->GetGroupRight().
- Снятие штатной проверки прав (options.php). Константа NOT_CHECK_PERMISSIONS = true больше не объявляется — проверка прав выполняется ядром, а страница дополнительно проверяет права и sessid самостоятельно. У модуля включены групповые права (MODULE_GROUP_RIGHTS).
- CSRF на копии страницы настроек (/bitrix/admin/lcld_template_settings.php). Закрыт теми же проверками — обе страницы теперь используют одну реализацию.
- Небезопасная десериализация данных импорта (services/classes/chighloadblocks.php). Значения с префиксом serialize# передавались в unserialize() без ограничения классов (PHP Object Injection). Добавлен параметр allowed_classes = false и обработка некорректного значения.
- Небезопасная десериализация настроек модуля. Во всех точках (header.php, footer.php, captcha.php, страницы настроек, шаблон формы) вызовы unserialize(Option::Get(...)) заменены на класс \Logicloud\Health\Settings с allowed_classes = false, проверкой типа и статическим кешем.
- Вывод настроек без экранирования. Адрес, телефоны, график работы, ссылки на соцсети, favicon и логотипы в header.php и footer.php экранируются; на внешние ссылки добавлен rel="noopener noreferrer". Поле «Сторонние скрипты» (scripts_text) по-прежнему выводится как HTML — это его назначение, но записать его теперь может только пользователь с правом «W» на модуль и только с валидным sessid.
- Экранирование в шаблоне формы и хлебных крошках. Заголовки, описания, плейсхолдеры, значения и коды полей, ссылки и заголовки цепочки навигации выводятся через htmlspecialcharsbx(), настройки формы передаются в JS через CUtil::PhpToJSObject().
2. Переход с Google reCAPTCHA v3 на Яндекс SmartCaptcha
- Валидация форм переведена на Яндекс SmartCaptcha. Токен проверяется на сервере по адресу https://smartcaptcha.cloud.yandex.ru/validate, результат читается из поля status ответа (ok/failed).
- Поля настроек заменены: вместо «Приватный ключ», «Публичный ключ» и «Минимальное значение оценки пользователя» теперь «Клиентский ключ» и «Серверный ключ». Порог оценки убран — у SmartCaptcha ответ бинарный, оценки score в нём нет. Ссылка на консоль для получения ключей ведёт на Yandex Cloud.
- Ключи хранятся в тех же настройках модуля под новыми именами captcha_client_key и captcha_server_key. Неприменимые ключи Google (captcha_public_key, captcha_private_key, captcha_value) удаляются из настроек автоматически при обновлении.
- В шаблоне подключается виджет https://smartcaptcha.cloud.yandex.ru/captcha.js в режиме render=onload. Капча невидимая: виджет отрисовывается в скрытый контейнер формы и запускается при отправке, посетитель видит проверку только в спорных случаях.
- Промежуточный запрос к ajax/captcha.php убран, и это не оптимизация, а требование сервиса: токен SmartCaptcha одноразовый и живёт пять минут, поэтому предварительная проверка «расходовала» бы его и последующая отправка формы всегда завершалась бы ошибкой. Токен уходит сразу в ajax/submit.php вместе с данными формы. Файл ajax/captcha.php оставлен и отвечает кодом 410, чтобы закешированные у посетителей старые версии скриптов получали понятный ответ, а не 404.
- Обработаны ошибки виджета (network-error, javascript-error) и добавлена страховка по таймауту: если посетитель закрыл окно проверки, форма не остаётся в подвешенном состоянии.
- После успешной отправки и после ошибки виджет сбрасывается — повторная отправка формы работает без перезагрузки страницы.
- Ответ сервера с кодом 403 (в том числе непройденная капча) показывается посетителю отдельным сообщением, а не общей «ошибкой сервера».
3. Результаты аудита перед публикацией (skills bitrix-tools/marketplace-security-skills)
Сборка проверена скилами scan и journal-review из набора bitrix-tools/marketplace-security-skills. Найденные при этой проверке уязвимости среднего уровня исправлены в этой же версии.
- Обход каталога на странице настроек шаблона. Поле tmp_name виджета Bitrix\Main\UI\FileInput подставлялось в путь как есть: $_SERVER['DOCUMENT_ROOT'] . '/upload/tmp' . $fileData['tmp_name']. Значение приходит из скрытого поля формы, поэтому пользователь с правом «W» на модуль мог передать tmp_name вида '/../../bitrix/php_interface/dbconn.php' вместе с name='logo.png': CFile::SaveFile проверяет расширение по полю name, а копирует файл по пути из tmp_name. Произвольный файл сервера попадал в web-доступный /upload/ и становился доступен по прямой ссылке, а из-за параметра 'del' => 'Y' ещё и удалялся с исходного места. Теперь путь нормализуется через realpath() и принимается, только если он остался внутри /upload/tmp и указывает на существующий файл; дополнительно действует белый список расширений (jpg, jpeg, png, gif, webp, svg, ico), а отклонённый файл выводит понятное сообщение вместо тихого сохранения.
- Сохранённая XSS в отзывах на детальной странице услуги. Шаблон news.list/services.detail выводил свойства REVIEW и FIO связанных отзывов без экранирования, тогда как в двух других шаблонах отзывов санитайзер уже применялся. Источник данных анонимный — отзывы создаёт публичная форма, поэтому посетитель мог внедрить скрипт, который выполнялся у всех читателей страницы услуги и у администратора при просмотре элемента. Текст отзыва теперь проходит через CBXSanitizer (SECURE_LEVEL_MIDDLE), ФИО и дата экранируются полностью.
- Отражённая XSS в шапке внутренних страниц. headerBufferedContent.php печатал конкатенацией $APPLICATION->GetTitle() и параметры страницы (pageDescription, servicesDesc, minPrice, doctorPrice, doctorMeasure), а значения linkMaking, doctorImg и doctorMakingLink подставлял прямо в атрибуты href и src. Метод GetTitle(), в отличие от ShowTitle(), возвращает заголовок без HTML-кодирования, а шаблон поиска кладёт в заголовок страницы значение параметра запроса q — обращение вида /search/?q=<img src=x onerror=...> выполняло скрипт в браузере посетителя без какой-либо авторизации. Все значения экранируются через htmlspecialcharsbx().
- Бессрочная подпись параметров формы. Подпись закрывала подмену параметров, но не содержала ни области действия, ни срока: снятая однажды со страницы, она оставалась валидной неограниченно долго, и после изменения настроек компонента (например, снятия ACTIVE='Y' для отправки заявок на модерацию) старую конфигурацию можно было продолжать использовать. В подписываемый набор добавлены SITE_ID, FORM_ID и отметка времени; ajax/submit.php принимает подпись только для текущего сайта и не старше суток, с допуском 5 минут на расхождение часов, иначе отвечает 403 SIGNATURE_EXPIRED.
Остальные находки аудита — вывод контента инфоблоков и параметров компонентов без htmlspecialcharsbx примерно в тридцати шаблонах. Источник этих данных — администратор или контент-редактор, то есть доверенная в 1С-Битрикс роль, поэтому они классифицированы как hardening шаблонов и вынесены за рамки этой версии.
4. Поддержка PHP 8.5
- Убраны короткие открывающие теги <? в языковых файлах шаблона, в include/breadcrumb.php и в install/version.php — при short_open_tag = Off код выводился как текст.
- Устранено переобъявление констант (NO_KEEP_STATISTIC, NOT_CHECK_PERMISSIONS) — в PHP 8.5 переобъявление констант объявлено устаревшим; используется define() с проверкой defined().
- Инициализированы переменные $by/$order, передаваемые по ссылке в CEventMessage::GetList(), CLanguage::GetList(), CSite::GetList() (install/events.php, install/index.php, chighloadblocks.php, cfiles.php, ciblock.php) — ранее выдавалось «Undefined variable».
- Убраны вызовы strlen()/trim() с потенциальным null (wizard.php, cfiles.php) — передача null в строковые функции устарела с PHP 8.1.
- Свойства класса установщика объявлены явно вместо var и динамических свойств (устарели с PHP 8.2).
- Сортировки usort(..., strcmp($a["SORT"], $b["SORT"])) заменены на числовое сравнение с оператором «» и значением по умолчанию — strcmp(null, null) устарел, а лексикографическое сравнение чисел давало неверный порядок (10 перед 2).
- Устранены обращения к несуществующим ключам массивов в шаблонах и result_modifier-ах (?? и isset), count() на потенциальном null — в PHP 8 это TypeError.
- Исправлены вызовы приватных методов класса CHighloadblocks как глобальных функций (__hlImportPrepareField, __getEnumUserFields) — фатальная ошибка при импорте HL-блоков.
- В установщик добавлена проверка минимальной версии PHP с понятным сообщением.
- Устаревшие ветки с прямыми вызовами mysqli_* в services/classes/autoload.php заменены на API ядра.
5. Ошибки кеширования
- PAGE_DESCRIPTION регистрировался в SetResultCacheKeys() только для корневого раздела (doctors.section, prices, news/prices/catalog.section). На всех остальных страницах component_epilog.php читал незарегистрированный ключ, и при попадании в кеш описание раздела пропадало, а в PHP 8 добавлялось предупреждение. Ключ регистрируется безусловно.
- То же исправлено для SERV_DESC, MIN_PRICE и LINK_MAKING в news.list/services.detail: LINK_MAKING теперь всегда попадает в кеш-ключи (раньше задавался только при наличии врачей).
- В news/prices/section.php переменная $sectionIds не инициализировалась: при отсутствии подразделов в endDataCache() уходило неопределённое значение, а после чтения кеша данные не соответствовали структуре.
- Кеш выборки прайса (news/prices/section.php, news/prices/news.php) жил только по времени и не сбрасывался при изменении контента. Добавлена регистрация тега инфоблока (iblock_id_N) через управляемый кеш — выборка обновляется сразу после правки услуг и разделов.
- Время жизни кеша уменьшено с 36 000 000 секунд (более 400 дней) до разумных значений во всех компонентах шаблона и в собственном кеше прайса.
- Выборка разделов глубже второго уровня выполнялась без фильтра по инфоблоку и собирала разделы всех инфоблоков сайта — добавлен IBLOCK_ID.
- Значения, прочитанные из кеша, проверяются на тип перед использованием.
6. Исправление ошибок
- Файл include.php модуля был пустым — классы из каталога lib/ никогда не подключались. Добавлена регистрация автозагрузки.
- В каталоге lib/ существовали два файла, различающиеся только регистром имени (Main.php и main.php) с разными классами: на Windows и macOS один затирал другой при распаковке дистрибутива. Оставлена одна реализация — lib/main.php, класс \Logicloud\Health\Main.
- При удалении модуля страница настроек оставалась в /bitrix/admin: DeleteDirFiles() вызывался с несуществующим путём install/admin вместо install/assets/admin.
- news.list/services.detail: при отсутствии раздела (неверный PARENT_SECTION или снятая активность) страница падала с ошибкой «Call to a member function GetFields() on null». Аналогично исправлено в news.list/documents, news.list/making.doctors, news.list/services.section.
- news.list/services.detail: сортировка врачей применялась к несуществующему ключу $sections[ID]['DOCTORS'] и фактически не работала.
- news.list/services.detail: расчёт минимальной цены учитывал нулевые и пустые значения и мог дать 0.
- news.list/documents: обращение к $sections[...] до его создания давало предупреждение «Undefined array key», а элементы несуществующих разделов терялись.
- Мастер установки: условие выбора шаблона по умолчанию было взаимоисключающим и всегда сбрасывало шаблон в пустую строку.
- Мастер установки: содержимое css/style.css читалось с диска и вставлялось в страницу внутри цикла по шаблонам — столько раз, сколько найдено шаблонов. Стиль читается один раз и кешируется.
- services/classes/autoload.php: метод IsUtf8() мог вернуть неопределённую переменную и не распознавал кодировки utf8mb4/utf8mb3.
- Форма обратной связи: успешной считалась любая непустая строка ответа. Теперь дополнительно проверяется HTTP-статус 200, эндпоинт возвращает корректные коды 403/405/413/415/500.
- Прайс: подразделы третьего уровня выбирались отдельным запросом на каждый элемент списка (N*M запросов на страницу). Выборка вынесена из цикла.
- news.list/services.detail: данные врачей выбирались по одному элементу на каждую услугу (проблема N+1). Заменено на одну выборку по списку ID.
7. Рефакторинг
- Добавлен слой классов модуля с автозагрузкой: \Logicloud\Health\Settings (чтение и запись настроек), \Logicloud\Health\Main (общие утилиты), \Logicloud\Health\Security\FormSigner (подпись параметров форм), \Logicloud\Health\Security\Captcha (серверная проверка reCAPTCHA), \Logicloud\Health\Iblock\Resolver (поиск инфоблоков по коду и белый список свойств).
- Страница настроек шаблона существовала в виде двух разошедшихся копий по 390 строк (options.php и install/assets/admin/lcld_template_settings.php). Реализация вынесена в admin/settings_page.php, обе точки входа сведены к тонким обёрткам.
- Глобальная функция array_filter_recursive() заменена методом \Logicloud\Health\Main::filterRecursive(); старая функция оставлена как обёртка для обратной совместимости и защищена от повторного объявления.
- Компонент logicloud.health:forms переписан: убрано дублирование при разборе параметров полей, добавлены значения по умолчанию, экранирование и единая карта соответствия сообщений об ошибках.
- Шаблон modal формы: два почти одинаковых блока инициализации JS сведены к одному, настройки передаются через CUtil::PhpToJSObject() вместо ручной сборки JSON.
- callback.js: два продублированных почти дословно блока показа ошибок формы сведены в метод _showErrors(), работа с капчей вынесена в _getCaptchaToken() на промисах.
- include/breadcrumb.php переписан на читаемую разметку с микроразметкой schema.org для всех элементов цепочки, включая текущую страницу.
- Убран мёртвый и закомментированный код, приведены к единому стилю отступы, кавычки и объявления типов в затронутых файлах; добавлены комментарии, поясняющие нетривиальные решения.
8. Несовместимые изменения
- Пользовательское событие формы (параметр «Использовать пользовательское событие») теперь ищется среди обработчиков, зарегистрированных на модуль logicloud.health, а не main. Существующие обработчики нужно перерегистрировать: AddEventHandler('logicloud.health', 'ВАШЕ_СОБЫТИЕ', ...).
- Обработчик события получает массив только из полей, объявленных в настройках компонента, вместо всего $_POST.
- Публичный эндпоинт ajax/submit.php принимает только запросы с корректной подписью параметров. Прямые вызовы из стороннего кода, собиравшие POST вручную, перестанут работать.
- После обновления форму защищает капча только при заполненных ключах SmartCaptcha. Ключи Google не переносятся (они неприменимы) и удаляются из настроек. Получите клиентский и серверный ключ в Yandex Cloud SmartCaptcha и укажите их в настройках модуля — до этого формы работают без капчи.
- Эндпоинт ajax/captcha.php больше не выполняет проверку и отвечает кодом 410. Если вы обращались к нему из своего кода, уберите этот вызов.
- Подпись параметров формы действительна сутки и только для того сайта, где была выдана. Если страница с формой отдаётся из долгоживущего кеша (композитный кеш, CDN), убедитесь, что время жизни кеша меньше суток, иначе посетитель получит ответ 403 SIGNATURE_EXPIRED.
- Для страницы настроек модуля требуется право «W» на модуль logicloud.health. После обновления проверьте права групп пользователей.
- Изменения в шаблоне сайта (каталог install/wizards/.../site/templates/logicloud.health) попадают в /bitrix/wizards и применяются при установке мастером. Для уже развёрнутого сайта соответствующие файлы шаблона нужно перенести вручную с учётом ваших правок.
|