Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/content-visibility-for-divi-builder/content-visibility-for-divi-builder.php
+++ b/content-visibility-for-divi-builder/content-visibility-for-divi-builder.php
@@ -7,7 +7,7 @@
* Plugin Name: Content Visibility for Divi Builder
* Plugin URI: https://aod-tech.com/
* Description: Allows Sections and Modules to be displayed/hidden based on the outcome of a PHP boolean expression.
- * Version: 4.02
+ * Version: 5.00
* Author: AoD Technologies LLC
* Author URI: https://aod-tech.com/
* License: GPL-2.0+
@@ -32,12 +32,13 @@
*/
// If this file is called directly, abort.
-if ( ! defined( 'WPINC' ) ) {
+if ( !defined( 'WPINC' ) ) {
die;
}
-if ( ! defined( 'CVDB_PLUGIN' ) ) {
+if ( !defined( 'CVDB_PLUGIN' ) ) {
define( 'CVDB_PLUGIN', __FILE__ );
}
+require_once 'includes/global-eval-helper.php';
require_once 'includes/plugin.class.php';
--- a/content-visibility-for-divi-builder/includes/global-eval-helper.php
+++ b/content-visibility-for-divi-builder/includes/global-eval-helper.php
@@ -0,0 +1,24 @@
+<?php
+
+// Intentionally NO namespace declaration. eval()'d code runs in the namespace
+// of the calling function — keeping this helper in the global namespace means
+// identifiers in $expression (functions, classes, constants) resolve against
+// the global namespace at evaluation time, matching what plugin authors expect.
+//
+// This is the SOLE eval() call site in the entire plugin. It is used by:
+// - AoDTechnologiesContentVisibilityForDiviBuilderContentVisibilityForDiviBuilder::evaluate_visibility_expression()
+// - AoDTechnologiesContentVisibilityForDiviBuilderContentVisibilityForDiviBuilder::is_eval_available() (smoke probe)
+//
+// If you are reviewing this file for security: the upstream callers run
+// $expression through validate_expression() before reaching here, which
+// enforces token + callable allowlists.
+
+if ( ! defined( 'WPINC' ) ) {
+ die;
+}
+
+if ( ! function_exists( 'cvdb_eval_expression' ) ) {
+ function cvdb_eval_expression( $expression ) {
+ return eval( 'return ' . $expression . ';' );
+ }
+}
--- a/content-visibility-for-divi-builder/includes/plugin.class.php
+++ b/content-visibility-for-divi-builder/includes/plugin.class.php
@@ -3,7 +3,7 @@
namespace AoDTechnologiesContentVisibilityForDiviBuilder;
// If this file is called directly, abort.
-if ( ! defined( 'WPINC' ) ) {
+if ( !defined( 'WPINC' ) ) {
die;
}
@@ -18,12 +18,14 @@
protected static $should_force_shortcode_manager_to_register_all_shortcodes = false;
protected static $cvdb_et_pb_children = array();
- protected $underscore_text_domain;
+ protected static $underscore_text_domain;
+ protected static $validation_option_key;
+
protected $show_rating_notice_option_key;
protected $is_saving_cache = false;
public static function get_version() {
- return '4.01';
+ return '5.00';
}
public static function get_text_domain() {
@@ -34,12 +36,18 @@
return __( 'Content Visibility For Divi Builder', self::get_text_domain() );
}
+ public static function get_underscore_text_domain() {
+ return self::$underscore_text_domain;
+ }
+
public static function init() {
if (self::$initialized) {
return;
}
self::$wp_version = get_bloginfo( 'version' );
+ self::$underscore_text_domain = str_replace( '-', '_', self::get_text_domain() );
+ self::$validation_option_key = self::$underscore_text_domain . '_expression_validation_enabled';
self::get_instance();
self::$initialized = true;
@@ -54,14 +62,14 @@
}
public static function uninstall() {
- $underscore_text_domain = str_replace( '-', '_', self::get_text_domain() );
-
- delete_option( "{$underscore_text_domain}_authentication_tokens" );
+ delete_option( self::$underscore_text_domain . '_authentication_tokens' );
}
public function __construct($actions_and_filters_priority = 10) {
- $this->underscore_text_domain = str_replace( '-', '_', self::get_text_domain() );
- $this->show_rating_notice_option_key = "{$this->underscore_text_domain}_show-rating-notice";
+ require_once plugin_dir_path( CVDB_PLUGIN ) . 'includes/security-scanner.class.php';
+ SecurityScanner::init();
+
+ $this->show_rating_notice_option_key = self::$underscore_text_domain . '_show_rating_notice';
add_action( 'plugins_loaded', array( $this, 'actions_and_filters' ), $actions_and_filters_priority );
@@ -142,12 +150,34 @@
}
private function maybe_run_migrations() {
- $stored_version = get_option( "{$this->underscore_text_domain}_version" );
-
+ $stored_version = get_option( self::$underscore_text_domain . '_version' );
+
+ if ( $stored_version === false ) {
+ // New install — validation on by default
+ update_option( self::$validation_option_key, '1' );
+ } else if ( version_compare( $stored_version, '5.00', '<' ) ) {
+ // Upgrade — validation pending, don't overwrite if already set
+ if ( get_option( self::$validation_option_key ) === false ) {
+ update_option( self::$validation_option_key, '0' );
+ }
+
+ // Rename the per-user rating-notice key from the legacy mixed-case form
+ // (`<udt>_show-rating-notice`) to the standardized fully-underscored form
+ // (`<udt>_show_rating_notice`). Single SQL UPDATE migrates every user at once.
+ global $wpdb;
+ $old_key = self::$underscore_text_domain . '_show-rating-notice';
+ $new_key = self::$underscore_text_domain . '_show_rating_notice';
+ $wpdb->query( $wpdb->prepare(
+ "UPDATE {$wpdb->usermeta} SET meta_key = %s WHERE meta_key = %s",
+ $new_key,
+ $old_key
+ ) );
+ }
+
// Migration code for already active plugins
if (self::get_version() !== $stored_version) {
- update_option( "{$this->underscore_text_domain}_version", self::get_version() );
-
+ update_option( self::$underscore_text_domain . '_version', self::get_version() );
+
self::maybe_force_regenerate_templates();
}
}
@@ -184,12 +214,14 @@
add_action( 'init', array( $this, 'run_detections' ), 1337 );
- add_filter( "{$this->underscore_text_domain}_prevent_texturize_shortcodes", array( $this, 'prevent_texturize_shortcodes' ) );
+ add_filter( self::$underscore_text_domain . '_prevent_texturize_shortcodes', array( $this, 'prevent_texturize_shortcodes' ) );
add_action( 'et_builder_modules_loaded', array( $this, 'detect_saving_cache' ), 0 );
add_action( 'et_builder_ready', array( $this, 'hook_into_builder_shortcodes' ), 1337 );
+ add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );
+
if ( is_admin() ) {
add_filter( 'plugin_action_links_' . plugin_basename( CVDB_PLUGIN ), array( $this, 'plugin_action_links' ), 1337 );
@@ -199,8 +231,7 @@
add_action( 'admin_notices', array( $this, 'render_admin_notices' ) );
- add_action( 'wp_ajax_' . self::get_text_domain() . '_dismiss-rating-notice', array( $this, 'ajax_dismiss_rating_notice' ) );
- add_action( 'wp_ajax_' . self::get_text_domain() . '_click-rating-link', array( $this, 'ajax_click_rating_link' ) );
+ add_action( 'admin_init', array( $this, 'handle_validation_toggle' ) );
if ( current_user_can( 'manage_options' ) && get_user_option( $this->show_rating_notice_option_key ) === false ) {
// TODO: Find a better way to detect when a user has actually used the features of this plugin
@@ -226,12 +257,334 @@
$this->is_saving_cache = apply_filters( 'et_builder_modules_is_saving_cache', false );
}
+ public static function is_eval_available() {
+ static $cached = null;
+ if ( $cached !== null ) {
+ return $cached;
+ }
+ try {
+ $cached = ( cvdb_eval_expression( '42' ) === 42 );
+ } catch ( Throwable $e ) {
+ $cached = false;
+ }
+ return $cached;
+ }
+
+ public static function get_allowed_callables() {
+ return apply_filters( self::$underscore_text_domain . '_allowed_callables', array(
+ // WP conditional tags — pure read-only context queries (default allowlist).
+ // Site admins extend this list via the filter to opt in custom helpers.
+ 'is_user_logged_in', 'current_user_can', 'is_admin', 'is_super_admin',
+ 'is_singular', 'is_single', 'is_page', 'is_home', 'is_front_page',
+ 'is_archive', 'is_category', 'is_tag', 'is_author', 'is_search',
+ 'is_404', 'is_attachment', 'is_post_type_archive', 'is_tax',
+ 'is_main_query', 'is_feed', 'is_rtl', 'wp_is_mobile',
+ 'has_tag', 'has_term', 'has_category', 'in_category',
+ 'get_current_user_id', 'get_the_ID', 'comments_open', 'pings_open',
+ ) );
+ }
+
+ public static function normalize_callable_name( $name ) {
+ $name = (string) $name;
+ // Strip leading literal `namespace` keyword (case-insensitive on the keyword)
+ if ( strncasecmp( $name, 'namespace\', 10 ) === 0 ) {
+ $name = substr( $name, 10 );
+ }
+ // Strip leading `` (fully-qualified marker)
+ $name = ltrim( $name, '\' );
+ // Class & function names are case-insensitive in PHP — lowercase for stable matching
+ return strtolower( $name );
+ }
+
+ /**
+ * Pre-process a token stream so multi-token namespaced names emitted by
+ * the PHP 7.x tokenizer (e.g. `FooBar` → 4 tokens) collapse into a
+ * single synthetic `T_STRING` carrying the full qualified name. PHP 8.x
+ * already emits single tokens (T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED,
+ * T_NAME_RELATIVE) so most chains pass through; this only fires on PHP 7
+ * or on rare residual `T_NS_SEPARATOR T_STRING` patterns.
+ */
+ public static function merge_namespace_tokens( $tokens ) {
+ $result = array();
+ $count = count( $tokens );
+ $i = 0;
+ while ( $i < $count ) {
+ $tok = $tokens[ $i ];
+ $is_array_tok = is_array( $tok );
+ $is_ns_sep = $is_array_tok && $tok[0] === T_NS_SEPARATOR;
+ $is_namespace_kw = $is_array_tok && $tok[0] === T_NAMESPACE;
+ $is_string = $is_array_tok && $tok[0] === T_STRING;
+
+ if ( !( $is_ns_sep || $is_namespace_kw || $is_string ) ) {
+ $result[] = $tok;
+ $i++;
+ continue;
+ }
+
+ $start = $i;
+ $name = '';
+ $line = $is_array_tok && isset( $tok[2] ) ? $tok[2] : 0;
+
+ if ( $is_namespace_kw ) {
+ $name = 'namespace';
+ $i++;
+ if ( $i >= $count || ! is_array( $tokens[ $i ] ) || $tokens[ $i ][0] !== T_NS_SEPARATOR ) {
+ // `namespace` keyword used standalone — emit as-is, will fail token allowlist anyway
+ $result[] = $tokens[ $start ];
+ $i = $start + 1;
+ continue;
+ }
+ $name .= '\';
+ $i++;
+ } elseif ( $is_ns_sep ) {
+ $name = '\';
+ $i++;
+ }
+
+ if ( $i >= $count || ! is_array( $tokens[ $i ] ) || $tokens[ $i ][0] !== T_STRING ) {
+ $result[] = $tokens[ $start ];
+ $i = $start + 1;
+ continue;
+ }
+
+ $name .= $tokens[ $i ][1];
+ $i++;
+
+ while ( $i + 1 < $count
+ && is_array( $tokens[ $i ] ) && $tokens[ $i ][0] === T_NS_SEPARATOR
+ && is_array( $tokens[ $i + 1 ] ) && $tokens[ $i + 1 ][0] === T_STRING ) {
+ $name .= '\' . $tokens[ $i + 1 ][1];
+ $i += 2;
+ }
+
+ if ( $i === $start + 1 && $is_string ) {
+ // Unchanged plain T_STRING — emit original
+ $result[] = $tokens[ $start ];
+ } else {
+ $result[] = array( T_STRING, $name, $line );
+ }
+ }
+ return $result;
+ }
+
+ public static function get_blocked_functions() {
+ return apply_filters( self::$underscore_text_domain . '_blocked_functions', array(
+ // Command execution
+ 'exec', 'system', 'shell_exec', 'passthru', 'proc_open', 'popen', 'pcntl_exec',
+ // Code execution
+ 'eval', 'assert', 'create_function', 'call_user_func', 'call_user_func_array',
+ 'preg_replace_callback', 'array_map', 'array_filter', 'array_walk', 'array_walk_recursive',
+ 'usort', 'uasort', 'uksort', 'register_shutdown_function', 'register_tick_function', 'ob_start',
+ // File I/O
+ 'file_put_contents', 'fwrite', 'fputs', 'fopen', 'unlink', 'rename', 'copy', 'mkdir', 'rmdir',
+ 'chmod', 'chown', 'chgrp', 'symlink', 'link', 'tmpfile', 'tempnam', 'touch',
+ 'file_get_contents', 'file', 'readfile', 'fread', 'fgets', 'fgetc', 'fpassthru',
+ 'highlight_file', 'show_source', 'php_strip_whitespace',
+ // Network
+ 'curl_init', 'curl_exec', 'curl_multi_exec', 'fsockopen', 'pfsockopen',
+ 'stream_socket_client', 'mail', 'wp_mail', 'wp_remote_get', 'wp_remote_post',
+ 'wp_remote_request', 'wp_remote_head', 'wp_safe_remote_get', 'wp_safe_remote_post',
+ 'wp_safe_remote_request', 'wp_safe_remote_head', 'download_url',
+ // WP write operations
+ 'wp_insert_user', 'wp_create_user', 'wp_delete_user', 'wp_update_user',
+ 'wp_set_auth_cookie', 'wp_set_current_user', 'wp_set_password', 'wp_logout',
+ 'wp_insert_post', 'wp_update_post', 'wp_delete_post', 'wp_trash_post',
+ 'update_option', 'delete_option', 'add_option',
+ 'update_user_meta', 'delete_user_meta', 'add_user_meta',
+ 'update_post_meta', 'delete_post_meta', 'add_post_meta',
+ // Output/info
+ 'header', 'setcookie', 'setrawcookie', 'phpinfo', 'php_uname', 'getenv', 'putenv',
+ 'ini_set', 'ini_alter', 'ini_restore', 'dl',
+ 'get_defined_functions', 'get_defined_vars', 'get_defined_constants',
+ ) );
+ }
+
+ public static function validate_expression( $expression ) {
+ $blocked_functions = self::get_blocked_functions();
+ $allowed_callables = array_map( array( __CLASS__, 'normalize_callable_name' ), self::get_allowed_callables() );
+
+ $allowed_tokens = apply_filters( self::$underscore_text_domain . '_allowed_tokens', array(
+ T_STRING,
+ T_LNUMBER, T_DNUMBER, T_CONSTANT_ENCAPSED_STRING,
+ T_BOOLEAN_AND, T_BOOLEAN_OR, T_LOGICAL_AND, T_LOGICAL_OR, T_LOGICAL_XOR,
+ T_IS_EQUAL, T_IS_IDENTICAL, T_IS_NOT_EQUAL, T_IS_NOT_IDENTICAL,
+ T_IS_GREATER_OR_EQUAL, T_IS_SMALLER_OR_EQUAL, T_COALESCE,
+ T_DOUBLE_COLON, T_OBJECT_OPERATOR,
+ T_NS_SEPARATOR, T_ARRAY, T_DOUBLE_ARROW, T_WHITESPACE,
+ ) );
+
+ // PHP 8+ token constants
+ if ( defined( 'T_NULLSAFE_OPERATOR' ) ) {
+ $allowed_tokens[] = T_NULLSAFE_OPERATOR;
+ }
+ if ( defined( 'T_NAME_QUALIFIED' ) ) {
+ $allowed_tokens[] = T_NAME_QUALIFIED;
+ }
+ if ( defined( 'T_NAME_FULLY_QUALIFIED' ) ) {
+ $allowed_tokens[] = T_NAME_FULLY_QUALIFIED;
+ }
+ if ( defined( 'T_NAME_RELATIVE' ) ) {
+ $allowed_tokens[] = T_NAME_RELATIVE;
+ }
+
+ $allowed_chars = apply_filters( self::$underscore_text_domain . '_allowed_chars', array(
+ '(', ')', ',', '!', '<', '>', '+', '-', '*', '/', '%', '.', '?', ':', '[', ']',
+ ) );
+
+ $tokens = token_get_all( '<?php ' . $expression );
+ array_shift( $tokens ); // drop the opening <?php
+
+ // Drop whitespace tokens — they're never significant for what we check
+ $tokens = array_values( array_filter( $tokens, function( $t ) {
+ return ! ( is_array( $t ) && $t[0] === T_WHITESPACE );
+ } ) );
+
+ // Collapse PHP 7's multi-token namespaced names into single synthetic T_STRING tokens
+ $tokens = self::merge_namespace_tokens( $tokens );
+
+ $count = count( $tokens );
+ $prev_significant_token = null;
+
+ // Token types that introduce a callable name
+ $name_token_types = array( T_STRING );
+ if ( defined( 'T_NAME_QUALIFIED' ) ) $name_token_types[] = T_NAME_QUALIFIED;
+ if ( defined( 'T_NAME_FULLY_QUALIFIED' ) ) $name_token_types[] = T_NAME_FULLY_QUALIFIED;
+ if ( defined( 'T_NAME_RELATIVE' ) ) $name_token_types[] = T_NAME_RELATIVE;
+
+ for ( $i = 0; $i < $count; $i++ ) {
+ $token = $tokens[ $i ];
+
+ if ( is_array( $token ) ) {
+ $token_type = $token[0];
+ $token_value = $token[1];
+
+ if ( !in_array( $token_type, $allowed_tokens, true ) ) {
+ return sprintf( 'Disallowed token type: %s ("%s")', token_name( $token_type ), $token_value );
+ }
+
+ $is_name = in_array( $token_type, $name_token_types, true );
+ $next = $i + 1 < $count ? $tokens[ $i + 1 ] : null;
+
+ if ( $is_name ) {
+ // Static method call: <Name> :: <Name> (
+ if ( is_array( $next ) && $next[0] === T_DOUBLE_COLON ) {
+ $method_tok = $i + 2 < $count ? $tokens[ $i + 2 ] : null;
+ $after = $i + 3 < $count ? $tokens[ $i + 3 ] : null;
+ if ( is_array( $method_tok ) && $method_tok[0] === T_STRING && $after === '(' ) {
+ $callable = $token_value . '::' . $method_tok[1];
+ $normalized = self::normalize_callable_name( $callable );
+ if ( in_array( $normalized, $blocked_functions, true ) ) {
+ return sprintf( 'Blocked function: %s', $callable );
+ }
+ if ( !in_array( $normalized, $allowed_callables, true ) ) {
+ return sprintf( 'Unknown callable: %s — not on the allowlist. Contact the site administrator if it should be added.', $callable );
+ }
+ $prev_significant_token = $tokens[ $i + 2 ];
+ $i += 2;
+ continue;
+ }
+ // Class::CONSTANT (no parens) — read-only access; allowed. Walk through.
+ $prev_significant_token = $token;
+ continue;
+ }
+
+ // Function call: <Name> (
+ if ( $next === '(' ) {
+ $normalized = self::normalize_callable_name( $token_value );
+ if ( in_array( $normalized, $blocked_functions, true ) ) {
+ return sprintf( 'Blocked function: %s', $token_value );
+ }
+ if ( !in_array( $normalized, $allowed_callables, true ) ) {
+ return sprintf( 'Unknown callable: %s — not on the allowlist. Contact the site administrator if it should be added.', $token_value );
+ }
+ $prev_significant_token = $token;
+ continue;
+ }
+
+ // Bare name — only allowed if it's a literal or the second part of Class::X
+ $preceded_by_double_colon = is_array( $prev_significant_token ) && $prev_significant_token[0] === T_DOUBLE_COLON;
+ $lower_value = strtolower( $token_value );
+ if ( $preceded_by_double_colon || $lower_value === 'true' || $lower_value === 'false' || $lower_value === 'null' ) {
+ $prev_significant_token = $token;
+ continue;
+ }
+ return sprintf( 'Unknown identifier: %1$s — must be a function call (e.g. %1$s()), static method, class constant, or a true/false/null literal.', $token_value );
+ }
+
+ // Instance method call: -> <Name> (
+ $is_object_op = $token_type === T_OBJECT_OPERATOR ||
+ ( defined( 'T_NULLSAFE_OPERATOR' ) && $token_type === T_NULLSAFE_OPERATOR );
+ if ( $is_object_op ) {
+ $name_tok = $next;
+ $after = $i + 2 < $count ? $tokens[ $i + 2 ] : null;
+ if ( is_array( $name_tok ) && $name_tok[0] === T_STRING && $after === '(' ) {
+ return sprintf( 'Instance method call ->%s() cannot be allowlisted — rewrite as a static helper', $name_tok[1] );
+ }
+ }
+
+ if ( $token_type === T_CONSTANT_ENCAPSED_STRING ) {
+ $prev_significant_token = $token;
+ continue;
+ }
+
+ $prev_significant_token = $token;
+ } else {
+ if ( !in_array( $token, $allowed_chars, true ) ) {
+ return sprintf( 'Disallowed character: "%s"', $token );
+ }
+
+ // Anything-as-callable: when `(` follows a value that isn't a name token, the
+ // thing being called is the result of an expression and can't be tied to an
+ // allowlistable callable. Catches:
+ // 'phpinfo'() — string-as-callable
+ // ('phpinfo')() — parenthesized string
+ // ('php' . 'info')() — concatenation result
+ // func()() — chained call (call returns callable, then call)
+ // ['phpinfo'][0]() — array literal indexed then called
+ if ( $token === '(' && $prev_significant_token !== null ) {
+ if ( is_array( $prev_significant_token ) && $prev_significant_token[0] === T_CONSTANT_ENCAPSED_STRING ) {
+ return sprintf( 'String used as callable: %s', $prev_significant_token[1] );
+ }
+ if ( $prev_significant_token === ')' ) {
+ return 'Call invocation on a non-name expression — `(...)()` cannot be allowlisted; rewrite as a static helper';
+ }
+ if ( $prev_significant_token === ']' ) {
+ return 'Call invocation on an array element — `[...]()` cannot be allowlisted; rewrite as a static helper';
+ }
+ }
+
+ $prev_significant_token = $token;
+ }
+ }
+
+ return true;
+ }
+
public static function evaluate_visibility_expression($expression, $type, $data) {
$visibility = true;
+ $validation_enabled = get_option( self::$validation_option_key );
+ if ( $validation_enabled === '1' ) {
+ $validation_result = self::validate_expression( $expression );
+ if ( $validation_result !== true ) {
+ global $wp;
+ global $wp_filesystem;
+
+ try {
+ $error_message_format = "A visibility expression has been BLOCKED by expression validation.nThe content will be shown by default (not hidden).nnPage URL:n%1$snnVisibility expression:n%2$snnValidation error:n%3$s";
+
+ wp_mail( get_bloginfo( 'admin_email' ), '[' . get_bloginfo( 'name' ) . '] Content Visibility for Divi Builder - Blocked Expression Detected', sprintf( $error_message_format, home_url( add_query_arg( isset( $_SERVER['QUERY_STRING'] ) ? $_SERVER['QUERY_STRING'] : array(), '', isset( $wp->request ) ? $wp->request : '' ) ), $expression, $validation_result ), array( 'Content-Type: text/plain; charset=UTF-8' ) );
+ } catch ( Exception $e ) {
+ // Silently fail if email cannot be sent
+ }
+
+ return $visibility;
+ }
+ }
+
try {
- eval( '$visibility = ' . $expression . ';' );
- } catch (ParseError | Error $error) {
+ $visibility = (bool) cvdb_eval_expression( $expression );
+ } catch (ParseError | Error $error) {
global $wp;
global $wp_filesystem;
@@ -301,22 +654,22 @@
}
}
- $cvdb_tags = apply_filters( $this->underscore_text_domain . '_prevent_texturize_shortcodes', array_unique( $cvdb_tags ) );
+ $cvdb_tags = apply_filters( self::$underscore_text_domain . '_prevent_texturize_shortcodes', array_unique( $cvdb_tags ) );
add_filter( 'no_texturize_shortcodes', function( $default_no_texturize_shortcodes ) use ( $cvdb_tags ) {
return array_unique( array_merge( $default_no_texturize_shortcodes, $cvdb_tags ) );
} );
// Something temporary until a better solution is found
- if ( function_exists( 'et_pb_is_pagebuilder_used' ) && apply_filters( $this->underscore_text_domain . '_remove_wptexturize_from_builder_pages', true ) ) {
+ if ( function_exists( 'et_pb_is_pagebuilder_used' ) && apply_filters( self::$underscore_text_domain . '_remove_wptexturize_from_builder_pages', true ) ) {
add_filter( 'the_content', array( $this, 'remove_wptexturize_from_builder_content' ), 9 );
}
// TODO: Lazy-loaded callables will not be included here (unless we are on our API reference page)!
- do_action( $this->underscore_text_domain, self::$cvdb_et_pb_children );
+ do_action( self::$underscore_text_domain, self::$cvdb_et_pb_children );
foreach ( self::$cvdb_et_pb_children as $tag => $func ) {
- new CVDB_ET_Builder_Element( $func, $tag, $this->underscore_text_domain, self::get_text_domain() );
+ new CVDB_ET_Builder_Element( $func, $tag, self::$underscore_text_domain, self::get_text_domain() );
}
if ( $this->is_saving_cache && method_exists( 'ET_Builder_Element', 'save_cache' ) ) {
@@ -339,7 +692,7 @@
foreach ( $path_parts as $key ) {
if ( !is_array( $cvdb_content_visibility_check ) || !isset( $cvdb_content_visibility_check[$key] ) ) {
return apply_filters(
- "{$this->underscore_text_domain}_block_render_callback",
+ self::$underscore_text_domain . '_block_render_callback',
call_user_func( $render_callback, $block_attributes, $content, $block ),
$block_attributes,
$content,
@@ -353,7 +706,7 @@
if ( !is_string( $cvdb_content_visibility_check ) ) {
return apply_filters(
- "{$this->underscore_text_domain}_block_render_callback",
+ self::$underscore_text_domain . '_block_render_callback',
call_user_func( $render_callback, $block_attributes, $content, $block ),
$block_attributes,
$content,
@@ -369,7 +722,7 @@
}
return apply_filters(
- "{$this->underscore_text_domain}_block_render_callback",
+ self::$underscore_text_domain . '_block_render_callback',
call_user_func( $render_callback, $block_attributes, $content, $block ),
$block_attributes,
$content,
@@ -393,14 +746,14 @@
$func = $GLOBALS['shortcode_tags'][$tag];
self::$cvdb_et_pb_children[$tag] = $func;
remove_shortcode( $tag, $func );
- new CVDB_ET_Builder_Element( $func, $tag, $this->underscore_text_domain, self::get_text_domain() );
+ new CVDB_ET_Builder_Element( $func, $tag, self::$underscore_text_domain, self::get_text_domain() );
}
public function enqueue_scripts() {
if ( self::$is_using_divi_builder_5 ) {
if ( did_action( 'divi_visual_builder_initialize' ) && self::$has_ET_Builder_Framework_Utility_Conditions && ETBuilderFrameworkUtilityConditions::is_vb_app_window() ) {
// Adds the Content Visibility field to Divi 5's editor
- wp_enqueue_script( self::get_text_domain() . '_gutenberg-filters', plugins_url( '/js/gutenberg-filters.js', CVDB_PLUGIN ), array( 'divi-vendor-wp-hooks' ), self::get_version() );
+ wp_enqueue_script( self::get_text_domain() . '_gutenberg-filters', plugins_url( '/js/gutenberg-filters.js', CVDB_PLUGIN ), array( 'divi-vendor-wp-hooks', 'divi-vendor-react', 'divi-field-library', 'wp-api-fetch' ), self::get_version(), true );
}
}
}
@@ -423,9 +776,8 @@
public function enqueue_admin_scripts() {
wp_enqueue_style( self::get_text_domain() . '_admin_styles', plugins_url( '/css/admin-styles.css', CVDB_PLUGIN ), array(), self::get_version() );
- wp_enqueue_script( self::get_text_domain() . '_admin-script', plugins_url( '/js/admin.js' , CVDB_PLUGIN ), array( 'jquery' ), self::get_version() );
- wp_localize_script( self::get_text_domain() . '_admin-script', 'cvdbAdminScript', array(
- 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
+ wp_enqueue_script( self::get_text_domain() . '_admin-script', plugins_url( '/js/admin.js' , CVDB_PLUGIN ), array( 'jquery', 'wp-api-fetch' ), self::get_version() );
+ wp_localize_script( self::get_text_domain() . '_admin-script', 'cvdbAdminScript', array(
'textDomain' => self::get_text_domain()
) );
@@ -443,16 +795,21 @@
) );
}
- public function ajax_dismiss_rating_notice() {
- update_user_option( get_current_user_id(), $this->show_rating_notice_option_key, '0' );
-
- exit;
+ public function register_rest_routes() {
+ register_rest_route( 'cvdb/v1', '/notices/rating/dismiss', array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'permission_callback' => function() {
+ $current_user_id = get_current_user_id();
+
+ return $current_user_id !== 0 && current_user_can( 'edit_user', $current_user_id );
+ },
+ 'callback' => array( $this, 'rest_dismiss_rating_notice' ),
+ ) );
}
- public function ajax_click_rating_link() {
- wp_redirect( 'https://wordpress.org/support/view/plugin-reviews/' . self::get_text_domain() . '?rate=5#postform' );
-
- exit;
+ public function rest_dismiss_rating_notice() {
+ update_user_option( get_current_user_id(), $this->show_rating_notice_option_key, '0' );
+ return new WP_REST_Response( null, 204 );
}
public function add_menu_items() {
@@ -483,6 +840,8 @@
'general' => __( 'General Actions and Filters', self::get_text_domain() ),
'specific' => __( 'Legacy Divi Module-Specific Actions and Filters', self::get_text_domain() ),
'available' => __( 'Currently Available Legacy Divi Module-Specific Actions and Filters', self::get_text_domain() ),
+ 'security' => __( 'Expression Validation', self::get_text_domain() ),
+ 'validation-filters' => __( 'Expression Validation Filters', self::get_text_domain() ),
);
foreach ( $all_tabs as $tab_key => $tab_caption ) {
$active = $tab == $tab_key ? ' nav-tab-active' : '';
@@ -528,6 +887,76 @@
<h3> <?php printf( /* translators: 1: the Module-Specific filter */ __( 'Filter: %1$s', self::get_text_domain() ), "<code>content_visibility_for_divi_builder_shortcode_$tag</code>" ); ?></h3>
<hr><?php
}
+ } else if ( $tab === 'security' ) {
+ $validation_enabled = get_option( self::$validation_option_key );
+?>
+<h2 class="title"><?php _e( 'Expression Validation', self::get_text_domain() ); ?></h2>
+<p><?php _e( 'Expression validation prevents potentially dangerous PHP code from being executed via visibility expressions. When enabled, all expressions are checked against an allowlist of safe tokens and a denylist of dangerous functions before evaluation.', self::get_text_domain() ); ?></p>
+
+<h3><?php _e( 'Status', self::get_text_domain() ); ?></h3>
+<?php if ( $validation_enabled === '1' ) { ?>
+<p><strong style="color: #46b450;"><?php _e( 'Expression validation is active.', self::get_text_domain() ); ?></strong></p>
+<p><?php _e( 'Use the scanner below to check your content for expressions that would be blocked by validation.', self::get_text_domain() ); ?></p>
+<?php } else { ?>
+<p><strong style="color: #dc3232;"><?php _e( 'Expression validation is not yet enabled.', self::get_text_domain() ); ?></strong></p>
+<p><?php _e( 'Use the scanner below to check your content for expressions that would be blocked, then enable validation when ready.', self::get_text_domain() ); ?></p>
+<?php } ?>
+
+<?php SecurityScanner::render_scanner_section(); ?>
+
+<?php if ( $validation_enabled !== '1' ) { ?>
+<hr>
+<h3><?php _e( 'Enable Validation', self::get_text_domain() ); ?></h3>
+<p><?php _e( 'Once you have reviewed the scan results and resolved any flagged expressions, enable validation to protect your site. You can disable it again later if something unexpected breaks.', self::get_text_domain() ); ?></p>
+<form method="post">
+ <?php wp_nonce_field( self::get_text_domain() . '_enable_validation', '_cvdb_validation_nonce' ); ?>
+ <input type="hidden" name="cvdb_enable_validation" value="1">
+ <?php submit_button( __( 'Enable Validation', self::get_text_domain() ), 'primary', 'submit', true ); ?>
+</form>
+<?php } else { ?>
+<hr>
+<h3><?php _e( 'Disable Validation', self::get_text_domain() ); ?></h3>
+<p><?php _e( 'If validation is causing unexpected behavior on your site, you can disable it temporarily while you investigate. While disabled, expressions are evaluated as-is — anything dangerous in your content will run.', self::get_text_domain() ); ?></p>
+<form method="post" style="background:#fcf0f1;border-left:4px solid #d63638;padding:12px 16px;max-width:560px;">
+ <p style="margin-top:0;"><?php printf( __( 'Type %s in the field below to confirm:', self::get_text_domain() ), '<code>DISABLE</code>' ); ?></p>
+ <?php wp_nonce_field( self::get_text_domain() . '_disable_validation', '_cvdb_validation_nonce' ); ?>
+ <input type="hidden" name="cvdb_disable_validation" value="1">
+ <input type="text" name="cvdb_disable_confirmation" pattern="DISABLE" required autocomplete="off" placeholder="DISABLE" style="width:200px;font-family:monospace;text-transform:uppercase;" />
+ <?php submit_button( __( 'Disable Validation', self::get_text_domain() ), 'delete', 'submit', false ); ?>
+</form>
+<?php
+ }
+ } else if ( $tab === 'validation-filters' ) {
+?>
+<h2 class="title"><?php _e( 'Expression Validation Filters', self::get_text_domain() ); ?></h2>
+<p><?php _e( 'These filters let you adjust what the expression validator and the content scanner consider safe. Three of them affect both runtime evaluation and the scanner; one only affects scanner warnings. All accept and must return an array of strings (or token type constants for <code>allowed_tokens</code>).', self::get_text_domain() ); ?></p>
+<hr>
+
+<h2 class="title"><?php /* translators: 1: filter name, 2: parameter list */ printf( __( 'Filter: %1$s<br>Parameters:<br>%2$s', self::get_text_domain() ), '<code>content_visibility_for_divi_builder_blocked_functions</code>', sprintf( __( ' %1$s: Array of lowercase function names that are blocked when present in any expression. Returning an unfiltered call to one of these names from an expression produces a hard validation error.', self::get_text_domain() ), '<code>$blocked_functions</code>' ) ); ?></h2>
+<p><?php _e( 'Affects both runtime evaluation and the scanner. Use this to add organization-specific dangerous functions to the denylist. Note: <strong>removing</strong> entries weakens the security posture — only do so if you have audited the function in question.', self::get_text_domain() ); ?></p>
+<p><?php _e( 'Example: block calls to a custom helper that performs writes.', self::get_text_domain() ); ?></p>
+<pre><code>add_filter( 'content_visibility_for_divi_builder_blocked_functions', function( $names ) {
+ $names[] = 'mytheme_force_login';
+ return $names;
+} );</code></pre>
+<hr>
+
+<h2 class="title"><?php /* translators: 1: filter name, 2: parameter list */ printf( __( 'Filter: %1$s<br>Parameters:<br>%2$s', self::get_text_domain() ), '<code>content_visibility_for_divi_builder_allowed_tokens</code>', sprintf( __( ' %1$s: Array of PHP tokenizer type constants (e.g. <code>T_STRING</code>, <code>T_LNUMBER</code>) that are permitted to appear in expressions.', self::get_text_domain() ), '<code>$allowed_tokens</code>' ) ); ?></h2>
+<p><?php _e( 'Affects both runtime evaluation and the scanner. Tokens not in this list cause validation to fail with "Disallowed token type". The default list permits identifiers, literals, comparison/logical operators, namespacing, and array syntax — but excludes things like <code>T_VARIABLE</code> (no <code>$vars</code>) and assignment operators.', self::get_text_domain() ); ?></p>
+<hr>
+
+<h2 class="title"><?php /* translators: 1: filter name, 2: parameter list */ printf( __( 'Filter: %1$s<br>Parameters:<br>%2$s', self::get_text_domain() ), '<code>content_visibility_for_divi_builder_allowed_chars</code>', sprintf( __( ' %1$s: Array of single-character tokens (e.g. <code>(</code>, <code>)</code>, <code>,</code>) that are permitted to appear in expressions.', self::get_text_domain() ), '<code>$allowed_chars</code>' ) ); ?></h2>
+<p><?php _e( 'Affects both runtime evaluation and the scanner. Characters not in this list cause validation to fail with "Disallowed character". The default list excludes characters that enable side effects (e.g. <code>;</code>, <code>=</code>, <code>$</code>, <code>` </code>).', self::get_text_domain() ); ?></p>
+<hr>
+
+<h2 class="title"><?php /* translators: 1: filter name, 2: parameter list */ printf( __( 'Filter: %1$s<br>Parameters:<br>%2$s', self::get_text_domain() ), '<code>content_visibility_for_divi_builder_allowed_callables</code>', sprintf( __( ' %1$s: Array of lowercase callable names that the scanner considers known-safe and will not flag with a "custom callable" warning.', self::get_text_domain() ), '<code>$allowed_callables</code>' ) ); ?></h2>
+<p><?php _e( 'Affects only the scanner output, not runtime evaluation. The validator can't inspect the body of a user-defined function or method, so any callable that isn't on this list (and isn't already blocked) is surfaced as a warning prompting manual review. Adding your own well-audited helpers here suppresses those warnings on subsequent scans.', self::get_text_domain() ); ?></p>
+<p><?php _e( 'Example: trust your theme's helper after auditing it.', self::get_text_domain() ); ?></p>
+<pre><code>add_filter( 'content_visibility_for_divi_builder_allowed_callables', function( $names ) {
+ $names[] = 'mytheme_should_be_visible';
+ return $names;
+} );</code></pre>
+<?php
}
?>
</div><?php
@@ -535,7 +964,7 @@
public function render_admin_notices() {
if ( get_user_option( $this->show_rating_notice_option_key ) === '1' ) {
- $rating_link = '<a href="' . esc_url( admin_url( 'admin-ajax.php?action=' . self::get_text_domain() . '_click-rating-link' ) ) . '" target="_blank">' . _x( 'rating', 'present participle: I enjoyed rating the awesome WordPress plugin', self::get_text_domain() ) . '</a>';
+ $rating_link = '<a href="' . esc_url( 'https://wordpress.org/support/view/plugin-reviews/' . self::get_text_domain() . '?rate=5#postform' ) . '" target="_blank" rel="noopener">' . _x( 'rating', 'present participle: I enjoyed rating the awesome WordPress plugin', self::get_text_domain() ) . '</a>';
?>
<div id="<?php echo esc_attr( self::get_text_domain() ); ?>_rating-notice" class="notice notice-info is-dismissible" style="position: relative;"><p><?php
/* translators: 1: The plugin's name 2: The translated text for "rating" in the present participle (e.g. I enjoyed rating the awesome WordPress plugin) 3: A "smiley" emoticon at the end of the translated text */
@@ -548,7 +977,61 @@
?></div>
<?php
}
+
+ $validation_enabled = get_option( self::$validation_option_key );
+ if ( $validation_enabled === '0' && current_user_can( 'manage_options' ) ) {
+ $security_tab_url = admin_url( 'tools.php?page=' . self::get_text_domain() . '-api-reference&tab=security' );
+?>
+<div class="notice notice-warning"><p><?php
+ printf(
+ __( '<strong>%1$s:</strong> Expression validation is not yet enabled. Please review the <a href="%2$s">Expression Validation</a> tab to scan your content and enable validation.', self::get_text_domain() ),
+ esc_html( self::get_name() ),
+ esc_url( $security_tab_url )
+ );
+?></p></div>
+<?php
+ }
+
+ if ( !self::is_eval_available() ) {
+?>
+<div class="notice notice-error">
+ <p><strong><?php echo esc_html( self::get_name() ); ?>:</strong> <?php _e( 'PHP <code>eval()</code> appears to be disabled on this host (commonly via the Suhosin extension or a custom hardening policy). Visibility expressions cannot be evaluated until <code>eval()</code> is re-enabled. Contact your hosting provider, or remove this plugin if <code>eval()</code> cannot be restored.', self::get_text_domain() ); ?></p>
+</div>
+<?php
+ }
}
+
+ public function handle_validation_toggle() {
+ $enable = isset( $_POST['cvdb_enable_validation'] ) && $_POST['cvdb_enable_validation'] === '1';
+ $disable = isset( $_POST['cvdb_disable_validation'] ) && $_POST['cvdb_disable_validation'] === '1';
+ if ( !$enable && !$disable ) {
+ return;
+ }
+
+ if ( !current_user_can( 'manage_options' ) ) {
+ return;
+ }
+
+ $nonce_action = $enable
+ ? self::get_text_domain() . '_enable_validation'
+ : self::get_text_domain() . '_disable_validation';
+ if ( !isset( $_POST['_cvdb_validation_nonce'] ) || !wp_verify_nonce( $_POST['_cvdb_validation_nonce'], $nonce_action ) ) {
+ return;
+ }
+
+ if ( $disable ) {
+ $confirmation = isset( $_POST['cvdb_disable_confirmation'] ) ? trim( wp_unslash( $_POST['cvdb_disable_confirmation'] ) ) : '';
+ if ( $confirmation === 'DISABLE' ) {
+ update_option( self::$validation_option_key, '0' );
+ }
+ } else {
+ update_option( self::$validation_option_key, '1' );
+ }
+
+ wp_safe_redirect( admin_url( 'tools.php?page=' . self::get_text_domain() . '-api-reference&tab=security' ) );
+ exit;
+ }
+
}
ContentVisibilityForDiviBuilder::init();
--- a/content-visibility-for-divi-builder/includes/security-scanner.class.php
+++ b/content-visibility-for-divi-builder/includes/security-scanner.class.php
@@ -0,0 +1,788 @@
+<?php
+
+namespace AoDTechnologiesContentVisibilityForDiviBuilder;
+
+if ( !defined( 'WPINC' ) ) {
+ die;
+}
+
+class SecurityScanner {
+ const FINDINGS_META_KEY = '_content_visibility_for_divi_builder_validation_findings';
+
+ private static $reflection_available = null;
+
+ public static function is_reflection_available() {
+ if ( self::$reflection_available !== null ) {
+ return self::$reflection_available;
+ }
+ if (
+ !extension_loaded( 'Reflection' ) ||
+ !class_exists( 'ReflectionFunction', false ) ||
+ !class_exists( 'ReflectionMethod', false )
+ ) {
+ self::$reflection_available = false;
+ return false;
+ }
+ try {
+ // Smoke test: construct a Reflection on a function that always exists,
+ // and call the methods we actually use. If anything throws (disable_classes,
+ // other host hardening), treat Reflection as unavailable.
+ $probe = new ReflectionFunction( 'print_r' );
+ $probe->getFileName();
+ $probe->getStartLine();
+ self::$reflection_available = true;
+ } catch ( Throwable $e ) {
+ self::$reflection_available = false;
+ }
+ return self::$reflection_available;
+ }
+
+ public static function init() {
+ add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
+ add_action( 'admin_enqueue_scripts', array( __CLASS__, 'maybe_enqueue_scanner_script' ), 12 );
+
+ // Publish gate
+ add_filter( 'rest_pre_insert_post', array( __CLASS__, 'gate_rest_publish' ), 10, 2 );
+ add_filter( 'wp_insert_post_data', array( __CLASS__, 'gate_classic_publish' ), 10, 2 );
+
+ // Save-time analysis + edit-screen notice (always-on backstop, regardless of validation toggle)
+ add_action( 'save_post', array( __CLASS__, 'on_save_post' ), 20, 3 );
+ add_action( 'admin_notices', array( __CLASS__, 'render_findings_notice' ) );
+ }
+
+ public static function on_save_post( $post_id, $post, $update ) {
+ // Skip autosaves and revisions — only act on user-initiated saves of the live post
+ if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
+ return;
+ }
+ if ( !is_object( $post ) || !isset( $post->post_content ) ) {
+ return;
+ }
+
+ // Fast path: skip the expensive analysis when there are no cvdb markers in content at all
+ if ( strpos( $post->post_content, 'cvdb_content_visibility_check' ) === false
+ && strpos( $post->post_content, 'contentVisibilityCheck' ) === false ) {
+ delete_post_meta( $post_id, self::FINDINGS_META_KEY );
+ return;
+ }
+
+ $errors = self::find_validation_errors( $post->post_content );
+ if ( empty( $errors ) ) {
+ delete_post_meta( $post_id, self::FINDINGS_META_KEY );
+ } else {
+ update_post_meta( $post_id, self::FINDINGS_META_KEY, $errors );
+ }
+ }
+
+ public static function render_findings_notice() {
+ global $pagenow;
+ if ( $pagenow !== 'post.php' ) {
+ return;
+ }
+ $post_id = isset( $_GET['post'] ) ? (int) $_GET['post'] : 0;
+ if ( ! $post_id || !current_user_can( 'edit_post', $post_id ) ) {
+ return;
+ }
+
+ $findings = get_post_meta( $post_id, self::FINDINGS_META_KEY, true );
+ if ( empty( $findings ) || !is_array( $findings ) ) {
+ return;
+ }
+
+ $is_strict = self::validation_enabled();
+ $class = $is_strict ? 'notice-error' : 'notice-warning';
+ $headline = $is_strict
+ ? sprintf(
+ _n(
+ '%d visibility expression failed validation and will be blocked at runtime:',
+ '%d visibility expressions failed validation and will be blocked at runtime:',
+ count( $findings ),
+ ContentVisibilityForDiviBuilder::get_text_domain()
+ ),
+ count( $findings )
+ )
+ : sprintf(
+ _n(
+ '%d visibility expression would be blocked when validation is enabled:',
+ '%d visibility expressions would be blocked when validation is enabled:',
+ count( $findings ),
+ ContentVisibilityForDiviBuilder::get_text_domain()
+ ),
+ count( $findings )
+ );
+ ?>
+ <div class="notice <?php echo esc_attr( $class ); ?>">
+ <p><strong><?php _e( 'Content Visibility for Divi Builder', ContentVisibilityForDiviBuilder::get_text_domain() ); ?>:</strong> <?php echo esc_html( $headline ); ?></p>
+ <ul style="margin-left:24px;list-style:disc;">
+ <?php foreach ( $findings as $e ) :
+ $module = $e['module_name'] !== '' ? $e['module_name'] : '(unknown module)';
+ if ( $e['admin_label'] !== '' ) {
+ $module .= ' "' . $e['admin_label'] . '"';
+ }
+ ?>
+ <li>
+ <code><?php echo esc_html( $e['expression'] ); ?></code>
+ <em style="color:#666;">in <?php echo esc_html( $module ); ?></em>
+ <br>→ <?php echo esc_html( $e['error'] ); ?>
+ </li>
+ <?php endforeach; ?>
+ </ul>
+ </div>
+ <?php
+ }
+
+ /**
+ * Find every expression in $content that fails validation. Returns an array
+ * of { expression, type, module_name, admin_label, error } entries — empty
+ * if everything validates.
+ */
+ private static function find_validation_errors( $content ) {
+ if ( !is_string( $content ) || $content === '' ) {
+ return array();
+ }
+ $errors = array();
+ foreach ( self::extract_expressions_from_content( $content ) as $expr ) {
+ $result = ContentVisibilityForDiviBuilder::validate_expression( $expr['expression'] );
+ if ( $result !== true ) {
+ $errors[] = array(
+ 'expression' => $expr['expression'],
+ 'type' => $expr['type'],
+ 'module_name' => isset( $expr['module_name'] ) ? $expr['module_name'] : '',
+ 'admin_label' => isset( $expr['admin_label'] ) ? $expr['admin_label'] : '',
+ 'error' => $result,
+ );
+ }
+ }
+ return $errors;
+ }
+
+ private static function validation_enabled() {
+ return get_option( ContentVisibilityForDiviBuilder::get_underscore_text_domain() . '_expression_validation_enabled' ) === '1';
+ }
+
+ private static function format_publish_error_message( $errors ) {
+ $lines = array();
+ $lines[] = sprintf(
+ _n(
+ '%d visibility expression failed validation and is blocking the save:',
+ '%d visibility expressions failed validation and are blocking the save:',
+ count( $errors ),
+ ContentVisibilityForDiviBuilder::get_text_domain()
+ ),
+ count( $errors )
+ );
+ $lines[] = '';
+ foreach ( $errors as $e ) {
+ $module = $e['module_name'] !== '' ? $e['module_name'] : '(unknown module)';
+ if ( $e['admin_label'] !== '' ) {
+ $module .= ' "' . $e['admin_label'] . '"';
+ }
+ $lines[] = '• ' . $e['expression'];
+ $lines[] = ' in ' . $module;
+ $lines[] = ' → ' . $e['error'];
+ $lines[] = '';
+ }
+ $lines[] = __( 'No changes were written — your existing post is unchanged. Fix or remove the offending expressions and save again.', ContentVisibilityForDiviBuilder::get_text_domain() );
+ return implode( "n", $lines );
+ }
+
+ private static function format_publish_error_html( $errors ) {
+ $text_domain = ContentVisibilityForDiviBuilder::get_text_domain();
+ $out = '<h1>' . esc_html__( 'Save blocked by Content Visibility validation', $text_domain ) . '</h1>';
+ $out .= '<p>' . esc_html( sprintf(
+ _n(
+ '%d visibility expression failed validation. Your existing post has NOT been changed — use your browser's Back button to return to the editor, fix the expression(s), and save again.',
+ '%d visibility expressions failed validation. Your existing post has NOT been changed — use your browser's Back button to return to the editor, fix the expressions, and save again.',
+ count( $errors ),
+ $text_domain
+ ),
+ count( $errors )
+ ) ) . '</p>';
+ $out .= '<ul style="margin-left:24px;list-style:disc;">';
+ foreach ( $errors as $e ) {
+ $module = $e['module_name'] !== '' ? $e['module_name'] : '(unknown module)';
+ if ( $e['admin_label'] !== '' ) {
+ $module .= ' "' . $e['admin_label'] . '"';
+ }
+ $out .= '<li><code>' . esc_html( $e['expression'] ) . '</code>'
+ . ' <em style="color:#666;">' . sprintf( esc_html__( 'in %s', $text_domain ), esc_html( $module ) ) . '</em>'
+ . '<br>→ ' . esc_html( $e['error'] ) . '</li>';
+ }
+ $out .= '</ul>';
+ return $out;
+ }
+
+ /**
+ * REST publish gate — fires for Gutenberg/Divi 5 VB and any REST API client.
+ * Returning WP_Error blocks the publish; the editor surfaces the message in its standard error UI.
+ */
+ public static function gate_rest_publish( $prepared_post, $request ) {
+ if ( !self::validation_enabled() || !is_object( $prepared_post ) ) {
+ return $prepared_post;
+ }
+
+ // Resolve the EFFECTIVE status & content after this update. On partial updates,
+ // only the fields the client sent are present on $prepared_post — fall back to the
+ // existing stored post for whatever's missing.
+ $existing = !empty( $prepared_post->ID ) ? get_post( $prepared_post->ID ) : null;
+
+ $status = isset( $prepared_post->post_status )
+ ? $prepared_post->post_status
+ : ( $existing ? $existing->post_status : '' );
+ if ( $status !== 'publish' && $status !== 'future' ) {
+ return $prepared_post;
+ }
+
+ $content = isset( $prepared_post->post_content )
+ ? $prepared_post->post_content
+ : ( $existing ? $existing->post_content : '' );
+
+ $errors = self::find_validation_errors( $content );
+ if ( empty( $errors ) ) {
+ return $prepared_post;
+ }
+ return new WP_Error(
+ 'cvdb_validation_failed',
+ self::format_publish_error_message( $errors ),
+ array( 'status' => 400, 'cvdb_errors' => $errors ),
+ );
+ }
+
+ /**
+ * Classic-editor publish gate. Fires for the classic post.php form, Divi 3/4 backend builder,
+ * and Divi 3/4 visual builder AJAX saves (et_fb_ajax_save). Cannot return WP_Error from this
+ * filter, so when validation fails we either abort the request (interactive contexts) or
+ * silently preserve the existing post's content & status (non-interactive contexts).
+ *
+ * Either way, the existing post is left in its current state — already-published pages stay
+ * published with their previous content. No demote-to-draft. The user's in-progress edits stay
+ * in the VB's React state (AJAX) or in the browser's form history (classic), so a Back-button
+ * → fix → save flow recovers cleanly.
+ */
+ public static function gate_classic_publish( $data, $postarr ) {
+ if ( !self::validation_enabled() ) {
+ return $data;
+ }
+ if ( !isset( $data['post_status'] ) ) {
+ return $data;
+ }
+ if ( $data['post_status'] !== 'publish' && $data['post_status'] !== 'future' ) {
+ return $data;
+ }
+
+ // Resolve EFFECTIVE content. wp_update_post() with only status changed leaves
+ // $data['post_content'] empty — fall back to the existing stored post content.
+ // Note: wp_insert_post() runs wp_slash() on $data before this filter fires (and
+ // wp_unslash() afterwards), so $data['post_content'] is escaped here. Unslash
+ // before parsing so the shortcode regex / shortcode_parse_atts see real quotes.
+ $content = isset( $data['post_content'] ) && $data['post_content'] !== ''
+ ? wp_unslash( $data['post_content'] )
+ : '';
+ $existing = isset( $postarr['ID'] ) && (int) $postarr['ID'] > 0 ? get_post( (int) $postarr['ID'] ) : null;
+ if ( $content === '' && $existing ) {
+ $content = $existing->post_content;
+ }
+
+ $errors = self::find_validation_errors( $content );
+ if ( empty( $errors ) ) {
+ return $data;
+ }
+
+ // Interactive context — abort with a clear error. The user's in-progress edits remain in
+ // the VB's React state (AJAX) or the browser's form history (classic post.php) so they can
+ // fix and re-save.
+ if ( is_admin() || wp_doing_ajax() ) {
+ if ( wp_doing_ajax() ) {
+ wp_send_json_error( array(
+ 'message' => self::format_publish_error_message( $errors ),
+ 'html' => self::format_publish_error_html( $errors ),
+ 'cvdb_errors' => $errors,
+ ), 400 );
+ // wp_send_json_error() calls wp_die() internally; this return is just a safety net.
+ return $data;
+ }
+
+ wp_die(
+ self::format_publish_error_html( $errors ),
+ __( 'Save blocked — Content Visibility validation', ContentVisibilityForDiviBuilder::get_text_domain() ),
+ array( 'back_link' => true, 'response' => 400 )
+ );
+ }
+
+ // Non-interactive context (cron, WP-CLI, programmatic wp_update_post calls). wp_die-ing
+ // would crash a background process. Instead, silently no-op the post_status + post_content
+ // to the existing values so the bad expression never reaches the database. Other field
+ // changes (title, excerpt, etc.) still go through.
+ if ( $existing ) {
+ $data['post_status'] = $existing->post_status;
+ $data['post_content'] = wp_slash( $existing->post_content );
+ } else {
+ // No existing post (this is an insert). Demote to draft so the bad expression doesn't
+ // go live; there's no live page to preserve.
+ $data['post_status'] = 'draft';
+ }
+ return $data;
+ }
+
+ public static function register_rest_routes() {
+ register_rest_route( 'cvdb/v1', '/security/scan', array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'permission_callback' => function() { return current_user_can( 'manage_options' ); },
+ 'callback' => array( __CLASS__, 'rest_scan' ),
+ 'args' => array(
+ 'offset' => array(
+ 'type' => 'integer',
+ 'default' => 0,
+ 'minimum' => 0,
+ 'sanitize_callback' => 'absint',
+ ),
+ 'batch_size' => array(
+ 'type' => 'integer',
+ 'default' => 50,
+ 'minimum' => 1,
+ 'maximum' => 200,
+ 'sanitize_callback' => 'absint',
+ ),
+ 'include_revisions' => array(
+ 'type' => 'boolean',
+ 'default' => false,
+ ),
+ ),
+ ) );
+
+ register_rest_route( 'cvdb/v1', '/security/validate-expression', array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'permission_callback' => function() { return current_user_can( 'manage_options' ); },
+ 'callback' => array( __CLASS__, 'rest_validate' ),
+ 'args' => array(
+ 'expression' => array(
+ 'type' => 'string',
+ 'required' => true,
+ ),
+ ),
+ ) );
+ }
+
+ public static function rest_validate( WP_REST_Request $request ) {
+ $expression = trim( (string) $request->get_param( 'expression' ) );
+
+ // Empty expression is always valid — means "always show".
+ if ( $expression === '' ) {
+ return new WP_REST_Response( array(
+ 'valid' => true,
+ 'error' => null,
+ 'warnings' => array(),
+ 'validation_enabled' => get_option( ContentVisibilityForDiviBuilder::get_underscore_text_domain() . '_expression_validation_enabled' ) === '1',
+ ), 200 );
+ }
+
+ $analysis = self::analyze_expression( $expression );
+
+ return new WP_REST_Response( array(
+ 'valid' => $analysis['valid'],
+ 'error' => $analysis['error'],
+ 'warnings' => $analysis['warnings'],
+ 'validation_enabled' => get_option( ContentVisibilityForDiviBuilder::get_underscore_text_domain() . '_expression_validation_enabled' ) === '1',
+ ), 200 );
+ }
+
+ public static function maybe_enqueue_scanner_script() {
+ global $pagenow;
+ $text_domain = ContentVisibilityForDiviBuilder::get_text_domain();
+ if (
+ $pagenow !== 'tools.php' ||
+ !isset( $_GET['page'] ) || $_GET['page'] !== $text_domain . '-api-reference' ||
+ !isset( $_GET['tab'] ) || $_GET['tab'] !== 'security'
+ ) {
+ return;
+ }
+
+ wp_enqueue_script( $text_domain . '_security-scanner', plugins_url( '/js/security-scanner.js', CVDB_PLUGIN ), array( 'jquery', 'wp-api-fetch' ), ContentVisibilityForDiviBuilder::get_version() );
+ }
+
+ public static function render_scanner_section() {
+ $text_domain = ContentVisibilityForDiviBuilder::get_text_domain();
+?>
+<hr>
+<h3><?php _e( 'Content Scanner', $text_domain ); ?></h3>
+<div id="cvdb-security-scan">
+ <p>
+ <label>
+ <input type="checkbox" id="cvdb-scan-include-revisions">
+ <?php _e( 'Include post revisions (slower; flags expressions in saved revisions even if the current content is clean)', $text_domain ); ?>
+ </label>
+ </p>
+ <p><button type="button" id="cvdb-scan-start" class="button button-secondary"><?php _e( 'Start Scan', $text_domain ); ?></button></p>
+ <div id="cvdb-scan-progress" style="display:none;">
+ <p id="cvdb-scan-progress-text"></p>
+ <div style="background:#e0e0e0;height:20px;border-radius:3px;margin:10px 0;">
+ <div id="cvdb-scan-progress-bar" style="background:#0073aa;height:20px;border-radius:3px;width:0;transition:width 0.3s;"></div>
+ </div>
+ </div>
+ <div id="cvdb-scan-results" style="display:none;">
+ <h4 id="cvdb-scan-summary"></h4>
+ <table class="widefat striped" id="cvdb-scan-results-table" style="display:none;">
+ <thead>
+ <tr>
+ <th><?php _e( 'Post', $text_domain ); ?></th>
+ <th><?php _e( 'Expression', $text_domain ); ?></th>
+ <th><?php _e( 'Editor', $text_domain ); ?></th>
+ <th><?php _e( 'Error', $text_domain ); ?></th>
+ </tr>
+ </thead>
+ <tbody id="cvdb-scan-results-body"></tbody>
+ </table>
+ </div>
+</div>
+<?php
+ }
+
+ public static function rest_scan( WP_REST_Request $request ) {
+ global $wpdb;
+
+ $offset = (int) $request->get_param( 'offset' );
+ $batch_size = (int) $request->get_param( 'batch_size' );
+ $include_revisions = (bool) $request->get_param( 'include_revisions' );
+
+ $revision_clause = $include_revisions ? '' : "AND post_type != 'revision'";
+
+ $like_shortcode = '%' . $wpdb->esc_like( 'cvdb_content_visibility_check' ) . '%';
+ $like_block = '%' . $wpdb->esc_like( 'contentVisibilityCheck' ) . '%';
+
+ $total = null;
+ if ( $offset === 0 ) {
+ $total = (int) $wpdb->get_var( $wpdb->prepare(
+ "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status != 'auto-draft' {$revision_clause} AND (post_content LIKE %s OR post_content LIKE %s)",
+ $like_shortcode,
+ $like_block
+ ) );
+ }
+
+ $posts = $wpdb->get_results( $wpdb->prepare(
+ "SELECT ID, post_title, post_content, post_type, post_parent, post_date FROM {$wpdb->posts} WHERE post_status != 'auto-draft' {$revision_clause} AND (post_content LIKE %s OR post_content LIKE %s) ORDER BY ID ASC LIMIT %d OFFSET %d",
+ $like_shortcode,
+ $like_block,
+ $batch_size,
+ $offset
+ ) );
+
+ $parent_data = array();
+ if ( $include_revisions ) {
+ $parent_ids = array();
+ foreach ( $posts as $post ) {
+ if ( $post->post_type === 'revision' && (int) $post->post_parent > 0 ) {
+ $parent_ids[ (int) $post->post_parent ] = true;
+ }
+ }
+ if ( !empty( $parent_ids ) ) {
+ $parent_id_list = array_keys( $parent_ids );
+ $placeholders = implode( ',', array_fill( 0, count( $parent_id_list ), '%d' ) );
+ $parent_rows = $wpdb->get_results( $wpdb->prepare(
+ "SELECT ID, post_title, post_content FROM {$wpdb->posts} WHERE ID IN ({$placeholders})",
+ $parent_id_list
+ ) );
+ foreach ( $parent_rows as $p ) {
+ $flagged_count = 0;
+ $parent_expressions = self::extract_expressions_from_content( $p->post_content );
+ foreach ( $parent_expressions as $expr ) {
+ if ( ContentVisibilityForDiviBuilder::validate_expression( $expr['expression'] ) !== true ) {
+ $flagged_count++;
+ }
+ }
+ $parent_data[ (int) $p->ID ] = array(
+ 'id' => (int) $p->ID,
+ 'title' => $p->post_title,
+ 'edit_url' => get_edit_post_link( $p->ID, 'raw' ),
+ 'current_flagged' => $flagged_count,
+ );
+ }
+ }
+ }
+
+ $text_domain = ContentVisibilityForDiviBuilder::get_text_domain();
+ $results = array();
+ foreach ( $posts as $post ) {
+ $expressions = self::extract_expressions_from_content( $post->post_content );
+ if ( empty( $expressions ) ) {
+ continue;
+ }
+
+ $post_expressions = array();
+ foreach ( $expressions as $expr ) {
+ $analysis = self::analyze_expression( $expr['expression'] );
+ $post_expressions[] = array(
+ 'expression' => $expr['expression'],
+ 'editor' => $expr['type'] === 'block' ? __( 'Divi 5 (block)', $text_domain ) : __( 'Divi 4 (shortcode)', $text_domain ),
+ 'module_name' => isset( $expr['module_name'] ) ? $expr['module_name'] : '',
+ 'admin_label' => isset( $expr['admin_label'] ) ? $expr['admin_label'] : '',
+ 'valid' => $analysis['valid'],
+ 'error' => $analysis['error'],
+ 'warnings' => $analysis['warnings'],
+ );
+ }
+
+ $is_revision = $post->post_type === 'revision';
+ $results[] = array(
+ 'id' => (int) $post->ID,
+ 'title' => $post->post_title,
+ 'edit_url' => get_edit_post_link( $post->ID, 'raw' ),
+ 'expressions' => $post_expressions,
+ 'is_revision' => $is_revision,
+ 'post_date' => $post->post_date,
+ 'parent' => ( $is_revision && isset( $parent_data[ (int) $post->post_parent ] ) ) ? $parent_data[ (int) $post->post_parent ] : nu