PluginProbe ʕ •ᴥ•ʔ
JetFormBuilder — Dynamic Blocks Form Builder / 3.6.5.2
JetFormBuilder — Dynamic Blocks Form Builder v3.6.5.2
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 / security / restricted-preset-notice.php
jetformbuilder / modules / security Last commit date
csrf 1 month ago exceptions 2 years ago honeypot 1 month ago wp-nonce 2 months ago module.php 4 days ago restricted-preset-notice.php 4 days ago
restricted-preset-notice.php
557 lines
1 <?php
2
3 namespace JFB_Modules\Security;
4
5 use Jet_Form_Builder\Blocks\Block_Helper;
6 use JFB_Modules\Post_Type\Module as Post_Type_Module;
7
8 if ( ! defined( 'WPINC' ) ) {
9 die;
10 }
11
12 /**
13 * Finds forms affected by the issues-tracker #20359 hardening:
14 *
15 * 1. Settings that never checked permissions before (validation rules, date
16 * min/max, conditional blocks, action conditions, dynamic value, and the
17 * rich-trusted value limits on Number/Range/Textarea/Media/Switcher) now
18 * do. A preset pointing at another user's post/user/term data silently
19 * resolves to an empty string - which does not blank a field on screen
20 * but *changes a decision*: `Equals` starts blocking the form,
21 * `Must contain characters` stops matching anything, a date loses its
22 * min/max, a conditional block flips, an action fires when it shouldn't.
23 * The form author can restore the old behaviour per preset by switching
24 * "Restrict access" off, but they have to know which forms to look at -
25 * hence this notice.
26 *
27 * Presets that are safe for everyone anyway (query_var, which only reads the
28 * visitor's own $_GET) are opted out automatically and never reported.
29 *
30 * Modelled on JFB_Modules\Gateways\Secure_Price_Notice.
31 */
32 class Restricted_Preset_Notice {
33
34 const SCAN_VERSION_OPTION = 'jet_fb_restricted_preset_scan_version';
35 const NOTICE_OPTION = 'jet_fb_restricted_preset_notice';
36 const DISMISS_META_KEY = 'jet_fb_restricted_preset_notice_dismissed';
37 const NOTICE_QUERY_ARG = 'jet_fb_dismiss_restricted_preset_notice';
38 const SCAN_SCHEMA_VERSION = '3';
39 const SCAN_BATCH_SIZE = 30;
40
41 /**
42 * Sources that read data belonging to some *other* object/user, i.e. the
43 * ones whose permission check can now turn a value into ''. `query_var`
44 * is deliberately absent: it only ever reads the caller's own $_GET.
45 */
46 const RESTRICTED_SOURCES = array( 'post', 'user', 'term', 'option_page' );
47
48 public function init_hooks() {
49 add_action( 'save_post_' . Post_Type_Module::SLUG, array( $this, 'invalidate_scan_cache' ) );
50
51 if ( ! is_admin() ) {
52 return;
53 }
54
55 add_action( 'admin_init', array( $this, 'maybe_scan_affected_forms' ) );
56 add_action( 'admin_init', array( $this, 'maybe_dismiss_notice' ) );
57 add_action( 'admin_notices', array( $this, 'render_affected_forms_notice' ) );
58 }
59
60 public function remove_hooks() {
61 remove_action( 'save_post_' . Post_Type_Module::SLUG, array( $this, 'invalidate_scan_cache' ) );
62 remove_action( 'admin_init', array( $this, 'maybe_scan_affected_forms' ) );
63 remove_action( 'admin_init', array( $this, 'maybe_dismiss_notice' ) );
64 remove_action( 'admin_notices', array( $this, 'render_affected_forms_notice' ) );
65 }
66
67 public function invalidate_scan_cache() {
68 delete_option( self::SCAN_VERSION_OPTION );
69 }
70
71 public function maybe_scan_affected_forms() {
72 if ( ! current_user_can( 'manage_options' ) ) {
73 return;
74 }
75
76 if (
77 wp_doing_ajax() ||
78 wp_doing_cron() ||
79 ( defined( 'REST_REQUEST' ) && REST_REQUEST )
80 ) {
81 return;
82 }
83
84 $version = $this->get_scan_version();
85
86 if ( get_option( self::SCAN_VERSION_OPTION, '' ) === $version ) {
87 return;
88 }
89
90 $notice = get_option( self::NOTICE_OPTION, array() );
91
92 $tracked_ids = array_values(
93 array_unique(
94 array_filter(
95 array_map( 'intval', $notice['tracked_ids'] ?? array() )
96 )
97 )
98 );
99
100 // Re-scan only the forms already known to be affected, unless the
101 // scan schema changed (then everything must be walked again).
102 if (
103 array_key_exists( 'tracked_ids', $notice )
104 && $this->notice_uses_current_scan_schema( $notice )
105 ) {
106 $forms = empty( $tracked_ids )
107 ? array()
108 : $this->scan_specific_forms( $tracked_ids );
109
110 $this->save_scan( $version, $tracked_ids, $forms );
111
112 return;
113 }
114
115 $forms = $this->scan_affected_forms();
116 $tracked_ids = array_values( array_unique( array_map( 'intval', wp_list_pluck( $forms, 'id' ) ) ) );
117
118 $this->save_scan( $version, $tracked_ids, $forms );
119 }
120
121 public function maybe_dismiss_notice() {
122 if ( ! current_user_can( 'manage_options' ) ) {
123 return;
124 }
125
126 if ( empty( $_GET[ self::NOTICE_QUERY_ARG ] ) ) {
127 return;
128 }
129
130 check_admin_referer( self::NOTICE_QUERY_ARG );
131
132 update_user_meta(
133 get_current_user_id(),
134 self::DISMISS_META_KEY,
135 $this->get_dismiss_version()
136 );
137
138 wp_safe_redirect(
139 remove_query_arg(
140 array(
141 self::NOTICE_QUERY_ARG,
142 '_wpnonce',
143 )
144 )
145 );
146 exit;
147 }
148
149 public function render_affected_forms_notice() {
150 if ( ! current_user_can( 'manage_options' ) ) {
151 return;
152 }
153
154 $notice = get_option( self::NOTICE_OPTION, array() );
155
156 if ( empty( $notice['forms'] ) || empty( $notice['version'] ) ) {
157 return;
158 }
159
160 if ( $this->get_scan_version() !== $notice['version'] || $this->is_notice_dismissed( $notice ) ) {
161 return;
162 }
163
164 $forms = array_slice( $notice['forms'], 0, 5 );
165 $more_forms = count( $notice['forms'] ) - count( $forms );
166 $dismiss_url = wp_nonce_url(
167 add_query_arg( self::NOTICE_QUERY_ARG, '1' ),
168 self::NOTICE_QUERY_ARG
169 );
170
171 ?>
172 <div class="notice notice-warning">
173 <p>
174 <strong><?php esc_html_e( 'JetFormBuilder - Security Update: Review Presets in Forms', 'jet-form-builder' ); ?></strong>
175 </p>
176 <p>
177 <?php esc_html_e( 'Presets used in validation rules, field limits (Number/Range min-max-step, Textarea min-max length, Media limits, Switcher active value), date limits, conditional blocks, and action conditions are now restricted for guest visitors by default.', 'jet-form-builder' ); ?>
178 </p>
179 <p>
180 <strong><?php esc_html_e( 'Action required:', 'jet-form-builder' ); ?></strong>
181 <?php esc_html_e( 'If the preset data is intended to be public, edit the affected forms below and turn off "Restrict access" in the preset settings:', 'jet-form-builder' ); ?>
182 </p>
183 <ul style="list-style: disc; margin-left: 1.5em;">
184 <?php foreach ( $forms as $form ) : ?>
185 <li>
186 <a href="<?php echo esc_url( $form['edit_link'] ); ?>">
187 <?php echo esc_html( $form['title'] ); ?>
188 </a>
189 <?php echo esc_html( ' (#' . $form['id'] . ')' ); ?>
190 :
191 <?php echo esc_html( implode( '; ', $form['places'] ) ); ?>
192 </li>
193 <?php endforeach; ?>
194 </ul>
195 <?php if ( $more_forms > 0 ) : ?>
196 <p>
197 <?php
198 echo esc_html(
199 sprintf(
200 /* translators: %d: number of hidden forms */
201 __( 'Plus %d more form(s) that should be reviewed.', 'jet-form-builder' ),
202 $more_forms
203 )
204 );
205 ?>
206 </p>
207 <?php endif; ?>
208 <p>
209 <a href="<?php echo esc_url( admin_url( 'edit.php?post_type=' . Post_Type_Module::SLUG ) ); ?>" class="button button-primary">
210 <?php esc_html_e( 'Review Forms', 'jet-form-builder' ); ?>
211 </a>
212 <a href="<?php echo esc_url( $dismiss_url ); ?>" class="button button-secondary">
213 <?php esc_html_e( 'Dismiss', 'jet-form-builder' ); ?>
214 </a>
215 </p>
216 </div>
217 <?php
218 }
219
220 private function save_scan( string $version, array $tracked_ids, array $forms ) {
221 update_option( self::SCAN_VERSION_OPTION, $version, false );
222 update_option(
223 self::NOTICE_OPTION,
224 array(
225 'version' => $version,
226 'notice_token' => wp_generate_uuid4(),
227 'tracked_ids' => $tracked_ids,
228 'forms' => $forms,
229 ),
230 false
231 );
232 }
233
234 private function scan_affected_forms(): array {
235 $affected_forms = array();
236 $offset = 0;
237
238 while ( true ) {
239 $form_ids = get_posts(
240 array(
241 'post_type' => Post_Type_Module::SLUG,
242 'post_status' => array( 'publish', 'draft', 'pending', 'future', 'private' ),
243 'posts_per_page' => self::SCAN_BATCH_SIZE,
244 'offset' => $offset,
245 'fields' => 'ids',
246 'orderby' => 'ID',
247 'order' => 'ASC',
248 'no_found_rows' => true,
249 'update_post_meta_cache' => false,
250 'update_post_term_cache' => false,
251 )
252 );
253
254 if ( empty( $form_ids ) ) {
255 break;
256 }
257
258 foreach ( $form_ids as $form_id ) {
259 $form = $this->get_affected_form( (int) $form_id );
260
261 if ( $form ) {
262 $affected_forms[] = $form;
263 }
264 }
265
266 if ( count( $form_ids ) < self::SCAN_BATCH_SIZE ) {
267 break;
268 }
269
270 $offset += self::SCAN_BATCH_SIZE;
271 }
272
273 return $affected_forms;
274 }
275
276 private function scan_specific_forms( array $form_ids ): array {
277 $affected_forms = array();
278
279 foreach ( $form_ids as $form_id ) {
280 $form_id = (int) $form_id;
281
282 if ( $form_id <= 0 || Post_Type_Module::SLUG !== get_post_type( $form_id ) ) {
283 continue;
284 }
285
286 $form = $this->get_affected_form( $form_id );
287
288 if ( $form ) {
289 $affected_forms[] = $form;
290 }
291 }
292
293 return $affected_forms;
294 }
295
296 private function get_affected_form( int $form_id ): array {
297 $places = array_merge(
298 $this->find_in_blocks( Block_Helper::get_blocks_by_post( $form_id, true, true ) ),
299 $this->find_in_actions( $form_id )
300 );
301
302 $places = array_values( array_unique( $places ) );
303
304 if ( empty( $places ) ) {
305 return array();
306 }
307
308 return array(
309 'id' => $form_id,
310 'title' => get_the_title( $form_id ) ?: sprintf(
311 /* translators: %d: form ID */
312 __( 'Form #%d', 'jet-form-builder' ),
313 $form_id
314 ),
315 'edit_link' => get_edit_post_link( $form_id, 'raw' ) ?: admin_url( 'post.php?post=' . absint( $form_id ) . '&action=edit' ),
316 'places' => array_slice( $places, 0, 6 ),
317 );
318 }
319
320 /**
321 * Walks the form's blocks looking for restricted presets in settings -
322 * NOT in a field's `default`, which has always been permission-checked
323 * and already exposes its own "Restrict access" toggle.
324 *
325 * @param array $blocks
326 * @param string $repeater
327 *
328 * @return string[] Human-readable "where" labels.
329 */
330 private function find_in_blocks( array $blocks, string $repeater = '' ): array {
331 $places = array();
332
333 foreach ( $blocks as $block ) {
334 if ( ! is_array( $block ) ) {
335 continue;
336 }
337
338 $attrs = is_array( $block['attrs'] ?? null ) ? $block['attrs'] : array();
339 $name = (string) ( $attrs['name'] ?? '' );
340 $label = $name ?: Block_Helper::delete_namespace( $block['blockName'] ?? '' );
341
342 if ( $repeater ) {
343 $label = $repeater . ' > ' . $label;
344 }
345
346 // Advanced validation rules.
347 $has_validation_rule = false;
348
349 foreach ( $attrs['validation']['rules'] ?? array() as $rule ) {
350 if ( is_array( $rule ) && $this->is_restricted_preset( $rule['value'] ?? '' ) ) {
351 $has_validation_rule = true;
352 }
353 }
354
355 if ( $has_validation_rule ) {
356 $places[] = sprintf(
357 /* translators: %s: field name */
358 __( 'validation rule on "%s"', 'jet-form-builder' ),
359 $label
360 );
361 }
362
363 // Trusted-parsed attributes: date/time/datetime min-max (parsed via
364 // Date_Tools::time_to_string(), already trusted) share the same
365 // attribute names as Number/Range min-max-step, Textarea
366 // minlength/maxlength, Media max_files/max_size and Switcher
367 // value_active/calc_value_active (parsed via
368 // Base::apply_attribute()'s `rich-trusted` branch). All of them
369 // only matter here because each has its own "Restrict access"
370 // toggle in the editor for the site owner to act on.
371 $has_value_limit = false;
372
373 foreach (
374 array(
375 'min',
376 'max',
377 'step',
378 'minlength',
379 'maxlength',
380 'max_files',
381 'max_size',
382 'value_active',
383 'calc_value_active',
384 ) as $limit
385 ) {
386 if ( $this->is_restricted_preset( $attrs[ $limit ] ?? '' ) ) {
387 $has_value_limit = true;
388 }
389 }
390
391 if ( $has_value_limit ) {
392 $places[] = sprintf(
393 /* translators: %s: field name */
394 __( 'value limits on "%s"', 'jet-form-builder' ),
395 $label
396 );
397 }
398
399 // Conditional block conditions.
400 $has_block_condition = false;
401
402 foreach ( $attrs['conditions'] ?? array() as $condition ) {
403 if ( is_array( $condition ) && $this->is_restricted_preset( $condition['value'] ?? '' ) ) {
404 $has_block_condition = true;
405 }
406 }
407
408 if ( $has_block_condition ) {
409 $places[] = __( 'conditional block', 'jet-form-builder' );
410 }
411
412 // Dynamic value groups.
413 $has_dynamic_value = false;
414
415 foreach ( $attrs['value']['groups'] ?? array() as $group ) {
416 if ( ! is_array( $group ) ) {
417 continue;
418 }
419
420 $found = $this->is_restricted_preset( $group['to_set'] ?? '' );
421
422 foreach ( $group['conditions'] ?? array() as $condition ) {
423 if ( is_array( $condition ) && $this->is_restricted_preset( $condition['value'] ?? '' ) ) {
424 $found = true;
425 }
426 }
427
428 if ( $found ) {
429 $has_dynamic_value = true;
430 }
431 }
432
433 if ( $has_dynamic_value ) {
434 $places[] = sprintf(
435 /* translators: %s: field name */
436 __( 'dynamic value on "%s"', 'jet-form-builder' ),
437 $label
438 );
439 }
440
441 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
442 $block_slug = Block_Helper::delete_namespace( $block['blockName'] ?? '' );
443
444 $places = array_merge(
445 $places,
446 $this->find_in_blocks(
447 $block['innerBlocks'],
448 'repeater-field' === $block_slug
449 ? $label
450 : $repeater
451 )
452 );
453 }
454 }
455
456 return $places;
457 }
458
459 /**
460 * @param int $form_id
461 *
462 * @return string[]
463 */
464 private function find_in_actions( int $form_id ): array {
465 $places = array();
466 $actions = jet_form_builder()->post_type->get_actions( $form_id );
467
468 if ( ! is_array( $actions ) ) {
469 return $places;
470 }
471
472 foreach ( $actions as $action ) {
473 if ( ! is_array( $action ) ) {
474 continue;
475 }
476
477 $has_action_condition = false;
478
479 foreach ( $action['conditions'] ?? array() as $condition ) {
480 if ( is_array( $condition ) && $this->is_restricted_preset( $condition['default'] ?? '' ) ) {
481 $has_action_condition = true;
482 }
483 }
484
485 if ( $has_action_condition ) {
486 $places[] = sprintf(
487 /* translators: %s: action type */
488 __( 'condition on action "%s"', 'jet-form-builder' ),
489 (string) ( $action['type'] ?? __( 'unknown', 'jet-form-builder' ) )
490 );
491 }
492 }
493
494 return $places;
495 }
496
497 /**
498 * True when the value is a preset JSON whose result can change after the
499 * permission hardening. A trusted, admin-authored `restricted: false`
500 * remains an explicit opt-out for every source, including Options Page.
501 *
502 * @param mixed $value
503 *
504 * @return bool
505 */
506 private function is_restricted_preset( $value ): bool {
507 if ( ! is_string( $value ) || false === strpos( $value, 'jet_preset' ) ) {
508 return false;
509 }
510
511 $preset = json_decode( $value, true );
512
513 if ( ! is_array( $preset ) || empty( $preset['jet_preset'] ) ) {
514 return false;
515 }
516
517 $source = (string) ( $preset['from'] ?? '' );
518
519 if ( ! in_array( $source, self::RESTRICTED_SOURCES, true ) ) {
520 return false;
521 }
522
523 return ! array_key_exists( 'restricted', $preset ) || $preset['restricted'];
524 }
525
526 private function get_scan_version(): string {
527 return jet_form_builder()->get_version() . ':' . self::SCAN_SCHEMA_VERSION;
528 }
529
530 private function notice_uses_current_scan_schema( array $notice ): bool {
531 $version = (string) ( $notice['version'] ?? '' );
532 $suffix = ':' . self::SCAN_SCHEMA_VERSION;
533
534 return strlen( $version ) > strlen( $suffix )
535 && substr( $version, -strlen( $suffix ) ) === $suffix;
536 }
537
538 private function get_dismiss_version(): string {
539 return 'schema:' . self::SCAN_SCHEMA_VERSION;
540 }
541
542 private function is_notice_dismissed( array $notice ): bool {
543 $dismissed = (string) get_user_meta( get_current_user_id(), self::DISMISS_META_KEY, true );
544 $dismiss_version = $this->get_dismiss_version();
545 $notice_token = (string) ( $notice['notice_token'] ?? $notice['version'] ?? '' );
546
547 if ( '' === $dismissed ) {
548 return false;
549 }
550
551 return (
552 $dismiss_version === $dismissed ||
553 $notice_token === $dismissed
554 );
555 }
556 }
557