PluginProbe
Booking Calendar / 11.8.4
Booking Calendar v11.8.4
11.8.4 11.8.3 11.8.2 11.8.1 11.8 11.7 11.6.1 11.6 11.5 11.4.3 11.4.2 11.4.1 11.4 11.3 11.2.1 11.2 11.1 11.0 10.15.7 10.15.6 10.1.3 10.10 10.10.1 10.10.2 10.11 All 204 releases
← All changes | includes/save-load-option/save-load-option.php +203 -75 10.15.611.8.4 View file →
@@ -1,4 +1,4 @@
1 1 <?php
2 2 /**
3 3 * General Option Loader/Saver (AJAX)
4 4 *
@@ -11,10 +11,9 @@
11 11 *
12 12 * Data attributes on clickable elements:
13 13 * Save:
14 14 * data-wpbc-u-save-name — option key (required)
15 - * data-wpbc-u-save-nonce — nonce value (required for SAVE)
16 - * data-wpbc-u-save-action — nonce action (required for SAVE)
15 + * SAVE requests use a fixed, server-generated nonce localized with this module.
17 16 * data-wpbc-u-save-value — RAW scalar to save (optional)
18 17 * data-wpbc-u-save-value-json— JSON string to save (optional)
19 18 * data-wpbc-u-save-fields — CSV of selectors; values serialized with jQuery.param (optional)
20 19 * data-wpbc-u-busy-text — custom text during AJAX (optional)
@@ -34,9 +33,9 @@
34 33 *
35 34 * @package Booking Calendar
36 35 * @author wpdevelop
37 36 * @since 11.0.0
38 - * @version 1.0.1
37 + * @version 1.0.2
39 38 */
40 39
41 40 if ( ! defined( 'ABSPATH' ) ) {
42 41 exit;
@@ -43,12 +42,15 @@
43 42 }
44 43
45 44 class wpbc_option_saver_loader {
46 45
47 - private static $ajax_action_save = 'wpbc_ajax_option_save';
48 - private static $ajax_action_load = 'wpbc_ajax_option_load';
49 - private static $option_prefix = '';
50 - private static $asset_version = '1.0.1';
46 + private static $ajax_action_save = 'wpbc_ajax_option_save';
47 + private static $ajax_action_load = 'wpbc_ajax_option_load';
48 + private static $nonce_action_save = 'wpbc_option_save';
49 + private static $nonce_action_load = 'wpbc_option_load';
50 + private static $option_prefix = '';
51 + private static $asset_version = '1.0.2';
52 + private static $save_policies = array();
51 53
52 54 public static function init() {
53 55 add_action( 'init', array( __CLASS__, 'register_ajax_handlers' ) );
54 56 add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );
@@ -54,8 +56,104 @@
54 56 add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );
55 57 }
56 58
57 59 /**
60 + * Register an option-specific save and load policy.
61 + *
62 + * Registration is the endpoint allowlist. Unregistered option names are
63 + * rejected even when the current user has the configured capability.
64 + *
65 + * Supported policy keys:
66 + * - can_save callable(): bool
67 + * - permission_message string
68 + * - normalize_raw callable( mixed $data_raw, string $data_name ): mixed
69 + * - force_mode string
70 + * - allowed_keys array|callable(): array
71 + * - normalize_item callable( string $option_key, mixed $value, string $data_name ): mixed
72 + *
73 + * @param string $option_name Option name from data_name.
74 + * @param array $policy Policy definition.
75 + *
76 + * @return void
77 + */
78 + public static function register_option_policy( $option_name, $policy ) {
79 +
80 + $option_name = sanitize_key( (string) $option_name );
81 +
82 + if ( empty( $option_name ) || ! is_array( $policy ) ) {
83 + return;
84 + }
85 +
86 + self::$save_policies[ $option_name ] = $policy;
87 + }
88 +
89 + /**
90 + * Get a registered option save policy.
91 + *
92 + * @param string $option_name Option name from data_name.
93 + *
94 + * @return array
95 + */
96 + private static function get_option_policy( $option_name ) {
97 +
98 + $option_name = sanitize_key( (string) $option_name );
99 +
100 + return ( isset( self::$save_policies[ $option_name ] ) && is_array( self::$save_policies[ $option_name ] ) )
101 + ? self::$save_policies[ $option_name ]
102 + : array();
103 + }
104 +
105 + /**
106 + * Check whether an option name was explicitly registered for this endpoint.
107 + *
108 + * Registration is the writable and readable option allowlist. Sanitizing an
109 + * arbitrary WordPress option name does not make that option safe to expose.
110 + *
111 + * @param string $option_name Option name from data_name.
112 + *
113 + * @return bool True when a policy was explicitly registered.
114 + */
115 + private static function has_option_policy( $option_name ) {
116 +
117 + $option_name = sanitize_key( (string) $option_name );
118 +
119 + return '' !== $option_name
120 + && isset( self::$save_policies[ $option_name ] )
121 + && is_array( self::$save_policies[ $option_name ] );
122 + }
123 +
124 + /**
125 + * Get allowed option keys from a policy.
126 + *
127 + * @param array $policy Policy definition.
128 + *
129 + * @return array
130 + */
131 + private static function get_policy_allowed_keys( $policy ) {
132 +
133 + if ( empty( $policy['allowed_keys'] ) ) {
134 + return array();
135 + }
136 +
137 + $allowed_keys = is_callable( $policy['allowed_keys'] )
138 + ? call_user_func( $policy['allowed_keys'] )
139 + : $policy['allowed_keys'];
140 +
141 + if ( ! is_array( $allowed_keys ) ) {
142 + return array();
143 + }
144 +
145 + return array_values(
146 + array_filter(
147 + array_map(
148 + 'sanitize_key',
149 + array_map( 'strval', $allowed_keys )
150 + )
151 + )
152 + );
153 + }
154 +
155 + /**
58 156 * Register AJAX handlers (logged-in admin).
59 157 *
60 158 * @return void
61 159 */
@@ -92,13 +190,15 @@
92 190
93 191 wp_localize_script(
94 192 'wpbc-save-load-option',
95 193 'wpbc_option_saver_loader_config',
96 - array(
97 - 'ajax_url' => admin_url( 'admin-ajax.php' ),
98 - 'action_save' => self::$ajax_action_save,
99 - 'action_load' => self::$ajax_action_load,
100 - )
194 + array(
195 + 'ajax_url' => admin_url( 'admin-ajax.php' ),
196 + 'action_save' => self::$ajax_action_save,
197 + 'action_load' => self::$ajax_action_load,
198 + 'save_nonce' => wp_create_nonce( self::$nonce_action_save ),
199 + 'load_nonce' => wp_create_nonce( self::$nonce_action_load ),
200 + )
101 201 );
102 202 }
103 203
104 204 /**
@@ -106,10 +206,9 @@
106 206 *
107 207 * Expected POST:
108 208 * - data_name string Option key.
109 209 * - data_value string RAW scalar | query-string | JSON string.
110 - * - nonce_action string Nonce action name.
111 - * - nonce string Nonce value.
210 + * - nonce string Nonce for the fixed wpbc_option_save action.
112 211 *
113 212 * @return void
114 213 */
115 214 public static function handle_ajax_save() {
@@ -118,44 +217,57 @@
118 217 if ( ! current_user_can( $capability ) ) {
119 218 wp_send_json_error( array( 'message' => __( 'You do not have permission to save settings.', 'booking' ) ) );
120 219 }
121 220
122 - $data_name = isset( $_POST['data_name'] ) ? sanitize_key( wp_unslash( $_POST['data_name'] ) ) : '';
123 - /* phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing */
124 - $data_raw = isset( $_POST['data_value'] ) ? wp_unslash( $_POST['data_value'] ) : '';
125 - // Optional: split JSON object into multiple options.
126 - $data_mode = isset( $_POST['data_mode'] ) ? sanitize_key( wp_unslash( $_POST['data_mode'] ) ) : '';
127 - $data_fields = isset( $_POST['data_fields'] ) ? sanitize_text_field( wp_unslash( $_POST['data_fields'] ) ) : '';
221 + $data_name = isset( $_POST['data_name'] ) ? sanitize_key( wp_unslash( $_POST['data_name'] ) ) : '';
222 + /* phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing */
223 + $data_raw = isset( $_POST['data_value'] ) ? wp_unslash( $_POST['data_value'] ) : '';
224 + $nonce_value = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
225 +
226 + if ( ! wp_verify_nonce( $nonce_value, self::$nonce_action_save ) ) {
227 + wp_send_json_error( array( 'message' => __( 'Invalid nonce.', 'booking' ) ) );
228 + }
128 229
129 - $nonce_name = isset( $_POST['nonce_action'] ) ? sanitize_key( wp_unslash( $_POST['nonce_action'] ) ) : '';
130 - $nonce_value = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
230 + if ( empty( $data_name ) ) {
231 + wp_send_json_error( array( 'message' => __( 'Missing data name.', 'booking' ) ) );
232 + }
233 +
234 + if ( ! self::has_option_policy( $data_name ) ) {
235 + wp_send_json_error( array( 'message' => __( 'This option cannot be saved by this request.', 'booking' ) ) );
236 + }
131 237
132 - if ( empty( $nonce_name ) || ! wp_verify_nonce( $nonce_value, $nonce_name ) ) {
133 - wp_send_json_error( array( 'message' => __( 'Invalid nonce.', 'booking' ) ) );
238 + $save_policy = self::get_option_policy( $data_name );
239 +
240 + if ( ! empty( $save_policy['can_save'] ) && is_callable( $save_policy['can_save'] ) && ! call_user_func( $save_policy['can_save'], $data_name ) ) {
241 + $permission_message = ( ! empty( $save_policy['permission_message'] ) && is_string( $save_policy['permission_message'] ) )
242 + ? $save_policy['permission_message']
243 + : __( 'You do not have permission to save this option.', 'booking' );
244 + wp_send_json_error( array( 'message' => $permission_message ) );
134 245 }
135 246
136 - if ( empty( $data_name ) ) {
137 - wp_send_json_error( array( 'message' => __( 'Missing data name.', 'booking' ) ) );
247 + if ( ! empty( $save_policy['normalize_raw'] ) && is_callable( $save_policy['normalize_raw'] ) ) {
248 + $data_raw = call_user_func( $save_policy['normalize_raw'], $data_raw, $data_name );
138 249 }
139 250
251 + $data_mode = ( ! empty( $save_policy['force_mode'] ) && 'split' === sanitize_key( (string) $save_policy['force_mode'] ) ) ? 'split' : '';
252 +
253 + $policy_allowed_keys = self::get_policy_allowed_keys( $save_policy );
254 + if ( 'split' === $data_mode && empty( $policy_allowed_keys ) ) {
255 + wp_send_json_error( array( 'message' => __( 'This option does not define any writable fields.', 'booking' ) ) );
256 + }
257 +
140 258 $value_to_store = self::normalize_incoming_value( $data_raw );
141 259
142 260 // Split mode: JSON object => multiple options saved separately.
143 - if ( 'split' === $data_mode && is_array( $value_to_store ) ) {
261 + if ( 'split' === $data_mode ) {
262 +
263 + if ( ! is_array( $value_to_store ) ) {
264 + wp_send_json_error( array( 'message' => __( 'Invalid option data.', 'booking' ) ) );
265 + }
266 +
267 + $allowed_keys = array_fill_keys( $policy_allowed_keys, true );
268 + $saved = array();
144 269
145 - $allowed_keys = array();
146 - if ( '' !== trim( $data_fields ) ) {
147 - $parts = explode( ',', (string) $data_fields );
148 - foreach ( $parts as $p ) {
149 - $k = sanitize_key( trim( (string) $p ) );
150 - if ( '' !== $k ) {
151 - $allowed_keys[ $k ] = true;
152 - }
153 - }
154 - }
155 -
156 - $saved = array();
157 -
158 270 foreach ( $value_to_store as $k => $v ) {
159 271
160 272 if ( ! is_scalar( $k ) ) {
161 273 continue;
@@ -165,10 +277,9 @@
165 277 if ( '' === $opt_key ) {
166 278 continue;
167 279 }
168 280
169 - // If allowlist provided, only save those keys.
170 - if ( ! empty( $allowed_keys ) && ! isset( $allowed_keys[ $opt_key ] ) ) {
281 + if ( ! isset( $allowed_keys[ $opt_key ] ) ) {
171 282 continue;
172 283 }
173 284
174 285 // Values: allow scalar or arrays (already sanitized by normalize_incoming_value()).
@@ -180,8 +291,12 @@
180 291 } else {
181 292 $opt_val = '';
182 293 }
183 294
295 + if ( ! empty( $save_policy['normalize_item'] ) && is_callable( $save_policy['normalize_item'] ) ) {
296 + $opt_val = call_user_func( $save_policy['normalize_item'], $opt_key, $opt_val, $data_name );
297 + }
298 +
184 299 self::update_option( self::$option_prefix . $opt_key, $opt_val );
185 300 $saved[ $opt_key ] = $opt_val;
186 301 }
187 302
@@ -212,10 +327,11 @@
212 327
213 328 /**
214 329 * AJAX: Load option.
215 330 *
216 - * Expected GET:
217 - * - data_name string Option key.
331 + * Expected GET:
332 + * - data_name string Option key.
333 + * - nonce string Nonce for the fixed wpbc_option_load action.
218 334 *
219 335 * @return void
220 336 */
221 337 public static function handle_ajax_load() {
@@ -220,19 +336,32 @@
220 336 */
221 337 public static function handle_ajax_load() {
222 338
223 339 $capability = apply_filters( 'wpbc_option_saver_loader_cap_load', ( function_exists( 'wpbc_bfb_get_manage_cap' ) ) ? wpbc_bfb_get_manage_cap() : 'manage_options' );
224 - if ( ! current_user_can( $capability ) ) {
225 - wp_send_json_error( array( 'message' => __( 'You do not have permission to load settings.', 'booking' ) ) );
226 - }
227 -
228 - /* phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing */
229 - $data_name = isset( $_GET['data_name'] ) ? sanitize_key( wp_unslash( $_GET['data_name'] ) ) : '';
230 - if ( empty( $data_name ) ) {
231 - wp_send_json_error( array( 'message' => __( 'Missing data name.', 'booking' ) ) );
232 - }
233 -
234 - $option_key = self::$option_prefix . $data_name;
340 + if ( ! current_user_can( $capability ) ) {
341 + wp_send_json_error( array( 'message' => __( 'You do not have permission to load settings.', 'booking' ) ) );
342 + }
343 +
344 + $nonce_value = isset( $_GET['nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['nonce'] ) ) : '';
345 + if ( ! wp_verify_nonce( $nonce_value, self::$nonce_action_load ) ) {
346 + wp_send_json_error( array( 'message' => __( 'Invalid nonce.', 'booking' ) ) );
347 + }
348 +
349 + $data_name = isset( $_GET['data_name'] ) ? sanitize_key( wp_unslash( $_GET['data_name'] ) ) : '';
350 + if ( empty( $data_name ) ) {
351 + wp_send_json_error( array( 'message' => __( 'Missing data name.', 'booking' ) ) );
352 + }
353 +
354 + if ( ! self::has_option_policy( $data_name ) ) {
355 + wp_send_json_error( array( 'message' => __( 'This option cannot be loaded by this request.', 'booking' ) ) );
356 + }
357 +
358 + $load_policy = self::get_option_policy( $data_name );
359 + if ( ! empty( $load_policy['can_save'] ) && is_callable( $load_policy['can_save'] ) && ! call_user_func( $load_policy['can_save'], $data_name ) ) {
360 + wp_send_json_error( array( 'message' => __( 'You do not have permission to load this option.', 'booking' ) ) );
361 + }
362 +
363 + $option_key = self::$option_prefix . $data_name;
235 364 $value = self::get_option( $option_key, array() );
236 365
237 366 wp_send_json_success( array( 'value' => $value ) );
238 367 }
@@ -354,8 +483,10 @@
354 483 return get_option( $option_key, $default );
355 484 }
356 485 }
357 486
487 +require_once __DIR__ . '/option-save-policies.php';
488 +
358 489 add_action( 'plugins_loaded', array( 'wpbc_option_saver_loader', 'init' ) );
359 490
360 491
361 492 /**
@@ -363,37 +494,34 @@
363 494 *
364 495 * 1) Save RAW scalar (On/Off).
365 496 *
366 497
367 -<?php
368 -$opt_name = 'booking_timeslot_picker';
369 -$nonce_action = 'wpbc_nonce_' . $opt_name;
370 -?>
498 +<?php
499 +$opt_name = 'booking_timeslot_picker';
500 +?>
371 501 <a href="javascript:void(0);"
372 502 class="button button-secondary"
373 - onclick="(function(btn){var $=jQuery, $chk=$('.js-toggle-timeslot-picker').first(); $(btn).data('wpbc-u-save-value',$chk.is(':checked')?'On':'Off'); wpbc_save_option_from_element(btn);})(this)"
374 - data-wpbc-u-save-name="<?php echo esc_attr( $opt_name ); ?>"
375 - data-wpbc-u-save-nonce="<?php echo esc_attr( wp_create_nonce( $nonce_action ) ); ?>"
376 - data-wpbc-u-save-action="<?php echo esc_attr( $nonce_action ); ?>"
377 - data-wpbc-u-busy-text="<?php esc_attr_e( 'Saving…', 'booking' ); ?>">
503 + onclick="(function(btn){var $=jQuery, $chk=$('.js-toggle-timeslot-picker').first(); $(btn).data('wpbc-u-save-value',$chk.is(':checked')?'On':'Off'); wpbc_save_option_from_element(btn);})(this)"
504 + data-wpbc-u-save-name="<?php echo esc_attr( $opt_name ); ?>"
505 + data-wpbc-u-busy-text="<?php esc_attr_e( 'Saving…', 'booking' ); ?>">
378 506 <?php esc_html_e( 'Save Toggle', 'booking' ); ?>
379 507 </a>
380 508
381 509 *
382 - * 2) Save complex structure (RAW JSON)
510 + * 2) Save complex structure (RAW JSON).
511 + *
512 + * Register an exact server-side policy for wpbc_bfb_form_structure before
513 + * rendering this control. Client attributes never register writable options.
383 514 *
384 515
385 -<?php
386 -$opt_name = 'wpbc_bfb_form_structure';
387 -$nonce_action = 'wpbc_nonce_' . $opt_name;
388 -?>
516 +<?php
517 +$opt_name = 'wpbc_bfb_form_structure';
518 +?>
389 519 <a href="javascript:void(0);"
390 520 class="button button-primary"
391 - onclick="(function(btn){var s=window.wpbc_bfb && window.wpbc_bfb.get_structure ? window.wpbc_bfb.get_structure() : []; jQuery(btn).data('wpbc-u-save-value-json', JSON.stringify(s)); wpbc_save_option_from_element(btn);})(this)"
392 - data-wpbc-u-save-name="<?php echo esc_attr( $opt_name ); ?>"
393 - data-wpbc-u-save-nonce="<?php echo esc_attr( wp_create_nonce( $nonce_action ) ); ?>"
394 - data-wpbc-u-save-action="<?php echo esc_attr( $nonce_action ) ; ?>"
395 - data-wpbc-u-busy-text="<?php esc_attr_e( 'Saving…', 'booking' ); ?>">
521 + onclick="(function(btn){var s=window.wpbc_bfb && window.wpbc_bfb.get_structure ? window.wpbc_bfb.get_structure() : []; jQuery(btn).data('wpbc-u-save-value-json', JSON.stringify(s)); wpbc_save_option_from_element(btn);})(this)"
522 + data-wpbc-u-save-name="<?php echo esc_attr( $opt_name ); ?>"
523 + data-wpbc-u-busy-text="<?php esc_attr_e( 'Saving…', 'booking' ); ?>">
396 524 <?php esc_html_e( 'Save Form Structure', 'booking' ); ?>
397 525 </a>
398 526
399 527 *
@@ -418,5 +546,5 @@
418 546 } catch(e){ console.error(e); }
419 547 }
420 548 </script>
421 549 *
422 - */
550 + */