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
← 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 }