Segurium Research Formidable Forms

Formidable Forms 6.34 closes a stored XSS in displayed entry values

Segurium Research 11 min read

Plugin
Formidable Forms formidable
Affected versions
6.33.1 and earlier
Fixed in
6.34
Class
Cross-site scripting
Severity
high Segurium Research assessment. No CVSS score published yet.
CVE
None assigned at the time of writing
Installs
300,000 active
Patch released
Sources
Advisory record for Formidable Forms 6.34 The same finding as a structured entry in the advisory directory.

Formidable Forms shipped 6.34 on 2026-08-27. The plugin runs on about 300,000 sites. The vendor put one security line at the top of the changelog: “Additional validation has been added to guarantee that submitted HTML in form data by untrusted users cannot be used for XSS.”

The diff backs that line up. The release adds a new file, classes/models/FrmHtmlSanitizer.php, with one public method, sanitize_url_attributes(). That method is wired into three display paths. It reads href and src attribute values, decodes HTML entities in them, and then decides if the URL is safe. Values that fail are replaced with an empty attribute.

The release also changes who may own an entry, and the vendor said nothing about that in the changelog. Both changes are below. No CVE exists for this release yet.

What the release fixes

Formidable Forms displays stored entry values as HTML. The display path ran wp_kses_post() on the value, then decoded HTML entities in it. Decoding after sanitising undoes part of the sanitising. A URL that was entity-encoded when the checks ran could be plain text by the time it reached the browser. 6.34 adds a check that decodes first and validates second.

  • Affected versions: 6.33.1 and earlier
  • Fixed in: 6.34
  • Class: Stored cross-site scripting (XSS)
  • Installs: about 300,000, per the wordpress.org plugin directory

How the bug works

Where entry values reach the screen

The clearest of the three call sites is FrmFieldType::filter_value_for_table_html(). This is the value that goes into an HTML table cell. Here is the whole change:

	public function filter_value_for_table_html( $value ) {
-		return wp_kses_post( $value );
+		return FrmHtmlSanitizer::sanitize_url_attributes( wp_kses_post( $value ) );
	}

wp_kses_post() allows <a> with href and <img> with src. That is the point of using it here. The plugin wants links in entry data to work. So the tag survives and the attribute survives. Only the attribute value decides whether the browser does something dangerous.

The second call site is in FrmAppHelper, at line 938:

		self::sanitize_value( self::class . '::strip_most_html', $value );
	}
	self::decode_specialchars( $value );
+	self::sanitize_value( 'FrmHtmlSanitizer::sanitize_url_attributes', $value );

Read the order. strip_most_html runs. Then decode_specialchars runs. Then, in 6.34 only, the URL check runs. Before 6.34 the last step in this function was the decode. Anything that the decode turned back into a live protocol was never looked at again.

The third call site is in FrmFieldType, at line 1782, right after the field type prepares its own display value:

		$value = $this->prepare_display_value( $value, $atts );

+		FrmAppHelper::sanitize_value( 'FrmHtmlSanitizer::sanitize_url_attributes', $value );
+
		if ( ! is_array( $value ) ) {
			return $value;
		}

The diff does not include the bodies of strip_most_html, decode_specialchars or sanitize_value. The hunk headers for the FrmAppHelper change and the line 1782 change carry no enclosing function name, so the names of those two callers are not established here.

The new check, in full

	public static function sanitize_url_attributes( $value ) {
		if ( '' === $value || ( ! str_contains( $value, 'href' ) && ! str_contains( $value, 'src' ) ) ) {
			return $value;
		}

		$sanitized = preg_replace_callback(
			'/\b(href|src)\s*=\s*"([^"]*)"/',
			array( self::class, 'sanitize_url_attribute_value' ),
			$value
		);

		return $sanitized ?? '';
	}

The pattern matches double-quoted attribute values only. That is enough on the filter_value_for_table_html path, because wp_kses_post() rebuilds every tag it keeps and writes attributes back with double quotes. The check depends on that ordering. It does not parse HTML itself.

Each match goes to the callback:

	private static function sanitize_url_attribute_value( $matches ) {
		$url = trim( html_entity_decode( $matches[2], ENT_QUOTES, 'UTF-8' ) );

		if ( str_starts_with( $url, '#' ) ) {
			return $matches[1] . '="' . esc_attr( $url ) . '"';
		}

		if ( 'src' === $matches[1] && self::is_png_data_uri( $url ) ) {
			return $matches[1] . '="' . esc_attr( $url ) . '"';
		}

		if ( ! preg_match( '/^(https?:\/\/|mailto:|tel:)/i', $url ) ) {
			return $matches[1] . '=""';
		}

		$safe = esc_url( $url, array( 'http', 'https', 'mailto', 'tel' ) );

		if ( '' === $safe ) {
			return $matches[1] . '=""';
		}

		$host = wp_parse_url( $safe, PHP_URL_HOST );

		if ( $host && preg_match( '/%[0-9a-f]{2}/i', $host ) ) {
			return $matches[1] . '=""';
		}

		return $matches[1] . '="' . esc_attr( $safe ) . '"';
	}

The decode order, which is the whole bug

Line 1 of the callback is the fix. html_entity_decode( $matches[2], ENT_QUOTES, 'UTF-8' ) turns the attribute value into what a browser will see. Every check after that line reads the decoded string. The vendor wrote the reason into the docblock:

	/**
	 * Sanitize href and src attribute values to valid URLs only.
	 *
	 * Decodes HTML entities in the attribute value before validating,
	 * so entity-encoded payloads are rejected.
	 */

Compare that with the old order in FrmAppHelper. The HTML filter ran on the encoded string. An entity in the middle of a protocol name breaks the protocol name, so a filter that looks for a protocol name does not see one. The value passes. Then decode_specialchars runs and puts the protocol name back together. Nothing checks the string again. The browser gets an attribute with a live protocol in it.

An HTML entity does not need a semicolon for many browsers to accept it inside an attribute value. Numeric entities, named entities, mixed case and leading zeros all decode to the same character. That is a large space of spellings for the same protocol, and a filter that matches on text will miss most of them. Decoding first collapses that space to one string. Then one comparison decides the answer.

The allow list after the decode is short:

		if ( ! preg_match( '/^(https?:\/\/|mailto:|tel:)/i', $url ) ) {
			return $matches[1] . '=""';
		}

Only http://, https://, mailto: and tel: pass. Everything else becomes an empty attribute. A fragment link starting with # is allowed earlier and is not a URL scheme at all.

The percent-encoded host check

After esc_url() accepts the URL, the code splits the host out and rejects it if it holds a percent escape:

		$host = wp_parse_url( $safe, PHP_URL_HOST );

		if ( $host && preg_match( '/%[0-9a-f]{2}/i', $host ) ) {
			return $matches[1] . '=""';
		}

esc_url() keeps percent escapes as it found them. It does not decode them and re-check the result. So the string esc_url() approved and the string the browser resolves are not always the same string. A percent escape inside the host is the place where those two readings split apart. The check refuses that case rather than trying to decide which reading wins.

The one exception, and why it is narrow

	private static function is_png_data_uri( $url ) {
		return 1 === preg_match( '#^data:image/png;base64,[A-Za-z0-9+/]+={0,2}$#D', $url );
	}

This exists for drawn signature images, which are stored as PNG data URIs. The pattern is tight. It only applies to src, never to href. After the fixed prefix it allows base64 characters and up to two = characters, and nothing else. The D modifier makes $ mean end of string, so a trailing newline cannot carry extra text past the end of the pattern. The value cannot declare its own media type and cannot hold a quote, a space or an angle bracket.

What an attacker gets

Anyone who can submit a form can store the value. The plugin stores it and shows it later. The script runs in the browser of the person who opens the entry. On the entries screen in wp-admin that person is an administrator or an editor with entry access. Script in that session can act as that account.

The fix runs at display time, not at save time. Values already in the database keep their original text. 6.34 cleans them on the way out through these three paths. Code that reads entry values and prints them without going through filter_value_for_table_html or the two FrmAppHelper paths is not covered by this change.

A second change the changelog does not mention

FrmEntry decides which user owns a new entry. Before 6.34:

	private static function get_entry_user_id( $values ) {
		if ( isset( $values['frm_user_id'] ) && ( is_numeric( $values['frm_user_id'] ) || FrmAppHelper::is_admin() ) ) {
			return $values['frm_user_id'];
		}

Read the condition. A numeric frm_user_id in $values was accepted on its own. The is_admin() arm only mattered for a non-numeric value. So the submitted number decided the owner, and nothing asked who submitted it.

6.34 adds a capability gate:

	private static function get_entry_user_id( $values, $type = 'standard' ) {
		if ( isset( $values['frm_user_id'] ) && self::can_set_entry_user_id_from_values( $type ) ) {
			return $values['frm_user_id'];
		}
	private static function can_set_entry_user_id_from_values( $type = 'standard' ) {
		if ( 'xml' === $type || ( defined( 'WP_IMPORTING' ) && WP_IMPORTING ) ) {
			return true;
		}

		return current_user_can( 'frm_edit_entries' ) || current_user_can( 'administrator' );
	}

The vendor’s own docblock says who this was open to:

	 * The owner is only taken from the submitted value when the current user is allowed to manage
	 * entries, or during a trusted import that restores each entry's original owner. On a public
	 * submission neither is true, so the owner falls back to the current user and cannot be set to
	 * another account.

The same gate is added to the update path, where the old test was is_numeric() alone:

-		if ( isset( $values['frm_user_id'] ) && is_numeric( $values['frm_user_id'] ) ) {
+		if ( isset( $values['frm_user_id'] ) && is_numeric( $values['frm_user_id'] ) && self::can_set_entry_user_id_from_values( $update_type ) ) {
			$new_values['user_id'] = $values['frm_user_id'];
		}

The $type and $update_type values are threaded down from FrmEntry::update() and package_entry_data() so that an XML import keeps its old behaviour. The diff shows the check and the plumbing. It does not show where $values['frm_user_id'] is filled from the request, so the exact request key is not established here.

The gated content capability change

The changelog lists this as a fix for 404 errors and custom permissions. The diff shows two separate changes in FrmGatedContentController::maybe_unlock_post(). First, the plugin now checks that the post belongs to a published gated content action before it does anything:

+		// Only act on posts that are registered in an active gated content action.
+		// Posts unrelated to gated content must not have their access interfered with.
+		if ( ! self::has_gated_action_for_item( $post_item ) ) {
+			return;
+		}

Second, the capability is now taken from the post type instead of hardcoded:

-		$is_restricted_private = 'private' === $post->post_status && ! current_user_can( 'read_private_posts', $post_id );
+		$post_type_obj         = get_post_type_object( $post->post_type );
+		$read_private_cap      = $post_type_obj ? $post_type_obj->cap->read_private_posts : 'read_private_posts';
+		$is_restricted_private = 'private' === $post->post_status && ! current_user_can( $read_private_cap, $post_id );

A custom post type registered with its own capability_type does not use read_private_posts. The old line asked the wrong question for those types. A user holding the type’s real capability failed the generic check, and the plugin then treated the post as restricted and forced a 404. That is loss of access, not a bypass. WordPress core still applies its own checks on the reverse case, so the diff does not show this granting anyone extra read access.

The new lookup query is prepared, with %i for the table identifier:

		$action_ids = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT ID FROM %i WHERE post_type = %s AND post_excerpt = %s AND post_status = 'publish'",
				$wpdb->posts,
				FrmFormActionsController::$action_post_type,
				FrmGatedContentAction::$slug
			)
		);

Who is exposed

Sites running Formidable Forms 6.33.1 or earlier. 6.34 carries the fix.

For the XSS, a site needs a published form that stores entries, and someone with access who opens those entries in wp-admin. The person who submits the form needs no account when the form is public. The script runs in the viewer’s session, so the exposure is to the account that opens the entry, not to public visitors of the form.

The value has to reach one of the three display paths that 6.34 patched. filter_value_for_table_html() is the entries list table. The other two are in FrmAppHelper and FrmFieldType, and the diff does not name their enclosing functions.

The diff does not show which field types let HTML through strip_most_html, so it does not establish which fields on a form can carry the value. That answer is not in these bytes.

For the entry owner change, any site that takes public submissions was accepting a submitted owner id. The practical effect depends on what reads user_id on the entry. Formidable Pro features that show a user their own entries, or that let a user edit their own entries, are keyed on that column. On a Lite-only site the column is stored and reported but drives less.

The gated content change only matters on sites that use Gated Content actions. The capability part only matters for custom post types that map their own capabilities.

What to do

Update to 6.34. That is the whole fix for all three changes.

After updating, check the entries you already stored. The patch cleans values as they are displayed. It does not rewrite rows. Look through stored entry values for href and src attributes whose value is not a plain http:// or https:// URL, and for values holding HTML entities inside an attribute. Those are the values the old order would have passed through.

Check entry ownership. Entries live in the frm_items table, with your database prefix, and the owner is the user_id column. Compare it against who you expect to have submitted each entry. An entry created by a public visitor should carry 0 or the id of the logged-in visitor. An entry that carries the id of an administrator, and that you did not import, was given that owner by the submission.

Then look at your administrator accounts, your user list and your site options for anything you did not change yourself. Stored XSS that fires in an admin session can do anything that account can do.

The patch adds one class and three calls to it. FrmHtmlSanitizer::sanitize_url_attributes() decodes entities in each href and src value, then allows only http://, https://, mailto:, tel:, a # fragment, and a strict PNG data URI in src. Everything else becomes an empty attribute. Alongside that, FrmEntry::can_set_entry_user_id_from_values() now requires the frm_edit_entries capability, an administrator, or an XML import before a submitted frm_user_id sets the entry owner. Once you are on 6.34, all of it is in place.

More research

Segurium is a WordPress malware scanner and cleanup tool on the official directory at wordpress.org/plugins/segurium/.