CVE-2026-102391 The keys the sanitiser never visits in JetFormBuilder

Intrudify's autonomous pentesting engine discovered an unauthenticated stored cross-site scripting vulnerability in JetFormBuilder, affecting all versions up to and including 3.6.5.4. The plugin's recursive input sanitiser cleans every submitted value and carries every array key through untouched. A visitor with no account puts the payload in the key, and the Select field renderer prints it back into a public page with no escaping.

Discovered by
Intrudify autonomous engine
Validated & reported by
Disclosure
30 September 2026
Severity
High · CVSS 7.1
Vulnerability class
Stored XSS, CWE-79
Affected
JetFormBuilder ≤ 3.6.5.4
Fixed in
3.6.6
Active installs
80,000+
Attacker privilege
None, unauthenticated
User interaction
None, the payload runs for every visitor
Precondition
A public form whose Select field reads its options from a meta key the form writes

Summary

JetFormBuilder is a block-based form builder by Crocoblock. Intrudify's autonomous testing engine found two defects that are each survivable alone and fatal together. The input sanitiser recurses through submitted arrays and sanitises every value it reaches, while carrying each array key through verbatim. The Select field renderer prints its option value into an attribute and its option label into element content, escaping neither.

A visitor with no account submits one POST with the payload in an array key. The payload is stored, and the page that renders those options serves it as live markup to everyone who opens it afterwards.

Technical detail

The renderer that escapes everything except the option

At modules/option-field/blocks/select/block-template.php:131:

// line 128, three lines above
$calc = ' data-calculate="' . esc_attr( $option['calculate'] ) . '"';

// line 131
printf( '<option value="%1$s" %3$s%4$s>%2$s</option>', $val, $label, $selected, $calc );

$val lands inside a double-quoted attribute and $label lands in element content. Neither is escaped. The author escaped $calc three lines above, so escaping was on their mind while writing this block.

The sibling renderers for the same option data do escape. Radio escapes the value at radio/block-render.php:85 with esc_attr() and the label at line 97 with wp_kses_post(). Checkbox escapes the value at checkbox/block-render.php:118. Select is the one of the three that omits it.

The suppression that covers the line

The sniff that exists to catch this is switched off across the region. Line 102 opens it and line 139 closes it, so the vulnerable printf on line 131 sits inside:

// line 102
// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
        printf( '<option value="" %1$s>%2$s</option>', $additional_attrs, $placeholder );

        ... 27 lines ...

        printf( '<option value="%1$s" %3$s%4$s>%2$s</option>', $val, $label, $selected, $calc );

// line 139
// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped

The placeholder printf on line 103 emits pre-built attribute markup, which is a legitimate reason to silence the sniff on that line. The suppression was opened as a block and spans thirty-seven lines, so the option loop added later inherited the silence.

The sanitiser that never visits the keys

includes/classes/tools.php:536 walks submitted input:

public static function sanitize_recursive( $source = null ) {
    if ( ! is_array( $source ) ) {
        return self::sanitize( $source );
    }

    $result = array();

    foreach ( $source as $key => $value ) {
        $result[ $key ] = self::sanitize_recursive( $value );   // the key is copied
    }

    return $result;
}

Every value reaches sanitize(), which routes to sanitize_text_field(), and that applies wp_kses_post() to any string containing a <. A script tag submitted as a value is removed. The key beside it is read once, in the foreach, and written straight into the result array.

The function is named for recursion and it does recurse. It descends through nested arrays and cleans every leaf it lands on. The key is the other half of each entry, and no code path touches it.

The chain

  1. An anonymous visitor fetches the public page holding the form and reads the _wpnonce that is printed for every logged-out visitor.
  2. One POST carries the payload in an array key. The form's Insert Post action writes the array to post meta, key included.
  3. The Select field reads its options from that meta key and prints them. The value breaks out of the value=" attribute, and </select> leaves the "in select" HTML insertion mode, after which the parser treats what follows as ordinary markup.
  4. The injected <img onerror> fires as the broken image loads, and the injected <script> runs. Every visitor of that page executes it, including an administrator.
// the POST, unauthenticated, payload in the array KEY
_wpnonce=<nonce>&_jet_engine_booking_form_id=<form id>&__queried_post_id=<page id>
&_jfb_current_render_states[]=DEFAULT.STATE
&colors["></option></select><img src=x onerror=alert(document.domain)>]=1

// what the public page then serves to every visitor
<option value="a:1:{s:...:""></option></select><img src=x onerror=alert(document.domain)>...

The "in select" insertion mode is what makes the second break-out necessary. Inside a <select> the parser ignores most tags, so closing the attribute alone would leave the payload inert. Closing the element as well returns the parser to a mode where an <img> and a <script> are real elements.

Verification

We reproduced this on a clean WordPress 7.1 installation with JetFormBuilder 3.6.5.4 at its shipped settings. The form was a public one with a field named colors, a Select field whose options come from a dynamic source reading the meta key the form writes, and an Insert Post action mapping the field to that key. The attacker held no account.

One anonymous POST returned 302 to ?inserted_post_id=<n>&status=success, and the stored meta held the payload with its markup intact, read back out of the database itself. Opening the public page executed alert(document.domain) and set window.__JFB_XSS = 1, both observed in a real browser.

The same payload submitted as a field value is the control. It travels through sanitize_recursive() into sanitize_text_field(), where wp_kses_post() strips the script tag, and nothing executes. The key is the only way through, which is what identifies the sanitiser as the defect and the renderer as the sink.

Impact

The payload is stored and runs in the site origin for every visitor of the affected page. No authentication is needed to plant it and no interaction is needed to trigger it beyond opening a public page.

An administrator who opens that page executes it in an authenticated session, where it can read the wp_rest nonce and act as them. The reachable end states include creating a second administrator and installing a plugin, and plugin installation is arbitrary PHP on the host. Every other visitor of the page is exposed to whatever the payload does to them directly.

Remediation

Update JetFormBuilder to 3.6.6 or later.

Crocoblock fixed both halves in that release. The renderer now reads printf( ..., esc_attr( $val ), esc_html( $label ), $selected, $calc ), and sanitize_recursive() passes string keys through a new sanitize_array_key() that strips tags, with a guard that keeps the first value when two sanitised keys collide.

Fixing both is the right call even though either one alone closes this chain. The sanitiser governs every consumer of submitted data, and the renderer governs every source of option data. A fix to one leaves the other defect available to the next chain that comes along.

The wider lesson

A recursive sanitiser is a function whose whole purpose is to leave nothing untouched, and its name advertises exactly that. It descends through every level of nesting and cleans every leaf it reaches. The key is half of every entry in a PHP array, it is attacker-controlled on any form submission, and it is the half the recursion never visits. Any sanitiser of this shape is worth one test: put the payload in a key and see what comes out the other side.

The second point is about suppressions. The escaping sniff would have flagged line 131 the day it was written. It was silenced by a phpcs:disable opened for a different line twenty-eight lines earlier, for a reason that was good at the time. A suppression written as a block keeps applying to code that did not exist when the decision was made, and it applies silently, which is the property that makes it dangerous. Scoping a suppression to the single line that needs it costs one comment and expires on its own.

Disclosure timeline

  • 2026-09-24 Vulnerability identified and full chain validated by Intrudify, reported via Patchstack
  • 2026-09-29 Fixed release 3.6.6 published by Crocoblock
  • 2026-09-30 Public disclosure · CVE-2026-102391 assigned

Questions

What is CVE-2026-102391?

CVE-2026-102391 is an unauthenticated stored cross-site scripting vulnerability in the JetFormBuilder WordPress plugin, versions 3.6.5.4 and earlier. It was discovered autonomously by the Intrudify AI penetration testing engine. The plugin's recursive input sanitiser cleans every submitted value and passes array keys through untouched, and the Select field renderer prints the option value and label into the page with no escaping.

Who can exploit CVE-2026-102391, and does the victim have to do anything?

An anonymous visitor with no account submits one POST to a public form. The payload is stored in post meta and runs for every later visitor of the page that renders it, with no interaction beyond opening the page. An administrator who opens that page runs it in an authenticated session.

How do I fix CVE-2026-102391?

Update JetFormBuilder to version 3.6.6 or later. Crocoblock fixed both halves in that release: the Select renderer now escapes the option value with esc_attr and the label with esc_html, and the recursive sanitiser strips tags from array keys.

References

More discovered CVEs from Intrudify

See what a pentest
finds in your app.