You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This works, but it's a bit of an anti-pattern. The ideal approach here is to separate the saving and normalisation pipeline and continue to normalise the output. [...] this will leave the door open for stored XSS. Something to keep in mind - it should hopefully only be applicable to older data.
Long term, it would be worth considering shifting away from the current methods of loading data and rendering blocks. Relying on a global render flag ($GLOBALS['SITEORIGIN_WIDGET_BLOCK_RENDER']) and parent-to-iframe DOM cloning is inherently fragile. Because these are pragmatic patches over legacy foundations, fixing one race condition or sanitisation edge case often exposes another [...] Creating a proper update and render pipeline is more robust than a global flag/DOM cloning approach.
#2344 and #2345 shipped as pragmatic patches. This issue records the research behind Alex's review and sets the direction for the structural fix, so the next patch in this area builds toward it instead of adding another band-aid. Part A is the problem statement. Part B is the deep discovery and the decisions for Phase 1, ready to plan from.
Line references are against develop at 3b986134 (includes #2345).
Part A: the problem
Problem 1: update() fuses sanitisation with normalisation
SiteOrigin_Widget::update() (base/siteorigin-widget.class.php:806-855) is the only path that re-applies every field sanitiser and the unknown-key strip while also canonicalising the instance. widget() and form() run modify_instance() and add_defaults() on their own (:171, :515), but neither reshapes field values. update_fields() (:857) runs every field's sanitize(), which does two unrelated jobs in one call:
Sanitisation. Decide what HTML the saver may store. Capability-keyed. Only meaningful at save.
Two sanitisers consult the current user:
SiteOrigin_Widget_Field_TinyMCE::sanitize_field_input() (base/inc/fields/tinymce.class.php:557) branches on current_user_can( 'unfiltered_html' ).
The base sanitize => 'text' case (base/inc/fields/base.class.php:378-388) runs sanitize_text_field() only when the current user is logged in and lacks unfiltered_html. At render a logged-out visitor and an admin skip it; a logged-in subscriber runs it. The comment there already admits why: "Fields can be sanitized for setup purposes during display." No bundled widget declares 'sanitize' => 'text'; the case is reachable only by third-party fields.
Because normalisation and sanitisation are one method, every render path that needs normalised data has to call update(), and so runs a capability check against whoever is the current user at render. That user is the viewer, not the author. #2345 is the symptom: a logged-out visitor stripped an admin's iframe.
Callers that re-enter update( $instance, $instance ) outside an ordinary widget save:
Call site
Context
compat/block-editor/widget-block.php:721 in render_widget_block()
Front-end render: block slow path. Taken when widgetMarkup is empty or holds "No widget preview available.", when $_POST is non-empty, for three always-render widgets, on the exclude filter, or under WPML (:702-712)
widget-block.php:880 in get_widget_preview(), from the REST previews route (siteorigin-widgets-resource.class.php:140-146)
Editor preview. The JS stores the returned HTML into widgetMarkup (widget-block.js:198-201)
widget-block.php:880 from sanitize_block() via server_side_validation() (rest_pre_insert_{post_type}, :52)
Save validation. Rebuilds the block attributes from scratch (:934-940) so inbound widgetMarkup never persists
Front-end render (Page Builder): calls widget() with noupdate()
So the block slow path is the only front-end path that re-runs sanitisers under the viewer. Page Builder and sidebar widgets never did. Page Builder's layout block deliberately skips it (compat/layout-block.php:343-370, citing SOWB PR #2316: "never re-execute sanitizers against their own stored output").
#2345 patches only the block-render path, only for the TinyMCE field. It does so by having the render set SITEORIGIN_WIDGET_BLOCK_RENDER and the field sanitiser read it three layers down. The same global is already read by Premium (addons/plugin/tabs/tabs.php:113-116, accordion/accordion.php:115-118, carousel/carousel.php:142-145) for a different reason: their block-only form fields (content_type, content_layout, one level inside a repeater row) are declared only when is_admin() || REST_REQUEST || the global is true, so the render-time update() unknown-key strip (container-base.class.php:151) does not delete those keys. Without them a "Layout builder" tab renders as empty text. After #2345 one flag carries two meanings: "declare the block form shape" and "skip viewer-keyed sanitisation".
Stored XSS window (Alex's point, quantified)
Before #2345 the block slow path re-ran wp_kses_post() on TinyMCE values for any viewer without unfiltered_html, but rendered raw for an admin viewer. It was never a complete output filter: the admin, the higher-value target, always saw the stored HTML. After #2345 the stored value renders as stored for every viewer, on the block path only.
serialised into data-options, rendered as HTML by InfoWindow.content (js/sow.google-map.js:252,265)
none at the HTML level
Tabs, Features, Testimonials, Hero, Contact Form, Google Maps consent notice
templates
wp_kses_post
Premium WooCommerce Thank You success.message
wc-thankyou-order-status.php:66
none (failed.message at :52 is kses'd)
The guarantee now rests on save-time sanitisation, which has gaps:
Content saved before WordPress 5.3.1 was never attribute-sanitised by core. filter_block_content() was added in 5.3.1 (wp-includes/blocks.php:2111, function at :2121).
WXR imports run under the importing admin. Core removes kses for unfiltered_html users, so imported attributes are stored raw. This matches core's own trust model for every core block.
Direct database writes: WP-CLI commands that bypass wp_insert_post(), and importers that write posts directly. The SiteOrigin Importer inserts into $wpdb->posts directly (siteorigin-importer/importer/importer.class.php:539), so no kses runs.
Sites that revoked unfiltered_html (DISALLOW_UNFILTERED_HTML, multisite) after content was saved.
The plugin's own REST-save validation runs under the saver, so the normal editor path is covered twice.
Problem 2: editor forms live inside the canvas, so admin assets are cloned into it
In iframed editors (the Site Editor, and post-editor canvases that qualify) the widget form HTML is injected into the block inside the canvas document (widget-block.js:804, dangerouslySetInnerHTML; the non-iframe fallback is at :1404). Every admin form dependency therefore has to exist in the canvas document: jQuery and jQuery UI, TinyMCE, quicktags, wplink, the colour picker, the media modal templates, Widgets Bundle field scripts, and Page Builder for builder fields.
The plugin supplies them by walking a hard-coded allowlist of 99 selectors, mostly element IDs (sowbCanvasCloneElements, widget-block.js:1586-1721) and cloning matching nodes from the parent document (sowbCloneElementsToCanvas, :2595). This is the mechanism #2341 / #2344 repaired: the list's first entry never matched on a default install because core concatenates scripts, so jQuery was never cloned and every dependant threw.
Why cloning is structurally fragile, not just buggy:
The allowlist is coupled to core and plugin script handles, concatenation settings, and SCRIPT_DEBUG. Any change in any of those silently drops an asset.
Ordering and readiness are the plugin's problem: the canvas loads its own assets asynchronously, so every clone needs a gate, and every gate needs a retry.
Two jQuery instances (parent and canvas) own different plugins, so the code has to pick the right one per node (formWindow.jQuery || jQuery, :270, :1448) and bridge init with postMessage (:454-534).
Core's own iframe asset collector, _wp_get_iframed_editor_assets() (wp-includes/block-editor.php:301), explains at :337-339 that editor scripts are not loaded in the iframe, then applies should_load_block_editor_scripts_and_styles → false at :340, and enqueues block styles and front-end block assets. That is strong evidence the iframe asset contract covers block content assets, not wp-admin form UI. Cloning works against that contract.
Cost so far: 32 commits to widget-block.js since 2026-05-01, 14 of them fixes, guards, races, retries or waits. #2344 alone needed 14 commits.
Direction
Three phases. Each lands on its own and is useful alone.
Phase 1: separate normalisation from sanitisation (PHP). Invariant: normalisation never consults the current user. Fully specified in Part B.
Phase 2: block data loading and render. Today the block attributes hold both the data (widgetData) and a cached preview (widgetMarkup), and render_widget_block() picks a slow or fast path by heuristics including ! empty( $_POST ) (widget-block.php:708), which means any POST to a page re-renders every widget block. The cache is blanked at save whenever the render carries a <style> tag, so most styled widgets always take the slow path anyway. get_widget_preview() adds siteorigin_widgets_is_preview__return_true (:865) and never removes it. Make the block a plain dynamic block: attributes carry data only, render always goes normalise → widget(). If a render cache is still wanted, hold it server-side keyed by an instance hash (overlaps Widget Preview REST Endpoint Recompiles LESS per Request With No Cache #2333 and Widget Block: Save-Path Hardening Against Autosave Attribute Corruption #2332). Drop the $_POST heuristic and the string match. If widget() itself is made to normalise for all render paths, re-read the Page Builder precedent above first: sidebar and Page Builder renders have never re-run field code at render.
Phase 3: move the widget form out of the canvas (editor JS). Render the form in the parent document, where every admin asset is already loaded: a modal (as Page Builder does) or a portal into the inspector sidebar. The canvas shows only the server-rendered preview. Delete sowbCanvasCloneElements, the clone walk, the canvas readiness gate, the retry timers, the dual-jQuery selection and the postMessage init bridge. Block Editor: TinyMCE editor field renders only in first repeater item inside post-editor iframe #2307 (TinyMCE only in the first repeater item inside the canvas) and Image Widget: Form Fields Render Incorrectly with Twenty Seventeen Active #2351 (theme editor styles restyling the form) should close with this phase.
Part B: Phase 1 discovery and decisions
Field inventory
Every sanitize_field_input() in base/inc/fields/*.class.php was classified as (a) shape or coercion that must also run at render, (b) viewer-independent validation that is safe at render, or (c) capability-keyed or save-only work. Result:
(c) work exists in exactly three places. TinyMCE (tinymce.class.php:571-581: the kses decision). The base 'text' switch case (base.class.php:378-388). The builder field (builder.class.php:69-71): it calls siteorigin_panels_process_raw_widgets(), which runs each nested widget's update() and kses_deep() for non-widgets when the current user lacks unfiltered_html (siteorigin-panels/inc/admin.php:1233, :1237). On the block slow path a logged-out viewer's capability therefore kses's non-SOWB widgets inside a Premium layout tab today.
Everything else is (a) or (b): checkbox, checkboxes, color, date-range, font, icon, image-radio, image-shape, image-size, link, measurement, multi-measurement, media (the upload_files check at media.class.php:75 is form-render only), multiple-media, number, order, posts, radio, select, slider, text-input-base (wp_kses_post when allow_html, else sanitize_text_field, always, no user read), and the identity fields (code, error, html, presets, tabs).
Containers (container-base.class.php:98-154): recurse into sub-fields via sanitize() and sanitize_instance(), never passing old_value, then strip unknown keys per row (:145-151). Repeater loops rows through the same method. Section, toggle and widget fields inherit.
The code field overrides sanitize() itself (code.class.php:51-53) to return the value untouched.
One Premium field class (bulk-address/fields/bulk-addresses.class.php); its sanitiser is a no-op. No Premium sanitize() overrides. Premium never calls update_fields(), sanitize_instance() or get_widget_preview(); its update() callers are saves (admin/options.php:657, cpt-builder.php:467).
siteorigin_widgets_sanitize_instance_so-wc-checkout-order-review (wc-checkout-order-review.php:63-80) calls update_option() from a sanitise filter. Because the block slow path runs update(), that option is rewritten on every front-end block render of the widget.
No plugin or theme outside the three SiteOrigin repos reads SITEORIGIN_WIDGET_BLOCK_RENDER. Page Builder does not read it. SO_WIDGETS_BUNDLE_PREVIEW_RENDER is read only by widgets/editor/editor.php:134.
Third-party surface
update() is public; no widget in the local ecosystem overrides it (44 SiteOrigin_Widget subclasses across four third-party plugins checked). Signature and save behaviour must not change.
update_fields() is private.
sanitize() is public and overridden by the code field. sanitize_field_input() is abstract protected and implemented by every field; three third-party implementations found, none consults capabilities. Adding a parameter to either is a PHP 8 fatal for any override with fewer parameters. This rules out a context argument.
The sanitise filters (siteorigin_widgets_sanitize_field_*, siteorigin_widgets_sanitize_instance*, siteorigin_widgets_field_allow_unfiltered_html) have only in-bundle and Premium consumers. The docblocks are the only contract; nothing in the readmes documents these methods.
Tests
PHP unit suite: npm run test:unit runs phpunit.xml against ./tests with Brain Monkey. No PHP test on develop calls update(), update_fields(), sanitize(), sanitize_field_input() or get_widget_preview(). Nothing stubs current_user_can.
A real-widget harness (tests/phpunit/bootstrap-widget.php, phpunit-widget.xml, tests/phpunit/widget/WidgetUpdateChainTest.php, tests/phpunit/KsesEmulation.php) exists only on PR AI Abilities: Expose Widget Blocks for Read and Write #2342's branch (feature/ai-widget-block-abilities, unmerged).
Two were put to Andrew in chat and confirmed; the rest follow from discovery.
API shape: new normalize() methods, not a context argument. Add normalize( $value, $instance ), normalize_field_input( $value, $instance ) (non-abstract, defaults to sanitize_field_input()) and normalize_instance( $instance ) (defaults to sanitize_instance()) to the field base class, and SiteOrigin_Widget::normalize( $instance, $form_type ) mirroring update() without its save-only work (Beaver Builder unwrap, delete_css(), timestamp, the sanitise-instance filters). An unmigrated field behaves at render exactly as today because the defaults delegate. Only tinymce, builder, container-base, repeater and posts need a normalize_field_input(). A field that overrides sanitize() but not normalize() (the code field, any third-party field) gets normalize() delegated to its own sanitize( $value, $instance, $value ), detected by reflection and cached per class. normalize() sets old_value = $value, matching what update( $instance, $instance ) gives top-level fields today. Rejected: a $context argument (fatal for third-party overrides, see above); a context property (leaves one method doing two jobs, the anti-pattern the issue names).
No unknown-key strip at render (confirmed by Andrew). normalize() and the container normaliser leave undeclared keys untouched; update() keeps the strip for save and save validation. The strip exists so an unsanitised key cannot persist; at render nothing persists. Page Builder and sidebar widgets never strip at render. Rejected: strip behind a declared form shape via a third argument on the siteorigin_widgets_form_options* filters, because form_options() caches per widget object and widget() caches per id_base in wp_cache (siteorigin-widget.class.php:92-102), so a context-dependent shape leaks across contexts and, with a persistent object cache, across requests.
The global is removed and nothing replaces it. With decision 2, Premium's block-only keys survive render with no signal, and its gate falls back to is_admin() || REST_REQUEST for forms, previews and save validation. Premium keeps its ! empty( $GLOBALS['SITEORIGIN_WIDGET_BLOCK_RENDER'] ) term for now: it is a harmless false on new SOWB, and dropping it would regress Premium users still on an older SOWB whose block path strips. Premium may drop it once it requires this SOWB version (Premium follow-up). Rejected: keep setting the global for one release (keeps the try/finally restore alive for a value nothing reads).
Only the block slow path moves to normalize() (confirmed by Andrew). Editor previews, editor forms, VC inline preview and save validation keep update(): the previewing user is the author, so a user without unfiltered_html previews what save will store, and Premium's gate is already true under admin and REST. Save validation is the persistence chokepoint and keeps update().
Output policy: viewer-independent, with one opt-in filter. The block slow path renders the normalised stored value with no kses; template escaping stays as in the table above. Add apply_filters( 'siteorigin_widgets_tinymce_render_kses', false, $field, $widget ) in the tinymce normaliser; when true, wp_kses_post() runs before balanceTags() for every viewer alike. Documented as compatibility-affecting (strips iframes and embeds for everyone). Rejected: keying on user_can( $post->post_author, 'unfiltered_html' ) (no post for sidebar widgets, wrong for multi-author edits); a one-time re-sanitise of stored data (destructive, cannot know the original saver); a widget-level filter on rendered HTML (would strip the widgets' own iframes and inline scripts).
Builder field at render: normalize_field_input() does json_decode and sets panels_info.builder, and does not call siteorigin_panels_process_raw_widgets(). Stored builder values were processed at save. This also fixes the pre-existing logged-out kses of non-SOWB widgets inside Premium layout tabs. Rejected: process_raw_widgets( ..., $structural_only = true ), because the SOWB wrapper siteorigin_panels_process_raw_widgets() (inc/functions.php:57) exposes only three parameters.
The invariant is proven two ways. Runtime: the harness defines current_user_can(), is_user_logged_in() and wp_get_current_user() as functions that log calls; normalize() on a fixture widget declaring every migrated field type must log nothing, while update() as a logged-in non-capable user must log calls. Static: a reflection source scan of every normalize* method in the field classes and widget base class asserts none contains current_user_can, is_user_logged_in, wp_get_current_user or SITEORIGIN_WIDGET_BLOCK_RENDER. Plus idempotence (normalize( normalize( $x ) ) === normalize( $x )) and equivalence (for a capable user, update( $x, $x ) minus the timestamp and minus stripped unknown keys equals normalize( $x )), which pins today's admin-viewer output on the block slow path.
Six commits, each with a proving test. Run npm run test:unit and vendor/bin/phpunit -c phpunit-widget.xml before every commit.
Harness with update() baselines.phpunit-widget.xml, tests/phpunit/bootstrap-widget.php (capability stand-ins that log calls), KsesEmulation.php, a fixture widget declaring every field type, and WidgetUpdateChainTest.php asserting today's update() behaviour: strip, kses per user class, recursive rows, timestamp on change, Premium-style row keys stripped.
Field base API. Extract the custom-sanitiser dispatch into one private helper used by both sanitize() and normalize(); add normalize(), normalize_field_input(), normalize_instance(); the 'text' case does nothing in normalize(); reflection fallback for sanitize() overrides. Test: 'text' logs a capability call under sanitize() and nothing under normalize(); url/email/number/callable give the same result from both.
Containers, repeater, posts, builder. Container normalize_field_input() recurses with no strip; repeater loops rows; posts shares its prepare/rebuild helpers between both methods; builder per decision 6. Test: undeclared row keys kept by normalize() and dropped by update(); nested tinymce reaches the normaliser with no capability call; posts round-trips idempotently; builder never calls siteorigin_panels_process_raw_widgets.
TinyMCE split and filter.normalize_field_input(): wpautop under the existing condition, the siteorigin_widgets_tinymce_render_kses filter, balanceTags. sanitize_field_input(): delete the SITEORIGIN_WIDGET_BLOCK_RENDER branch (:564-577); save output stays byte-identical. Test: <iframe> and <script> survive normalize() for a non-capable user and are stripped by sanitize(); the filter strips for everyone; idempotent on <p> content.
SiteOrigin_Widget::normalize() and the invariant tests. Extract field instantiation from update_fields() into a shared helper; add normalize_fields() (per-field normalize() + normalize_instance(), then siteorigin_widgets_normalize_instance and _{id_base} filters, no strip); add normalize(); fix the modify_instance() and update() docblocks. Test: decision 7 in full.
Switch the block slow path and remove the global.widget-block.php:721 → $widget->normalize( $instance ); remove the flag set (:671-672) and restore (:767-771), keep the try/finally, buffer unwind and filter cleanup from Widget Blocks: Stop Stripping Saved Rich-Text Content for Logged-Out Visitors #2345. E2E: a fourth test in wb-widget-block-render-capability.test.js asserting a logged-in subscriber sees an admin's iframe on the POST-forced slow path. git grep SITEORIGIN_WIDGET_BLOCK_RENDER returns nothing.
Verification (manual, after all steps)
Admin publishes an Editor widget block with an <iframe>. Logged out: present. Any POST to the URL: present. Logged in as subscriber: present. Repeat with Accordion and Anything Carousel blocks.
An author (no unfiltered_html) edits and saves: content kses'd by core at save as before; the form still loads and previews.
Premium Tabs block with a "Layout builder" tab containing an Editor widget with an iframe: logged-out front end renders the layout and the nested iframe. Same for Accordion and Carousel.
Sidebar widget save: value shape unchanged, timestamp stamped.
Page Builder save still strips an injected unknown key from panels_data.
Editor preview for a user without unfiltered_html shows kses'd content.
With add_filter( 'siteorigin_widgets_tinymce_render_kses', '__return_true' ), the iframe is stripped for admin, subscriber and logged-out viewers alike on the slow path.
Premium WooCommerce Checkout Order Review block: so_order_review_settings written at save and no longer on every front-end render.
Widget global settings dialog still saves.
Risks
Output drift on the block slow path. Admin viewer: byte-identical, pinned by the equivalence test. Logged-in viewer without unfiltered_html: output changes from kses'd to stored (the fix). Third-party fields declaring 'sanitize' => 'text' no longer run it at render; no bundled field does.
wpautop at render is unchanged from today on the block path. wpautop is not idempotent for every fragment; the idempotence tests use balanced <p> content.
Premium version matrix. New SOWB with current Premium: fine. Current SOWB with a future Premium that drops the global read: the block slow path strips the layout keys. Premium must keep the term until it can require this SOWB version.
Rollback: each step is independent of the ones after it. Reverting step 6 alone puts update() back on the slow path with the global gone, so the tinymce field would kses for logged-out viewers again; a rollback of step 6 must also revert step 4.
Follow-ups outside this issue
Premium: drop the SITEORIGIN_WIDGET_BLOCK_RENDER term once Premium requires this SOWB version; kses success.message at wc-thankyou-order-status.php:66 like failed.message at :52; review toggle-visibility/inc/metabox.php:307-309 (returns a tinymce value as the_content); the wc-checkout-order-review save hook writes an option from a sanitise filter, a side effect in the wrong place even at save.
Bundled output: the Google Maps marker info value renders as HTML with no kses at the HTML level.
tests/results/ and tests/cache/storageState.json are committed Playwright artefacts.
Why this issue exists
Alex approved #2345 with this review:
#2344 and #2345 shipped as pragmatic patches. This issue records the research behind Alex's review and sets the direction for the structural fix, so the next patch in this area builds toward it instead of adding another band-aid. Part A is the problem statement. Part B is the deep discovery and the decisions for Phase 1, ready to plan from.
Line references are against
developat3b986134(includes #2345).Part A: the problem
Problem 1:
update()fuses sanitisation with normalisationSiteOrigin_Widget::update()(base/siteorigin-widget.class.php:806-855) is the only path that re-applies every field sanitiser and the unknown-key strip while also canonicalising the instance.widget()andform()runmodify_instance()andadd_defaults()on their own (:171,:515), but neither reshapes field values.update_fields()(:857) runs every field'ssanitize(), which does two unrelated jobs in one call:wpautopTinyMCE content, coerce numbers, strip unknown keys. Idempotent. Needs no user.Two sanitisers consult the current user:
SiteOrigin_Widget_Field_TinyMCE::sanitize_field_input()(base/inc/fields/tinymce.class.php:557) branches oncurrent_user_can( 'unfiltered_html' ).sanitize => 'text'case (base/inc/fields/base.class.php:378-388) runssanitize_text_field()only when the current user is logged in and lacksunfiltered_html. At render a logged-out visitor and an admin skip it; a logged-in subscriber runs it. The comment there already admits why: "Fields can be sanitized for setup purposes during display." No bundled widget declares'sanitize' => 'text'; the case is reachable only by third-party fields.Because normalisation and sanitisation are one method, every render path that needs normalised data has to call
update(), and so runs a capability check against whoever is the current user at render. That user is the viewer, not the author. #2345 is the symptom: a logged-out visitor stripped an admin's iframe.Callers that re-enter
update( $instance, $instance )outside an ordinary widget save:compat/block-editor/widget-block.php:721inrender_widget_block()widgetMarkupis empty or holds "No widget preview available.", when$_POSTis non-empty, for three always-render widgets, on the exclude filter, or under WPML (:702-712)widget-block.php:880inget_widget_preview(), from the REST previews route (siteorigin-widgets-resource.class.php:140-146)widgetMarkup(widget-block.js:198-201)widget-block.php:880fromsanitize_block()viaserver_side_validation()(rest_pre_insert_{post_type},:52):934-940) so inboundwidgetMarkupnever persistsbase/inc/actions.php:25base/inc/routes/siteorigin-widgets-resource.class.php:101compat/visual-composer/visual-composer.php:266siteorigin_widget_vc_template.php:7) callsrender_widget()with noupdate()compat/visual-composer/visual-composer.php:181content_save_prebase/siteorigin-widget.class.php:1517siteorigin-panels/inc/admin.php:1233(process_raw_widgets())siteorigin-panels/inc/admin.php:1463(render_form(), raw)siteorigin-panels/inc/renderer.php:1052widget()with noupdate()So the block slow path is the only front-end path that re-runs sanitisers under the viewer. Page Builder and sidebar widgets never did. Page Builder's layout block deliberately skips it (
compat/layout-block.php:343-370, citing SOWB PR #2316: "never re-execute sanitizers against their own stored output").#2345 patches only the block-render path, only for the TinyMCE field. It does so by having the render set
SITEORIGIN_WIDGET_BLOCK_RENDERand the field sanitiser read it three layers down. The same global is already read by Premium (addons/plugin/tabs/tabs.php:113-116,accordion/accordion.php:115-118,carousel/carousel.php:142-145) for a different reason: their block-only form fields (content_type,content_layout, one level inside a repeater row) are declared only whenis_admin() || REST_REQUEST || the globalis true, so the render-timeupdate()unknown-key strip (container-base.class.php:151) does not delete those keys. Without them a "Layout builder" tab renders as empty text. After #2345 one flag carries two meanings: "declare the block form shape" and "skip viewer-keyed sanitisation".Stored XSS window (Alex's point, quantified)
Before #2345 the block slow path re-ran
wp_kses_post()on TinyMCE values for any viewer withoutunfiltered_html, but rendered raw for an admin viewer. It was never a complete output filter: the admin, the higher-value target, always saw the stored HTML. After #2345 the stored value renders as stored for every viewer, on the block path only.Template escaping is unchanged and uneven:
widgets/editor/tpl/default.php:6widgets/accordion/accordion.php:315widgets/anything-carousel/anything-carousel.php:337infodata-options, rendered as HTML byInfoWindow.content(js/sow.google-map.js:252,265)wp_kses_postsuccess.messagewc-thankyou-order-status.php:66failed.messageat:52is kses'd)The guarantee now rests on save-time sanitisation, which has gaps:
filter_block_content()was added in 5.3.1 (wp-includes/blocks.php:2111, function at:2121).unfiltered_htmlusers, so imported attributes are stored raw. This matches core's own trust model for every core block.wp_insert_post(), and importers that write posts directly. The SiteOrigin Importer inserts into$wpdb->postsdirectly (siteorigin-importer/importer/importer.class.php:539), so no kses runs.unfiltered_html(DISALLOW_UNFILTERED_HTML, multisite) after content was saved.The plugin's own REST-save validation runs under the saver, so the normal editor path is covered twice.
Problem 2: editor forms live inside the canvas, so admin assets are cloned into it
In iframed editors (the Site Editor, and post-editor canvases that qualify) the widget form HTML is injected into the block inside the canvas document (
widget-block.js:804,dangerouslySetInnerHTML; the non-iframe fallback is at:1404). Every admin form dependency therefore has to exist in the canvas document: jQuery and jQuery UI, TinyMCE, quicktags, wplink, the colour picker, the media modal templates, Widgets Bundle field scripts, and Page Builder for builder fields.The plugin supplies them by walking a hard-coded allowlist of 99 selectors, mostly element IDs (
sowbCanvasCloneElements,widget-block.js:1586-1721) and cloning matching nodes from the parent document (sowbCloneElementsToCanvas,:2595). This is the mechanism #2341 / #2344 repaired: the list's first entry never matched on a default install because core concatenates scripts, so jQuery was never cloned and every dependant threw.Why cloning is structurally fragile, not just buggy:
SCRIPT_DEBUG. Any change in any of those silently drops an asset.formWindow.jQuery || jQuery,:270,:1448) and bridge init withpostMessage(:454-534)._wp_get_iframed_editor_assets()(wp-includes/block-editor.php:301), explains at:337-339that editor scripts are not loaded in the iframe, then appliesshould_load_block_editor_scripts_and_styles→ false at:340, and enqueues block styles and front-end block assets. That is strong evidence the iframe asset contract covers block content assets, not wp-admin form UI. Cloning works against that contract.Cost so far: 32 commits to
widget-block.jssince 2026-05-01, 14 of them fixes, guards, races, retries or waits. #2344 alone needed 14 commits.Direction
Three phases. Each lands on its own and is useful alone.
widgetData) and a cached preview (widgetMarkup), andrender_widget_block()picks a slow or fast path by heuristics including! empty( $_POST )(widget-block.php:708), which means any POST to a page re-renders every widget block. The cache is blanked at save whenever the render carries a<style>tag, so most styled widgets always take the slow path anyway.get_widget_preview()addssiteorigin_widgets_is_preview__return_true(:865) and never removes it. Make the block a plain dynamic block: attributes carry data only, render always goes normalise →widget(). If a render cache is still wanted, hold it server-side keyed by an instance hash (overlaps Widget Preview REST Endpoint Recompiles LESS per Request With No Cache #2333 and Widget Block: Save-Path Hardening Against Autosave Attribute Corruption #2332). Drop the$_POSTheuristic and the string match. Ifwidget()itself is made to normalise for all render paths, re-read the Page Builder precedent above first: sidebar and Page Builder renders have never re-run field code at render.sowbCanvasCloneElements, the clone walk, the canvas readiness gate, the retry timers, the dual-jQuery selection and thepostMessageinit bridge. Block Editor: TinyMCE editor field renders only in first repeater item inside post-editor iframe #2307 (TinyMCE only in the first repeater item inside the canvas) and Image Widget: Form Fields Render Incorrectly with Twenty Seventeen Active #2351 (theme editor styles restyling the form) should close with this phase.Part B: Phase 1 discovery and decisions
Field inventory
Every
sanitize_field_input()inbase/inc/fields/*.class.phpwas classified as (a) shape or coercion that must also run at render, (b) viewer-independent validation that is safe at render, or (c) capability-keyed or save-only work. Result:tinymce.class.php:571-581: the kses decision). The base'text'switch case (base.class.php:378-388). The builder field (builder.class.php:69-71): it callssiteorigin_panels_process_raw_widgets(), which runs each nested widget'supdate()andkses_deep()for non-widgets when the current user lacksunfiltered_html(siteorigin-panels/inc/admin.php:1233,:1237). On the block slow path a logged-out viewer's capability therefore kses's non-SOWB widgets inside a Premium layout tab today.upload_filescheck atmedia.class.php:75is form-render only), multiple-media, number, order, posts, radio, select, slider, text-input-base (wp_kses_postwhenallow_html, elsesanitize_text_field, always, no user read), and the identity fields (code, error, html, presets, tabs).container-base.class.php:98-154): recurse into sub-fields viasanitize()andsanitize_instance(), never passingold_value, then strip unknown keys per row (:145-151). Repeater loops rows through the same method. Section, toggle and widget fields inherit.codefield overridessanitize()itself (code.class.php:51-53) to return the value untouched.base.class.php:402-408): a callable or thesiteorigin_widgets_sanitize_field_{name}filter. Bundled consumer:widgets/contact/contact.php:53(multiple_emails). PR Fix: Don't pass null $old_value to single-arg callable sanitizers #2358 (open) touches this branch.Premium
bulk-address/fields/bulk-addresses.class.php); its sanitiser is a no-op. No Premiumsanitize()overrides. Premium never callsupdate_fields(),sanitize_instance()orget_widget_preview(); itsupdate()callers are saves (admin/options.php:657,cpt-builder.php:467).siteorigin_widgets_sanitize_instance_so-wc-checkout-order-review(wc-checkout-order-review.php:63-80) callsupdate_option()from a sanitise filter. Because the block slow path runsupdate(), that option is rewritten on every front-end block render of the widget.SITEORIGIN_WIDGET_BLOCK_RENDER. Page Builder does not read it.SO_WIDGETS_BUNDLE_PREVIEW_RENDERis read only bywidgets/editor/editor.php:134.Third-party surface
update()is public; no widget in the local ecosystem overrides it (44SiteOrigin_Widgetsubclasses across four third-party plugins checked). Signature and save behaviour must not change.update_fields()is private.sanitize()is public and overridden by the code field.sanitize_field_input()is abstract protected and implemented by every field; three third-party implementations found, none consults capabilities. Adding a parameter to either is a PHP 8 fatal for any override with fewer parameters. This rules out a context argument.siteorigin_widgets_sanitize_field_*,siteorigin_widgets_sanitize_instance*,siteorigin_widgets_field_allow_unfiltered_html) have only in-bundle and Premium consumers. The docblocks are the only contract; nothing in the readmes documents these methods.Tests
npm run test:unitrunsphpunit.xmlagainst./testswith Brain Monkey. No PHP test ondevelopcallsupdate(),update_fields(),sanitize(),sanitize_field_input()orget_widget_preview(). Nothing stubscurrent_user_can.tests/phpunit/bootstrap-widget.php,phpunit-widget.xml,tests/phpunit/widget/WidgetUpdateChainTest.php,tests/phpunit/KsesEmulation.php) exists only on PR AI Abilities: Expose Widget Blocks for Read and Write #2342's branch (feature/ai-widget-block-abilities, unmerged).KsesEmulationclass already answers Test Harness: wp_kses_post() Stub Does Not Reflect Real kses Behaviour #2340's "one shared definition" point.tests/e2e/wb-widget-block-render-capability.test.js(from Widget Blocks: Stop Stripping Saved Rich-Text Content for Logged-Out Visitors #2345), three tests, all must keep passing. No test references the global by name.Decisions
Two were put to Andrew in chat and confirmed; the rest follow from discovery.
normalize()methods, not a context argument. Addnormalize( $value, $instance ),normalize_field_input( $value, $instance )(non-abstract, defaults tosanitize_field_input()) andnormalize_instance( $instance )(defaults tosanitize_instance()) to the field base class, andSiteOrigin_Widget::normalize( $instance, $form_type )mirroringupdate()without its save-only work (Beaver Builder unwrap,delete_css(), timestamp, the sanitise-instance filters). An unmigrated field behaves at render exactly as today because the defaults delegate. Only tinymce, builder, container-base, repeater and posts need anormalize_field_input(). A field that overridessanitize()but notnormalize()(the code field, any third-party field) getsnormalize()delegated to its ownsanitize( $value, $instance, $value ), detected by reflection and cached per class.normalize()setsold_value = $value, matching whatupdate( $instance, $instance )gives top-level fields today. Rejected: a$contextargument (fatal for third-party overrides, see above); a context property (leaves one method doing two jobs, the anti-pattern the issue names).normalize()and the container normaliser leave undeclared keys untouched;update()keeps the strip for save and save validation. The strip exists so an unsanitised key cannot persist; at render nothing persists. Page Builder and sidebar widgets never strip at render. Rejected: strip behind a declared form shape via a third argument on thesiteorigin_widgets_form_options*filters, becauseform_options()caches per widget object andwidget()caches perid_baseinwp_cache(siteorigin-widget.class.php:92-102), so a context-dependent shape leaks across contexts and, with a persistent object cache, across requests.is_admin() || REST_REQUESTfor forms, previews and save validation. Premium keeps its! empty( $GLOBALS['SITEORIGIN_WIDGET_BLOCK_RENDER'] )term for now: it is a harmlessfalseon new SOWB, and dropping it would regress Premium users still on an older SOWB whose block path strips. Premium may drop it once it requires this SOWB version (Premium follow-up). Rejected: keep setting the global for one release (keeps the try/finally restore alive for a value nothing reads).normalize()(confirmed by Andrew). Editor previews, editor forms, VC inline preview and save validation keepupdate(): the previewing user is the author, so a user withoutunfiltered_htmlpreviews what save will store, and Premium's gate is already true under admin and REST. Save validation is the persistence chokepoint and keepsupdate().apply_filters( 'siteorigin_widgets_tinymce_render_kses', false, $field, $widget )in the tinymce normaliser; when true,wp_kses_post()runs beforebalanceTags()for every viewer alike. Documented as compatibility-affecting (strips iframes and embeds for everyone). Rejected: keying onuser_can( $post->post_author, 'unfiltered_html' )(no post for sidebar widgets, wrong for multi-author edits); a one-time re-sanitise of stored data (destructive, cannot know the original saver); a widget-level filter on rendered HTML (would strip the widgets' own iframes and inline scripts).normalize_field_input()doesjson_decodeand setspanels_info.builder, and does not callsiteorigin_panels_process_raw_widgets(). Stored builder values were processed at save. This also fixes the pre-existing logged-out kses of non-SOWB widgets inside Premium layout tabs. Rejected:process_raw_widgets( ..., $structural_only = true ), because the SOWB wrappersiteorigin_panels_process_raw_widgets()(inc/functions.php:57) exposes only three parameters.current_user_can(),is_user_logged_in()andwp_get_current_user()as functions that log calls;normalize()on a fixture widget declaring every migrated field type must log nothing, whileupdate()as a logged-in non-capable user must log calls. Static: a reflection source scan of everynormalize*method in the field classes and widget base class asserts none containscurrent_user_can,is_user_logged_in,wp_get_current_userorSITEORIGIN_WIDGET_BLOCK_RENDER. Plus idempotence (normalize( normalize( $x ) ) === normalize( $x )) and equivalence (for a capable user,update( $x, $x )minus the timestamp and minus stripped unknown keys equalsnormalize( $x )), which pins today's admin-viewer output on the block slow path.git show origin/feature/ai-widget-block-abilities:<path>, so the two branches differ minimally when AI Abilities: Expose Widget Blocks for Read and Write #2342 merges.phpunit.xmlgains<exclude>./tests/phpunit</exclude>. Rejected: waiting for AI Abilities: Expose Widget Blocks for Read and Write #2342 (unrelated feature PR, no merge date).Phase 1 step outline
Six commits, each with a proving test. Run
npm run test:unitandvendor/bin/phpunit -c phpunit-widget.xmlbefore every commit.update()baselines.phpunit-widget.xml,tests/phpunit/bootstrap-widget.php(capability stand-ins that log calls),KsesEmulation.php, a fixture widget declaring every field type, andWidgetUpdateChainTest.phpasserting today'supdate()behaviour: strip, kses per user class, recursive rows, timestamp on change, Premium-style row keys stripped.sanitize()andnormalize(); addnormalize(),normalize_field_input(),normalize_instance(); the'text'case does nothing innormalize(); reflection fallback forsanitize()overrides. Test:'text'logs a capability call undersanitize()and nothing undernormalize();url/email/number/callable give the same result from both.normalize_field_input()recurses with no strip; repeater loops rows; posts shares its prepare/rebuild helpers between both methods; builder per decision 6. Test: undeclared row keys kept bynormalize()and dropped byupdate(); nested tinymce reaches the normaliser with no capability call; posts round-trips idempotently; builder never callssiteorigin_panels_process_raw_widgets.normalize_field_input():wpautopunder the existing condition, thesiteorigin_widgets_tinymce_render_ksesfilter,balanceTags.sanitize_field_input(): delete theSITEORIGIN_WIDGET_BLOCK_RENDERbranch (:564-577); save output stays byte-identical. Test:<iframe>and<script>survivenormalize()for a non-capable user and are stripped bysanitize(); the filter strips for everyone; idempotent on<p>content.SiteOrigin_Widget::normalize()and the invariant tests. Extract field instantiation fromupdate_fields()into a shared helper; addnormalize_fields()(per-fieldnormalize()+normalize_instance(), thensiteorigin_widgets_normalize_instanceand_{id_base}filters, no strip); addnormalize(); fix themodify_instance()andupdate()docblocks. Test: decision 7 in full.widget-block.php:721→$widget->normalize( $instance ); remove the flag set (:671-672) and restore (:767-771), keep the try/finally, buffer unwind and filter cleanup from Widget Blocks: Stop Stripping Saved Rich-Text Content for Logged-Out Visitors #2345. E2E: a fourth test inwb-widget-block-render-capability.test.jsasserting a logged-in subscriber sees an admin's iframe on the POST-forced slow path.git grep SITEORIGIN_WIDGET_BLOCK_RENDERreturns nothing.Verification (manual, after all steps)
<iframe>. Logged out: present. Any POST to the URL: present. Logged in as subscriber: present. Repeat with Accordion and Anything Carousel blocks.unfiltered_html) edits and saves: content kses'd by core at save as before; the form still loads and previews.panels_data.unfiltered_htmlshows kses'd content.add_filter( 'siteorigin_widgets_tinymce_render_kses', '__return_true' ), the iframe is stripped for admin, subscriber and logged-out viewers alike on the slow path.so_order_review_settingswritten at save and no longer on every front-end render.Risks
unfiltered_html: output changes from kses'd to stored (the fix). Third-party fields declaring'sanitize' => 'text'no longer run it at render; no bundled field does.wpautopat render is unchanged from today on the block path.wpautopis not idempotent for every fragment; the idempotence tests use balanced<p>content.base.class.php:402-408; whichever lands second rebases onto the shared helper. PR AI Abilities: Expose Widget Blocks for Read and Write #2342 adds the same harness paths; whichever lands second reconcilesbootstrap-widget.php.update()back on the slow path with the global gone, so the tinymce field would kses for logged-out viewers again; a rollback of step 6 must also revert step 4.Follow-ups outside this issue
SITEORIGIN_WIDGET_BLOCK_RENDERterm once Premium requires this SOWB version; ksessuccess.messageatwc-thankyou-order-status.php:66likefailed.messageat:52; reviewtoggle-visibility/inc/metabox.php:307-309(returns a tinymce value asthe_content); thewc-checkout-order-reviewsave hook writes an option from a sanitise filter, a side effect in the wrong place even at save.infovalue renders as HTML with no kses at the HTML level.tests/results/andtests/cache/storageState.jsonare committed Playwright artefacts.Related
#2341, #2344, #2345 (the patches this issue supersedes), #2332, #2333, #2340, #2342, #2358, #2325, #2360, #2307, #2351, PR #2316.