| 1 |
<?php |
| 2 |
|
| 3 |
|
| 4 |
namespace JFB_Modules\Validation\Advanced_Rules; |
| 5 |
|
| 6 |
use JFB_Modules\Validation\Ssr; |
| 7 |
use Jet_Form_Builder\Exceptions\Repository_Exception; |
| 8 |
use JFB_Components\Repository\Repository_Pattern_Trait; |
| 9 |
use JFB_Modules\Block_Parsers\Field_Data_Parser; |
| 10 |
use JFB_Modules\Validation\Handlers\Validation_Handler; |
| 11 |
use Jet_Form_Builder\Request\Request_Tools; |
| 12 |
|
| 13 |
// If this file is called directly, abort. |
| 14 |
if ( ! defined( 'WPINC' ) ) { |
| 15 |
die; |
| 16 |
} |
| 17 |
|
| 18 |
class Server_Side_Rule extends Rule { |
| 19 |
|
| 20 |
use Repository_Pattern_Trait; |
| 21 |
|
| 22 |
/** |
| 23 |
* Blacklist of dangerous functions that should never be allowed as callbacks. |
| 24 |
* All values MUST be lowercase for case-insensitive comparison. |
| 25 |
* |
| 26 |
* @since 3.5.6.2 Security fix: expanded list and case-insensitive check |
| 27 |
*/ |
| 28 |
const NOT_ALLOWED = array( |
| 29 |
// Debug/output functions |
| 30 |
'var_dump', |
| 31 |
'var_export', |
| 32 |
'print_r', |
| 33 |
'sprintf', |
| 34 |
'printf', |
| 35 |
// Command execution |
| 36 |
'shell_exec', |
| 37 |
'system', |
| 38 |
'exec', |
| 39 |
'passthru', |
| 40 |
'proc_open', |
| 41 |
'popen', |
| 42 |
'pcntl_exec', |
| 43 |
'proc_nice', |
| 44 |
'proc_terminate', |
| 45 |
'proc_close', |
| 46 |
// Code execution |
| 47 |
'eval', |
| 48 |
'assert', |
| 49 |
'create_function', |
| 50 |
'call_user_func', |
| 51 |
'call_user_func_array', |
| 52 |
'preg_replace_callback', |
| 53 |
'array_map', |
| 54 |
'array_filter', |
| 55 |
'array_reduce', |
| 56 |
'usort', |
| 57 |
'uasort', |
| 58 |
'uksort', |
| 59 |
'array_walk', |
| 60 |
'array_walk_recursive', |
| 61 |
// User mutations |
| 62 |
'wp_insert_user', |
| 63 |
'wp_update_user', |
| 64 |
// File inclusion |
| 65 |
'include', |
| 66 |
'include_once', |
| 67 |
'require', |
| 68 |
'require_once', |
| 69 |
// Serialization (object injection) |
| 70 |
'unserialize', |
| 71 |
'maybe_unserialize', |
| 72 |
// File operations |
| 73 |
'file_get_contents', |
| 74 |
'file_put_contents', |
| 75 |
'fwrite', |
| 76 |
'fputs', |
| 77 |
'fopen', |
| 78 |
'readfile', |
| 79 |
'file', |
| 80 |
'fread', |
| 81 |
'fgets', |
| 82 |
'fgetc', |
| 83 |
'fgetcsv', |
| 84 |
'fpassthru', |
| 85 |
'move_uploaded_file', |
| 86 |
'copy', |
| 87 |
'rename', |
| 88 |
'unlink', |
| 89 |
'rmdir', |
| 90 |
'mkdir', |
| 91 |
'chmod', |
| 92 |
'chown', |
| 93 |
'chgrp', |
| 94 |
// Network functions |
| 95 |
'curl_exec', |
| 96 |
'curl_multi_exec', |
| 97 |
'fsockopen', |
| 98 |
'pfsockopen', |
| 99 |
'stream_socket_client', |
| 100 |
'stream_socket_server', |
| 101 |
// Dangerous PHP functions |
| 102 |
'parse_str', |
| 103 |
'extract', |
| 104 |
'putenv', |
| 105 |
'ini_set', |
| 106 |
'ini_alter', |
| 107 |
'dl', |
| 108 |
'mail', |
| 109 |
'header', |
| 110 |
'setcookie', |
| 111 |
'setrawcookie', |
| 112 |
// POSIX functions |
| 113 |
'posix_kill', |
| 114 |
'posix_mkfifo', |
| 115 |
'posix_setpgid', |
| 116 |
'posix_setsid', |
| 117 |
'posix_setuid', |
| 118 |
'posix_setgid', |
| 119 |
'posix_seteuid', |
| 120 |
'posix_setegid', |
| 121 |
// Apache functions |
| 122 |
'apache_child_terminate', |
| 123 |
'apache_setenv', |
| 124 |
// Reflection/class manipulation |
| 125 |
'get_defined_functions', |
| 126 |
'get_defined_vars', |
| 127 |
'get_defined_constants', |
| 128 |
'phpinfo', |
| 129 |
'highlight_file', |
| 130 |
'show_source', |
| 131 |
'php_strip_whitespace', |
| 132 |
'get_cfg_var', |
| 133 |
'get_current_user', |
| 134 |
'getmyuid', |
| 135 |
'getmypid', |
| 136 |
'getenv', |
| 137 |
// WordPress data/state mutation (added 3.6.5.3 — not blocked by the allowlist |
| 138 |
// alone, since the allowlist only records what an editor once typed, not what |
| 139 |
// is actually safe to expose to an unauthenticated caller's argument). |
| 140 |
'wp_delete_file', |
| 141 |
'wp_delete_post', |
| 142 |
'wp_delete_attachment', |
| 143 |
// Zero-parameter — `call_user_func( $name, $value, $context )` succeeds and |
| 144 |
// silently discards the extra arguments (PHP does not enforce arity against |
| 145 |
// arguments beyond what a function declares), so it is reachable by every SSR- |
| 146 |
// gated form submission and permanently deletes all >7-day-old auto-drafts on |
| 147 |
// every call, regardless of $value/$context (review finding, issues-tracker |
| 148 |
// #20361 follow-up). |
| 149 |
'wp_delete_auto_drafts', |
| 150 |
'wp_trash_post', |
| 151 |
'delete_option', |
| 152 |
'delete_site_option', |
| 153 |
'delete_post_meta', |
| 154 |
'delete_user_meta', |
| 155 |
'delete_metadata', |
| 156 |
'update_option', |
| 157 |
'add_option', |
| 158 |
'update_site_option', |
| 159 |
'update_post_meta', |
| 160 |
'update_user_meta', |
| 161 |
'wp_set_password', |
| 162 |
'wp_delete_user', |
| 163 |
'add_user_meta', |
| 164 |
'add_role', |
| 165 |
'remove_role', |
| 166 |
'wp_update_post', |
| 167 |
'wp_insert_post', |
| 168 |
'do_shortcode', |
| 169 |
'do_action', |
| 170 |
'apply_filters', |
| 171 |
'deactivate_plugins', |
| 172 |
'activate_plugin', |
| 173 |
'switch_to_blog', |
| 174 |
'restore_current_blog', |
| 175 |
'wp_remote_get', |
| 176 |
'wp_remote_post', |
| 177 |
'wp_remote_request', |
| 178 |
'wp_safe_remote_get', |
| 179 |
'wp_safe_remote_post', |
| 180 |
'wp_safe_remote_request', |
| 181 |
'wp_remote_head', |
| 182 |
'delete_transient', |
| 183 |
'delete_site_transient', |
| 184 |
'set_transient', |
| 185 |
'set_site_transient', |
| 186 |
'wp_cache_add', |
| 187 |
'wp_cache_set', |
| 188 |
'wp_cache_replace', |
| 189 |
'wp_cache_delete', |
| 190 |
'wp_cache_flush', |
| 191 |
'wp_cache_flush_group', |
| 192 |
'wp_cache_flush_runtime', |
| 193 |
'wp_cache_incr', |
| 194 |
'wp_cache_decr', |
| 195 |
'clean_post_cache', |
| 196 |
'clean_term_cache', |
| 197 |
'clean_comment_cache', |
| 198 |
'flush_rewrite_rules', |
| 199 |
'wp_delete_comment', |
| 200 |
'wp_trash_comment', |
| 201 |
'wp_delete_term', |
| 202 |
'wp_remove_object_terms', |
| 203 |
'wp_die', |
| 204 |
// Callback-injection / dynamic execution (added 3.6.5.3 — the legacy-migration |
| 205 |
// auto-trust path relies on this denylist as its primary safety barrier, since |
| 206 |
// migrated names no longer sit in a manual-review queue; see |
| 207 |
// docs/vulnerability/20361/not-allow-list.md for the full audit). |
| 208 |
'array_diff_ukey', |
| 209 |
'array_diff_uassoc', |
| 210 |
'array_udiff', |
| 211 |
'array_udiff_assoc', |
| 212 |
'array_udiff_uassoc', |
| 213 |
'array_uintersect', |
| 214 |
'array_uintersect_assoc', |
| 215 |
'array_uintersect_uassoc', |
| 216 |
'array_intersect_ukey', |
| 217 |
'array_intersect_uassoc', |
| 218 |
'forward_static_call', |
| 219 |
'forward_static_call_array', |
| 220 |
'iterator_apply', |
| 221 |
'register_shutdown_function', |
| 222 |
'register_tick_function', |
| 223 |
'spl_autoload_register', |
| 224 |
'preg_replace', |
| 225 |
// Process/signal control |
| 226 |
'pcntl_fork', |
| 227 |
'pcntl_signal', |
| 228 |
'pcntl_wait', |
| 229 |
'pcntl_waitpid', |
| 230 |
'posix_getpwuid', |
| 231 |
'posix_getpwnam', |
| 232 |
'posix_getgrgid', |
| 233 |
'posix_getgrnam', |
| 234 |
// File I/O (writes, or read/write primitives) |
| 235 |
'tmpfile', |
| 236 |
'tempnam', |
| 237 |
'symlink', |
| 238 |
'link', |
| 239 |
'touch', |
| 240 |
'glob', |
| 241 |
'scandir', |
| 242 |
'opendir', |
| 243 |
'readdir', |
| 244 |
'closedir', |
| 245 |
'fseek', |
| 246 |
'ftruncate', |
| 247 |
'flock', |
| 248 |
'parse_ini_file', |
| 249 |
'parse_ini_string', |
| 250 |
// Network |
| 251 |
'get_headers', |
| 252 |
'dns_get_record', |
| 253 |
'checkdnsrr', |
| 254 |
'gethostbyname', |
| 255 |
'gethostbyaddr', |
| 256 |
'gethostbynamel', |
| 257 |
'socket_create', |
| 258 |
'socket_connect', |
| 259 |
'ftp_connect', |
| 260 |
'ftp_login', |
| 261 |
'ftp_get', |
| 262 |
'ftp_put', |
| 263 |
'ssh2_connect', |
| 264 |
'stream_context_create', |
| 265 |
// PHP runtime / environment |
| 266 |
'settype', |
| 267 |
'set_error_handler', |
| 268 |
'set_exception_handler', |
| 269 |
'restore_error_handler', |
| 270 |
'restore_exception_handler', |
| 271 |
'error_reporting', |
| 272 |
'define', |
| 273 |
'debug_backtrace', |
| 274 |
'debug_print_backtrace', |
| 275 |
// WordPress mutations |
| 276 |
'wp_create_user', |
| 277 |
'wp_redirect', |
| 278 |
'wp_safe_redirect', |
| 279 |
'wp_send_json', |
| 280 |
'wp_send_json_success', |
| 281 |
'wp_send_json_error', |
| 282 |
'wp_mail', |
| 283 |
'wp_schedule_event', |
| 284 |
'wp_schedule_single_event', |
| 285 |
'wp_clear_scheduled_hook', |
| 286 |
'wp_unschedule_event', |
| 287 |
'update_network_option', |
| 288 |
'delete_network_option', |
| 289 |
'add_network_option', |
| 290 |
'update_blog_option', |
| 291 |
'delete_blog_option', |
| 292 |
'wp_update_term', |
| 293 |
'wp_insert_term', |
| 294 |
'wp_delete_category', |
| 295 |
'wp_set_object_terms', |
| 296 |
'wp_set_post_terms', |
| 297 |
'wp_set_post_categories', |
| 298 |
'remove_action', |
| 299 |
'remove_filter', |
| 300 |
'remove_all_actions', |
| 301 |
'remove_all_filters', |
| 302 |
'add_action', |
| 303 |
'add_filter', |
| 304 |
'wp_upload_bits', |
| 305 |
'wp_handle_upload', |
| 306 |
'wp_handle_sideload', |
| 307 |
'media_handle_upload', |
| 308 |
'media_handle_sideload', |
| 309 |
'wp_insert_attachment', |
| 310 |
'wp_set_current_user', |
| 311 |
'wp_set_auth_cookie', |
| 312 |
'wp_clear_auth_cookie', |
| 313 |
'wp_logout', |
| 314 |
'wp_generate_auth_cookie', |
| 315 |
'retrieve_password', |
| 316 |
'switch_theme', |
| 317 |
'delete_plugins', |
| 318 |
'delete_theme', |
| 319 |
// `delete_site` (the pre-4th-pass entry here) is a capability name, not a callable |
| 320 |
// PHP function — `function_exists( 'delete_site' )` is always false, so it could |
| 321 |
// never have matched a real callback. The actual WP core function is |
| 322 |
// `wp_delete_site()` (found during the 4th-pass audit below); it stays here, next |
| 323 |
// to the other single-purpose destructive mutations already in this block, rather |
| 324 |
// than moving to the alphabetized 4th-pass block. |
| 325 |
'wp_delete_site', |
| 326 |
// Independent audit follow-up (see docs/vulnerability/20361/deep-research-report |
| 327 |
// for the full comparison against the previous pass) — closes gaps the first pass |
| 328 |
// missed and moves the previously-deferred "read oracle" class (get_option and |
| 329 |
// friends) into the denylist: for an unauthenticated call_user_func($name, $value, |
| 330 |
// $context) bridge, reading an arbitrary option/meta/path by visitor-controlled key |
| 331 |
// is itself a secret/PII-disclosure primitive, not merely a low-risk read. |
| 332 |
'activate_plugins', |
| 333 |
'add_shortcode', |
| 334 |
'add_site_option', |
| 335 |
'apache_getenv', |
| 336 |
'apply_filters_ref_array', |
| 337 |
'apply_shortcodes', |
| 338 |
'chdir', |
| 339 |
'class_alias', |
| 340 |
'class_exists', |
| 341 |
'compact', |
| 342 |
'copy_dir', |
| 343 |
'delete_comment_meta', |
| 344 |
'delete_metadata_by_mid', |
| 345 |
'delete_term_meta', |
| 346 |
'die', |
| 347 |
'disk_free_space', |
| 348 |
'disk_total_space', |
| 349 |
'diskfreespace', |
| 350 |
'do_action_ref_array', |
| 351 |
'download_url', |
| 352 |
'enum_exists', |
| 353 |
'exit', |
| 354 |
'extension_loaded', |
| 355 |
'file_exists', |
| 356 |
'func_get_arg', |
| 357 |
'func_get_args', |
| 358 |
'func_num_args', |
| 359 |
'get_comment_meta', |
| 360 |
'get_loaded_extensions', |
| 361 |
'get_metadata', |
| 362 |
'get_network_option', |
| 363 |
'get_option', |
| 364 |
'get_post_meta', |
| 365 |
'get_site_option', |
| 366 |
'get_term_meta', |
| 367 |
'get_user_meta', |
| 368 |
'grant_super_admin', |
| 369 |
'ini_get_all', |
| 370 |
'interface_exists', |
| 371 |
'is_readable', |
| 372 |
'is_writable', |
| 373 |
'is_writeable', |
| 374 |
'mb_parse_str', |
| 375 |
'ob_start', |
| 376 |
'pathinfo', |
| 377 |
'php_uname', |
| 378 |
'phpversion', |
| 379 |
'posix_access', |
| 380 |
'posix_mknod', |
| 381 |
'preg_replace_callback_array', |
| 382 |
'realpath', |
| 383 |
'revoke_super_admin', |
| 384 |
'set_include_path', |
| 385 |
'stream_filter_register', |
| 386 |
'stream_wrapper_register', |
| 387 |
'sys_get_temp_dir', |
| 388 |
'time_nanosleep', |
| 389 |
'trait_exists', |
| 390 |
'trigger_error', |
| 391 |
'unzip_file', |
| 392 |
'user_error', |
| 393 |
'wp_insert_comment', |
| 394 |
'wp_publish_post', |
| 395 |
'wp_remote_fopen', |
| 396 |
'wp_safe_remote_head', |
| 397 |
'wp_set_comment_status', |
| 398 |
'wp_set_post_tags', |
| 399 |
'wp_untrash_post', |
| 400 |
'wp_update_comment', |
| 401 |
'zend_version', |
| 402 |
// 4th-pass audit follow-up (docs/vulnerability/20361/not-allow-list.md) — closes |
| 403 |
// remaining gaps: WP Multisite site/blog mutations, cron-array bypass, theme-mod |
| 404 |
// mutations (wrappers around update_option that the option-level entries above |
| 405 |
// don't catch), nav-menu mutations plus the wp_nav_menu() callback-dispatch gadget |
| 406 |
// (its `fallback_cb` argument, like ob_start()/preg_replace_callback_array() |
| 407 |
// above, can itself invoke an attacker-influenced callback), block/post-type/ |
| 408 |
// taxonomy/widget runtime registration, remaining read-oracle paths alongside |
| 409 |
// get_option()/get_*_meta() above, alternative filesystem read/write APIs |
| 410 |
// (gz*/bz* compression wrappers, hash-by-path, chown/chgrp variants), direct |
| 411 |
// DB/network connection primitives, and the reflection/introspection siblings of |
| 412 |
// class_exists()/compact()/func_get_args() already denylisted above. |
| 413 |
'_set_cron_array', |
| 414 |
'add_meta', |
| 415 |
'bzopen', |
| 416 |
'delete_meta', |
| 417 |
'delete_post_meta_by_key', |
| 418 |
'delete_site_meta', |
| 419 |
'delete_site_meta_by_key', |
| 420 |
'dns_get_mx', |
| 421 |
'error_log', |
| 422 |
'fileatime', |
| 423 |
'filectime', |
| 424 |
'filegroup', |
| 425 |
'filemtime', |
| 426 |
'fileowner', |
| 427 |
'fileperms', |
| 428 |
'filesize', |
| 429 |
'filetype', |
| 430 |
'get_blog_option', |
| 431 |
'get_meta_tags', |
| 432 |
'get_metadata_by_mid', |
| 433 |
'get_metadata_raw', |
| 434 |
'get_site_meta', |
| 435 |
'get_theme_mod', |
| 436 |
'get_theme_mods', |
| 437 |
'get_user_option', |
| 438 |
'getmxrr', |
| 439 |
'gzfile', |
| 440 |
'gzopen', |
| 441 |
'hash_file', |
| 442 |
'is_callable', |
| 443 |
'is_dir', |
| 444 |
'is_executable', |
| 445 |
'is_file', |
| 446 |
'is_link', |
| 447 |
'is_subclass_of', |
| 448 |
'lchgrp', |
| 449 |
'lchown', |
| 450 |
'linkinfo', |
| 451 |
'lstat', |
| 452 |
'md5_file', |
| 453 |
'method_exists', |
| 454 |
'mysqli_connect', |
| 455 |
'pg_connect', |
| 456 |
'pg_pconnect', |
| 457 |
'property_exists', |
| 458 |
'readgzfile', |
| 459 |
'readlink', |
| 460 |
'register_block_type', |
| 461 |
'register_block_type_from_metadata', |
| 462 |
'register_post_type', |
| 463 |
'register_taxonomy', |
| 464 |
'register_widget', |
| 465 |
'remove_theme_mod', |
| 466 |
'remove_theme_mods', |
| 467 |
'remove_user_from_blog', |
| 468 |
'reset_password', |
| 469 |
'set_theme_mod', |
| 470 |
'sha1_file', |
| 471 |
'socket_addrinfo_lookup', |
| 472 |
'socket_create_listen', |
| 473 |
'spl_autoload', |
| 474 |
'spl_autoload_call', |
| 475 |
'stat', |
| 476 |
'syslog', |
| 477 |
'unregister_widget', |
| 478 |
'update_blog_details', |
| 479 |
'update_meta', |
| 480 |
'wp_add_post_tags', |
| 481 |
'wp_authenticate', |
| 482 |
'wp_create_nav_menu', |
| 483 |
'wp_delete_nav_menu', |
| 484 |
'wp_delete_object_term_relationships', |
| 485 |
'wp_delete_post_revision', |
| 486 |
'wp_get_http', |
| 487 |
'wp_handle_comment_submission', |
| 488 |
'wp_initialize_site', |
| 489 |
'wp_insert_site', |
| 490 |
'wp_login', |
| 491 |
'wp_nav_menu', |
| 492 |
'wp_restore_post_revision', |
| 493 |
'wp_signon', |
| 494 |
'wp_trash_post_comments', |
| 495 |
'wp_uninitialize_site', |
| 496 |
'wp_unschedule_hook', |
| 497 |
'wp_untrash_post_comments', |
| 498 |
'wp_update_nav_menu_item', |
| 499 |
'wp_update_nav_menu_object', |
| 500 |
'wp_update_site', |
| 501 |
'wpmu_delete_blog', |
| 502 |
'wpmu_delete_user', |
| 503 |
// Zero-required-parameter WP core functions that mutate persistent state (review |
| 504 |
// finding, issues-tracker #20361 follow-up: `wp_delete_auto_drafts()`, moved next |
| 505 |
// to `wp_delete_post`/`wp_delete_attachment` above, was the first found this way). |
| 506 |
// PHP does not enforce arity for arguments beyond what a function declares, so |
| 507 |
// `call_user_func( $name, $value, $context )` succeeds against any of these and |
| 508 |
// silently discards $value/$context — unlike a function with a required parameter, |
| 509 |
// which throws `ArgumentCountError` (see `accepts_context_argument()`) or a |
| 510 |
// `FIXED_SAFE`/registry entry, which is deliberately vetted either way. That makes |
| 511 |
// every one of these reachable and triggerable by any SSR-gated form submission |
| 512 |
// regardless of what an admin ever trusted. The first pass here only grepped for |
| 513 |
// mutation-sounding function names (`delete_*`, `reset_*`, `clean_*`, ...), which |
| 514 |
// missed `wp_scheduled_delete()` (review finding, issues-tracker #20361 follow-up) |
| 515 |
// — its name doesn't match that pattern despite doing exactly that. The follow-up |
| 516 |
// pass instead scanned every zero-required-parameter function in this codebase's |
| 517 |
// bundled WP core for known-dangerous write/delete calls in its body, regardless of |
| 518 |
// name, and denylisted every one that touches persistent state; request-local-only |
| 519 |
// globals (`wp_reset_query`, `wp_reset_postdata`, `remove_all_shortcodes`) and |
| 520 |
// deprecated no-ops were left out as not a real mutation primitive. |
| 521 |
'delete_all_user_settings', |
| 522 |
'wp_clean_update_cache', |
| 523 |
'wp_delete_all_temp_backups', |
| 524 |
'wp_scheduled_delete', |
| 525 |
'wp_update_urls_to_https', |
| 526 |
'_wp_menus_changed', |
| 527 |
'check_theme_switched', |
| 528 |
'do_all_enclosures', |
| 529 |
'do_all_pingbacks', |
| 530 |
'do_all_trackbacks', |
| 531 |
'_wp_batch_split_terms', |
| 532 |
'_wp_batch_update_comment_type', |
| 533 |
); |
| 534 |
|
| 535 |
/** |
| 536 |
* Function names that ship with the plugin as selectable "Server-Side callback" |
| 537 |
* options and are safe by construction — they never reach `call_user_func()`, |
| 538 |
* `validate()` resolves them straight to a `Ssr\Base_Validation_Callback` instance. |
| 539 |
* Kept separate from the allowlist option so a fresh install already offers them. |
| 540 |
* |
| 541 |
* @since 3.6.5.2 |
| 542 |
*/ |
| 543 |
const BUILTIN_ALLOWED = array(); |
| 544 |
|
| 545 |
/** |
| 546 |
* Small, immutable set of WordPress Core functions that are safe to call as a |
| 547 |
* "Server-Side callback" without any site configuration: each accepts exactly one |
| 548 |
* argument, returns `bool`, and performs no I/O or state changes. Available |
| 549 |
* unconditionally because the plugin's minimum supported WordPress version (see |
| 550 |
* `Requires at least` in the main plugin file) already guarantees all of them. |
| 551 |
* |
| 552 |
* @since 3.6.5.3 |
| 553 |
*/ |
| 554 |
const FIXED_SAFE = array( |
| 555 |
'rest_is_boolean', |
| 556 |
'rest_is_integer', |
| 557 |
'rest_is_array', |
| 558 |
'wp_is_numeric_array', |
| 559 |
'is_serialized_string', |
| 560 |
'wp_check_jsonp_callback', |
| 561 |
); |
| 562 |
|
| 563 |
/** |
| 564 |
* @var string[]|null Lazily built, request-scoped cache of built-in callback IDs. |
| 565 |
* |
| 566 |
* @since 3.6.5.3 |
| 567 |
*/ |
| 568 |
private static $builtin_callback_ids; |
| 569 |
|
| 570 |
/** |
| 571 |
* @var array<string, int>|null Request-scoped cache of `accepts_context_argument()`'s |
| 572 |
* `ReflectionFunction::getNumberOfParameters()` lookups, keyed by function name. |
| 573 |
* |
| 574 |
* @since 3.6.5.3 |
| 575 |
*/ |
| 576 |
private static $callback_max_params; |
| 577 |
|
| 578 |
public function __construct() { |
| 579 |
$this->rep_install(); |
| 580 |
} |
| 581 |
|
| 582 |
/** |
| 583 |
* Whether $function_name resolves to one of the built-in callbacks (registered via |
| 584 |
* `rep_instances()`/`get_id()`), i.e. it will never be passed to `call_user_func()`. |
| 585 |
* |
| 586 |
* The built-in list is constant for the lifetime of a request (it only depends on hooks |
| 587 |
* already registered by the time SSR validation runs), so it is built once and cached |
| 588 |
* rather than reconstructing `self()` and all 4 callback objects, plus re-dispatching |
| 589 |
* `jet-form-builder/validation-callbacks`, on every call. This is called once per SSR |
| 590 |
* rule per form both in `Version_3_6_5_3`'s migration scan (up to 200 forms per batch) |
| 591 |
* and on every advanced-validation form render, so the uncached cost was not negligible. |
| 592 |
* |
| 593 |
* @since 3.6.5.2 |
| 594 |
* @since 3.6.5.3 Cached for the request. |
| 595 |
*/ |
| 596 |
public static function is_builtin_callback( string $function_name ): bool { |
| 597 |
if ( null === self::$builtin_callback_ids ) { |
| 598 |
self::$builtin_callback_ids = array_map( |
| 599 |
static function ( $callback ) { |
| 600 |
return $callback->get_id(); |
| 601 |
}, |
| 602 |
( new self() )->rep_instances() |
| 603 |
); |
| 604 |
} |
| 605 |
|
| 606 |
return in_array( $function_name, self::$builtin_callback_ids, true ); |
| 607 |
} |
| 608 |
|
| 609 |
public function rep_instances(): array { |
| 610 |
return apply_filters( |
| 611 |
'jet-form-builder/validation-callbacks', |
| 612 |
array( |
| 613 |
new Ssr\Is_User_Login_Unique(), |
| 614 |
new Ssr\Is_User_Email_Unique(), |
| 615 |
new Ssr\Is_Field_Value_Unique(), |
| 616 |
new Ssr\Is_User_Password_Valid(), |
| 617 |
) |
| 618 |
); |
| 619 |
} |
| 620 |
|
| 621 |
public function get_id(): string { |
| 622 |
return 'ssr'; |
| 623 |
} |
| 624 |
|
| 625 |
public function get_label(): string { |
| 626 |
return __( 'Server-Side callback', 'jet-form-builder' ); |
| 627 |
} |
| 628 |
|
| 629 |
public function validate_field( Field_Data_Parser $parser ) { |
| 630 |
$function_name = $this->get_setting( 'value' ); |
| 631 |
|
| 632 |
if ( ! $this->validate_submission_signature( $parser ) ) { |
| 633 |
$parser->collect_error( 'rule:ssr:invalid_signature', $this->get_setting( 'message' ) ); |
| 634 |
|
| 635 |
return; |
| 636 |
} |
| 637 |
|
| 638 |
$is_valid = $this->validate( $parser, $function_name ); |
| 639 |
|
| 640 |
if ( $is_valid ) { |
| 641 |
return; |
| 642 |
} |
| 643 |
|
| 644 |
$parser->collect_error( 'rule:ssr:' . $function_name, $this->get_setting( 'message' ) ); |
| 645 |
} |
| 646 |
|
| 647 |
/** |
| 648 |
* @return Ssr\Base_Validation_Callback[] |
| 649 |
*/ |
| 650 |
public function get_callbacks(): array { |
| 651 |
return $this->rep_get_items(); |
| 652 |
} |
| 653 |
|
| 654 |
protected function validate( Field_Data_Parser $parser, string $function_name ): bool { |
| 655 |
try { |
| 656 |
/** @var Ssr\Base_Validation_Callback $callback */ |
| 657 |
$callback = $this->rep_get_item( $function_name ); |
| 658 |
} catch ( Repository_Exception $exception ) { |
| 659 |
return $this->validate_custom( $parser, $function_name ); |
| 660 |
} |
| 661 |
|
| 662 |
return $callback->is_valid_with_parser( $parser ); |
| 663 |
} |
| 664 |
|
| 665 |
protected function validate_submission_signature( Field_Data_Parser $parser ): bool { |
| 666 |
$rule_index = $this->get_setting( '_rule_index' ); |
| 667 |
|
| 668 |
if ( false === $rule_index ) { |
| 669 |
return true; |
| 670 |
} |
| 671 |
|
| 672 |
return Validation_Handler::validate_main_signature( |
| 673 |
Request_Tools::get_request(), |
| 674 |
jet_fb_handler()->get_form_id(), |
| 675 |
explode( '.', $parser->get_scoped_name() ), |
| 676 |
absint( $rule_index ) |
| 677 |
); |
| 678 |
} |
| 679 |
|
| 680 |
protected function validate_custom( Field_Data_Parser $parser, string $function_name ): bool { |
| 681 |
$name = $this->validate_callback( $function_name ); |
| 682 |
|
| 683 |
if ( ! $name ) { |
| 684 |
return false; |
| 685 |
} |
| 686 |
|
| 687 |
if ( in_array( strtolower( $name ), self::FIXED_SAFE, true ) || ! self::accepts_context_argument( $name ) ) { |
| 688 |
return (bool) call_user_func( $name, $parser->get_value() ); |
| 689 |
} |
| 690 |
|
| 691 |
return (bool) call_user_func( $name, $parser->get_value(), $parser->get_context() ); |
| 692 |
} |
| 693 |
|
| 694 |
/** |
| 695 |
* Whether $function_name can be called with a second ($context) argument without |
| 696 |
* PHP throwing `ArgumentCountError`. |
| 697 |
* |
| 698 |
* `FIXED_SAFE` callbacks are always called with one argument regardless of this check |
| 699 |
* (see `validate_custom()`) — this only matters for everything else: a registry entry |
| 700 |
* (admin-added manually, or restored by the legacy migration) is never required to |
| 701 |
* accept two arguments, only to exist and clear the denylist/builtin checks. A single- |
| 702 |
* parameter internal PHP function (e.g. `is_numeric`) or a strictly-typed user function |
| 703 |
* is a legitimate, common shape for a validator — unlike a user-defined function with |
| 704 |
* optional/variadic extra parameters, PHP's internal functions reject a call with more |
| 705 |
* arguments than they declare, which without this check turned an otherwise-valid |
| 706 |
* submission into a fatal `ArgumentCountError` instead of a normal pass/fail result |
| 707 |
* (review finding, issues-tracker #20361 follow-up). |
| 708 |
* |
| 709 |
* @since 3.6.5.3 |
| 710 |
*/ |
| 711 |
protected static function accepts_context_argument( string $function_name ): bool { |
| 712 |
if ( null === self::$callback_max_params ) { |
| 713 |
self::$callback_max_params = array(); |
| 714 |
} |
| 715 |
|
| 716 |
if ( ! array_key_exists( $function_name, self::$callback_max_params ) ) { |
| 717 |
try { |
| 718 |
$reflection = new \ReflectionFunction( $function_name ); |
| 719 |
|
| 720 |
self::$callback_max_params[ $function_name ] = $reflection->getNumberOfParameters(); |
| 721 |
} catch ( \ReflectionException $exception ) { |
| 722 |
// Not expected to be reachable — `validate_callback()` already confirmed |
| 723 |
// `function_exists( $name )` — but fail closed to the safe, one-argument |
| 724 |
// call shape rather than letting the exception surface as a fatal error. |
| 725 |
self::$callback_max_params[ $function_name ] = 1; |
| 726 |
} |
| 727 |
} |
| 728 |
|
| 729 |
return self::$callback_max_params[ $function_name ] >= 2; |
| 730 |
} |
| 731 |
|
| 732 |
/** |
| 733 |
* Keeps only syntactically-valid function names, i.e. names that survive |
| 734 |
* `preg_replace('/[^\w]/i', '', ...)` unchanged. Shared between runtime validation |
| 735 |
* and the global registry so the two enforcement points can never drift. |
| 736 |
* |
| 737 |
* @since 3.6.5.3 |
| 738 |
* |
| 739 |
* @return string Empty string if the name contains anything but word characters, |
| 740 |
* lowercased name otherwise. |
| 741 |
*/ |
| 742 |
public static function sanitize_callback_name( string $function_name ): string { |
| 743 |
$name = preg_replace( '/[^\w]/i', '', $function_name ); |
| 744 |
|
| 745 |
if ( '' === $name || $name !== $function_name ) { |
| 746 |
return ''; |
| 747 |
} |
| 748 |
|
| 749 |
return strtolower( $name ); |
| 750 |
} |
| 751 |
|
| 752 |
/** |
| 753 |
* Validate callback function name for security. |
| 754 |
* |
| 755 |
* Checks applied, all must pass: |
| 756 |
* 1. Denylist (`NOT_ALLOWED`) — kept as defense in depth, blocks known-catastrophic |
| 757 |
* functions outright even if the allowlist below is ever misconfigured. Evaluated |
| 758 |
* before the registry lookup, so it always takes priority. |
| 759 |
* 2. Allowlist — a function must be explicitly known-safe: either shipped with the |
| 760 |
* plugin, in the fixed-safe WordPress Core list, explicitly added by a site admin |
| 761 |
* to the global `Ssr_Callback_Registry`, or added by a site via the |
| 762 |
* `jet-form-builder/ssr-validation/allowed-callbacks` filter. A denylist alone |
| 763 |
* cannot enumerate every dangerous function in PHP core + WordPress core + active |
| 764 |
* plugins, so functions unknown to either list are rejected by default. |
| 765 |
* |
| 766 |
* @since 3.5.6.2 Denylist introduced. |
| 767 |
* @since 3.6.5.2 Allowlist enforcement added. |
| 768 |
* @since 3.6.5.3 Allowlist source moved from a per-form derived list to a single |
| 769 |
* admin-managed global registry; lazy/autosave re-collection removed. |
| 770 |
* |
| 771 |
* @param string $function_name The function name to validate. |
| 772 |
* |
| 773 |
* @return string Empty string if invalid, function name if valid. |
| 774 |
*/ |
| 775 |
protected function validate_callback( string $function_name ): string { |
| 776 |
$name = self::sanitize_callback_name( $function_name ); |
| 777 |
|
| 778 |
if ( '' === $name ) { |
| 779 |
return ''; |
| 780 |
} |
| 781 |
|
| 782 |
if ( in_array( $name, self::NOT_ALLOWED, true ) ) { |
| 783 |
return ''; |
| 784 |
} |
| 785 |
|
| 786 |
if ( ! function_exists( $name ) ) { |
| 787 |
return ''; |
| 788 |
} |
| 789 |
|
| 790 |
$allowed = $this->get_allowed_callbacks(); |
| 791 |
|
| 792 |
return in_array( $name, $allowed, true ) ? $name : ''; |
| 793 |
} |
| 794 |
|
| 795 |
/** |
| 796 |
* @since 3.6.5.2 |
| 797 |
* @since 3.6.5.3 Scoped per-form instead of site-wide, then sourced from the single |
| 798 |
* site-wide `Ssr_Callback_Registry` instead of a per-form derived list in |
| 799 |
* the same release. The `$form_id` argument is still passed to the |
| 800 |
* `jet-form-builder/ssr-validation/allowed-callbacks` filter for backward |
| 801 |
* compatibility, even though the registry itself is no longer per-form. |
| 802 |
* |
| 803 |
* @return string[] Lowercased function names allowed to run via `call_user_func()`. |
| 804 |
*/ |
| 805 |
protected function get_allowed_callbacks(): array { |
| 806 |
$allowed = array_merge( |
| 807 |
self::BUILTIN_ALLOWED, |
| 808 |
self::FIXED_SAFE, |
| 809 |
Ssr_Callback_Registry::get_allowed_callbacks() |
| 810 |
); |
| 811 |
|
| 812 |
$allowed = (array) apply_filters( |
| 813 |
'jet-form-builder/ssr-validation/allowed-callbacks', |
| 814 |
array_values( array_unique( array_map( 'strtolower', $allowed ) ) ), |
| 815 |
(int) jet_fb_handler()->get_form_id() |
| 816 |
); |
| 817 |
|
| 818 |
$allowed = array_map( |
| 819 |
static function ( $callback ): string { |
| 820 |
return is_string( $callback ) ? strtolower( $callback ) : ''; |
| 821 |
}, |
| 822 |
$allowed |
| 823 |
); |
| 824 |
|
| 825 |
return array_values( array_unique( array_filter( $allowed ) ) ); |
| 826 |
} |
| 827 |
|
| 828 |
} |
| 829 |
|