CVE-2026-100510 Two paths into the editor iframe in Post and Page Builder
Intrudify's autonomous pentesting engine discovered two independent stored cross-site scripting paths in Post and Page Builder by BoldGrid, affecting all versions up to and including 1.27.14. One writes attacker markup into a site-wide option through a save handler with no nonce and no capability check. The other carries it through a shortcode into a widget instance rendered without sanitisation. Both end in the editor iframe, which is same-origin with wp-admin, and both are reachable by someone with no account at all.
Summary
Post and Page Builder by BoldGrid is a visual drag and drop editor that replaces the default post editor. Intrudify's autonomous testing engine found two separate ways to move attacker-controlled data across the boundary into an administrator's wp-admin session, and they share nothing beyond the entry point and the place the payload lands.
Both execute inside the TinyMCE editor iframe. That document is about:blank, which inherits the origin of the page that created it, so script in it is same-origin with wp-admin and reaches the administrator's cookies, the wp_rest nonce and window.parent.
They are reported together because they are the same class in the same component. Fixing one leaves the other open.
The shared primitive
wp-admin/post-new.php calls get_default_post_to_edit( $post_type, true ) on every load. The second argument makes it create the auto-draft through wp_insert_post(), which fires save_post. The page also prefills the editor from $_REQUEST['content'], and it carries no nonce of its own.
So merely opening post-new.php runs every plugin save_post handler against whatever the request carries. An attacker who gets an administrator to follow one link gets one authenticated write with parameters of their choosing. Both vectors below use this.
Vector A: a GET to a site-wide option
includes/class-boldgrid-editor.php:294 registers the handler:
add_action( 'save_post', array( $builder_styles, 'save' ), 10, 2 );Boldgrid_Editor_Builder_Styles::save() at includes/builder/class-boldgrid-editor-builder-styles.php:186:
public function save() {
if ( isset( $_REQUEST['boldgrid-control-styles'] ) ) {
$styles = ! empty( $_REQUEST['boldgrid-control-styles'] ) ?
sanitize_text_field( wp_unslash( $_REQUEST['boldgrid-control-styles'] ) ) : '';
$styles = json_decode( $styles, true );
...
Boldgrid_Editor_Option::update( 'styles', ... ); // site-wide option
}
}There is no nonce check and no capability check. $_REQUEST means a GET carries it. The destination is the site-wide boldgrid_editor['styles'] option, so one write affects every administrator from then on.
The sanitiser at the wrong layer
sanitize_text_field() runs on the JSON text, before json_decode(). Its tag-stripping branch fires only on a literal <. Writing the payload with JSON unicode escapes removes every literal < from the string that gets sanitised, and json_decode() puts the metacharacters back afterwards:
// what sanitize_text_field() sees: no literal angle bracket anywhere
{"id":"x\u0022\u003e\u003c/style\u003e\u003cimg src=x onerror=alert(document.domain)\u003e",
"css":"body{}","priority":50}
// what json_decode() hands back
id = x"></style><img src=x onerror=alert(document.domain)>The validator that checks the other field
The plugin does look for markup. validate() at line 170:
public function validate( $styles ) {
$validated_styles = array();
foreach( $styles as &$style ) {
if ( ! preg_match( '#</?\w+#', $style['css'] ) ) { // only 'css'
$validated_styles[] = $style;
}
}
return $validated_styles;
}The regular expression is a reasonable markup check, and it is pointed at $style['css']. The id travels in the same structure, is never examined, and is the field that reaches the DOM.
The sink
The option is emitted to the page as BoldgridEditor.control_styles, and the style updater builds the element by string concatenation:
createStyleTag( t ) {
return $( '<style type="text/css" id="' + t + '"></style>' );
}An id containing "></style> closes the attribute and the element, and whatever follows is parsed as markup inside the editor iframe. addAllStyles() runs on editor load, so nothing has to be clicked, and because the option is site-wide it fires again for every administrator who opens the builder afterwards.
Vector B: a shortcode to an unsanitised widget instance
The editor registers a TinyMCE view for the [boldgrid_component] shortcode, so opening a post that contains one posts its opts attribute, raw from the post content, to the boldgrid_component AJAX action. The victim's own browser makes that request, with the victim's valid nonce, and no click is involved.
ajax_shortcode() at components/class-boldgrid-components-shortcode.php:353:
protected function ajax_shortcode( $component, $type ) {
Boldgrid_Editor_Ajax::validate_nonce( 'gridblock_save' );
$attrs = $this->parse_attrs( $_POST ); // merged raw, nothing sanitised
$method = 'get_' . $type;
wp_send_json( array(
'content' => $this->$method( $component, $attrs )
) );
}get_content() hands that raw array to the widget as its instance. WP_Widget_Block::widget() echoes $instance['content'] as markup, and the widget_block_content filters do not escape it.
The hardening that stopped at the sibling
Fifty lines above, in the same class, register_shortcodes() renders shortcode previews through an entry that was hardened in 1.27.12:
// register_shortcodes(), hardened in 1.27.12
$allowed = $this->get_editor_shortcode_allowlist(); // explicit tag allow-list
...
if ( ! $this->shortcode_text_matches_tag( $text, $tag ) ) {
wp_send_json_error( null, 400 );
}
wp_send_json( array( 'content' => wp_kses_post( $html ) ) );
// ajax_shortcode(), the sibling renderer, received none of it
wp_send_json( array( 'content' => $this->$method( $component, $attrs ) ) );The guard that tests the reader
The block widget is treated as dangerous elsewhere in the file, at line 148, and the comment above it says why:
/*
* If the user is not an admin, skip the 'block'
* widget because it allows the users to add
* arbitrary HTML and JavaScript.
*/
if ( ! current_user_can( 'manage_options' ) && 'block' === $widget->id_base ) {
continue;
}current_user_can() asks about the person viewing the page. The arbitrary HTML comes from the person who wrote the post. When an administrator reviews a Contributor's pending submission those are two different people, the check passes, and the widget renders.
The payload
A Contributor pastes plain text with no literal < in it, so wp_filter_post_kses stores it unchanged:
// as pasted: the opts attribute is URL-encoded, so no literal angle bracket
[boldgrid_component type=wp_block opts=%7B%22a%22%3A%5B%7B%22content%22%3A%22
%3Cimg%20src%3Dx%20loading%3Deager%20onerror%3Dalert(document.domain)%3E%22%7D%5D%7D]
// what the editor iframe ends up containing
<div class="widget widget_block"><img src="x" loading="eager" onerror="alert(document.domain)"></div>It stays dormant while the Contributor views their own post, because the block widget is skipped for non-administrators. It fires when the administrator opens it.
The same shortcode also survives the esc_html() applied to the editor prefill, so it can be delivered as post-new.php?content=[boldgrid_component ...] with no account at all. That is the unauthenticated variant of this vector.
Verification
We reproduced both on a clean WordPress installation with Post and Page Builder 1.27.14 at its shipped settings and the default theme, with the builder as the default post editor.
For Vector A, an administrator following one link stored boldgrid_editor['styles'].configuration[0].id as the break-out payload, read back out of the option itself, and the editor iframe <head> contained a live <img> with the handler on it. Opening an unrelated post afterwards at post.php?post=1&action=edit fired it again, which is what establishes that the write is site-wide and persistent.
For Vector B, a Contributor submitted the shortcode for review and it stayed inert in their own session. An administrator opening the pending post executed alert(document.domain) in the editor iframe, confirmed in a real browser.
The control for Vector A is the same payload written with literal angle brackets. It reaches sanitize_text_field() with a literal < present, the tag-stripping branch fires, and nothing is stored. The unicode-escaped form is the only one that survives, which is what identifies the layering of the sanitiser as the defect.
Impact
Script runs in the administrator's wp-admin session. The editor iframe is about:blank and therefore same-origin, so there is no barrier between it and the parent admin page: the payload reads cookies, lifts the wp_rest nonce, and acts through window.parent.
From there the reachable end states are a new administrator account and a plugin installation, which is arbitrary PHP on the host. Vector A is worse in duration: the option is site-wide, so a single successful write keeps firing for every administrator who opens the builder until somebody clears the option.
No plugin configuration is involved in either. The builder is the default editor out of the box, and reviewing a pending post is the routine action an editorial workflow is built on.
Remediation
Update Post and Page Builder by BoldGrid to 1.27.15 or later.
BoldGrid closed both paths in that release. The style save handler now requires manage_options, verifies a dedicated nonce, reads $_POST instead of $_REQUEST, sanitises the style id with sanitize_key(), and rejects the whole save if validation dropped any entry. The component AJAX renderer now sanitises its request attributes, restricts the render type to a known pair, and passes its output through wp_kses_post() or a form-specific allow-list.
Rejecting the entire save when validation drops an entry is the detail worth copying. A validator that silently discards bad items leaves the caller unable to tell a clean payload from a cleaned one, and the sanitised remainder is written anyway. Failing the whole request makes a rejected entry visible.
The wider lesson
Three of the defects here are guards that exist and are aimed slightly wrong, which is a different failure from an absent guard and a harder one to see. validate() looks for markup in $style['css'] while the id beside it goes to the DOM untouched. sanitize_text_field() runs on JSON text, one layer above the values it was meant to clean, so a unicode escape walks past it and json_decode() undoes the protection. Each one reads, at a glance, as the security control it is named after.
The capability check is the sharpest example. Its comment states the threat model exactly: the block widget allows arbitrary HTML and JavaScript, so it should be kept away from users who are not administrators. The check then asks whether the current viewer is an administrator. In any review workflow the author and the viewer are different people, and the dangerous content belongs to the author. A capability check answers "may this person do this", and the question here was "where did this content come from". Those look alike and diverge the moment one person's content is displayed to another.
The last point is about hardening that stops at the first caller. Version 1.27.12 added an allow-list and a wp_kses_post() to one shortcode preview renderer. A sibling renderer fifty lines below in the same class, reached by a different AJAX action, does the same kind of work and received none of it. A fix applied to the path the report described is worth a search for every other path that reaches the same sink, because the next report will be about whichever one was left.
Disclosure timeline
- 2026-09-19 Both vectors identified and validated by Intrudify, reported via Patchstack
- 2026-09-28 Fixed release 1.27.15 published by BoldGrid
- 2026-09-30 Public disclosure · CVE-2026-100510 assigned
Questions
What is CVE-2026-100510?
CVE-2026-100510 covers two independent stored cross-site scripting paths in the Post and Page Builder by BoldGrid WordPress plugin, versions 1.27.14 and earlier. They were discovered autonomously by the Intrudify AI penetration testing engine. One writes attacker markup into a site-wide option through a save handler with no nonce and no capability check. The other carries attacker markup through a shortcode into a widget instance that is rendered without sanitisation. Both execute in the editor iframe, which is same-origin with wp-admin.
Why does the payload run in the administrator's session?
Both paths end in the TinyMCE editor iframe. That document is about:blank, which inherits the origin of the page that created it, so script running inside it is same-origin with wp-admin. It reaches the administrator's cookies, the wp_rest nonce and window.parent.
How do I fix CVE-2026-100510?
Update Post and Page Builder by BoldGrid to version 1.27.15 or later. That release fixes both paths: the style save handler now requires manage_options and a nonce, reads POST only, and sanitises the style id; and the component AJAX renderer now sanitises its request attributes and passes its output through kses.