From 6b978eb48fa404d9a7d76e5586942ea15e9b948a Mon Sep 17 00:00:00 2001
From: Gawuww
- +
@@ -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,332 @@ 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 )
+ || ! 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 ( 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 ( '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 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 +666,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.
From 4f3d86db7cf8c04fb7b7168763c6464e39404b2f Mon Sep 17 00:00:00 2001
From: Gawuww