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