PluginProbe ʕ •ᴥ•ʔ
JetFormBuilder — Dynamic Blocks Form Builder / trunk
JetFormBuilder — Dynamic Blocks Form Builder vtrunk
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 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.2.7 1.3.0 1.3.1 1.3.2 1.3.3 1.4.0 1.4.1 1.4.2 1.4.3 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 2.0.0 2.0.1 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.1.0 2.1.1 2.1.10 2.1.11 2.1.2 2.1.3 2.1.4 2.1.5 2.1.6 2.1.7 2.1.8 2.1.9 3.0.0 3.0.0.1 3.0.0.2 3.0.0.3 3.0.1 3.0.1.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.0.1 3.1.1 3.1.2 3.1.3 3.1.4 3.1.5 3.1.6 3.1.7 3.1.8 3.1.9 3.2.0 3.2.1 3.2.2 3.2.3 3.3.0 3.3.1 3.3.2 3.3.3 3.3.3.1 3.3.4 3.3.4.1 3.3.4.2 3.4.0 3.4.1 3.4.2 3.4.3 3.4.4 3.4.5 3.4.5.1 3.4.5.2 3.4.6 3.4.7 3.4.7.1 3.5.0 3.5.1 3.5.1.1 3.5.1.2 3.5.2 3.5.2.1 3.5.3 3.5.4 3.5.5 3.5.6 3.5.6.1 3.5.6.2 3.5.6.3 3.6.0
jetformbuilder / modules / validation / advanced-rules / ssr-callback-allowlist.php
jetformbuilder / modules / validation / advanced-rules Last commit date
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