Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/siteorigin-panels/compat/layout-block.php
+++ b/siteorigin-panels/compat/layout-block.php
@@ -151,7 +151,11 @@
SiteOrigin_Panels_Post_Content_Filters::add_filters( true );
}
- $rendered_layout = SiteOrigin_Panels::renderer()->render( $builder_id, ! $is_editing, $panels_data );
+ if ( $is_editing || ! $this->return_layout ) {
+ $rendered_layout = SiteOrigin_Panels_Admin::render_and_restore_post_globals( $builder_id, ! $is_editing, $panels_data );
+ } else {
+ $rendered_layout = SiteOrigin_Panels::renderer()->render( $builder_id, true, $panels_data );
+ }
if ( $is_editing ) {
SiteOrigin_Panels_Post_Content_Filters::remove_filters( true );
--- a/siteorigin-panels/compat/lazy-load-backgrounds.php
+++ b/siteorigin-panels/compat/lazy-load-backgrounds.php
@@ -9,7 +9,11 @@
! empty( $style['background_display'] ) &&
! empty( $style['background_image_attachment'] ) &&
$style['background_display'] != 'parallax' &&
- $style['background_display'] != 'parallax-original'
+ $style['background_display'] != 'parallax-original' &&
+ // When there's an overlay, the image is painted (and lazy loaded) on the
+ // overlay div only. Tagging the style div here would let the lazy loader
+ // re-add the image the plugin deliberately strips, defeating the opacity.
+ ! SiteOrigin_Panels_Styles::has_overlay( array( 'style' => $style ) )
) {
$url = SiteOrigin_Panels_Styles::get_attachment_image_src( $style['background_image_attachment'], 'full' );
--- a/siteorigin-panels/inc/admin.php
+++ b/siteorigin-panels/inc/admin.php
@@ -260,7 +260,7 @@
SiteOrigin_Panels_Post_Content_Filters::add_filters();
$GLOBALS[ 'SITEORIGIN_PANELS_POST_CONTENT_RENDER' ] = true;
- $post_content = SiteOrigin_Panels::renderer()->render( $layout_id, false, $panels_data );
+ $post_content = self::render_and_restore_post_globals( $layout_id, false, $panels_data );
$post_css = SiteOrigin_Panels::renderer()->generate_css( $layout_id, $panels_data );
SiteOrigin_Panels_Post_Content_Filters::remove_filters();
unset( $GLOBALS[ 'SITEORIGIN_PANELS_POST_CONTENT_RENDER' ] );
@@ -1082,31 +1082,44 @@
$info[ 'class' ] = apply_filters( 'siteorigin_panels_widget_class', $info[ 'class' ] );
- if ( ! empty( $info['raw'] ) || $force ) {
- $the_widget = SiteOrigin_Panels::get_widget_instance( $info['class'] );
-
- if ( ! empty( $the_widget ) &&
- method_exists( $the_widget, 'update' ) ) {
- if (
- ! empty( $old_widgets_by_id ) &&
- ! empty( $widget[ 'panels_info' ][ 'widget_id' ] ) &&
- ! empty( $old_widgets_by_id[ $widget[ 'panels_info' ][ 'widget_id' ] ] )
- ) {
- $old_widget = $old_widgets_by_id[ $widget[ 'panels_info' ][ 'widget_id' ] ];
- } else {
- $old_widget = $widget;
- }
-
- /** @var WP_Widget $the_widget */
- $the_widget = SiteOrigin_Panels::get_widget_instance( $info['class'] );
- $instance = $the_widget->update( $widget, $old_widget );
- $instance = apply_filters( 'widget_update_callback', $instance, $widget, $old_widget, $the_widget );
-
- $widget = $instance;
-
- unset( $info['raw'] );
+ // Always run the widget's own update() when its class resolves to a
+ // widget exposing one. The client-controlled $info['raw'] flag (and the
+ // $force argument) must NOT decide whether sanitization runs: an attacker
+ // who omits the raw flag would otherwise persist unsanitized markup,
+ // bypassing the widget's update() (e.g. WP_Widget_Custom_HTML's
+ // wp_kses_post() carve-out for users lacking unfiltered_html). See the
+ // stored-XSS fix for panels_data.
+ $the_widget = SiteOrigin_Panels::get_widget_instance( $info['class'] );
+
+ if ( ! empty( $the_widget ) &&
+ method_exists( $the_widget, 'update' ) ) {
+ if (
+ ! empty( $old_widgets_by_id ) &&
+ ! empty( $widget[ 'panels_info' ][ 'widget_id' ] ) &&
+ ! empty( $old_widgets_by_id[ $widget[ 'panels_info' ][ 'widget_id' ] ] )
+ ) {
+ $old_widget = $old_widgets_by_id[ $widget[ 'panels_info' ][ 'widget_id' ] ];
+ } else {
+ $old_widget = $widget;
}
- }
+
+ /** @var WP_Widget $the_widget */
+ $instance = $the_widget->update( $widget, $old_widget );
+ $instance = apply_filters( 'widget_update_callback', $instance, $widget, $old_widget, $the_widget );
+
+ $widget = $instance;
+ } elseif ( ! current_user_can( 'unfiltered_html' ) ) {
+ // Defense in depth: the widget class did not resolve to something with
+ // an update() method, so its own sanitizer cannot run. For users
+ // lacking unfiltered_html, recursively wp_kses_post() every string
+ // field so unsanitized markup can never be persisted, even for an
+ // unknown or missing widget class.
+ $widget = self::kses_deep( $widget );
+ }
+
+ // The raw flag is only ever a transient editor hint and must never be
+ // persisted, regardless of which branch above ran.
+ unset( $info['raw'] );
if ( $escape_classes ) {
// Escaping for namespaced widgets.
@@ -1119,6 +1132,32 @@
return $widgets;
}
+ /**
+ * Recursively run wp_kses_post() over every string leaf of a value.
+ *
+ * Used as a defense-in-depth fallback in process_raw_widgets() for widget
+ * instances whose class cannot be resolved to a widget with an update()
+ * method, ensuring unprivileged users can never persist unsanitized markup.
+ *
+ * @param mixed $value Scalar or (nested) array to sanitize.
+ * @return mixed The sanitized value, preserving structure.
+ */
+ private static function kses_deep( $value ) {
+ if ( is_array( $value ) ) {
+ foreach ( $value as $key => $item ) {
+ $value[ $key ] = self::kses_deep( $item );
+ }
+
+ return $value;
+ }
+
+ if ( is_string( $value ) ) {
+ return wp_kses_post( $value );
+ }
+
+ return $value;
+ }
+
private function column_sizes_round( $size ) {
if ( is_array( $size ) ) {
return array_map( array( $this, 'column_sizes_round' ), $size );
@@ -1359,13 +1398,49 @@
public function generate_panels_preview( $post_id, $panels_data ) {
$GLOBALS[ 'SITEORIGIN_PANELS_PREVIEW_RENDER' ] = true;
- $return = SiteOrigin_Panels::renderer()->render( (int) $post_id, false, $panels_data );
+ $return = self::render_and_restore_post_globals( (int) $post_id, false, $panels_data );
unset( $GLOBALS[ 'SITEORIGIN_PANELS_PREVIEW_RENDER' ] );
return $return;
}
+ public static function render_and_restore_post_globals( $post_id = false, $enqueue_css = true, $panels_data = false, & $layout_data = array(), $is_preview = false ) {
+ $post_globals = array(
+ 'siteorigin_panels_current_post',
+ 'post',
+ 'id',
+ 'authordata',
+ 'currentday',
+ 'currentmonth',
+ 'page',
+ 'pages',
+ 'multipage',
+ 'more',
+ 'numpages',
+ );
+ $original_globals = array();
+
+ foreach ( $post_globals as $global_name ) {
+ $original_globals[ $global_name ] = array(
+ 'exists' => array_key_exists( $global_name, $GLOBALS ),
+ 'value' => array_key_exists( $global_name, $GLOBALS ) ? $GLOBALS[ $global_name ] : null,
+ );
+ }
+
+ try {
+ return SiteOrigin_Panels::renderer()->render( $post_id, $enqueue_css, $panels_data, $layout_data, $is_preview );
+ } finally {
+ foreach ( $original_globals as $global_name => $global_value ) {
+ if ( $global_value['exists'] ) {
+ $GLOBALS[ $global_name ] = $global_value['value'];
+ } else {
+ unset( $GLOBALS[ $global_name ] );
+ }
+ }
+ }
+ }
+
/**
* Get builder content based on the submitted panels_data.
*/
@@ -1400,7 +1475,7 @@
// Create a version of the builder data for post content.
SiteOrigin_Panels_Post_Content_Filters::add_filters();
$GLOBALS[ 'SITEORIGIN_PANELS_POST_CONTENT_RENDER' ] = true;
- echo SiteOrigin_Panels::renderer()->render( (int) $_POST['post_id'], false, $panels_data );
+ echo self::render_and_restore_post_globals( (int) $_POST['post_id'], false, $panels_data );
SiteOrigin_Panels_Post_Content_Filters::remove_filters();
unset( $GLOBALS[ 'SITEORIGIN_PANELS_POST_CONTENT_RENDER' ] );
@@ -1452,7 +1527,7 @@
// Create a version of the builder data for post content.
SiteOrigin_Panels_Post_Content_Filters::add_filters();
$GLOBALS[ 'SITEORIGIN_PANELS_POST_CONTENT_RENDER' ] = true;
- $return['post_content'] = SiteOrigin_Panels::renderer()->render( (int) $_POST['post_id'], false, $panels_data );
+ $return['post_content'] = self::render_and_restore_post_globals( (int) $_POST['post_id'], false, $panels_data );
SiteOrigin_Panels_Post_Content_Filters::remove_filters();
unset( $GLOBALS[ 'SITEORIGIN_PANELS_POST_CONTENT_RENDER' ] );
@@ -1530,7 +1605,7 @@
// We need this to get our widgets bundle to add it's styles inline for previews.
add_filter( 'siteorigin_widgets_is_preview', '__return_true' );
}
- $rendered_layout = SiteOrigin_Panels::renderer()->render( $builder_id, true, $panels_data, $layout_data );
+ $rendered_layout = self::render_and_restore_post_globals( $builder_id, true, $panels_data, $layout_data );
ob_start();
// Need to explicitly call `siteorigin_widget_print_styles` because Gutenberg previews don't render a full version of the front end,
--- a/siteorigin-panels/siteorigin-panels.php
+++ b/siteorigin-panels/siteorigin-panels.php
@@ -3,7 +3,7 @@
Plugin Name: Page Builder by SiteOrigin
Plugin URI: https://siteorigin.com/page-builder/
Description: A drag and drop, responsive page builder that simplifies building your website.
-Version: 2.34.3
+Version: 2.34.4
Author: SiteOrigin
Author URI: https://siteorigin.com
License: GPL3
@@ -11,7 +11,7 @@
Donate link: https://siteorigin.com/downloads/premium/
*/
-define( 'SITEORIGIN_PANELS_VERSION', '2.34.3' );
+define( 'SITEORIGIN_PANELS_VERSION', '2.34.4' );
if ( ! defined( 'SITEORIGIN_PANELS_JS_SUFFIX' ) ) {
define( 'SITEORIGIN_PANELS_JS_SUFFIX', '.min' );
--- a/siteorigin-panels/tests/SavePostRawFlagSanitizationTest.php
+++ b/siteorigin-panels/tests/SavePostRawFlagSanitizationTest.php
@@ -0,0 +1,213 @@
+<?php
+
+namespace SiteOriginTests;
+
+use BrainMonkey;
+use BrainMonkeyFunctions;
+use MockeryAdapterPhpunitMockeryPHPUnitIntegration;
+use PHPUnitFrameworkTestCase;
+
+/**
+ * Widget stub whose update() sanitizes its content the way a real
+ * capability-gated widget (e.g. WP_Widget_Custom_HTML) would. Declared as a
+ * named class (rather than an anonymous class) so the file stays parseable by
+ * older PHP parsers in the build toolchain.
+ */
+class SavePostRawFlagSanitizingWidgetStub {
+ public function update( $new, $old ) {
+ $new['content'] = preg_replace(
+ '/s*onw+s*=s*("[^"]*"|'[^']*'|[^s>]+)/i',
+ '',
+ (string) $new['content']
+ );
+
+ return $new;
+ }
+}
+
+/**
+ * Regression test locking the stored-XSS fix in
+ * SiteOrigin_Panels_Admin::process_raw_widgets().
+ *
+ * The client-controlled `raw` flag (and the `$force` argument) must NOT decide
+ * whether a widget's update() / sanitization runs. A widget whose class resolves
+ * to one with an update() method must ALWAYS be passed through update(), and a
+ * widget whose class does NOT resolve must be wp_kses_post()'d for users lacking
+ * the `unfiltered_html` capability.
+ *
+ * NOTE: This test is intentionally self-contained. The shared SiteOriginTests
+ * base class / phpunit.xml / composer PSR-4 autoload referenced by the plan live
+ * on the feature/ai-exposure-phase1-tests branch and are NOT present on this
+ * branch. See the "Open question for next agent" note in docs/plans/current.md.
+ *
+ * Build-toolchain note: the i18n .pot extraction (gulp-wp-pot) is the real reason
+ * tests/ is now excluded from that scan in build-config.js — its bundled
+ * php-parser cannot parse modern PHP. To minimise that surface this file avoids
+ * arrow functions and anonymous classes. The `: void` return types on
+ * setUp()/tearDown() are intentionally kept because PHPUnit 12 requires them; the
+ * file is no longer scanned by the parser, so they are safe.
+ */
+class SavePostRawFlagSanitizationTest extends TestCase {
+ use MockeryPHPUnitIntegration;
+
+ protected function setUp(): void {
+ parent::setUp();
+ MonkeysetUp();
+
+ // Minimal WP function stubs used by process_raw_widgets().
+ Functionswhen( '__' )->returnArg();
+
+ // apply_filters: return the value being filtered unchanged so the test
+ // observes the raw output of process_raw_widgets() itself.
+ Functionswhen( 'apply_filters' )->alias(
+ function ( $tag, $value = null ) {
+ return $value;
+ }
+ );
+
+ // wp_kses_post(): emulate the relevant behaviour — strip the on* event
+ // handler attribute that carries the XSS payload. This is enough to prove
+ // the fallback branch actually sanitized.
+ Functionswhen( 'wp_kses_post' )->alias(
+ function ( $value ) {
+ return preg_replace( '/s*onw+s*=s*("[^"]*"|'[^']*'|[^s>]+)/i', '', (string) $value );
+ }
+ );
+
+ $this->require_admin_class();
+ }
+
+ protected function tearDown(): void {
+ MonkeytearDown();
+ parent::tearDown();
+ }
+
+ /**
+ * Load inc/admin.php and the SiteOrigin_Panels stub it depends on once.
+ */
+ private function require_admin_class() {
+ if ( ! class_exists( 'SiteOrigin_Panels', false ) ) {
+ // Stub the SiteOrigin_Panels facade. get_widget_instance() is
+ // re-pointed per-test via the static $instance_resolver closure.
+ eval(
+ 'class SiteOrigin_Panels {'
+ . ' public static $instance_resolver = null;'
+ . ' public static function get_widget_instance( $class ) {'
+ . ' return self::$instance_resolver ? call_user_func( self::$instance_resolver, $class ) : null;'
+ . ' }'
+ . '}'
+ );
+ }
+
+ // Stubs for the WP base bits inc/admin.php references at include time.
+ if ( ! function_exists( 'add_action' ) ) {
+ Functionswhen( 'add_action' )->justReturn( true );
+ }
+
+ if ( ! class_exists( 'SiteOrigin_Panels_Admin', false ) ) {
+ require_once dirname( __DIR__ ) . '/inc/admin.php';
+ }
+ }
+
+ private function admin() {
+ // process_raw_widgets() is a plain instance method that does not rely on
+ // constructor state. The real constructor instantiates several admin
+ // collaborator singletons (Widget_Dialog, etc.) that are out of scope
+ // here, so create the object WITHOUT invoking the constructor.
+ $reflection = new ReflectionClass( SiteOrigin_Panels_Admin::class );
+
+ return $reflection->newInstanceWithoutConstructor();
+ }
+
+ /**
+ * Return a fresh sanitizing widget stub instance.
+ */
+ private function sanitizing_widget_stub() {
+ return new SavePostRawFlagSanitizingWidgetStub();
+ }
+
+ private const PAYLOAD = '<img src=x onerror=alert(1)>';
+ private const CLEANED = '<img src=x>';
+
+ // --- Core defect: no `raw` key, $force = false, must still sanitize. -----
+
+ public function test_widget_without_raw_flag_is_still_updated() {
+ $stub = $this->sanitizing_widget_stub();
+ SiteOrigin_Panels::$instance_resolver = function () use ( $stub ) {
+ return $stub;
+ };
+ Functionswhen( 'current_user_can' )->justReturn( false );
+
+ $widgets = array(
+ array(
+ 'content' => self::PAYLOAD,
+ 'panels_info' => array( 'class' => 'WP_Widget_Custom_HTML' ),
+ ),
+ );
+
+ $result = $this->admin()->process_raw_widgets( $widgets, array(), false, false );
+
+ $this->assertSame( self::CLEANED, $result[0]['content'], 'update() must run even when the raw flag is absent.' );
+ $this->assertArrayNotHasKey( 'raw', $result[0]['panels_info'] );
+ }
+
+ public function test_raw_false_is_ignored_and_widget_still_updated() {
+ $stub = $this->sanitizing_widget_stub();
+ SiteOrigin_Panels::$instance_resolver = function () use ( $stub ) {
+ return $stub;
+ };
+ Functionswhen( 'current_user_can' )->justReturn( false );
+
+ $widgets = array(
+ array(
+ 'content' => self::PAYLOAD,
+ 'panels_info' => array( 'class' => 'WP_Widget_Custom_HTML', 'raw' => false ),
+ ),
+ );
+
+ $result = $this->admin()->process_raw_widgets( $widgets, array(), false, false );
+
+ $this->assertSame( self::CLEANED, $result[0]['content'], 'raw => false must not skip update().' );
+ $this->assertArrayNotHasKey( 'raw', $result[0]['panels_info'] );
+ }
+
+ // --- Fallback branch: unresolved class + no unfiltered_html. -------------
+
+ public function test_unresolved_class_is_kses_filtered_for_unprivileged_user() {
+ SiteOrigin_Panels::$instance_resolver = function () {
+ return null;
+ };
+ Functionswhen( 'current_user_can' )->justReturn( false );
+
+ $widgets = array(
+ array(
+ 'content' => self::PAYLOAD,
+ 'panels_info' => array( 'class' => 'Some_Unknown_Widget' ),
+ ),
+ );
+
+ $result = $this->admin()->process_raw_widgets( $widgets, array(), false, false );
+
+ $this->assertSame( self::CLEANED, $result[0]['content'], 'Unresolved widget class must be wp_kses_post()ed for unprivileged users.' );
+ $this->assertArrayNotHasKey( 'raw', $result[0]['panels_info'] );
+ }
+
+ public function test_unresolved_class_passes_through_for_privileged_user() {
+ SiteOrigin_Panels::$instance_resolver = function () {
+ return null;
+ };
+ Functionswhen( 'current_user_can' )->justReturn( true );
+
+ $widgets = array(
+ array(
+ 'content' => self::PAYLOAD,
+ 'panels_info' => array( 'class' => 'Some_Unknown_Widget' ),
+ ),
+ );
+
+ $result = $this->admin()->process_raw_widgets( $widgets, array(), false, false );
+
+ $this->assertSame( self::PAYLOAD, $result[0]['content'], 'unfiltered_html users keep raw markup (fallback is a no-op).' );
+ $this->assertArrayNotHasKey( 'raw', $result[0]['panels_info'] );
+ }
+}
--- a/siteorigin-panels/tpl/live-editor-preview.php
+++ b/siteorigin-panels/tpl/live-editor-preview.php
@@ -34,7 +34,8 @@
) {
$data['widgets'] = SiteOrigin_Panels_Admin::single()->process_raw_widgets( $data['widgets'], false, false );
}
- echo siteorigin_panels_render( 'l' . md5( serialize( $data ) ), true, $data );
+ $builder_id = 'l' . md5( serialize( $data ) );
+ echo SiteOrigin_Panels_Admin::render_and_restore_post_globals( $builder_id, true, $data );
}
?>
</div><!-- .entry-content -->