CVE-2026-103339 The space esc_attr has no reason to encode in MetForm

Intrudify's autonomous pentesting engine discovered a stored cross-site scripting vulnerability in MetForm, affecting all versions up to and including 4.3.0. Every input widget prints the field's Name setting into an attribute on the front-end input element, escaped with esc_attr(). One of those attributes has no quotes around it. esc_attr() encodes the five characters that matter inside quotes and leaves the space alone, and in an unquoted attribute the space is the character that ends the value.

Discovered by
Intrudify autonomous engine
Validated & reported by
Disclosure
1 October 2026
Severity
Medium · CVSS 6.5
Vulnerability class
Stored XSS, CWE-79
Affected
MetForm ≤ 4.3.0
Fixed in
4.3.1
Active installs
600,000+
Attacker privilege
Contributor
User interaction
None, the handler fires on page load
Configuration
Default install, any page carrying a MetForm form

Summary

MetForm builds forms as Elementor widgets. Each input widget has a Name setting, mf_input_name, which identifies the field in the submitted data. That value is printed into the rendered input element in several places, each one wrapped in esc_attr().

Two of those places sit five lines apart in the same file. The name attribute has double quotes around its value and is safe. The aria-invalid attribute has none.

esc_attr() encodes &, <, >, " and '. Those are the characters that can escape a quoted attribute value, and the function has no reason to touch anything else. An unquoted attribute value ends at the first whitespace character, so every space in the stored name begins a new attribute on the element.

A Contributor who sets the Name to x autofocus onfocus=alert(document.domain) y gets an autofocus attribute and an onfocus handler on the input. The handler runs for every visitor who loads the page, with no interaction and no account.

Technical detail

The sink

// widgets/text/text.php, the same value, five lines apart

// line 199, inside quotes
name="<?php echo esc_attr( $mf_input_name ); ?>"

// line 204, no quotes
aria-invalid=${validation.errors['<?php echo esc_attr($mf_input_name); ?>'] ? 'true' : 'false'}

// The same pattern appears in 18 input widgets. An unquoted attribute value
// ends at the first whitespace character.

The escape is present and correct in both places. esc_attr() is the right function for an attribute value and it is applied to the right variable. The difference between the two lines is two characters.

What esc_attr does and does not do

esc_attr() exists to make a value safe inside a quoted attribute. Its contract covers the characters that can break out of quotes. We ran it against both payloads with WordPress core's own code:

// esc_attr() from wp-includes/formatting.php, run on each value

input    : x autofocus onfocus=alert(document.domain) y
esc_attr : x autofocus onfocus=alert(document.domain) y
changed  : no, byte for byte identical

input    : z style=animation-name:fa-spin;animation-duration:1s onanimationstart=alert(document.domain) w
esc_attr : z style=animation-name:fa-spin;animation-duration:1s onanimationstart=alert(document.domain) w
changed  : no, byte for byte identical

// Control, to show the function is running at all
input    : a" onfocus=alert(1) <b>
esc_attr : a&quot; onfocus=alert(1) &lt;b&gt;
changed  : yes

Neither payload contains a character esc_attr() encodes, so both come back unchanged. The control carries a quote and a tag and comes back encoded, which is how we know the function was running. esc_attr() gave a correct answer every time. The quotes are part of the escaping, and they were missing.

Why the markup reaches the browser as HTML

The ${...} syntax on that line is a JavaScript template placeholder, which raises the question of whether the browser ever parses it as an attribute at all:

// widgets/text/text.php:201
<?php if ( !$is_edit_mode ): ?>
    onInput=${parent.handleChange}
    aria-invalid=${validation.errors[...]}
<?php endif; ?>

// In the Elementor editor the ${...} template is compiled client-side.
// On the public front end Util::metform_content_renderer echoes this as
// ordinary HTML, so the browser parses the input tag itself.

In the Elementor editor the block is compiled client-side and the placeholders are resolved by JavaScript. The public front end takes the other branch, where Util::metform_content_renderer echoes the markup as ordinary HTML. The browser's HTML parser reads the input tag directly, so the space-separated tokens become real attributes before any script runs.

Attacker control

// traits/common-controls.php:82
$this->add_control(
    'mf_input_name',
    [
        'label'       => esc_html__( 'Name', 'metform' ),
        'type'        => Controls_Manager::TEXT,   // free text, no validation
        'default'     => $this->get_name(),
        'description' => esc_html__( 'Enter name without space or any special character...' ),
    ]
);

// The description asks for no spaces. Nothing enforces it.

mf_input_name is a free-form text control with no sanitize_key(), no allowlist and no pattern. The description asks the user to avoid spaces and special characters, and nothing enforces it. widgets/manifest.php registers all 18 input widgets on elementor/widgets/register for every Elementor document, so a Contributor can place one on a post they own.

Elementor stores widget settings in the _elementor_data post meta and filters them with wp_kses_post for any user without unfiltered_html. The payload contains no <, >, ", ' or &, so there is nothing for that filter to remove and it is stored byte for byte.

The chain

  1. The attacker holds a Contributor account, creates a draft, and reads the Elementor editor nonce from /wp-admin/post.php?post=<id>&action=elementor.
  2. One elementor_ajax save places a MetForm text widget whose settings.mf_input_name is the payload. Reading _elementor_data back shows it stored unchanged.
  3. An Editor or Administrator publishes the post through the normal review step.
  4. Any visitor loads the page and the browser parses the emitted element:
// What an anonymous visitor receives
<input type="text"
    name="x autofocus onfocus=alert(document.domain) y"
    aria-invalid=x autofocus onfocus=alert(document.domain) y...

// name= keeps the payload as one string because it has quotes around it.
// aria-invalid= ends at the first space. autofocus and onfocus are now
// attributes of the input element.

autofocus moves focus to the input as soon as the document is ready, which fires onfocus with no click. A variant removes even that dependency by borrowing an animation that Elementor's own Font Awesome stylesheet always defines:

z style=animation-name:fa-spin;animation-duration:1s onanimationstart=alert(document.domain) w sets a style attribute and an onanimationstart handler as separate tokens. fa-spin is a @keyframes rule that ships with Elementor, so the animation starts on load and the handler fires with no focus and no interaction.

Verification

We reproduced this on a clean WordPress 7.1.2 installation running Elementor 4.3.2 and MetForm 4.3.0 from wordpress.org on PHP 8.3, at default settings. The attacker was a stock Contributor account. Every step up to publication was performed over HTTP as that user.

The widget was saved through one admin-ajax.php call with action=elementor_ajax, which returned {"success":true}. Reading _elementor_data back confirmed the Name was stored byte for byte, so Elementor's save-time wp_kses_post removed nothing. After publication, an anonymous request with no cookie returned the page with autofocus and onfocus as separate attributes of the input, and alert(document.domain) executed on load in a logged-out browser window.

The escaping step is the part worth controlling, because the page's claim is that a correct escape call is present. The run in the previous section is that control: both payloads pass through esc_attr() byte for byte, and a value carrying a quote and a tag comes back encoded. The function was working the whole time.

One measurement trap is worth recording. Headless Chrome with --dump-dom does not fire autofocus, so the first payload reads as a false negative under that tool. The confirmation above used a real browser window. The animation variant fires under both, which is the reason to carry a focus-independent payload when the instrument is in question.

Impact

Arbitrary JavaScript executes in the browser of every visitor to the page, in the site's own origin, with no account and no interaction. An administrator who opens the page runs it in their authenticated session, which exposes the session cookie and the REST nonce to the script. An administrator session can create another administrator and can install a plugin, which is arbitrary PHP on the host.

Contributor is the role WordPress ships for people trusted to draft and not trusted to publish. The reviewing step that is supposed to catch a bad submission does not help here: the payload is a field name in a form widget's settings panel, and the rendered form looks ordinary.

The surface is every input type MetForm offers. Eighteen widgets carried the unquoted attribute, including text, email, number, password, telephone, textarea, date, time, checkbox, radio and file upload. MetForm is installed on more than 600,000 sites.

Remediation

Update MetForm to 4.3.1 or later.

Wpmet fixed this at both ends. The attribute gained quotes at all 18 widgets that interpolate the field name:

// 4.3.1, applied to all 18 widgets that interpolate the field name
- aria-invalid=${validation.errors['...'] ? 'true' : 'false'}
+ aria-invalid="${validation.errors['...'] ? 'true' : 'false'}"

Two further widgets carried the same unquoted attribute and were left alone, which is correct: recaptcha and simple-captcha use the constant strings 'g-recaptcha-response' and 'mf-captcha-challenge', so nothing attacker-controlled reaches them.

The input side was hardened as well, so the value can no longer carry a space to any sink the plugin adds later:

// 4.3.1, utils/util.php
public static function sanitize_input_name( $name ) {
    return is_string( $name ) ? preg_replace( '/[^A-Za-z0-9_\-\[\]]/', '', $name ) : '';
}

// 4.3.1, traits/common-controls.php, applied before any widget renders
public function get_settings_for_display( $setting_key = null ) {
    $settings = parent::get_settings_for_display( $setting_key );
    ... $settings['mf_input_name'] = Util::sanitize_input_name( ... );
}

The same release carried a second security change in the same file. react_entity_support() wraps a widget string into a JavaScript template literal that the front end compiles with new Function(), and in 4.3.0 it interpolated the string with no escaping for that context:

// 4.3.0, utils/util.php, react_entity_support()
$str = '${ parent.decodeEntities(`'. $str . "`) } ";

// 4.3.1 escapes the three characters that close a template literal first,
// with the vendor's own comment above it:

//   Wrap a widget string into the JS template literal the frontend compiles
//   with new Function(). Escape it for that context first: a backtick or
//   "${" would break out and run as code (esc_html() leaves both alone).
//   Backslash goes first so a trailing one cannot eat the next escape.

$str = str_replace(
    array( '\\', '`', '${' ),
    array( ... ),
    (string) $str
);

That second change is a separate defect from this CVE, closed in the same release. The 4.3.1 changelog also lists fixes for data exposure, email HTML injection and debug-file disclosure, so this was a multi-issue security release. The entry covering this CVE reads *Security hardening for form field names to prevent a stored XSS issue reported via Patchstack*.

The wider lesson

The quotes are part of the escaping. esc_attr() is named for the context it serves, and that context is a quoted attribute value. Read on its own, esc_attr($name) looks like a complete defence, and a reviewer scanning for unescaped output finds nothing to flag. The defect is in the two characters that are absent from the line, which is the hardest kind of thing to see. An escape function and the syntax around it are one unit, and reviewing either half alone gives a confident wrong answer.

A template defect ships once per copy. This markup exists in 20 widget files because the widget was written once and duplicated, and the missing quotes came along each time. The same duplication is what made the fix tractable, since 18 identical edits closed it. Any value printed by a template that gets copied is worth checking in every copy, and the count of copies is a good first estimate of how far a single mistake has travelled.

An input that documents a constraint should enforce it. The Name control tells the user to enter a name without spaces or special characters, which means the developer knew the shape the value needed. 4.3.1 turns that sentence into a preg_replace() over the allowed character class. A validation rule that lives only in help text is a rule the system does not have.

Disclosure timeline

  • 2026-09-25 Vulnerability identified and full chain validated by Intrudify, reported via Patchstack
  • 2026-09-30 Fixed release 4.3.1 published by Wpmet
  • 2026-10-01 Public disclosure · CVE-2026-103339 assigned

Questions

What is CVE-2026-103339?

CVE-2026-103339 is a stored cross-site scripting vulnerability in the MetForm WordPress plugin, versions 4.3.0 and earlier, reachable by any user holding a Contributor account. It was discovered autonomously by the Intrudify AI penetration testing engine. Eighteen input widgets print the field's Name setting into an aria-invalid attribute that has no quotes around its value, so each space in the name starts a new attribute on the rendered input element.

The value is passed through esc_attr. Why is that not enough?

esc_attr encodes the five characters that can break out of a quoted attribute value: ampersand, less-than, greater-than, double quote and single quote. It leaves the space and the equals sign alone, because neither can escape a quoted value. An unquoted attribute value ends at the first whitespace, so in that context the space is exactly the character that matters. The same file prints the same value safely five lines earlier, inside quotes.

How do I fix CVE-2026-103339?

Update MetForm to version 4.3.1 or later. Wpmet put double quotes around the aria-invalid value in all 18 affected widgets and added a sanitize_input_name helper that restricts the field name to letters, digits, underscore, hyphen and square brackets before any widget renders it.

References

More discovered CVEs from Intrudify

See what a pentest
finds in your app.