
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.
| 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 |
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: