diff --git a/modules/gateways/secure-price-notice.php b/modules/gateways/secure-price-notice.php index 01e077e48..3cb1bbea9 100644 --- a/modules/gateways/secure-price-notice.php +++ b/modules/gateways/secure-price-notice.php @@ -2,6 +2,8 @@ namespace JFB_Modules\Gateways; +use Jet_Form_Builder\Blocks\Block_Helper; +use JFB_Modules\Post_Type\Meta\Gateways_Meta; use JFB_Modules\Post_Type\Module as Post_Type_Module; if ( ! defined( 'WPINC' ) ) { @@ -14,9 +16,15 @@ class Secure_Price_Notice { const NOTICE_OPTION = 'jet_fb_secure_price_notice'; const DISMISS_META_KEY = 'jet_fb_secure_price_notice_dismissed'; const NOTICE_QUERY_ARG = 'jet_fb_dismiss_secure_price_notice'; - const SCAN_SCHEMA_VERSION = '1'; + const SCAN_SCHEMA_VERSION = '2'; const SCAN_BATCH_SIZE = 30; + const CALCULATED_MACRO_PATTERN = '/%' + . '((?:[a-zA-Z0-9_-]+::)?' + . '[a-zA-Z0-9_-]+' + . '(?:\|[a-zA-Z][a-zA-Z0-9]*(?:\([^%|()]*\))?)*)' + . '%/'; + public function init_hooks() { add_action( 'save_post_' . Post_Type_Module::SLUG, array( $this, 'invalidate_scan_cache' ) ); @@ -68,7 +76,10 @@ public function maybe_scan_affected_forms() { ) ); - if ( array_key_exists( 'tracked_ids', $notice ) ) { + if ( + array_key_exists( 'tracked_ids', $notice ) + && $this->notice_uses_current_scan_schema( $notice ) + ) { $forms = empty( $tracked_ids ) ? array() : $this->scan_specific_forms( $tracked_ids ); @@ -140,7 +151,7 @@ public function render_affected_forms_notice() {
- +
@@ -273,6 +284,14 @@ private function get_affected_form( int $form_id ): array { return array(); } + if ( + ! $this->has_explicit_price_protection_setting( $settings ) + && $this->has_safe_static_price_source( $form_id, $settings ) + && $this->enable_price_protection( $form_id, $settings ) + ) { + return array(); + } + return array( 'id' => $form_id, 'title' => get_the_title( $form_id ) ?: sprintf( @@ -285,6 +304,358 @@ private function get_affected_form( int $form_id ): array { ); } + private function has_explicit_price_protection_setting( array $settings ): bool { + if ( array_key_exists( 'protect_price_field', $settings ) ) { + return true; + } + + foreach ( $settings as $value ) { + if ( + is_array( $value ) + && $this->has_explicit_price_protection_setting( $value ) + ) { + return true; + } + } + + return false; + } + + private function has_safe_static_price_source( int $form_id, array $settings ): bool { + $field_name = is_string( $settings['price_field'] ?? null ) + ? sanitize_key( $settings['price_field'] ) + : ''; + + if ( ! $field_name ) { + return false; + } + + $blocks = Block_Helper::get_blocks_by_post( $form_id, true, true ); + $matches = $this->find_price_field_blocks( $field_name, $blocks ); + + if ( 1 !== count( $matches ) ) { + return false; + } + + $block = $matches[0]; + $type = Block_Helper::delete_namespace( $block['blockName'] ?? '' ); + + if ( 'calculated-field' === $type ) { + return $this->is_safe_static_calculated_field( $field_name, $block, $blocks ); + } + + if ( 'hidden-field' === $type ) { + return $this->is_safe_static_hidden_field( $block ); + } + + if ( in_array( $type, array( 'select-field', 'radio-field', 'checkbox-field' ), true ) ) { + return $this->is_safe_static_option_field( $block ); + } + + return false; + } + + private function find_price_field_blocks( string $field_name, array $blocks ): array { + $matches = array(); + + foreach ( $blocks as $block ) { + if ( ! is_array( $block ) ) { + continue; + } + + $attributes = is_array( $block['attrs'] ?? null ) ? $block['attrs'] : array(); + + if ( $field_name === ( $attributes['name'] ?? '' ) ) { + $matches[] = $block; + } + + if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { + $matches = array_merge( + $matches, + $this->find_price_field_blocks( $field_name, $block['innerBlocks'] ) + ); + } + } + + return $matches; + } + + private function is_safe_static_calculated_field( + string $field_name, + array $block, + array $blocks, + array $resolving_fields = array() + ): bool { + $attributes = is_array( $block['attrs'] ?? null ) ? $block['attrs'] : array(); + $formula = $attributes['calc_formula'] ?? ''; + $precision = $attributes['precision'] ?? 2; + + if ( + isset( $resolving_fields[ $field_name ] ) + || 'number' !== ( $attributes['value_type'] ?? 'number' ) + || ! is_string( $formula ) + || '' === trim( $formula ) + || strlen( $formula ) > Trusted_Price_Resolver::MAX_FORMULA_LENGTH + || substr_count( $formula, '%' ) / 2 > Trusted_Price_Resolver::MAX_MACRO_TOKENS + || ! is_numeric( $precision ) + || (float) (int) $precision !== (float) $precision + || (int) $precision < 0 + || (int) $precision > 100 + ) { + return false; + } + + $formula = html_entity_decode( $formula, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); + + if ( + false !== strpos( $formula, '^' ) + || $this->is_field_inside_dynamic_container( $field_name, $blocks ) + ) { + return false; + } + + $resolving_fields[ $field_name ] = true; + + return $this->is_safe_calculated_formula( $formula, $blocks, $resolving_fields ); + } + + private function is_safe_calculated_formula( + string $formula, + array $blocks, + array $resolving_fields + ): bool { + $replacements = array(); + $dependencies = array(); + $match = preg_match_all( + self::CALCULATED_MACRO_PATTERN, + $formula, + $macros, + PREG_SET_ORDER + ); + + if ( false === $match ) { + return false; + } + + foreach ( $macros as $macro ) { + $token = $macro[1]; + + if ( false !== strpos( $token, '|' ) ) { + return false; + } + + $parts = explode( '::', $token, 2 ); + + if ( 2 === count( $parts ) ) { + if ( 'field' !== strtolower( $parts[0] ) ) { + return false; + } + + $dependency = $parts[1]; + } else { + $dependency = $parts[0]; + } + + if ( + ! isset( $dependencies[ $dependency ] ) + && ! $this->is_safe_calculated_dependency( + $dependency, + $blocks, + $resolving_fields + ) + ) { + return false; + } + + $dependencies[ $dependency ] = true; + $replacements[ $macro[0] ] = '1'; + } + + try { + ( new Trusted_Price_Resolver_Expression_Parser( + strtr( $formula, $replacements ) + ) )->validate(); + } catch ( \Throwable $exception ) { + return false; + } + + return true; + } + + private function is_safe_calculated_dependency( + string $field_name, + array $blocks, + array $resolving_fields + ): bool { + $matches = $this->find_price_field_blocks( $field_name, $blocks ); + + if ( + 1 !== count( $matches ) + || $this->is_field_inside_dynamic_container( $field_name, $blocks ) + ) { + return false; + } + + $block = $matches[0]; + $type = Block_Helper::delete_namespace( $block['blockName'] ?? '' ); + + if ( 'calculated-field' === $type ) { + return $this->is_safe_static_calculated_field( + $field_name, + $block, + $blocks, + $resolving_fields + ); + } + + if ( 'hidden-field' === $type ) { + return $this->is_safe_static_hidden_field( $block ); + } + + if ( in_array( $type, array( 'select-field', 'radio-field', 'checkbox-field' ), true ) ) { + return $this->has_safe_static_option_configuration( $block, false ); + } + + return false; + } + + private function is_field_inside_dynamic_container( + string $field_name, + array $blocks, + bool $inside_dynamic_container = false + ): bool { + foreach ( $blocks as $block ) { + if ( ! is_array( $block ) ) { + continue; + } + + $type = Block_Helper::delete_namespace( $block['blockName'] ?? '' ); + $inside_current = $inside_dynamic_container || in_array( + $type, + array( 'repeater-field', 'conditional-block' ), + true + ); + $attributes = is_array( $block['attrs'] ?? null ) ? $block['attrs'] : array(); + + if ( $field_name === ( $attributes['name'] ?? '' ) ) { + return $inside_current; + } + + if ( + ! empty( $block['innerBlocks'] ) + && is_array( $block['innerBlocks'] ) + && $this->is_field_inside_dynamic_container( + $field_name, + $block['innerBlocks'], + $inside_current + ) + ) { + return true; + } + } + + return false; + } + + private function is_safe_static_hidden_field( array $block ): bool { + $attributes = is_array( $block['attrs'] ?? null ) ? $block['attrs'] : array(); + $default = $attributes['default'] ?? ''; + + return false === ( $attributes['render'] ?? true ) + && 'manual_input' === ( $attributes['field_value'] ?? '' ) + && '' === $default + && $this->is_positive_number( $attributes['hidden_value'] ?? null ); + } + + private function is_safe_static_option_field( array $block ): bool { + return $this->has_safe_static_option_configuration( $block, true ); + } + + private function has_safe_static_option_configuration( + array $block, + bool $require_positive_amount + ): bool { + $attributes = is_array( $block['attrs'] ?? null ) ? $block['attrs'] : array(); + + if ( + $this->has_custom_option( $block ) + || 'manual_input' !== ( $attributes['field_options_from'] ?? 'manual_input' ) + ) { + return false; + } + + $options = $attributes['field_options'] ?? array(); + + if ( empty( $options ) || ! is_array( $options ) ) { + return false; + } + + $values = array(); + + foreach ( $options as $option ) { + if ( ! is_array( $option ) || ! isset( $option['value'] ) || ! is_scalar( $option['value'] ) ) { + return false; + } + + $value = (string) $option['value']; + + if ( '' === $value || isset( $values[ $value ] ) ) { + return false; + } + + $calculate = $option['calculate'] ?? null; + $amount = null !== $calculate && '' !== $calculate ? $calculate : $value; + + if ( + ! $this->is_finite_number( $amount ) + || ( $require_positive_amount && (float) $amount <= 0 ) + ) { + return false; + } + + $values[ $value ] = true; + } + + return true; + } + + private function has_custom_option( array $block ): bool { + $type = Block_Helper::delete_namespace( $block['blockName'] ?? '' ); + + if ( ! in_array( $type, array( 'radio-field', 'checkbox-field' ), true ) ) { + return false; + } + + $attributes = is_array( $block['attrs'] ?? null ) ? $block['attrs'] : array(); + $custom_option = $attributes['custom_option'] ?? false; + + if ( is_array( $custom_option ) ) { + $custom_option = $custom_option['allow'] ?? false; + } + + return filter_var( $custom_option, FILTER_VALIDATE_BOOLEAN ); + } + + private function is_positive_number( $value ): bool { + return $this->is_finite_number( $value ) && (float) $value > 0; + } + + private function is_finite_number( $value ): bool { + return is_scalar( $value ) + && is_numeric( $value ) + && is_finite( (float) $value ); + } + + private function enable_price_protection( int $form_id, array $settings ): bool { + $settings['protect_price_field'] = true; + + return false !== update_post_meta( + $form_id, + Gateways_Meta::META_KEY, + wp_json_encode( $settings ) + ); + } + private function get_active_gateways( array $settings ): array { $gateways = array(); @@ -321,6 +692,14 @@ private function get_scan_version(): string { return jet_form_builder()->get_version() . ':' . self::SCAN_SCHEMA_VERSION; } + private function notice_uses_current_scan_schema( array $notice ): bool { + $version = (string) ( $notice['version'] ?? '' ); + $suffix = ':' . self::SCAN_SCHEMA_VERSION; + + return strlen( $version ) > strlen( $suffix ) + && substr( $version, -strlen( $suffix ) ) === $suffix; + } + private function get_dismiss_version(): string { return 'schema:' . self::SCAN_SCHEMA_VERSION; } diff --git a/modules/gateways/trusted-price-resolver-expression-parser.php b/modules/gateways/trusted-price-resolver-expression-parser.php index 577a6a949..f6d105bd4 100644 --- a/modules/gateways/trusted-price-resolver-expression-parser.php +++ b/modules/gateways/trusted-price-resolver-expression-parser.php @@ -35,23 +35,41 @@ public function __construct( string $expression ) { * @throws Gateway_Exception */ public function parse(): float { + $result = $this->evaluate_node( $this->parse_tree() ); + + if ( ! is_finite( $result ) ) { + throw new Gateway_Exception( 'Calculated price expression produced a non-finite value.' ); + } + + return $result; + } + + /** + * Validate the expression grammar without evaluating its runtime result. + * + * @throws Gateway_Exception + */ + public function validate(): void { + $this->validate_node( $this->parse_tree() ); + } + + /** + * @throws Gateway_Exception + */ + private function parse_tree(): array { if ( '' === $this->expression ) { throw new Gateway_Exception( 'Calculated price expression is empty.' ); } + $this->position = 0; + $tree = $this->parse_conditional(); if ( $this->position < $this->length ) { throw new Gateway_Exception( 'Calculated price expression contains invalid syntax.' ); } - $result = $this->evaluate_node( $tree ); - - if ( ! is_finite( $result ) ) { - throw new Gateway_Exception( 'Calculated price expression produced a non-finite value.' ); - } - - return $result; + return $tree; } /** @@ -319,6 +337,45 @@ private function evaluate_node( array $node ): float { throw new Gateway_Exception( 'Calculated price expression contains an invalid node.' ); } + /** + * @throws Gateway_Exception + */ + private function validate_node( array $node ): void { + switch ( $node[0] ) { + case 'number': + return; + + case 'constant': + $this->assert_supported_constant( $node[1] ); + return; + + case 'call': + $this->assert_supported_math_call( $node[1], $node[2] ); + + foreach ( $node[2] as $argument ) { + $this->validate_node( $argument ); + } + return; + + case 'unary': + $this->validate_node( $node[2] ); + return; + + case 'conditional': + $this->validate_node( $node[1] ); + $this->validate_node( $node[2] ); + $this->validate_node( $node[3] ); + return; + + case 'binary': + $this->validate_node( $node[2] ); + $this->validate_node( $node[3] ); + return; + } + + throw new Gateway_Exception( 'Calculated price expression contains an invalid node.' ); + } + /** * @throws Gateway_Exception */ @@ -415,7 +472,15 @@ private function to_int32( float $value ): int { * @throws Gateway_Exception */ private function evaluate_constant( string $name ): float { - $constants = array( + $constants = $this->get_supported_constants(); + + $this->assert_supported_constant( $name ); + + return (float) $constants[ $name ]; + } + + private function get_supported_constants(): array { + return array( 'true' => 1.0, 'false' => 0.0, 'Math.E' => M_E, @@ -427,18 +492,23 @@ private function evaluate_constant( string $name ): float { 'Math.SQRT1_2' => M_SQRT1_2, 'Math.SQRT2' => M_SQRT2, ); + } - if ( ! array_key_exists( $name, $constants ) ) { + /** + * @throws Gateway_Exception + */ + private function assert_supported_constant( string $name ): void { + if ( ! array_key_exists( $name, $this->get_supported_constants() ) ) { throw new Gateway_Exception( 'Calculated price expression contains an unsupported identifier.' ); } - - return (float) $constants[ $name ]; } /** * @throws Gateway_Exception */ private function evaluate_math_call( string $name, array $argument_nodes ): float { + $this->assert_supported_math_call( $name, $argument_nodes ); + $arguments = array_map( function ( $node ) { return $this->evaluate_node( $node ); @@ -448,52 +518,42 @@ function ( $node ) { switch ( $name ) { case 'Math.abs': - $this->assert_argument_count( $name, $arguments, 1 ); $result = abs( $arguments[0] ); break; case 'Math.ceil': - $this->assert_argument_count( $name, $arguments, 1 ); $result = ceil( $arguments[0] ); break; case 'Math.floor': - $this->assert_argument_count( $name, $arguments, 1 ); $result = floor( $arguments[0] ); break; case 'Math.round': - $this->assert_argument_count( $name, $arguments, 1 ); $result = $this->js_math_round( $arguments[0] ); break; case 'Math.trunc': - $this->assert_argument_count( $name, $arguments, 1 ); $result = $arguments[0] < 0 ? ceil( $arguments[0] ) : floor( $arguments[0] ); break; case 'Math.sign': - $this->assert_argument_count( $name, $arguments, 1 ); $result = $arguments[0] <=> 0; break; case 'Math.sqrt': - $this->assert_argument_count( $name, $arguments, 1 ); $result = sqrt( $arguments[0] ); break; case 'Math.pow': - $this->assert_argument_count( $name, $arguments, 2 ); $result = $arguments[0] ** $arguments[1]; break; case 'Math.min': - $this->assert_argument_count( $name, $arguments, 1, PHP_INT_MAX ); $result = min( $arguments ); break; case 'Math.max': - $this->assert_argument_count( $name, $arguments, 1, PHP_INT_MAX ); $result = max( $arguments ); break; @@ -508,6 +568,34 @@ function ( $node ) { return (float) $result; } + /** + * @throws Gateway_Exception + */ + private function assert_supported_math_call( string $name, array $arguments ): void { + switch ( $name ) { + case 'Math.abs': + case 'Math.ceil': + case 'Math.floor': + case 'Math.round': + case 'Math.trunc': + case 'Math.sign': + case 'Math.sqrt': + $this->assert_argument_count( $name, $arguments, 1 ); + return; + + case 'Math.pow': + $this->assert_argument_count( $name, $arguments, 2 ); + return; + + case 'Math.min': + case 'Math.max': + $this->assert_argument_count( $name, $arguments, 1, PHP_INT_MAX ); + return; + } + + throw new Gateway_Exception( 'Calculated price expression contains an unsupported function.' ); + } + /** * Reproduce JavaScript Math.round: round half toward +Infinity. * diff --git a/modules/post-type/module.php b/modules/post-type/module.php index cf1cbcf49..3dbe97bff 100644 --- a/modules/post-type/module.php +++ b/modules/post-type/module.php @@ -144,8 +144,8 @@ public function set_current_screen() { /** * Enable payment amount protection for newly created forms only. * - * Existing or imported gateway settings are preserved for backward - * compatibility and require an explicit opt-in. + * Existing or imported gateway settings are preserved here. A separate + * migration enables protection for strictly verified static price sources. * * @param int $post_id Form post ID. * @param \WP_Post $post Form post object.