| @@ -42,9 +42,9 @@ | ||
| 42 | 42 | * @var array|false |
| 43 | 43 | */ |
| 44 | 44 | private $booking_hash_scope; |
| 45 | 45 | |
| 46 | - public function __construct(){// $bookings, $booking_types ) { | |
| 46 | + public function __construct(){// $bookings, $booking_types ) { | |
| 47 | 47 | |
| 48 | 48 | $this->reset_data_in_previous_cell(); |
| 49 | 49 | $this->booking_hash_scope = false; |
| 50 | 50 | |
| @@ -125,8 +125,116 @@ | ||
| 125 | 125 | |
| 126 | 126 | } |
| 127 | 127 | |
| 128 | 128 | /** |
| 129 | + * Validate the server-generated DOM identifier used by Timeline navigation. | |
| 130 | + * | |
| 131 | + * Public AJAX requests may return this value in JavaScript and inline event | |
| 132 | + * attributes. Accepting only the established prefix and decimal suffix keeps | |
| 133 | + * it an identifier rather than caller-controlled markup or selector syntax. | |
| 134 | + * | |
| 135 | + * @param mixed $html_client_id Candidate Timeline DOM identifier. | |
| 136 | + * @return string Valid identifier, or an empty string. | |
| 137 | + */ | |
| 138 | + public static function normalize_html_client_id( $html_client_id ) { | |
| 139 | + if ( ! is_scalar( $html_client_id ) ) { | |
| 140 | + return ''; | |
| 141 | + } | |
| 142 | + | |
| 143 | + $html_client_id = (string) $html_client_id; | |
| 144 | + if ( 64 < strlen( $html_client_id ) ) { | |
| 145 | + return ''; | |
| 146 | + } | |
| 147 | + | |
| 148 | + return preg_match( '/\Awpbc_timeline_[0-9]+\z/D', $html_client_id ) | |
| 149 | + ? $html_client_id | |
| 150 | + : ''; | |
| 151 | + } | |
| 152 | + | |
| 153 | + /** | |
| 154 | + * Normalize the public timeline options contract. | |
| 155 | + * | |
| 156 | + * Timeline navigation round-trips options through the browser. Only Resource | |
| 157 | + * links are consumed by the renderer, so every other key is discarded rather | |
| 158 | + * than retained as attacker-controlled state for a later response. | |
| 159 | + * | |
| 160 | + * @param mixed $options Candidate timeline options. | |
| 161 | + * @return array<string,array<int,string>> Valid Resource links keyed by Resource ID. | |
| 162 | + */ | |
| 163 | + public static function normalize_options( $options ) { | |
| 164 | + if ( | |
| 165 | + ! is_array( $options ) | |
| 166 | + || empty( $options['resource_link'] ) | |
| 167 | + || ! is_array( $options['resource_link'] ) | |
| 168 | + ) { | |
| 169 | + return array(); | |
| 170 | + } | |
| 171 | + | |
| 172 | + $resource_links = array(); | |
| 173 | + foreach ( $options['resource_link'] as $resource_key => $resource_url ) { | |
| 174 | + if ( ! is_scalar( $resource_key ) || ! is_scalar( $resource_url ) ) { | |
| 175 | + continue; | |
| 176 | + } | |
| 177 | + | |
| 178 | + $resource_id = absint( $resource_key ); | |
| 179 | + $resource_url = esc_url_raw( (string) $resource_url ); | |
| 180 | + if ( empty( $resource_id ) || '' === $resource_url ) { | |
| 181 | + continue; | |
| 182 | + } | |
| 183 | + | |
| 184 | + $resource_links[ $resource_id ] = $resource_url; | |
| 185 | + } | |
| 186 | + | |
| 187 | + return empty( $resource_links ) | |
| 188 | + ? array() | |
| 189 | + : array( 'resource_link' => $resource_links ); | |
| 190 | + } | |
| 191 | + | |
| 192 | + /** | |
| 193 | + * Decode and normalize browser-submitted timeline options. | |
| 194 | + * | |
| 195 | + * @param mixed $encoded_options JSON text received from the timeline client. | |
| 196 | + * @return array<string,array<int,string>> Valid Resource links, or an empty array. | |
| 197 | + */ | |
| 198 | + public static function decode_options( $encoded_options ) { | |
| 199 | + if ( ! is_scalar( $encoded_options ) ) { | |
| 200 | + return array(); | |
| 201 | + } | |
| 202 | + | |
| 203 | + $decoded_options = json_decode( (string) $encoded_options, true, 32 ); | |
| 204 | + if ( JSON_ERROR_NONE !== json_last_error() ) { | |
| 205 | + return array(); | |
| 206 | + } | |
| 207 | + | |
| 208 | + return self::normalize_options( $decoded_options ); | |
| 209 | + } | |
| 210 | + | |
| 211 | + /** | |
| 212 | + * Encode timeline options as one complete JavaScript string literal. | |
| 213 | + * | |
| 214 | + * The timeline browser contract stores JSON text, rather than an object, in | |
| 215 | + * `timeline_obj.options`. Encoding the normalized options twice preserves that | |
| 216 | + * contract while the hexadecimal flags prevent quotes or HTML delimiters in a | |
| 217 | + * URL from terminating the inline script context. | |
| 218 | + * | |
| 219 | + * @param mixed $options Candidate timeline options. | |
| 220 | + * @return string JavaScript-safe JSON string literal, including its delimiters. | |
| 221 | + */ | |
| 222 | + public static function encode_options_for_inline_script( $options ) { | |
| 223 | + $options_json = wp_json_encode( self::normalize_options( $options ) ); | |
| 224 | + if ( false === $options_json ) { | |
| 225 | + $options_json = '{}'; | |
| 226 | + } | |
| 227 | + | |
| 228 | + $javascript_literal = wp_json_encode( | |
| 229 | + $options_json, | |
| 230 | + JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | |
| 231 | + ); | |
| 232 | + | |
| 233 | + return false === $javascript_literal ? '"{}"' : $javascript_literal; | |
| 234 | + } | |
| 235 | + | |
| 236 | + /** | |
| 129 | 237 | * Apply an exact booking and resource authorization scope to timeline SQL arguments. |
| 130 | 238 | * |
| 131 | 239 | * A booking hash is a bearer credential for one booking. It must never be |
| 132 | 240 | * converted into a customer-data keyword or used to broaden the query. Invalid |
| @@ -225,9 +333,9 @@ | ||
| 225 | 333 | |
| 226 | 334 | $this->is_frontend = true; |
| 227 | 335 | |
| 228 | 336 | // FixIn: 7.0.1.50. |
| 229 | - if ( isset( $attr['options'] ) ) { | |
| 337 | + if ( isset( $attr['options'] ) ) { | |
| 230 | 338 | |
| 231 | 339 | $shortcode_param__options = $attr['options']; |
| 232 | 340 | $shortcode_param__options = html_entity_decode( $shortcode_param__options ); // FixIn: 9.8.15.6. |
| 233 | 341 | $custom_params = array(); |
| @@ -251,11 +359,12 @@ | ||
| 251 | 359 | $this->options[ $matche_value[1] ][ $matche_value[2] ] = $matche_value[3]; |
| 252 | 360 | } |
| 253 | 361 | } |
| 254 | 362 | |
| 255 | -//debuge($this->options); | |
| 256 | - } | |
| 257 | - // FixIn: 7.0.1.50. | |
| 363 | +//debuge($this->options); | |
| 364 | + } | |
| 365 | + $this->options = self::normalize_options( $this->options ); | |
| 366 | + // FixIn: 7.0.1.50. | |
| 258 | 367 | |
| 259 | 368 | |
| 260 | 369 | //Ovverride some parameters |
| 261 | 370 | //if ( isset( $attr['resource_id'] ) ) { $attr['type'] = $attr['resource_id']; } |
| @@ -531,9 +640,9 @@ | ||
| 531 | 640 | $this->dates_array = $bookings_date_time[0]; |
| 532 | 641 | $this->time_array_new = $bookings_date_time[1]; |
| 533 | 642 | |
| 534 | 643 | |
| 535 | - $this->html_client_id = $attr['html_client_id']; | |
| 644 | + $this->html_client_id = self::normalize_html_client_id( $attr['html_client_id'] ); | |
| 536 | 645 | |
| 537 | 646 | return $this->html_client_id; |
| 538 | 647 | } |
| 539 | 648 | |
| @@ -589,9 +698,12 @@ | ||
| 589 | 698 | 'header_title' : "<?php echo esc_js( $this->timeline_titles['header_title'] ); ?>", |
| 590 | 699 | 'wh_trash' : "<?php echo esc_js( $this->request_args['wh_trash'] ); ?>", |
| 591 | 700 | 'limit_hours' : "<?php echo esc_js( $this->request_args['limit_hours'] ); ?>", |
| 592 | 701 | 'only_booked_resources': "<?php echo esc_js( $this->request_args['only_booked_resources'] ); ?>", |
| 593 | - 'options' : '<?php echo wp_json_encode( $this->options ); ?>', | |
| 702 | + 'options' : <?php | |
| 703 | + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Returns a complete JSON-encoded JavaScript string literal. | |
| 704 | + echo self::encode_options_for_inline_script( $this->options ); | |
| 705 | + ?>, | |
| 594 | 706 | 'booking_hash' : "<?php echo esc_js( $this->request_args['booking_hash'] ); ?>" |
| 595 | 707 | }; |
| 596 | 708 | </script> |
| 597 | 709 | <div class="flex_tl_nav"> |
| @@ -914,11 +1026,11 @@ | ||
| 914 | 1026 | } // FixIn: 7.0.1.14. |
| 915 | 1027 | if ( isset( $param['booking_hash'] ) ) { |
| 916 | 1028 | $this->request_args['booking_hash'] = $param['booking_hash']; |
| 917 | 1029 | } // FixIn: 8.1.3.5. |
| 918 | - if ( ( empty( $this->options ) ) && ( isset( $param['options'] ) ) ) { | |
| 919 | - $this->options = json_decode( wp_unslash( $param['options'] ), true ); // FixIn: 9.2.1.8. | |
| 920 | - } | |
| 1030 | + if ( ( empty( $this->options ) ) && ( isset( $param['options'] ) ) ) { | |
| 1031 | + $this->options = self::decode_options( $param['options'] ); // FixIn: 9.2.1.8. | |
| 1032 | + } | |
| 921 | 1033 | |
| 922 | 1034 | } |
| 923 | 1035 | |
| 924 | 1036 | |
| @@ -3381,9 +3493,17 @@ | ||
| 3381 | 3493 | $clean_key = sanitize_key( wp_unslash( (string) $tl_key ) ); |
| 3382 | 3494 | if ( '' === $clean_key || ! isset( $allowed_timeline_keys[ $clean_key ] ) ) { |
| 3383 | 3495 | continue; |
| 3384 | 3496 | } |
| 3385 | - $attr[ $clean_key ] = wpbc_clean_text_value( wp_unslash( (string) $tl_value ) ); | |
| 3497 | + $clean_value = wp_unslash( (string) $tl_value ); | |
| 3498 | + if ( 'options' === $clean_key ) { | |
| 3499 | + $normalized_options = WPBC_TimelineFlex::decode_options( $clean_value ); | |
| 3500 | + $encoded_options = wp_json_encode( $normalized_options ); | |
| 3501 | + $attr[ $clean_key ] = false === $encoded_options ? '{}' : $encoded_options; | |
| 3502 | + continue; | |
| 3503 | + } | |
| 3504 | + | |
| 3505 | + $attr[ $clean_key ] = wpbc_clean_text_value( $clean_value ); | |
| 3386 | 3506 | } |
| 3387 | 3507 | |
| 3388 | 3508 | // phpcs:ignore WordPress.Security.NonceVerification.Missing |
| 3389 | 3509 | if ( isset( $_POST['nav_step'] ) && ! is_scalar( $_POST['nav_step'] ) ) { |
| @@ -3390,11 +3510,16 @@ | ||
| 3390 | 3510 | status_header( 400 ); |
| 3391 | 3511 | wp_die( '' ); |
| 3392 | 3512 | } |
| 3393 | 3513 | |
| 3394 | - $attr['nav_step'] = isset( $_POST['nav_step'] ) ? wpbc_clean_text_value( wp_unslash( (string) $_POST['nav_step'] ) ) : '0'; // phpcs:ignore WordPress.Security.NonceVerification.Missing | |
| 3395 | - $attr['is_frontend'] = isset( $attr['is_frontend'] ) ? $attr['is_frontend'] : '1'; | |
| 3396 | - if ( empty( $attr['html_client_id'] ) ) { | |
| 3514 | + $attr['nav_step'] = isset( $_POST['nav_step'] ) | |
| 3515 | + ? wpbc_clean_text_value( wp_unslash( (string) $_POST['nav_step'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Missing | |
| 3516 | + : '0'; | |
| 3517 | + $attr['is_frontend'] = isset( $attr['is_frontend'] ) ? $attr['is_frontend'] : '1'; | |
| 3518 | + $attr['html_client_id'] = isset( $attr['html_client_id'] ) | |
| 3519 | + ? WPBC_TimelineFlex::normalize_html_client_id( $attr['html_client_id'] ) | |
| 3520 | + : ''; | |
| 3521 | + if ( '' === $attr['html_client_id'] ) { | |
| 3397 | 3522 | status_header( 400 ); |
| 3398 | 3523 | wp_die( '' ); |
| 3399 | 3524 | } |
| 3400 | 3525 | |