← All changes
|
modules/validation/advanced-rules/server-side-rule.php
+587
-9
3.6.4
→
3.6.6
View file →
| @@ -133,14 +133,480 @@ | ||
| 133 | 133 | 'get_current_user', |
| 134 | 134 | 'getmyuid', |
| 135 | 135 | 'getmypid', |
| 136 | 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', | |
| 137 | 533 | ); |
| 138 | 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 | + | |
| 139 | 578 | public function __construct() { |
| 140 | 579 | $this->rep_install(); |
| 141 | 580 | } |
| 142 | 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 | + | |
| 143 | 609 | public function rep_instances(): array { |
| 144 | 610 | return apply_filters( |
| 145 | 611 | 'jet-form-builder/validation-callbacks', |
| 146 | 612 | array( |
| @@ -168,9 +634,9 @@ | ||
| 168 | 634 | |
| 169 | 635 | return; |
| 170 | 636 | } |
| 171 | 637 | |
| 172 | - $is_valid = $this->validate( $parser, $function_name ); | |
| 638 | + $is_valid = $this->validate( $parser, $function_name ); | |
| 173 | 639 | |
| 174 | 640 | if ( $is_valid ) { |
| 175 | 641 | return; |
| 176 | 642 | } |
| @@ -192,9 +658,9 @@ | ||
| 192 | 658 | } catch ( Repository_Exception $exception ) { |
| 193 | 659 | return $this->validate_custom( $parser, $function_name ); |
| 194 | 660 | } |
| 195 | 661 | |
| 196 | - return $callback->is_valid( $parser->get_value(), $parser->get_context() ); | |
| 662 | + return $callback->is_valid_with_parser( $parser ); | |
| 197 | 663 | } |
| 198 | 664 | |
| 199 | 665 | protected function validate_submission_signature( Field_Data_Parser $parser ): bool { |
| 200 | 666 | $rule_index = $this->get_setting( '_rule_index' ); |
| @@ -217,34 +683,146 @@ | ||
| 217 | 683 | if ( ! $name ) { |
| 218 | 684 | return false; |
| 219 | 685 | } |
| 220 | 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 | + | |
| 221 | 691 | return (bool) call_user_func( $name, $parser->get_value(), $parser->get_context() ); |
| 222 | 692 | } |
| 223 | 693 | |
| 224 | 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 | + /** | |
| 225 | 753 | * Validate callback function name for security. |
| 226 | 754 | * |
| 227 | - * @since 3.5.6.2 | |
| 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. | |
| 228 | 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 | + * | |
| 229 | 771 | * @param string $function_name The function name to validate. |
| 230 | 772 | * |
| 231 | 773 | * @return string Empty string if invalid, function name if valid. |
| 232 | 774 | */ |
| 233 | 775 | protected function validate_callback( string $function_name ): string { |
| 234 | - $name = preg_replace( '/[^\w]/i', '', $function_name ); | |
| 776 | + $name = self::sanitize_callback_name( $function_name ); | |
| 235 | 777 | |
| 236 | - if ( $name !== $function_name ) { | |
| 778 | + if ( '' === $name ) { | |
| 237 | 779 | return ''; |
| 238 | 780 | } |
| 239 | 781 | |
| 240 | - // Case-insensitive blacklist check (PHP function names are case-insensitive) | |
| 241 | - $name_lower = strtolower( $name ); | |
| 782 | + if ( in_array( $name, self::NOT_ALLOWED, true ) ) { | |
| 783 | + return ''; | |
| 784 | + } | |
| 242 | 785 | |
| 243 | - if ( in_array( $name_lower, self::NOT_ALLOWED, true ) ) { | |
| 786 | + if ( ! function_exists( $name ) ) { | |
| 244 | 787 | return ''; |
| 245 | 788 | } |
| 246 | 789 | |
| 247 | - return function_exists( $name ) ? $name : ''; | |
| 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 ) ) ); | |
| 248 | 826 | } |
| 249 | 827 | |
| 250 | 828 | } |