
JetEngine <= 3.7.7 — Cross-Site Scripting stocké non authentifié via CCT REST API
Date: 11 mars 2026 Score CVSS: 7.1 (Élevé) Vecteur CVSS: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:L Plugin affecté: JetEngine <= 3.7.7 Slug du plugin: jet-engine Corrigé dans: 3.7.8 CVE: CVE-2025-67923 CWE: CWE-79 Chercheur: Bonds (via Patchstack) Signalé: 19 octobre 2025 Divulgué: janvier 2026 Version de WordPress testée: Dernière
CVE-2025-67923 est une vulnérabilité de Cross-Site Scripting stocké non authentifié dans le plugin WordPress JetEngine affectant toutes les versions jusqu'à 3.7.7 incluse. Un attaquant non authentifié peut écrire du HTML/JavaScript arbitraire dans un champ texte d'un type de contenu personnalisé (CCT) via l'API REST publique, qui est ensuite injecté non désinfecté dans le DOM via un sink JavaScript innerHTML dans le widget Maps Listing lorsqu'une victime visite une page contenant ce widget.
L'attaque ne nécessite ni authentification ni privilèges. Elle exige uniquement que :
public.✅ VULNÉRABILITÉ CONFIRMÉE — Charge utile XSS stockée écrite et exécutée
Impact confirmé :
La vulnérabilité est une combinaison de deux faiblesses indépendantes :
Absence de désinfection des entrées (stockage) : La méthode sanitize_field_value() du gestionnaire d'éléments CCT n'a aucun chemin de désinfection pour les champs de type text. Le cas default: ne fait que convertir les horodatages — le HTML brut passe et est persisté en base de données.
Sink XSS basé sur le DOM (rendu) : Le widget Maps Listing lit la valeur de champ stockée via get_marker_label(), l'enveloppe dans htmlspecialchars(json_encode(...)) pour l'attribut HTML data-markers, et le JavaScript frontal lit l'attribut avec getAttribute() (qui décode les entités HTML), puis insère markerData.label directement via innerHTML — exécutant tout HTML/JavaScript intégré.
| Fichier | Problème |
|---|---|
includes/modules/custom-content-types/inc/rest-api/public-controller.php:424 | create_item_permissions_check renvoie true lorsque le CCT a rest_put_access = 'public' — aucune authentification requise |
includes/modules/custom-content-types/inc/item-handler.php:489 | Branche default: de sanitize_field_value() — aucune désinfection HTML pour les champs de type text |
includes/modules/maps-listings/inc/render.php:233 | get_marker_label() renvoie la valeur meta brute sans esc_html() |
includes/modules/maps-listings/inc/render.php:247 | htmlspecialchars(json_encode($result)) — protège uniquement la limite de l'attribut, pas l'injection innerHTML |
includes/modules/maps-listings/assets/js/frontend-maps.js:112 | pinData.content = general.marker.html.replace('_marker_label_', markerData.label) — étiquette brute insérée dans la chaîne HTML |
includes/modules/maps-listings/assets/js/public/mapbox-maps.js:175 | el.innerHTML = data.content — sink d'exécution XSS |
includes/modules/maps-listings/assets/js/public/leaflet-maps.js:34 | contentHtml.innerHTML = content — sink d'exécution XSS |
Le contrôleur REST public des CCT vérifie les permissions via 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' );
}
Lorsqu'un CCT est configuré avec rest_put_access = 'public' (une configuration prise en charge et documentée pour les formulaires publics), le point de terminaison est entièrement non authentifié. N'importe quel client HTTP peut envoyer une requête POST à /wp-json/jet-cct/{slug}.
Le gestionnaire REST appelle $handler->update_item($params), qui atteint 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 champ de type text atteint la branche default:. maybe_to_timestamp() renvoie la valeur inchangée pour les chaînes non datées. La charge utile XSS `` est stockée telle quelle.
Lorsque le widget Maps Listing effectue le rendu, get_marker_label() lit le champ :
// 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 ""
}
La valeur renvoyée est placée dans le tableau de données des marqueurs :
// render.php:231-237
$result[] = array(
'id' => $post_id,
'latLang' => $latlang,
'label' => $this->get_marker_label( $post, $settings ), // raw XSS payload
// ...
);
Le tableau des marqueurs est encodé pour un attribut HTML :
// render.php:247
return htmlspecialchars( json_encode( $result ) );
json_encode sérialise `` comme la chaîne "". htmlspecialchars encode ensuite en HTML l'intégralité du JSON, produisant :
[{...,"label":"<img src=x onerror=alert(1)>",...}]
Ceci est écrit dans l'attribut HTML data-markers. L'encodage ne protège que la limite de l'attribut. Lorsque JavaScript lit l'attribut, le navigateur décode les entités HTML, restaurant les caractères d'origine :
// Browser automatically decodes entities when reading via dataset / getAttribute
const markers = JSON.parse(el.dataset.markers);
// markers[0].label === ''
Dans frontend-maps.js, la chaîne d'étiquette brute est insérée directement dans une chaîne de contenu HTML :
// frontend-maps.js:112
pinData.content = general.marker.html.replace( '_marker_label_', markerData.label );
// pinData.content = '<div class="jet-map-marker-wrap">
//
// </div>'