interfaces
2 years ago
traits
2 years ago
match-not-regexp-rule.php
2 years ago
match-regexp-rule.php
2 years ago
must-contain-characters-rule.php
2 years ago
must-equal-rule.php
2 years ago
must-not-contain-characters-rule.php
2 years ago
rule.php
2 years ago
server-side-rule.php
1 day ago
ssr-callback-allowlist.php
1 day ago
ssr-callback-allowlist.php
481 lines
| 1 | <?php |
| 2 | |
| 3 | |
| 4 | namespace JFB_Modules\Validation\Advanced_Rules; |
| 5 | |
| 6 | use Jet_Form_Builder\Blocks\Block_Helper; |
| 7 | |
| 8 | // If this file is called directly, abort. |
| 9 | if ( ! defined( 'WPINC' ) ) { |
| 10 | die; |
| 11 | } |
| 12 | |
| 13 | /** |
| 14 | * Builds and stores the allowlist of function names that the "Server-Side callback" |
| 15 | * validation rule (`Server_Side_Rule`) is permitted to invoke via `call_user_func()`. |
| 16 | * |
| 17 | * The list is collected from function names actually configured in saved forms and |
| 18 | * their reusable blocks. Only trusted saved content participates in collection. Saves |
| 19 | * by capable editors refresh the derived list immediately; programmatic saves invalidate |
| 20 | * it so the next request can safely re-collect from the new saved content. |
| 21 | * |
| 22 | * Storage is per-form (post meta on the form itself), not a single site-wide list — |
| 23 | * a function name typed into form A never becomes callable from form B just because |
| 24 | * both happen to be edited by users who share the (already admin-gated) capability to |
| 25 | * save a `jet-form-builder` post. `OPTION_KEY` remains only as a legacy source for code |
| 26 | * paths without a form identity. A valid form with missing meta is initialized lazily |
| 27 | * from its own saved content and never inherits the site-wide union. |
| 28 | * |
| 29 | * @since 3.6.5.2 |
| 30 | * @since 3.6.5.3 Storage moved from a single global option to per-form post meta. |
| 31 | */ |
| 32 | class Ssr_Callback_Allowlist { |
| 33 | |
| 34 | const OPTION_KEY = 'jet_fb_ssr_allowed_callbacks'; |
| 35 | const META_KEY = '_jfb_ssr_allowed_callbacks'; |
| 36 | |
| 37 | const REBUILD_PROGRESS_OPTION = 'jet_fb_ssr_allowlist_rebuild_progress'; |
| 38 | const REBUILD_LOCK_TRANSIENT = 'jet_fb_ssr_allowlist_rebuild_lock'; |
| 39 | const REBUILD_BATCH_SIZE = 50; |
| 40 | const REBUILD_AJAX_ACTION = 'jet_form_builder_ssr_allowlist_rebuild'; |
| 41 | |
| 42 | public function __construct() { |
| 43 | add_action( 'save_post_jet-form-builder', array( $this, 'collect_on_save' ), 20, 2 ); |
| 44 | add_action( 'save_post_wp_block', array( $this, 'rebuild_on_reusable_save' ), 20, 2 ); |
| 45 | add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_rebuild_runner' ) ); |
| 46 | add_action( 'wp_ajax_' . self::REBUILD_AJAX_ACTION, array( __CLASS__, 'process_rebuild_ajax' ) ); |
| 47 | } |
| 48 | |
| 49 | public function remove_hooks() { |
| 50 | remove_action( 'save_post_jet-form-builder', array( $this, 'collect_on_save' ), 20 ); |
| 51 | remove_action( 'save_post_wp_block', array( $this, 'rebuild_on_reusable_save' ), 20 ); |
| 52 | remove_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_rebuild_runner' ) ); |
| 53 | remove_action( 'wp_ajax_' . self::REBUILD_AJAX_ACTION, array( __CLASS__, 'process_rebuild_ajax' ) ); |
| 54 | } |
| 55 | |
| 56 | /** |
| 57 | * @param int $post_id |
| 58 | * @param \WP_Post $post |
| 59 | */ |
| 60 | public function collect_on_save( $post_id, $post ) { |
| 61 | if ( |
| 62 | ! $post instanceof \WP_Post || |
| 63 | wp_is_post_autosave( $post_id ) || |
| 64 | wp_is_post_revision( $post_id ) |
| 65 | ) { |
| 66 | return; |
| 67 | } |
| 68 | |
| 69 | if ( ! current_user_can( 'edit_post', $post_id ) ) { |
| 70 | delete_post_meta( $post_id, self::META_KEY ); |
| 71 | |
| 72 | return; |
| 73 | } |
| 74 | |
| 75 | $found = self::collect_from_content( $post->post_content ); |
| 76 | |
| 77 | update_post_meta( $post_id, self::META_KEY, $found ); |
| 78 | } |
| 79 | |
| 80 | /** |
| 81 | * Reusable blocks are resolved by the runtime validation pipeline. When one changes, |
| 82 | * every per-form allowlist is potentially stale, so invalidate the derived state. A |
| 83 | * capable editor also starts a fresh bounded rebuild; programmatic updates rely on safe |
| 84 | * lazy initialization. Forms remain protected while the remaining batches are pending. |
| 85 | * |
| 86 | * @param int $post_id |
| 87 | * @param \WP_Post $post |
| 88 | */ |
| 89 | public function rebuild_on_reusable_save( $post_id, $post ) { |
| 90 | if ( |
| 91 | ! $post instanceof \WP_Post || |
| 92 | wp_is_post_autosave( $post_id ) || |
| 93 | wp_is_post_revision( $post_id ) |
| 94 | ) { |
| 95 | return; |
| 96 | } |
| 97 | |
| 98 | delete_post_meta_by_key( self::META_KEY ); |
| 99 | delete_option( self::OPTION_KEY ); |
| 100 | delete_option( self::REBUILD_PROGRESS_OPTION ); |
| 101 | |
| 102 | if ( ! current_user_can( 'edit_post', $post_id ) ) { |
| 103 | return; |
| 104 | } |
| 105 | |
| 106 | self::start_rebuild(); |
| 107 | } |
| 108 | |
| 109 | /** |
| 110 | * Parses the given form `post_content`, including referenced reusable blocks, and |
| 111 | * returns every function name configured as the "value" of an SSR ('ssr') validation |
| 112 | * rule, excluding names that: |
| 113 | * - resolve to one of the built-in callbacks (those never reach `call_user_func()`); |
| 114 | * - are on the `NOT_ALLOWED` denylist (they can never pass validation anyway, so |
| 115 | * storing them here is pure noise that makes the stored allowlist confusing to |
| 116 | * audit — the denylist is still enforced independently at validation time); |
| 117 | * - do not resolve to an existing function at collection time. |
| 118 | * |
| 119 | * @param string $post_content |
| 120 | * |
| 121 | * @return string[] Lowercased, de-duplicated function names. |
| 122 | */ |
| 123 | public static function collect_from_content( string $post_content ): array { |
| 124 | if ( '' === trim( $post_content ) ) { |
| 125 | return array(); |
| 126 | } |
| 127 | |
| 128 | $found = array(); |
| 129 | $blocks = Block_Helper::get_blocks_from_content( $post_content ); |
| 130 | |
| 131 | foreach ( Block_Helper::generate_blocks_in_space( $blocks ) as $block ) { |
| 132 | $rules = $block['attrs']['validation']['rules'] ?? array(); |
| 133 | |
| 134 | foreach ( $rules as $rule ) { |
| 135 | if ( 'ssr' !== ( $rule['type'] ?? '' ) ) { |
| 136 | continue; |
| 137 | } |
| 138 | |
| 139 | $name = self::sanitize_callback_name( (string) ( $rule['value'] ?? '' ) ); |
| 140 | |
| 141 | if ( |
| 142 | '' === $name || |
| 143 | in_array( $name, Server_Side_Rule::NOT_ALLOWED, true ) || |
| 144 | Server_Side_Rule::is_builtin_callback( $name ) || |
| 145 | ! function_exists( $name ) |
| 146 | ) { |
| 147 | continue; |
| 148 | } |
| 149 | |
| 150 | $found[ $name ] = true; |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | return array_keys( $found ); |
| 155 | } |
| 156 | |
| 157 | /** |
| 158 | * Keeps only syntactically-valid function names. Existence is checked separately in |
| 159 | * `collect_from_content()` after normalization. |
| 160 | */ |
| 161 | private static function sanitize_callback_name( string $function_name ): string { |
| 162 | $name = preg_replace( '/[^\w]/i', '', $function_name ); |
| 163 | |
| 164 | if ( '' === $name || $name !== $function_name ) { |
| 165 | return ''; |
| 166 | } |
| 167 | |
| 168 | return strtolower( $name ); |
| 169 | } |
| 170 | |
| 171 | /** |
| 172 | * Allowed callbacks for one specific form. A valid form without per-form meta is |
| 173 | * initialized lazily from its already-saved post content. This keeps existing custom |
| 174 | * SSR validation working immediately after an unattended/CLI update, before an admin |
| 175 | * request has had a chance to run the background backfill. The submitted value never |
| 176 | * participates in collection, and the normal denylist/function-existence checks still |
| 177 | * apply, so this compatibility path does not widen the trust boundary. |
| 178 | * |
| 179 | * `OPTION_KEY` is used only when there is no form identity at all. A valid form never |
| 180 | * inherits the site-wide union: its own saved configuration is authoritative, including |
| 181 | * an explicitly empty callback list. |
| 182 | * |
| 183 | * @since 3.6.5.3 |
| 184 | * |
| 185 | * @param int $form_id |
| 186 | * |
| 187 | * @return string[] Lowercased function names allowed for this form. |
| 188 | */ |
| 189 | public static function get_allowed_callbacks_for_form( int $form_id ): array { |
| 190 | if ( ! $form_id ) { |
| 191 | return self::get_legacy_global_callbacks(); |
| 192 | } |
| 193 | |
| 194 | if ( ! metadata_exists( 'post', $form_id, self::META_KEY ) ) { |
| 195 | $post = get_post( $form_id ); |
| 196 | |
| 197 | if ( ! $post instanceof \WP_Post || 'jet-form-builder' !== $post->post_type ) { |
| 198 | return array(); |
| 199 | } |
| 200 | |
| 201 | $found = self::collect_from_content( $post->post_content ); |
| 202 | |
| 203 | if ( ! add_post_meta( $form_id, self::META_KEY, $found, true ) ) { |
| 204 | $stored = get_post_meta( $form_id, self::META_KEY, true ); |
| 205 | |
| 206 | return is_array( $stored ) ? $stored : array(); |
| 207 | } |
| 208 | |
| 209 | return $found; |
| 210 | } |
| 211 | |
| 212 | $stored = get_post_meta( $form_id, self::META_KEY, true ); |
| 213 | |
| 214 | return is_array( $stored ) ? $stored : array(); |
| 215 | } |
| 216 | |
| 217 | /** |
| 218 | * Re-collects a form's allowlist from its current saved content. |
| 219 | * |
| 220 | * This is used after a runtime allowlist miss for a callback that exists now but |
| 221 | * was unavailable when the form was migrated or last saved. The submitted callback |
| 222 | * name never participates in collection, so only names already present in trusted |
| 223 | * saved form content can be promoted. |
| 224 | * |
| 225 | * @since 3.6.5.3 |
| 226 | * |
| 227 | * @param int $form_id |
| 228 | * |
| 229 | * @return string[] Lowercased function names allowed for this form. |
| 230 | */ |
| 231 | public static function refresh_allowed_callbacks_for_form( int $form_id ): array { |
| 232 | if ( ! $form_id ) { |
| 233 | return array(); |
| 234 | } |
| 235 | |
| 236 | $post = get_post( $form_id ); |
| 237 | |
| 238 | if ( ! $post instanceof \WP_Post || 'jet-form-builder' !== $post->post_type ) { |
| 239 | return array(); |
| 240 | } |
| 241 | |
| 242 | $found = self::collect_from_content( $post->post_content ); |
| 243 | |
| 244 | update_post_meta( $form_id, self::META_KEY, $found ); |
| 245 | |
| 246 | return $found; |
| 247 | } |
| 248 | |
| 249 | /** |
| 250 | * @since 3.6.5.2 |
| 251 | * @deprecated 3.6.5.3 Use `get_allowed_callbacks_for_form()`. Kept only for code paths |
| 252 | * without a form identity, and as the final aggregate written by the |
| 253 | * background backfill. |
| 254 | * |
| 255 | * @return string[] Lowercased function names collected from saved forms so far. |
| 256 | */ |
| 257 | public static function get_legacy_global_callbacks(): array { |
| 258 | $stored = get_option( self::OPTION_KEY, array() ); |
| 259 | |
| 260 | return is_array( $stored ) ? $stored : array(); |
| 261 | } |
| 262 | |
| 263 | /** |
| 264 | * Starts a bounded backfill of per-form meta. The first batch is processed synchronously |
| 265 | * by the migration; a capable admin page then runs the remaining batches as a sequence |
| 266 | * of short AJAX requests. The last processed post ID and collected legacy callbacks are |
| 267 | * persisted in `REBUILD_PROGRESS_OPTION`. The backfill deliberately does not depend on |
| 268 | * WP-Cron. |
| 269 | * |
| 270 | * Correctness does not depend on this scan completing: `get_allowed_callbacks_for_form()` |
| 271 | * initializes any not-yet-processed form from its own saved content on first use. The |
| 272 | * background scan only warms all forms ahead of time and refreshes the legacy global |
| 273 | * option for code paths without a form identity. |
| 274 | * |
| 275 | * Logs (via `error_log()`, gated on `WP_DEBUG`) which forms/functions were |
| 276 | * grandfathered in, so a site owner can audit what an upgrade silently allowed — |
| 277 | * the migration itself cannot tell whether a historically-saved function name is |
| 278 | * actually safe, only that some editor with form-edit rights typed it once. |
| 279 | * |
| 280 | * @since 3.6.5.2 |
| 281 | * @since 3.6.5.3 Also writes per-form meta, not just the legacy global option. |
| 282 | */ |
| 283 | public static function rebuild_from_all_forms() { |
| 284 | self::start_rebuild(); |
| 285 | } |
| 286 | |
| 287 | /** |
| 288 | * Creates progress state once and processes one bounded batch immediately. |
| 289 | * Existing progress is resumed rather than reset, so retries never restart at form #1. |
| 290 | * |
| 291 | * @param int $batch_size Primarily exposed for deterministic tests. |
| 292 | */ |
| 293 | public static function start_rebuild( int $batch_size = self::REBUILD_BATCH_SIZE ) { |
| 294 | if ( false === get_option( self::REBUILD_PROGRESS_OPTION, false ) ) { |
| 295 | update_option( |
| 296 | self::REBUILD_PROGRESS_OPTION, |
| 297 | array( |
| 298 | 'last_id' => 0, |
| 299 | 'callbacks' => array(), |
| 300 | ), |
| 301 | false |
| 302 | ); |
| 303 | } |
| 304 | |
| 305 | self::process_rebuild_batch( $batch_size ); |
| 306 | } |
| 307 | |
| 308 | /** |
| 309 | * Starts the browser-side runner for an incomplete backfill. |
| 310 | * |
| 311 | * Each request processes one bounded batch. A short pause between requests prevents the |
| 312 | * migration from monopolizing PHP workers. Closing the page merely pauses the process; |
| 313 | * the persisted cursor lets the next capable admin page resume it. |
| 314 | */ |
| 315 | public static function enqueue_rebuild_runner() { |
| 316 | if ( |
| 317 | false === get_option( self::REBUILD_PROGRESS_OPTION, false ) || |
| 318 | ! current_user_can( 'manage_options' ) |
| 319 | ) { |
| 320 | return; |
| 321 | } |
| 322 | |
| 323 | wp_enqueue_script( 'jquery' ); |
| 324 | |
| 325 | $config = wp_json_encode( |
| 326 | array( |
| 327 | 'url' => admin_url( 'admin-ajax.php' ), |
| 328 | 'action' => self::REBUILD_AJAX_ACTION, |
| 329 | 'nonce' => wp_create_nonce( self::REBUILD_AJAX_ACTION ), |
| 330 | 'delay' => 1000, |
| 331 | ) |
| 332 | ); |
| 333 | |
| 334 | $script = "(function($,config){\n" . |
| 335 | "\tfunction runBatch(){\n" . |
| 336 | "\t\t$.post(config.url,{action:config.action,nonce:config.nonce})\n" . |
| 337 | "\t\t\t.done(function(response){\n" . |
| 338 | "\t\t\t\tif(response.success && !response.data.complete){\n" . |
| 339 | "\t\t\t\t\twindow.setTimeout(runBatch,config.delay);\n" . |
| 340 | "\t\t\t\t}\n" . |
| 341 | "\t\t\t});\n" . |
| 342 | "\t}\n" . |
| 343 | "\twindow.setTimeout(runBatch,config.delay);\n" . |
| 344 | '})(jQuery,' . $config . ');'; |
| 345 | |
| 346 | wp_add_inline_script( 'jquery-core', $script ); |
| 347 | } |
| 348 | |
| 349 | /** |
| 350 | * Processes one browser-requested batch and reports whether the backfill is complete. |
| 351 | */ |
| 352 | public static function process_rebuild_ajax() { |
| 353 | check_ajax_referer( self::REBUILD_AJAX_ACTION, 'nonce' ); |
| 354 | |
| 355 | if ( ! current_user_can( 'manage_options' ) ) { |
| 356 | wp_send_json_error( array( 'message' => 'Forbidden' ), 403 ); |
| 357 | } |
| 358 | |
| 359 | wp_send_json_success( |
| 360 | array( |
| 361 | 'complete' => self::process_rebuild_batch(), |
| 362 | ) |
| 363 | ); |
| 364 | } |
| 365 | |
| 366 | /** |
| 367 | * Processes at most one batch and persists a stable ID cursor before yielding. |
| 368 | * |
| 369 | * @param int $batch_size Primarily exposed for deterministic tests. |
| 370 | * |
| 371 | * @return bool True when the backfill is complete. |
| 372 | */ |
| 373 | public static function process_rebuild_batch( int $batch_size = self::REBUILD_BATCH_SIZE ): bool { |
| 374 | $progress = get_option( self::REBUILD_PROGRESS_OPTION, false ); |
| 375 | |
| 376 | if ( ! is_array( $progress ) ) { |
| 377 | return true; |
| 378 | } |
| 379 | |
| 380 | $batch_size = max( 1, min( 200, $batch_size ) ); |
| 381 | |
| 382 | if ( get_transient( self::REBUILD_LOCK_TRANSIENT ) ) { |
| 383 | return false; |
| 384 | } |
| 385 | |
| 386 | set_transient( self::REBUILD_LOCK_TRANSIENT, 1, MINUTE_IN_SECONDS * 5 ); |
| 387 | |
| 388 | try { |
| 389 | $last_id = max( 0, (int) ( $progress['last_id'] ?? 0 ) ); |
| 390 | $callbacks = array_fill_keys( (array) ( $progress['callbacks'] ?? array() ), true ); |
| 391 | $form_ids = self::get_rebuild_form_ids( $last_id, $batch_size ); |
| 392 | $processed_id = $last_id; |
| 393 | |
| 394 | foreach ( $form_ids as $form_id ) { |
| 395 | $processed_id = max( $processed_id, $form_id ); |
| 396 | |
| 397 | if ( metadata_exists( 'post', $form_id, self::META_KEY ) ) { |
| 398 | $stored = get_post_meta( $form_id, self::META_KEY, true ); |
| 399 | $found = is_array( $stored ) ? $stored : array(); |
| 400 | } else { |
| 401 | $post = get_post( $form_id ); |
| 402 | |
| 403 | if ( ! $post instanceof \WP_Post ) { |
| 404 | continue; |
| 405 | } |
| 406 | |
| 407 | $found = self::collect_from_content( $post->post_content ); |
| 408 | |
| 409 | if ( ! add_post_meta( $form_id, self::META_KEY, $found, true ) ) { |
| 410 | $stored = get_post_meta( $form_id, self::META_KEY, true ); |
| 411 | $found = is_array( $stored ) ? $stored : array(); |
| 412 | } |
| 413 | } |
| 414 | |
| 415 | if ( defined( 'WP_DEBUG' ) && WP_DEBUG && ! empty( $found ) ) { |
| 416 | error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log |
| 417 | sprintf( |
| 418 | '[jet-form-builder] SSR allowlist migration: form #%d grandfathered callbacks: %s', |
| 419 | $form_id, |
| 420 | implode( ', ', $found ) |
| 421 | ) |
| 422 | ); |
| 423 | } |
| 424 | |
| 425 | foreach ( $found as $name ) { |
| 426 | $callbacks[ $name ] = true; |
| 427 | } |
| 428 | } |
| 429 | |
| 430 | if ( count( $form_ids ) < $batch_size ) { |
| 431 | update_option( self::OPTION_KEY, array_values( array_keys( $callbacks ) ), false ); |
| 432 | delete_option( self::REBUILD_PROGRESS_OPTION ); |
| 433 | |
| 434 | return true; |
| 435 | } |
| 436 | |
| 437 | update_option( |
| 438 | self::REBUILD_PROGRESS_OPTION, |
| 439 | array( |
| 440 | 'last_id' => $processed_id, |
| 441 | 'callbacks' => array_values( array_keys( $callbacks ) ), |
| 442 | ), |
| 443 | false |
| 444 | ); |
| 445 | |
| 446 | return false; |
| 447 | } finally { |
| 448 | delete_transient( self::REBUILD_LOCK_TRANSIENT ); |
| 449 | } |
| 450 | } |
| 451 | |
| 452 | /** |
| 453 | * @return int[] Form post IDs ordered after the persisted cursor. |
| 454 | * @throws \RuntimeException When WordPress cannot read the next migration batch. |
| 455 | */ |
| 456 | private static function get_rebuild_form_ids( int $last_id, int $limit ): array { |
| 457 | global $wpdb; |
| 458 | |
| 459 | $form_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 460 | $wpdb->prepare( |
| 461 | "SELECT ID FROM {$wpdb->posts} |
| 462 | WHERE post_type = %s |
| 463 | AND post_status IN ('publish', 'draft', 'pending', 'private', 'future') |
| 464 | AND ID > %d |
| 465 | ORDER BY ID ASC |
| 466 | LIMIT %d", |
| 467 | 'jet-form-builder', |
| 468 | $last_id, |
| 469 | $limit |
| 470 | ) |
| 471 | ); |
| 472 | |
| 473 | if ( $wpdb->last_error ) { |
| 474 | throw new \RuntimeException( esc_html( $wpdb->last_error ) ); |
| 475 | } |
| 476 | |
| 477 | return array_map( 'intval', $form_ids ); |
| 478 | } |
| 479 | |
| 480 | } |
| 481 |