CVE-2026-104404 The block attribute that shipped without quotes in GiveWP
Intrudify's autonomous pentesting engine discovered a stored cross-site scripting vulnerability in GiveWP, affecting all versions up to and including 4.17.0. The Campaign block renders by printing its entire attribute array into an HTML attribute that has no quotes around it. json_encode() never emits a space, so every space in that output came from an attacker-supplied string, and the browser ends an unquoted attribute value at the first space. A Contributor can therefore append attributes of their own choosing to the element, including an event handler that runs with no interaction.
Summary
GiveWP's Campaign block drops an existing fundraising campaign into a post. The server side of that block is one line of markup: a div carrying the block's attributes as JSON, which the front-end script reads and turns into the campaign card. The attribute that holds the JSON is written without quotes.
WordPress gives almost every block a free-form className attribute, because a block that does not opt out of customClassName support gets one registered for it. The Campaign block does not opt out. A Contributor, the lowest role that can write a post at all, can set that class name to any string, and the string is printed into the unquoted attribute exactly as stored.
An HTML tokenizer ends an unquoted attribute value at the first whitespace character. Everything the Contributor wrote after a space is therefore parsed as further attributes of that div. tabindex=0 autofocus onfocus=... is enough: the browser focuses the element while the page is still loading and the handler runs with no click, no hover and no keystroke. It runs first for the administrator reviewing the pending submission, and then, if the post is approved, for every visitor who opens the page.
Technical detail
One line, and no quotes on it
// src/Campaigns/Blocks/Campaign/render.php, the whole file below the guard.
// Byte-identical in build/Campaigns/Blocks/Campaign/render.php.
if (
! isset($attributes['campaignId'])
|| ! Campaign::find($attributes['campaignId'])
) {
return;
}
?>
<div data-givewp-campaign-block data-attributes=<?= json_encode($attributes) ?>></div>
^ line 18, and no quotes around itjson_encode() is a JSON encoder. It escapes the double quote and the backslash because JSON requires it, and it has no opinion about HTML: the space, the equals sign, the parentheses and the apostrophe all pass through untouched. Inside a quoted attribute the escaped double quote would have been enough to contain the value. Inside an unquoted one it protects nothing, because the character that ends the value is a space and the encoder never produces one by itself.
Where the attacker's string comes from
// wp-includes/block-supports/custom-classname.php:17, WordPress 7.1.2
function wp_register_custom_classname_support( $block_type ) {
$has_custom_classname_support = block_has_support( $block_type, 'customClassName', true );
if ( $has_custom_classname_support ) {
...
$block_type->attributes['className'] = array( 'type' => 'string' );
}
}
// src/Campaigns/Blocks/Campaign/block.json, the whole supports object
"supports": { "align": ["wide", "full", "left", "center", "right"] }
// Nothing opts out, so className is registered: a string, no enum, no pattern.The block declares four attributes of its own, all of them numbers or booleans. className is not one of them. WordPress adds it, typed only as a string, to every block that leaves customClassName support at its default.
// wp-includes/class-wp-block-type.php:504, WordPress 7.1.2
foreach ( $attributes as $attribute_name => $value ) {
// If the attribute is not defined by the block type, it cannot be
// validated.
if ( ! isset( $this->attributes[ $attribute_name ] ) ) {
continue; // line 508
}
$schema = $this->attributes[ $attribute_name ];
$is_valid = rest_validate_value_from_schema( $value, $schema, $attribute_name );
if ( is_wp_error( $is_valid ) ) {
unset( $attributes[ $attribute_name ] );
}
}
// A declared attribute is schema-checked. An UNDECLARED one is kept verbatim,
// so the attacker is not even limited to the names the block knows about.The skip at line 508 widens this further. An attribute the block type does not declare is not validated at all, it is simply carried through, so an attacker is not confined to the names the block knows about. Anything stored in the block comment arrives in $attributes and is printed.
The post filter never sees a tag
A Contributor has no unfiltered_html capability, so WordPress runs wp_filter_post_kses over their saved content, and filter_block_kses_value walks the block attributes calling wp_kses($value, 'post') on each string. That filter is built to find dangerous markup. The payload is not markup while it is stored.
The stored string contains no <, and it does not need one: the injection point is already inside a tag. kses strips an onclick when the handler sits on a tag it is parsing, and it has nothing to say about a plain string that will become attributes later, in a template, after the filter has finished.
What the browser is served
// What GiveWP 4.17.0 prints for className = "x tabindex=0 autofocus
// onfocus=alert(document.domain) y=". One line, wrapped here to fit.
<div data-givewp-campaign-block data-attributes={"campaignId":1,"className":"x
tabindex=0 autofocus onfocus=alert(document.domain) y=","showImage":true,
"showDescription":true,"showGoal":true}></div>
// json_encode() emits no space of its own. Every space in that line came
// from the stored string, and the tokenizer ends an unquoted value at the
// first one it meets. Verification
The chain was confirmed over HTTP on a lab install running WordPress 7.1.2, GiveWP 4.17.0 and PHP 8.3 with no other plugin active, driven as the Contributor: the block was stored through the REST API, the post stayed in pending review because a Contributor cannot publish, and the handler fired on the administrator's own preview of that pending post, printing the administrator's session cookie.
The parsing step was then measured on its own, so the result does not rest on one lab. Each release's template was run over the same attribute array, the output was served over HTTP, and a real browser was asked what it had parsed.
// Each case rendered by the release's own template, served over HTTP and
// opened in Chromium. The browser is asked what it parsed.
attrs onfocus autofocus focused dialog
4.17.0 campaign block, payload 7 yes yes the div "127.0.0.1"
4.17.0 campaign block, benign 2 no no body none (control)
4.18.0 campaign block, payload 2 no no body none
4.17.0 campaign grid, payload 6 yes yes the div "127.0.0.1"
4.17.0 campaign grid, space only 2 no no body none (control)
4.17.0 campaign grid, benign 2 no no body none (control)
4.18.0 campaign grid, payload 2 no no body none
// Two attributes is the whole, correct div. Seven means the value ended
// early and five of them came from a Contributor. The dialog is the
// execution, and the focused column is why it needed no interaction.The benign rows are the control, and they are what makes the table mean anything. A measurement that reported seven attributes for every input would be describing the instrument, not the bug. With an ordinary class name the same template on the same release produces the correct two-attribute div, no handler and no dialog.
The filter step was measured the same way, by driving WordPress 7.1.2's own wp_kses() over the strings in question.
// wp_kses($value, 'post') from WordPress 7.1.2, driven directly over the
// strings a Contributor's post body carries.
input what wp_kses($v, 'post') gives back
the payload the same 56 characters, byte for byte
the apostrophe payload the same 58 characters, byte for byte
CONTROL <script>alert(1)</script> alert(1)
CONTROL a tag carrying onclick <a href="#">x</a>
a benign class my-plain-class
// The two controls prove the filter is running and that it does strip an
// event handler it can see. It sees handlers on TAGS. The payload is not a
// tag while it is stored: it is a bare string that only becomes attributes
// later, when the plugin prints it inside one.The block next door
The release diff showed a second file changed in exactly the same way, which the report did not name.
// src/Campaigns/Blocks/CampaignGrid/render.php:6 in 4.17.0
<div data-givewp-campaign-grid data-attributes='<?= json_encode($attributes) ?>'></div>
// Quoted, so a space is harmless here. json_encode() escapes the double
// quote for JSON and leaves the apostrophe alone, and the apostrophe is
// what ends this value:
className = "x' tabindex=0 autofocus onfocus=alert(document.domain) y='"
// Same defect, different closing character. Measured above: 6 attributes,
// a live handler, a dialog with no interaction.The Campaign Grid block quotes its attribute, with apostrophes, and json_encode() leaves apostrophes alone. The grid is therefore injectable through the same className with a payload shaped for a different closing character. The space-only row in the table above is the control for that claim: the payload that breaks the Campaign block leaves the grid intact, which is what a quoted attribute should do.
Impact
The attacker needs an account with edit_posts and nothing more. Contributor is the standard role for guest authors, volunteers and agency staff, and on a fundraising site it is frequently handed out, since GiveWP installs sit on charity and campaign sites where several people write updates.
The first victim is the reviewer. A Contributor cannot publish, so the submission waits for an editor or an administrator to look at it, and looking at it means opening the preview. The handler runs in that administrator's session, with their cookies, on the site's own origin. From there it can read document.cookie where the session cookie is not HttpOnly, or stay on the page and drive the administrator's own browser against the REST API with their nonce. That nonce is what the REST API accepts in place of a password, so the end of this path is a second administrator account, or a plugin installed from the dashboard, which is PHP of the attacker's choosing running on the server.
If the post is approved, the same payload is served to everyone who opens it, logged in or not. A donation site is a payment funnel, so script running on its pages can rewrite a donation form, redirect a checkout or harvest what donors type into it.
Neither victim has to do anything. The injected autofocus focuses the element during load, which is why the measurement above records the dialog with no input events at all.
Remediation
Update GiveWP to 4.18.0 or later. The fix is two characters and one function call, in both affected templates.
// 4.17.0 -> 4.18.0, src/Campaigns/Blocks/Campaign/render.php:18
- <div data-givewp-campaign-block data-attributes=<?= json_encode($attributes) ?>>
+ <div data-givewp-campaign-block data-attributes="<?= esc_attr(json_encode($attributes)) ?>">
// and src/Campaigns/Blocks/CampaignGrid/render.php:6
- <div data-givewp-campaign-grid data-attributes='<?= json_encode($attributes) ?>'>
+ <div data-givewp-campaign-grid data-attributes="<?= esc_attr(json_encode($attributes)) ?>">
// Two changes: the quotes, and an escaper that knows what a quote means in
// HTML. Either one alone would have stopped the reported payload. Both
// together stop the family.Sites that cannot update immediately can remove the Contributor role's ability to reach the block, but the honest mitigation is the update: the sink is a template that runs for any post containing the block, and the attacker controls an attribute WordPress itself registers.
After updating, review posts submitted by low-privileged users while 4.17.0 or earlier was installed. The payload is stored in the post content, so it keeps firing until the post is edited, and a stored class name attracts no attention in a list table.
For developers, the rule the fix restores: quote every attribute, and escape for the context you are printing into. json_encode() is an encoder for JSON, and the fact that its output usually looks harmless in HTML is a coincidence of the data, not a property of the function.
The wider lesson
GiveWP had already learned this, in this directory, two releases earlier.
// src/Campaigns/Blocks/CampaignComments/Controller/BlockRenderController.php
// in 4.16.0
$encodedAttributes = json_encode($blockAttributes->toArray());
return "<div id='givewp-campaign-comments-block-{$blockId}' data-secondary-color='{$secondaryColor}' data-givewp-campaign-comments data-attributes='{$encodedAttributes}'></div>";
// and in 4.16.1, two releases before this report
+ /**
+ * @since 4.16.1 escape attribute values in block markup
+ */
+ return sprintf(
+ "<div id='givewp-campaign-comments-block-%s' ... data-attributes='%s'></div>",
+ esc_attr((string) $blockAttributes->blockId),
+ esc_attr($secondaryColor),
+ esc_attr((string) json_encode($blockAttributes->toArray()))
+ );
// The lesson was already written down, in this plugin, in this directory.
// It was applied to one file.The Campaign Comments block was repaired in 4.16.1 with a changelog note that says what it is doing: escape attribute values in block markup. The repair stopped at that file. Campaign and Campaign Grid, in sibling directories, kept printing the same encoder's output into the same kind of attribute for two more releases.
There is a second signature on this release. GiveWP 4.18.0 closes CVE-2026-97643 as well, reported independently by Wordfence against the Campaign Grid shortcode, which reaches the grid template this advisory also reaches. Two researchers, arriving separately in the same week, found two halves of one defect, and the vendor's changelog names only the other one.
The pattern is worth stating plainly, because it is not about GiveWP. An escaping bug is a property of a sink, and a sink is almost never alone: templates get copied between features by the same hands, on the same afternoon. When a report names a file, the cheapest useful work is not fixing that file, it is asking the codebase what else is written that way. One grep for the pattern across this plugin returns both of the files 4.18.0 changed, and would have returned them in April.
And one more, for anyone reading a filter as a guarantee. kses is a good filter with a clear job: it inspects markup. A value that is not markup when it is saved and becomes markup when it is printed passes through it untouched, every time. The defence has to live where the value is printed, because that is the only place where its context is known.
Disclosure timeline
- 2026-09-26 Vulnerability identified and confirmed by Intrudify, reported via Patchstack
- 2026-10-01 Fixed release 4.18.0 published by StellarWP
- 2026-10-05 Public disclosure · CVE-2026-104404 assigned
Questions
What is CVE-2026-104404?
CVE-2026-104404 is a stored cross-site scripting vulnerability in the GiveWP donation plugin for WordPress, affecting all versions up to and including 4.17.0. The Campaign block prints its attribute array into an HTML attribute that carries no quotes, so a class name supplied by a Contributor ends the attribute value at its first space and the rest of the string is parsed as attributes of the element.
Who can exploit CVE-2026-104404?
Any user with the Contributor role or above, which is the lowest role that can write a post. The payload needs no unfiltered_html capability, because it contains no tag and therefore passes WordPress's kses filter unchanged. The attacker cannot publish, which does not help: the administrator who reviews the pending submission is the first person to run it.
Does the victim have to click anything?
No. The injected attributes include autofocus and tabindex, so the browser focuses the element during page load and the onfocus handler runs by itself. In the measurement published above, a browser opening the page produced the dialog with no input events at all.
How do I fix CVE-2026-104404?
Update GiveWP to 4.18.0 or later, which puts quotes around the attribute and runs esc_attr() over the encoded value in both the Campaign and Campaign Grid templates. Then review any posts submitted by low-privileged users while an earlier version was installed, because the payload lives in the post content and keeps firing until that post is edited.