Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/widget-options/includes/ajax-functions.php
+++ b/widget-options/includes/ajax-functions.php
@@ -28,6 +28,11 @@
return;
}
+ if (!current_user_can('manage_options')) {
+ wp_send_json_error('You do not have permission to manage settings.', 403);
+ exit;
+ }
+
switch ($_POST['method']) {
case 'activate':
case 'deactivate':
@@ -141,8 +146,14 @@
add_action('wp_ajax_widgetopts_hideRating', 'widgetopts_ajax_hide_rating');
endif;
+
function widgetopts_ajax_validate_expression()
{
+ if (!current_user_can('manage_options')) {
+ wp_send_json_error('You do not have permission to validate expressions.', 403);
+ exit;
+ }
+
if (!wp_verify_nonce($_POST['nonce'], 'widgetopts-expression-nonce')) {
echo json_encode(['response' => 'failed', 'message' => 'Security check failed. Please refresh the page and try again.']);
die();
--- a/widget-options/includes/extras.php
+++ b/widget-options/includes/extras.php
@@ -495,8 +495,7 @@
function widgetopts_safe_eval($expression)
{
if (widgetopts_is_widget_or_post_preview()) {
- // Always return true for previews unless the user is an administrator
- if (!current_user_can('administrator')) {
+ if (!current_user_can('manage_options')) {
return true;
}
}
@@ -609,9 +608,20 @@
'wordwrap',
// Array Manipulation
+ 'array_merge',
+ 'array_diff',
+ 'array_keys',
+ 'array_values',
'in_array',
'count',
'sizeof',
+ 'array_slice',
+ 'array_push',
+ 'array_pop',
+ 'array_intersect',
+ 'array_unique',
+ 'array_column',
+ 'array_reverse',
// Math Functions
'abs',
@@ -684,8 +694,7 @@
'pathinfo',
'basename',
'dirname',
- 'file_exists',
- 'readfile',
+ 'file_exists'
];
}
@@ -820,6 +829,28 @@
}
/**
+ * Return the nearest significant token relative to $index, skipping whitespace and comments.
+ *
+ * @param array $tokens Token array from token_get_all().
+ * @param int $index Starting position.
+ * @param int $dir 1 = look forward (next), -1 = look backward (prev).
+ * @return array|string|null The token, or null if none found.
+ */
+function widgetopts_adjacent_significant_token(array $tokens, int $index, int $dir)
+{
+ $skip = [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT];
+ $count = count($tokens);
+ for ($i = $index + $dir; $dir === 1 ? $i < $count : $i >= 0; $i += $dir) {
+ $t = $tokens[$i];
+ if (is_array($t) && in_array($t[0], $skip, true)) {
+ continue;
+ }
+ return $t;
+ }
+ return null;
+}
+
+/**
* Validate PHP code against allowed functions and detect obfuscated calls.
*
* @param string $code The PHP code to validate.
@@ -836,19 +867,42 @@
$tokens = token_get_all($code);
$is_safe = true;
- $last_token = null;
+
+ // 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];
foreach ($tokens as $index => $token) {
if (is_array($token)) {
- $token_type = $token[0];
+ $token_type = $token[0];
$token_value = $token[1];
- // **Fix: Properly detect function calls inside conditions**
- if ($token_type === T_STRING) {
- $function_name = strtolower($token_value);
- $next_token = $tokens[$index + 1] ?? null;
+ // Block language constructs (eval, include, require, exit, goto).
+ // These produce dedicated token types, not T_STRING, so the allowlist
+ // check below would silently pass them.
+ if (in_array($token_type, $forbidden_constructs, true)) {
+ $is_safe = false;
+ break;
+ }
- if ($next_token === '(' || (is_array($next_token) && $next_token[0] === T_WHITESPACE && ($tokens[$index + 2] ?? null) === '(')) {
+ // Detect function calls — skip non-significant tokens before '('
+ if ($token_type === T_STRING) {
+ $next = widgetopts_adjacent_significant_token($tokens, $index, 1);
+ if ($next === '(') {
+ // Block method/static/nullsafe calls: $obj->func(), Class::func(), $obj?->func()
+ // The identifier looks like an allowed function name but is actually a method —
+ // the allowlist covers only direct (free) function calls.
+ $prev = widgetopts_adjacent_significant_token($tokens, $index, -1);
+ $method_ops = [T_OBJECT_OPERATOR, T_DOUBLE_COLON];
+ if (defined('T_NULLSAFE_OBJECT_OPERATOR')) {
+ $method_ops[] = T_NULLSAFE_OBJECT_OPERATOR; // PHP 8.0+ (?->)
+ }
+ if (is_array($prev) && in_array($prev[0], $method_ops, true)) {
+ $is_safe = false;
+ break;
+ }
+ $function_name = strtolower($token_value);
if (!in_array($function_name, array_map('strtolower', $allowed_functions))) {
$is_safe = false;
break;
@@ -856,22 +910,30 @@
}
}
- // **Fix: Detect if T_ENCAPSED_AND_WHITESPACE is being treated as code**
+ // Detect if T_ENCAPSED_AND_WHITESPACE is being treated as code
if ($token_type === T_ENCAPSED_AND_WHITESPACE) {
$is_safe = false;
break;
}
- // **Fix: Dynamic Function Execution (`$func()` or `['test']()`)**
+ // Dynamic call via variable or string literal: `$fn()` / `'func'()`
+ // Skip non-significant tokens between the token and '('
if ($token_type === T_VARIABLE || $token_type === T_CONSTANT_ENCAPSED_STRING) {
- $next_token = $tokens[$index + 1] ?? null;
- if ($next_token === '(') {
+ $next = widgetopts_adjacent_significant_token($tokens, $index, 1);
+ if ($next === '(') {
$is_safe = false;
break;
}
}
-
- $last_token = $token;
+ } elseif ($token === ']' || $token === ')') {
+ // Subscript-then-call `$arr['key']()` and
+ // Concat-then-call `('fi'.'le_put_contents')()`
+ // Skip non-significant tokens (whitespace / comments) before '('
+ $next = widgetopts_adjacent_significant_token($tokens, $index, 1);
+ if ($next === '(') {
+ $is_safe = false;
+ break;
+ }
}
}
--- a/widget-options/includes/snippets/class-snippets-admin.php
+++ b/widget-options/includes/snippets/class-snippets-admin.php
@@ -117,7 +117,7 @@
// Migration page (hidden from menu, accessible via direct link)
self::$migration_hook = add_submenu_page(
- null,
+ '',
__('Display Logic Migration', 'widget-options'),
__('Display Logic Migration', 'widget-options'),
WIDGETOPTS_MIGRATION_PERMISSIONS,
--- a/widget-options/includes/widgets/gutenberg/gutenberg-toolbar.php
+++ b/widget-options/includes/widgets/gutenberg/gutenberg-toolbar.php
@@ -203,7 +203,7 @@
// Legacy display logic: admins keep as-is, non-admins have it stripped
if (isset($instance['extended_widget_opts-' . $obj->id]['class'])) {
- if (!current_user_can('administrator')) {
+ if (!current_user_can('manage_options')) {
$instance['extended_widget_opts-' . $obj->id]['class']['logic'] = '';
}
}
@@ -222,7 +222,7 @@
}
// Admins: don't touch legacy logic at all (no parse/serialize cycle)
- if (current_user_can('administrator')) {
+ if (current_user_can('manage_options')) {
return $post;
}
@@ -231,7 +231,8 @@
return $post;
}
- if (strpos($post->post_content, 'extended_widget_opts') === false) {
+ if (strpos($post->post_content, 'extended_widget_opts') === false
+ && strpos($post->post_content, 'start_widgetopts') === false) {
return $post;
}
@@ -283,6 +284,11 @@
$block['attrs']['extended_widget_opts']['class']['logic'] = '';
}
+ if (isset($block['attrs']['extended_widget_opts_block']['class']['logic'])
+ && $block['attrs']['extended_widget_opts_block']['class']['logic'] !== '') {
+ $block['attrs']['extended_widget_opts_block']['class']['logic'] = '';
+ }
+
if (isset($block['innerBlocks']) && !empty($block['innerBlocks'])) {
foreach ($block['innerBlocks'] as &$inner_block) {
widgetopt_modify_block_attributes($inner_block, $old_blocks_lookup);
@@ -299,11 +305,62 @@
*/
function widgetopts_strip_logic_from_blocks(&$blocks, &$changed) {
foreach ($blocks as &$block) {
+ // Standard Gutenberg block attributes
if (isset($block['attrs']['extended_widget_opts']['class']['logic'])
&& $block['attrs']['extended_widget_opts']['class']['logic'] !== '') {
$block['attrs']['extended_widget_opts']['class']['logic'] = '';
$changed = true;
}
+ if (isset($block['attrs']['extended_widget_opts_block']['class']['logic'])
+ && $block['attrs']['extended_widget_opts_block']['class']['logic'] !== '') {
+ $block['attrs']['extended_widget_opts_block']['class']['logic'] = '';
+ $changed = true;
+ }
+
+ // Legacy freeform format: <!--start_widgetopts {"class":{"logic":"..."}} end_widgetopts-->
+ // parse_blocks() stores this raw in innerContent (blockName = null, attrs = []),
+ // so the attribute checks above never fire for it.
+ if (empty($block['blockName']) && !empty($block['innerContent'])) {
+ foreach ($block['innerContent'] as &$chunk) {
+ if (!is_string($chunk) || strpos($chunk, 'start_widgetopts') === false) {
+ continue;
+ }
+ // Permissive outer pattern so crafted payloads like
+ // {...} <!--start_widgetopts end_widgetopts--> (parsing-differential
+ // attack) are also matched.
+ $chunk = preg_replace_callback(
+ '/<!--start_widgetoptss+([sS]*?)s*end_widgetopts-->/U',
+ static function ($m) use (&$changed) {
+ $raw = trim($m[1]);
+ $data = json_decode($raw, true);
+
+ if (!is_array($data)) {
+ // Trailing garbage after valid JSON (crafted payload).
+ // Find the last } and try decoding up to that point.
+ $pos = strrpos($raw, '}');
+ if ($pos !== false) {
+ $data = json_decode(substr($raw, 0, $pos + 1), true);
+ }
+ }
+
+ if (!is_array($data)) {
+ // Completely unrecoverable — remove entire marker.
+ $changed = true;
+ return '';
+ }
+
+ if (isset($data['class']['logic']) && $data['class']['logic'] !== '') {
+ $data['class']['logic'] = '';
+ $changed = true;
+ }
+ return '<!--start_widgetopts ' . wp_json_encode($data) . ' end_widgetopts-->';
+ },
+ $chunk
+ );
+ }
+ unset($chunk);
+ }
+
if (!empty($block['innerBlocks'])) {
widgetopts_strip_logic_from_blocks($block['innerBlocks'], $changed);
}
@@ -318,7 +375,7 @@
* @since 5.1
*/
add_filter('wp_insert_post_data', function($data, $postarr) {
- if (current_user_can('administrator')) {
+ if (current_user_can('manage_options')) {
return $data;
}
@@ -326,11 +383,17 @@
return $data;
}
- if (strpos($data['post_content'], 'extended_widget_opts') === false) {
+ // wp_insert_post_data fires BEFORE wp_unslash() inside wp_insert_post(),
+ // so post_content still carries magic-quote backslashes (" and ').
+ // Unslash before processing so parse_blocks sees clean JSON.
+ $content = wp_unslash($data['post_content']);
+
+ if (strpos($content, 'extended_widget_opts') === false
+ && strpos($content, 'start_widgetopts') === false) {
return $data;
}
- $new_blocks = parse_blocks($data['post_content']);
+ $new_blocks = parse_blocks($content);
if (!is_array($new_blocks) || empty($new_blocks)) {
return $data;
}
@@ -338,7 +401,9 @@
$changed = false;
widgetopts_strip_logic_from_blocks($new_blocks, $changed);
if ($changed) {
- $data['post_content'] = serialize_blocks($new_blocks);
+ // Re-slash so WordPress's subsequent wp_unslash() inside wp_insert_post()
+ // produces the correct clean string when writing to the database.
+ $data['post_content'] = wp_slash(serialize_blocks($new_blocks));
}
return $data;
}, 10, 2);
@@ -995,12 +1060,18 @@
/**
* Gutenberg ajax functions
*/
+function widgetopts_verify_gutenberg_ajax()
+{
+ if (!current_user_can('edit_posts')) {
+ wp_send_json_error('Permission denied.', 403);
+ exit;
+ }
+}
+
function widgetopts_get_types()
{
+ widgetopts_verify_gutenberg_ajax();
global $widgetopts_types;
- if (!(current_user_can('edit_pages') || current_user_can('edit_posts') || current_user_can('edit_theme_options'))) {
- die;
- }
wp_send_json_success(((!empty($widgetopts_types)) ? $widgetopts_types : widgetopts_global_types()));
die;
@@ -1010,10 +1081,8 @@
function widgetopts_get_taxonomies()
{
+ widgetopts_verify_gutenberg_ajax();
global $widgetopts_taxonomies;
- if (!(current_user_can('edit_pages') || current_user_can('edit_posts') || current_user_can('edit_theme_options'))) {
- die;
- }
wp_send_json_success(((!empty($widgetopts_taxonomies)) ? $widgetopts_taxonomies : widgetopts_global_taxonomies()));
die;
@@ -1022,9 +1091,7 @@
function widgetopts_acf_get_field_groups()
{
- if (!(current_user_can('edit_pages') || current_user_can('edit_posts') || current_user_can('edit_theme_options'))) {
- die;
- }
+ widgetopts_verify_gutenberg_ajax();
$fields = array();
if (function_exists('acf_get_field_groups')) {
@@ -1050,9 +1117,7 @@
function widgetopts_get_legacy_data()
{
- if (!(current_user_can('edit_pages') || current_user_can('edit_posts') || current_user_can('edit_theme_options'))) {
- die;
- }
+ widgetopts_verify_gutenberg_ajax();
if (isset($_POST['id_base'])) {
wp_send_json_success(array());
@@ -1076,15 +1141,9 @@
function widgetopts_get_settings_ajax()
{
- if (!(current_user_can('edit_pages') || current_user_can('edit_posts') || current_user_can('edit_theme_options'))) {
- die;
- }
+ widgetopts_verify_gutenberg_ajax();
$settings = widgetopts_get_settings();
- if (!current_user_can('administrator')) {
- $settings['logic'] = 'deactivate';
- }
-
wp_send_json_success($settings);
die;
}
@@ -1092,9 +1151,7 @@
function widgetopts_get_snippets_ajax()
{
- if (!(current_user_can('edit_pages') || current_user_can('edit_posts') || current_user_can('edit_theme_options'))) {
- die;
- }
+ widgetopts_verify_gutenberg_ajax();
$search = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
@@ -1123,9 +1180,7 @@
function widgetopts_get_pages()
{
- if (!(current_user_can('edit_pages') || current_user_can('edit_posts') || current_user_can('edit_theme_options'))) {
- die;
- }
+ widgetopts_verify_gutenberg_ajax();
$pages = [];
@@ -1156,9 +1211,7 @@
function widgetopts_get_terms()
{
- if (!(current_user_can('edit_pages') || current_user_can('edit_posts') || current_user_can('edit_theme_options'))) {
- die;
- }
+ widgetopts_verify_gutenberg_ajax();
$terms = array();
@@ -1178,12 +1231,9 @@
function widgetopts_get_users()
{
+ widgetopts_verify_gutenberg_ajax();
global $wp_version;
- if (!(current_user_can('edit_pages') || current_user_can('edit_posts') || current_user_can('edit_theme_options'))) {
- die;
- }
-
$authors = array();
$args = array();
@@ -1212,9 +1262,7 @@
function widgetopts_ajax_roles_search_block()
{
- if (!(current_user_can('edit_pages') || current_user_can('edit_posts') || current_user_can('edit_theme_options'))) {
- die;
- }
+ widgetopts_verify_gutenberg_ajax();
$response = [
'results' => [],
'pagination' => ['more' => false]
--- a/widget-options/includes/widgets/option-tabs/visibility.php
+++ b/widget-options/includes/widgets/option-tabs/visibility.php
@@ -412,6 +412,11 @@
// Page Options
function widgetopts_ajax_page_search()
{
+ if (!current_user_can('edit_posts')) {
+ wp_send_json_error('You do not have permission to search pages.', 403);
+ exit;
+ }
+
global $wp_version;
$response = [
@@ -449,6 +454,11 @@
// Taxonomy Options
function widgetopts_ajax_taxonomy_search()
{
+ if (!current_user_can('edit_posts')) {
+ wp_send_json_error('You do not have permission to search taxonomies.', 403);
+ exit;
+ }
+
$response = [
'results' => [],
'pagination' => ['more' => 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.2
+ * Version: 4.2.3
* 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.2');
+ define('WIDGETOPTS_VERSION', '4.2.3');
}
// Plugin Folder Path.