PluginProbe
JetFormBuilder — Dynamic Blocks Form Builder / 3.6.6
JetFormBuilder — Dynamic Blocks Form Builder v3.6.6
3.6.6.1 3.6.6 3.6.5.4 3.6.5.3 3.6.5.2 3.6.5.1 3.6.5 3.6.4.2 3.6.4.1 3.6.4 3.6.3.1 3.6.3 3.6.2.2 3.6.2.1 3.6.2 3.6.1.1 3.6.1 3.6.0.1 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 All 134 releases
jetformbuilder / modules / validation / advanced-rules / server-side-rule.php

server-side-rule.php in JetFormBuilder — Dynamic Blocks Form Builder 3.6.6, at modules/validation/advanced-rules/server-side-rule.php

829 lines 23.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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