Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/widget-options/includes/extras.php
+++ b/widget-options/includes/extras.php
@@ -492,8 +492,225 @@
* @param string $expression The boolean expression to evaluate.
* @return bool Returns true or false based on the evaluated expression, or false on error.
*/
+// Trust-flag for widgetopts_safe_eval(): wrap callsites whose expression
+// provably comes from a DB-backed, admin-controlled source so non-admin
+// viewers can still execute it. Missing wrapper → eval short-circuits to true.
+function widgetopts_eval_trust_begin()
+{
+ if (!isset($GLOBALS['_widgetopts_trust_depth'])) {
+ $GLOBALS['_widgetopts_trust_depth'] = 0;
+ }
+ $GLOBALS['_widgetopts_trust_depth']++;
+}
+
+function widgetopts_eval_trust_end()
+{
+ if (!empty($GLOBALS['_widgetopts_trust_depth'])) {
+ $GLOBALS['_widgetopts_trust_depth']--;
+ }
+}
+
+function widgetopts_eval_is_trusted()
+{
+ return !empty($GLOBALS['_widgetopts_trust_depth']);
+}
+
+function widgetopts_safe_eval_trusted($expression)
+{
+ widgetopts_eval_trust_begin();
+ try {
+ return widgetopts_safe_eval($expression);
+ } catch (Throwable $e) {
+ return false;
+ } finally {
+ widgetopts_eval_trust_end();
+ }
+}
+
+function widgetopts_blocks_collect_logic_hashes(array $blocks, array &$out)
+{
+ foreach ($blocks as $block) {
+ if (isset($block['attrs']['extended_widget_opts']['class']['logic'])
+ && is_string($block['attrs']['extended_widget_opts']['class']['logic'])
+ && $block['attrs']['extended_widget_opts']['class']['logic'] !== '') {
+ $out[hash('sha256', $block['attrs']['extended_widget_opts']['class']['logic'])] = true;
+ }
+ if (isset($block['attrs']['extended_widget_opts_block']['class']['logic'])
+ && is_string($block['attrs']['extended_widget_opts_block']['class']['logic'])
+ && $block['attrs']['extended_widget_opts_block']['class']['logic'] !== '') {
+ $out[hash('sha256', $block['attrs']['extended_widget_opts_block']['class']['logic'])] = true;
+ }
+ if (!empty($block['innerBlocks']) && is_array($block['innerBlocks'])) {
+ widgetopts_blocks_collect_logic_hashes($block['innerBlocks'], $out);
+ }
+ }
+}
+
+// Per-request, per-post sha256 allowlist of class.logic values actually
+// stored in this post's post_content. Cached because render_block_data fires
+// per block, and parse_blocks() is the expensive part.
+function widgetopts_get_post_logic_allowlist($post_id)
+{
+ static $cache = array();
+
+ $post_id = (int) $post_id;
+ if ($post_id <= 0) {
+ return array();
+ }
+ if (isset($cache[$post_id])) {
+ return $cache[$post_id];
+ }
+
+ $content = get_post_field('post_content', $post_id);
+ if (!is_string($content) || $content === ''
+ || strpos($content, 'extended_widget_opts') === false) {
+ return $cache[$post_id] = array();
+ }
+
+ $blocks = parse_blocks($content);
+ $hashes = array();
+ if (is_array($blocks)) {
+ widgetopts_blocks_collect_logic_hashes($blocks, $hashes);
+ }
+
+ return $cache[$post_id] = $hashes;
+}
+
+// Recursively neutralise legacy display-logic shapes inside a REST payload.
+// Recognises: extended_widget_opts[_block].class.logic, widgetopts_logic /
+// widgetopts_settings_logic keys, block/freeform strings.
+function widgetopts_rest_scrub_legacy_logic(&$value, &$modified)
+{
+ if (is_string($value)) {
+ // Pre-screen by substring to avoid parse_blocks() on unrelated content.
+ if (strpos($value, 'extended_widget_opts') !== false
+ || strpos($value, 'start_widgetopts') !== false) {
+ $blocks = parse_blocks($value);
+ if (is_array($blocks) && !empty($blocks)) {
+ $b_changed = false;
+ widgetopts_strip_logic_from_blocks($blocks, $b_changed);
+ if ($b_changed) {
+ $value = serialize_blocks($blocks);
+ $modified = true;
+ }
+ }
+ }
+ return;
+ }
+
+ if (!is_array($value) && !is_object($value)) {
+ return;
+ }
+
+ $is_object = is_object($value);
+ $items = $is_object ? get_object_vars($value) : $value;
+
+ foreach ($items as $k => $v) {
+ $key = (string) $k;
+
+ // Page-builder-specific inline keys. Both names are owned by this
+ // plugin's own UI — no third-party REST consumer would legitimately
+ // ship a setting under these exact identifiers.
+ if (is_string($v) && $v !== ''
+ && ($key === 'widgetopts_settings_logic' || $key === 'widgetopts_logic')) {
+ $v = '';
+ $modified = true;
+ }
+
+ // Scoped to plugin-owned containers — never touch class.logic under
+ // unrelated parents (third-party REST shapes commonly use them).
+ if (($key === 'extended_widget_opts' || $key === 'extended_widget_opts_block')
+ && (is_array($v) || is_object($v))) {
+ widgetopts_rest_scrub_extended_widget_opts($v, $modified);
+ }
+
+ if (is_array($v) || is_object($v) || is_string($v)) {
+ widgetopts_rest_scrub_legacy_logic($v, $modified);
+ }
+
+ if ($is_object) {
+ $value->$k = $v;
+ } else {
+ $value[$k] = $v;
+ }
+ }
+}
+
+// Inside a plugin-owned container, find and clear class.logic at any depth.
+function widgetopts_rest_scrub_extended_widget_opts(&$container, &$modified)
+{
+ if (!is_array($container) && !is_object($container)) {
+ return;
+ }
+
+ $is_object = is_object($container);
+ $items = $is_object ? get_object_vars($container) : $container;
+
+ foreach ($items as $k => $v) {
+ if ((string) $k === 'class' && (is_array($v) || is_object($v))) {
+ $cls_is_obj = is_object($v);
+ $cls_items = $cls_is_obj ? get_object_vars($v) : $v;
+ if (isset($cls_items['logic'])
+ && is_string($cls_items['logic'])
+ && $cls_items['logic'] !== '') {
+ if ($cls_is_obj) {
+ $v->logic = '';
+ } else {
+ $v['logic'] = '';
+ }
+ $modified = true;
+ }
+ }
+
+ if (is_array($v) || is_object($v)) {
+ widgetopts_rest_scrub_extended_widget_opts($v, $modified);
+ }
+
+ if ($is_object) {
+ $container->$k = $v;
+ } else {
+ $container[$k] = $v;
+ }
+ }
+}
+
+// Belt-and-braces net for REST routes without their own save-time gate.
+// Non-admin write requests only; GET/HEAD/OPTIONS untouched.
+add_filter('rest_request_before_callbacks', function ($response, $handler, $request) {
+ if (!($request instanceof WP_REST_Request)) {
+ return $response;
+ }
+ if (current_user_can('manage_options')) {
+ return $response;
+ }
+ $method = strtoupper((string) $request->get_method());
+ if ($method === 'GET' || $method === 'HEAD' || $method === 'OPTIONS') {
+ return $response;
+ }
+
+ $params = $request->get_params();
+ if (!is_array($params) || empty($params)) {
+ return $response;
+ }
+
+ foreach ($params as $key => $value) {
+ $changed = false;
+ widgetopts_rest_scrub_legacy_logic($value, $changed);
+ if ($changed) {
+ $request->set_param($key, $value);
+ }
+ }
+
+ return $response;
+}, 10, 3);
+
function widgetopts_safe_eval($expression)
{
+ // Closed default: non-admin without trust flag → "show" without eval.
+ if (!current_user_can('manage_options') && !widgetopts_eval_is_trusted()) {
+ return true;
+ }
+
if (widgetopts_is_widget_or_post_preview()) {
if (!current_user_can('manage_options')) {
return true;
@@ -868,10 +1085,12 @@
$tokens = token_get_all($code);
$is_safe = true;
- // Language constructs that are NOT T_STRING — the allowlist loop would silently skip them.
- // T_EVAL / T_INCLUDE* / T_REQUIRE* are also caught by the regex in widgetopts_validate_expression,
- // but blocking them here provides an independent second layer.
- $forbidden_constructs = [T_EVAL, T_INCLUDE, T_INCLUDE_ONCE, T_REQUIRE, T_REQUIRE_ONCE, T_EXIT, T_GOTO];
+ // Constructs that aren't T_STRING — allowlist loop would skip them.
+ // T_FUNCTION / T_FN block closure-define + immediate invoke.
+ $forbidden_constructs = [T_EVAL, T_INCLUDE, T_INCLUDE_ONCE, T_REQUIRE, T_REQUIRE_ONCE, T_EXIT, T_GOTO, T_FUNCTION];
+ if (defined('T_FN')) {
+ $forbidden_constructs[] = T_FN; // PHP 7.4+
+ }
foreach ($tokens as $index => $token) {
if (is_array($token)) {
@@ -925,10 +1144,8 @@
break;
}
}
- } elseif ($token === ']' || $token === ')') {
- // Subscript-then-call `$arr['key']()` and
- // Concat-then-call `('fi'.'le_put_contents')()`
- // Skip non-significant tokens (whitespace / comments) before '('
+ } elseif ($token === ']' || $token === ')' || $token === '}') {
+ // `$arr[k]()`, `(expr)()`, `${$fn}()` / `Foo::{'m'}()`.
$next = widgetopts_adjacent_significant_token($tokens, $index, 1);
if ($next === '(') {
$is_safe = false;
--- a/widget-options/includes/pagebuilders/beaver/beaver.php
+++ b/widget-options/includes/pagebuilders/beaver/beaver.php
@@ -927,7 +927,7 @@
}
$display_logic = htmlspecialchars_decode($display_logic, ENT_QUOTES);
try {
- if (!widgetopts_safe_eval($display_logic)) {
+ if (!widgetopts_safe_eval_trusted($display_logic)) {
return false;
}
} catch (ParseError $e) {
--- a/widget-options/includes/pagebuilders/elementor/render.php
+++ b/widget-options/includes/pagebuilders/elementor/render.php
@@ -388,7 +388,7 @@
}
$display_logic = htmlspecialchars_decode($display_logic, ENT_QUOTES);
try {
- if (!widgetopts_safe_eval($display_logic)) {
+ if (!widgetopts_safe_eval_trusted($display_logic)) {
return $placeholder;
}
} catch (ParseError $e) {
--- a/widget-options/includes/pagebuilders/siteorigin.php
+++ b/widget-options/includes/pagebuilders/siteorigin.php
@@ -52,7 +52,7 @@
}
$display_logic = htmlspecialchars_decode($display_logic, ENT_QUOTES);
try {
- if (!widgetopts_safe_eval($display_logic)) {
+ if (!widgetopts_safe_eval_trusted($display_logic)) {
unset($panels_data['widgets'][$key]);
}
} catch (ParseError $e) {
@@ -82,18 +82,18 @@
static $processing = false;
if ($processing) return $check;
- // Get old panels data from DB
- $old_data = get_post_meta($object_id, 'panels_data', true);
- if (!is_array($old_data) || empty($old_data['widgets'])) return $check;
-
- // Collect known old logic values
+ // No early-return when old data / old logic set is empty: that would
+ // let a contributor smuggle class.logic through the first save.
+ $old_data = get_post_meta($object_id, 'panels_data', true);
$old_logic_set = array();
- foreach ($old_data['widgets'] as $widget) {
- if (isset($widget['extended_widget_opts']['class']['logic']) && $widget['extended_widget_opts']['class']['logic'] !== '') {
- $old_logic_set[] = $widget['extended_widget_opts']['class']['logic'];
+ if (is_array($old_data) && !empty($old_data['widgets']) && is_array($old_data['widgets'])) {
+ foreach ($old_data['widgets'] as $widget) {
+ if (isset($widget['extended_widget_opts']['class']['logic'])
+ && $widget['extended_widget_opts']['class']['logic'] !== '') {
+ $old_logic_set[] = $widget['extended_widget_opts']['class']['logic'];
+ }
}
}
- if (empty($old_logic_set)) return $check;
// Check new data
$new_data = is_array($meta_value) ? $meta_value : maybe_unserialize($meta_value);
--- a/widget-options/includes/snippets/class-snippets-api.php
+++ b/widget-options/includes/snippets/class-snippets-api.php
@@ -149,7 +149,9 @@
// Decode and execute
$code = htmlspecialchars_decode($code, ENT_QUOTES);
- // Use the existing safe_eval function
+ if (function_exists('widgetopts_safe_eval_trusted')) {
+ return widgetopts_safe_eval_trusted($code);
+ }
if (function_exists('widgetopts_safe_eval')) {
return widgetopts_safe_eval($code);
}
@@ -239,7 +241,10 @@
}
$code = htmlspecialchars_decode($code, ENT_QUOTES);
-
+
+ if (function_exists('widgetopts_safe_eval_trusted')) {
+ return widgetopts_safe_eval_trusted($code);
+ }
if (function_exists('widgetopts_safe_eval')) {
return widgetopts_safe_eval($code);
}
--- a/widget-options/includes/widgets/display.php
+++ b/widget-options/includes/widgets/display.php
@@ -583,7 +583,7 @@
return true;
}
$display_logic = htmlspecialchars_decode($display_logic, ENT_QUOTES);
- if (!widgetopts_safe_eval($display_logic)) {
+ if (!widgetopts_safe_eval_trusted($display_logic)) {
return false;
}
}
--- a/widget-options/includes/widgets/gutenberg/gutenberg-toolbar.php
+++ b/widget-options/includes/widgets/gutenberg/gutenberg-toolbar.php
@@ -408,6 +408,74 @@
return $data;
}, 10, 2);
+// Flag the block-renderer REST dispatch so render_block_data below can
+// scope its sha256-allowlist work to that single route.
+add_filter('rest_pre_dispatch', function ($result, $server, $request) {
+ if ($request instanceof WP_REST_Request
+ && strpos((string) $request->get_route(), '/wp/v2/block-renderer/') === 0) {
+ $GLOBALS['_widgetopts_in_block_renderer'] = true;
+ }
+ return $result;
+}, 1, 3);
+
+add_filter('rest_post_dispatch', function ($response, $server, $request) {
+ unset($GLOBALS['_widgetopts_in_block_renderer']);
+ return $response;
+}, 1, 3);
+
+// Block-renderer accepts user-supplied attributes without a save step.
+// For non-admins, allowlist class.logic against a sha256 of values stored in
+// the post's post_content; mismatched values are zeroed before render_callback.
+add_filter('render_block_data', function ($parsed_block) {
+ if (!defined('REST_REQUEST') || !REST_REQUEST) {
+ return $parsed_block;
+ }
+ if (empty($GLOBALS['_widgetopts_in_block_renderer'])) {
+ return $parsed_block;
+ }
+
+ if (!is_array($parsed_block) || empty($parsed_block['attrs'])) {
+ return $parsed_block;
+ }
+ if (current_user_can('manage_options')) {
+ return $parsed_block;
+ }
+
+ $has_inline = (
+ (isset($parsed_block['attrs']['extended_widget_opts']['class']['logic'])
+ && $parsed_block['attrs']['extended_widget_opts']['class']['logic'] !== '')
+ || (isset($parsed_block['attrs']['extended_widget_opts_block']['class']['logic'])
+ && $parsed_block['attrs']['extended_widget_opts_block']['class']['logic'] !== '')
+ );
+ if (!$has_inline) {
+ return $parsed_block;
+ }
+
+ $post_id = 0;
+ $current = get_post();
+ if ($current instanceof WP_Post) {
+ $post_id = (int) $current->ID;
+ }
+ if (!$post_id && isset($_REQUEST['post_id'])) {
+ $post_id = absint($_REQUEST['post_id']);
+ }
+
+ $allow = $post_id ? widgetopts_get_post_logic_allowlist($post_id) : array();
+
+ foreach (array('extended_widget_opts', 'extended_widget_opts_block') as $key) {
+ if (isset($parsed_block['attrs'][$key]['class']['logic'])
+ && is_string($parsed_block['attrs'][$key]['class']['logic'])
+ && $parsed_block['attrs'][$key]['class']['logic'] !== '') {
+ $hash = hash('sha256', $parsed_block['attrs'][$key]['class']['logic']);
+ if (!isset($allow[$hash])) {
+ $parsed_block['attrs'][$key]['class']['logic'] = '';
+ }
+ }
+ }
+
+ return $parsed_block;
+}, 5);
+
add_filter('render_block', function ($block_content, $parsed_block, $obj) {
if (!is_admin()) {
add_filter("render_block_{$obj->name}", "blockopts_filter_before_display", 100, 3);
@@ -963,7 +1031,7 @@
// $display_logic = "return (" . $display_logic . ");";
// }
$display_logic = htmlspecialchars_decode($display_logic, ENT_QUOTES);
- if (!widgetopts_safe_eval($display_logic)) {
+ if (!widgetopts_safe_eval_trusted($display_logic)) {
return false;
}
}
--- a/widget-options/plugin.php
+++ b/widget-options/plugin.php
@@ -4,7 +4,7 @@
* Plugin Name: Widget Options
* Plugin URI: https://widget-options.com/
* Description: Additional Widget and Block options for better widget and block control. Turn Widget Options into an even more flexible widget and block area manager. Upgrade to <strong><a href="http://widget-options.com/" target="_blank" >Widget Options Extended</a></strong> today!
- * Version: 4.2.3
+ * Version: 4.2.4
* Author: Widget Options Team
* Author URI: https://widget-options.com/
* Text Domain: widget-options
@@ -92,7 +92,7 @@
// Plugin version.
if (!defined('WIDGETOPTS_VERSION')) {
- define('WIDGETOPTS_VERSION', '4.2.3');
+ define('WIDGETOPTS_VERSION', '4.2.4');
}
// Plugin Folder Path.