
JetEngine <= 3.7.7 — Unauthenticated Stored Cross-Site Scripting via CCT REST API
Date: March 11, 2026 CVSS Score: 7.1 (High) CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:L Affected Plugin: JetEngine <= 3.7.7 Plugin Slug: jet-engine Fixed In: 3.7.8 CVE: CVE-2025-67923 CWE: CWE-79 Researcher: Bonds (via Patchstack) Reported: October 19, 2025 Disclosed: January 2026 WordPress Version Tested: Latest
CVE-2025-67923 is an Unauthenticated Stored Cross-Site Scripting vulnerability in the JetEngine WordPress plugin affecting all versions up to and including 3.7.7. An unauthenticated attacker can write arbitrary HTML/JavaScript into a Custom Content Type (CCT) text field via the public REST API, which is then injected unsanitized into the DOM via a JavaScript innerHTML sink in the Maps Listing widget when a victim visits any page containing that widget.
The attack requires no authentication and no privileges. It requires only that:
public.✅ VULNERABILITY CONFIRMED — Stored XSS Payload Written and Executed
Confirmed impact:
The vulnerability is a combination of two independent weaknesses:
Missing input sanitization (store): The CCT item handler's sanitize_field_value() method has no sanitization path for text-type fields. The default: case only converts timestamps — raw HTML passes through and is persisted to the database.
DOM-based XSS sink (render): The Maps Listing widget reads the stored field value via get_marker_label(), wraps it in htmlspecialchars(json_encode(...)) for the data-markers HTML attribute, and the JavaScript frontend reads the attribute with getAttribute() (which decodes HTML entities), then inserts markerData.label directly via innerHTML — executing any embedded HTML/JavaScript.
| File | Issue |
|---|---|
includes/modules/custom-content-types/inc/rest-api/public-controller.php:424 | create_item_permissions_check returns true when CCT rest_put_access = 'public' — no auth required |
includes/modules/custom-content-types/inc/item-handler.php:489 | sanitize_field_value() default: branch — no HTML sanitization for text type fields |
includes/modules/maps-listings/inc/render.php:233 | get_marker_label() returns raw meta value with no esc_html() |
includes/modules/maps-listings/inc/render.php:247 | htmlspecialchars(json_encode($result)) — only protects attribute boundary, not innerHTML injection |
includes/modules/maps-listings/assets/js/frontend-maps.js:112 | pinData.content = general.marker.html.replace('_marker_label_', markerData.label) — raw label inserted into HTML string |
includes/modules/maps-listings/assets/js/public/mapbox-maps.js:175 | el.innerHTML = data.content — XSS execution sink |
includes/modules/maps-listings/assets/js/public/leaflet-maps.js:34 | contentHtml.innerHTML = content — XSS execution sink |
The CCT public REST controller checks 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' );
}
When a CCT is configured with rest_put_access = 'public' (a supported, documented configuration for public-facing forms), the endpoint is fully unauthenticated. Any HTTP client can POST to /wp-json/jet-cct/{slug}.
The REST handler calls $handler->update_item($params) which reaches 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;
}
A text-type field hits the default: branch. maybe_to_timestamp() returns the value unchanged for non-date strings. The XSS payload `` is stored verbatim.
When the Maps Listing widget renders, get_marker_label() reads the field:
// 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 ""
}
The returned value is placed into the marker data array:
// render.php:231-237
$result[] = array(
'id' => $post_id,
'latLang' => $latlang,
'label' => $this->get_marker_label( $post, $settings ), // raw XSS payload
// ...
);
The marker array is encoded for an HTML attribute:
// render.php:247
return htmlspecialchars( json_encode( $result ) );
json_encode serialises `` as the string "". htmlspecialchars then HTML-encodes the full JSON, producing:
[{...,"label":"<img src=x onerror=alert(1)>",...}]
This is written to the data-markers HTML attribute. The encoding only protects the attribute boundary. When JavaScript reads the attribute, the browser decodes HTML entities, restoring the original characters:
// Browser automatically decodes entities when reading via dataset / getAttribute
const markers = JSON.parse(el.dataset.markers);
// markers[0].label === ''
In frontend-maps.js, the raw label string is spliced directly into an HTML content string:
// frontend-maps.js:112
pinData.content = general.marker.html.replace( '_marker_label_', markerData.label );
// pinData.content = '<div class="jet-map-marker-wrap">
//
// </div>'
This content string is then set as the marker element's innerHTML:
// mapbox-maps.js:175
el.innerHTML = data.content; // XSS FIRES
// leaflet-maps.js:34
contentHtml.innerHTML = content; // XSS FIRES
Every visitor who loads a page containing the Maps Listing widget triggers the payload.