CVE-2026-105890 The heading level that never had to be a number in Kadence Blocks

Intrudify's autonomous pentesting engine discovered a stored cross-site scripting vulnerability in Kadence Blocks, affecting all versions up to and including 3.7.12. The Posts block builds its heading element by concatenating h with a block attribute that is never checked for being a number, and the template writes the angle brackets itself. Fixed in 3.7.12.1.

Discovered by
Intrudify autonomous engine
Validated & reported by
Disclosure
8 October 2026
Severity
Medium · CVSS 6.5
Vulnerability class
Stored cross-site scripting, CWE-79
Affected
Kadence Blocks ≤ 3.7.12
Fixed in
3.7.12.1
Active installs
600,000+
Attacker privilege
Contributor, the lowest role that can write a post
Victim
Every visitor to the published post, logged out included
Execution
One block attribute, no interaction

Summary

Kadence Blocks is a block library for the WordPress editor, on more than 600,000 sites. Its Posts block lists posts, and each post's title is rendered through a template that picks the heading element from the block's typography settings.

That element name is built by string concatenation: the letter h followed by whatever the level attribute holds. The template guards the expression with five isset() checks and an ! empty(), and none of them asks whether the value is a number.

The result is then passed through esc_attr(), which is the part worth looking at twice. In tag-name position that function is not an escaper. It encodes the characters that break out of a quoted attribute value, and the characters that matter here are the space and the equals sign, which it has no reason to touch. The template supplies the surrounding angle brackets, so a value containing a space adds further attributes to the heading.

Block attributes live in an HTML comment in the post content. wp_filter_post_kses recurses into block comments and strips tags, and the payload is tag-free, so a Contributor's post keeps it byte for byte.

Technical detail

How the tag name is assembled

// includes/templates/entry-loop-title.php, Kadence Blocks 3.7.12

$html_tag = ( isset( $attributes ) && is_array( $attributes )
    && isset( $attributes['titleFont'] ) && is_array( $attributes['titleFont'] )
    && isset( $attributes['titleFont'][0] )
    && isset( $attributes['titleFont'][0]['level'] )
    && ! empty( $attributes['titleFont'][0]['level'] )
        ? 'h' . $attributes['titleFont'][0]['level'] : 'h2' );


the_title(
    '<' . esc_attr( $html_tag ) . ' class="entry-title"><a href="'
        . esc_url( get_permalink() ) . '" rel="bookmark">',
    '</a></' . esc_attr( $html_tag ) . '>'
);

// five isset() checks and an ! empty(), and not one of them asks
// whether the value is a number.

Why the escaper does not help

// esc_attr() encodes &, <, >, " and '. it leaves the space and
// the equals sign, because inside a quoted attribute neither ends
// anything. this value is not inside an attribute.

// the template writes the angle brackets itself:
'<' . esc_attr( $html_tag ) . ' class="entry-title">'

// so with $html_tag = 'h1 tabindex=1 autofocus onfocus=...' the output is

<h1 tabindex=1 autofocus onfocus=alert(document.domain) class="entry-title">

// in tag-name position an attribute escaper is not an escaper. the
// characters that matter here are the space and the equals sign,
// and esc_attr() has no reason to touch either.

Nothing upstream validates the attribute either

// dist/blocks/posts/block.json

"titleFont": {
    "type": "array",
    "default": [ { "level": 2, ... } ]
}

// no "items" schema, so WP_Block_Type::prepare_attributes_for_render()
// checks only that the value is an array. the objects inside it are
// never validated, and 'level' arrives as whatever was stored.

The check exists in the same codebase

// the numeric check already existed elsewhere in the same codebase

// includes/blocks/class-kadence-blocks-testimonial-block.php:345
$level = ( ! empty( $titleFont[0]['level'] )
    && is_numeric( $titleFont[0]['level'] )
        ? $titleFont[0]['level'] : 2 );


// and the same gap existed in the shared base class, which the
// report did not name and 3.7.12.1 also fixed:

// includes/blocks/class-kadence-blocks-abstract-block.php:396
public function get_html_tag( $attributes, $tag_key, $default,
                              $allowed_tags = [], $level_key = '' ) {

    if ( ! empty( $attributes[ $tag_key ] ) && in_array( ... ) ) {
        if ( $attributes[ $tag_key ] === 'heading' ) {
            $level = ! empty( $attributes[ $level_key ] )
                ? $attributes[ $level_key ] : 2;              // line 401
            return 'h' . $level;                           // line 402
        }
        ...
    }
}

// five blocks call that helper.

The testimonial block reads the identical attribute and asks is_numeric() first. So the constraint was known and written down, and the Posts block template and the shared helper were both built without it.

Verification

WordPress 7.1.3, PHP 8.3, Kadence Blocks 3.7.12, default theme, default settings, on http://127.0.0.1:8519. Two accounts: admin, and contrib created as an ordinary Contributor. One published post for the Posts block to list, which the default install already provides.

# stored over HTTP as the Contributor, through the REST API

principal: contrib ['contributor'] | unfiltered_html=False

kept by kses byte for byte: True
stored level: "1 tabindex=1 autofocus onfocus=alert(document.domain)"

# block attributes live in an HTML comment in post_content.
# wp_filter_post_kses recurses into block comments and strips tags.
# the payload has no angle brackets, so there is nothing to strip.

What the published post serves

// anonymous GET of the published post

<h1 tabindex=1 autofocus onfocus=alert(document.domain) class="entry-title">


// as the browser parsed it:
H1   tabindex="1" | autofocus=""
     onfocus="alert(document.domain)" | class="entry-title"

cookies: 0   dialogs: 1   message: 127.0.0.1


// control: the identical block with a numeric level
H2   class="entry-title"
cookies: 0   dialogs: 0

// the only difference between the two runs is that one value.

autofocus with a tabindex gives the heading focus while the page is parsed, so the handler runs during load with nothing clicked. The control is the identical block with level set to 2, which isolates that one value as the cause.

3.7.12.1

// Kadence Blocks 3.7.12.1, same lab, the payload still in post_content

[3.7.12.1 payload]  H2   class="entry-title"   dialogs: 0
[3.7.12.1 control]  H2   class="entry-title"   dialogs: 0


// and a legitimate non-default level, to show the setting still works:

[3.7.12.1 level 4]  H4   class="entry-title"   dialogs: 0

The last line is the half that matters for believing the other two. A fix that ignored the level setting entirely would also render H2 for everything and score zero dialogs. A legitimate level 4 still renders an H4.

Impact

Contributor is the lowest WordPress role that can author content, and sites hand it out to guest writers, volunteers and agency staff. The assumption behind that is that wp_kses bounds what such a user can put on a page.

What a reviewer sees is a Posts block with ordinary typography settings. The payload lives in the block's attribute JSON, inside an HTML comment, which the editor renders as a normal block and nobody reads as text.

Once published, the script runs for every visitor who opens the post, logged out included, in the site's own origin, with no interaction at all. That was measured with zero cookies in the browser. An administrator who opens the post on the front end runs it in their session, where the session cookie and the REST nonce printed on the page are enough to create a second administrator account or install a plugin, which is PHP of the attacker's choosing on the server.

No configuration is required. The Posts block ships in the free plugin and the render chain is the default path.

The same shape was reachable through the plugin's shared get_html_tag() helper, which five blocks call. The report named the Posts block template; 3.7.12.1 fixed both.

Remediation

Update Kadence Blocks to 3.7.12.1 or later.

// Kadence Blocks 3.7.12.1, includes/templates/entry-loop-title.php

- $html_tag = ( ... && ! empty( $attributes['titleFont'][0]['level'] )
-     ? 'h' . $attributes['titleFont'][0]['level'] : 'h2' );

+ $level = filter_var(
+     $attributes['titleFont'][0]['level'] ?? null,
+     FILTER_VALIDATE_INT,
+     [ 'options' => [ 'min_range' => 1, 'max_range' => 6 ] ]
+ );
+ $html_tag = 'h' . ( false !== $level ? $level : 2 );


// and the same treatment in the shared helper, plus a strict
// comparison on the tag allowlist:

- if ( ... && in_array( $attributes[ $tag_key ], $allowed_tags ) ) {
+ if ( ... && in_array( $attributes[ $tag_key ], $allowed_tags, true ) ) {

+ @since 3.7.12.1 Only allows heading levels 1 to 6.

filter_var() with FILTER_VALIDATE_INT and a range of 1 to 6 is the right shape for this: it bounds the value to the things a heading level can actually be, and anything else falls back to 2. The release applied it in the template and in the shared helper, and tightened the helper's tag allowlist to a strict comparison at the same time.

A site attacked before updating needs no database cleanup. That was measured: the payload is still in the post content and 3.7.12.1 renders a plain H2. The post still carries a block attribute its author did not choose, so affected posts are worth opening and resaving.

The same release also escapes a stylesheet URL on output in two other files, which is separate hardening and not part of this report.

The wider lesson

The useful thing here is about where a value lands. Most escaping advice is framed around two contexts, HTML text and attribute values, and WordPress gives you a function for each. This value lands in neither. It becomes the name of an element, and there is no esc_tag(), because a tag name comes from a list the developer controls.

So when output is assembled piece by piece, write down what each piece is going to be in the finished markup. A tag name, an attribute name, a URL scheme, a CSS property and a JavaScript identifier are all positions where escaping is the wrong tool and an allowlist or a type check is the right one. The presence of esc_attr() on that line is what makes it read as handled.

The second thing is the shape of the guard that was there. Five isset() calls and an ! empty() is a lot of defensive code, and all of it is about whether the value exists. None of it is about what the value is. Length of guard is not strength of guard, and a reviewer skimming that line sees a great deal of checking and moves on.

Last, the check was already in the codebase, in the testimonial block, reading the same attribute. That is the most common shape in this research set: the constraint is known, it is written down somewhere in the same repository, and it was never applied on the path that needed it. Grepping your own code for the stricter version of a pattern you just wrote is cheap, and it is how the shared-helper sibling in this one was found.

Disclosure timeline

  • 2026-09-26 Vulnerability identified and confirmed by Intrudify, reported via Patchstack
  • 2026-10-06 Release 3.7.12.1 published by StellarWP
  • 2026-10-08 Public disclosure by Patchstack, CVE-2026-105890 published

Questions

What is CVE-2026-105890?

CVE-2026-105890 is a stored cross-site scripting vulnerability in Kadence Blocks, affecting all versions up to and including 3.7.12. The Posts block builds its heading element name by concatenating the letter h with a block attribute that is never checked for being numeric, and the template writes the surrounding angle brackets, so a value containing a space adds attributes to the heading element.

Who can exploit CVE-2026-105890?

Any user who can write a post, which means Contributor and above. Block attributes are stored in an HTML comment in the post content, and wp_filter_post_kses strips tags. The payload contains no angle brackets and no quotes, so it is stored byte for byte.

esc_attr() is applied to the value. Why does that not help?

Because the value is not in an attribute. It becomes the name of the element, and the template supplies the angle brackets around it. esc_attr() encodes the characters that escape a quoted attribute value and leaves the space and the equals sign alone, since inside a quoted value neither one ends anything. Those are exactly the characters this payload needs.

Does the visitor have to click anything?

No. The payload adds autofocus and a tabindex alongside the handler, so the heading takes focus while the page is parsed and the handler runs during load. This was confirmed on an anonymous page load with no cookies.

How do I fix CVE-2026-105890?

Update Kadence Blocks to 3.7.12.1 or later. That release validates the heading level with filter_var() and FILTER_VALIDATE_INT bounded to 1 through 6, in the Posts block template and in the shared helper that five blocks use, falling back to 2 for anything else. A payload already in post content renders harmlessly without any cleanup.

References

More discovered CVEs from Intrudify

See what a pentest
finds in your app.