0 ) { break; } } if ( $field_count > self::MAX_FIELDS ) { /* translators: %d: maximum number of fields a form may contain */ $errors[] = sprintf( __( 'Forms can contain at most %d fields.', 'chargeforms' ), self::MAX_FIELDS ); } if ( $logic_rules > self::MAX_LOGIC_RULES ) { /* translators: %d: maximum number of conditional-logic rules a form may contain */ $errors[] = sprintf( __( 'Forms can contain at most %d conditional rules.', 'chargeforms' ), self::MAX_LOGIC_RULES ); } if ( $logic_nodes > self::MAX_LOGIC_NODES ) { /* translators: %d: maximum number of conditional-logic nodes a form may contain */ $errors[] = sprintf( __( 'Forms can contain at most %d conditional-logic nodes.', 'chargeforms' ), self::MAX_LOGIC_NODES ); } if ( ! empty( $errors ) ) { return new \WP_Error( 'chargeforms_invalid_form', implode( ' ', $errors ) ); } return true; } private static function walk_field_tree( $field, $depth, &$field_count, &$logic_rules, &$logic_nodes, array &$errors ) { if ( ! is_array( $field ) ) { $errors[] = __( 'Each field must be an array.', 'chargeforms' ); return; } $field_count++; if ( $field_count > self::MAX_FIELDS ) { return; } if ( $depth > self::MAX_FIELD_DEPTH ) { /* translators: %d: maximum allowed field nesting depth */ $errors[] = sprintf( __( 'Field nesting cannot exceed %d levels.', 'chargeforms' ), self::MAX_FIELD_DEPTH ); return; } if ( empty( $field['id'] ) || ! is_scalar( $field['id'] ) ) { $errors[] = __( 'Every field must have a scalar id.', 'chargeforms' ); return; } $field_id = sanitize_key( (string) $field['id'] ); if ( '' === $field_id || strlen( $field_id ) > self::MAX_FIELD_ID_LENGTH ) { $errors[] = __( 'One or more field ids are invalid.', 'chargeforms' ); return; } if ( isset( $field['logicRule'] ) ) { $logic_rules++; $field_context = ! empty( $field['label'] ) ? sprintf( '%s (%s)', $field['label'], $field_id ) : $field_id; self::validate_logic_rule( $field['logicRule'], $logic_nodes, $errors, $field_context ); } // field.validation.regex (Field_Validator's format rule, roadmap // issue #32) runs against every real visitor submission just as // often as a conditional-logic regex condition does — same ReDoS // exposure, so it needs the same save-time guard, not a weaker one // just because it's configured in a different settings section. if ( ! empty( $field['validation']['regex'] ) && ! Field_Validator::is_safe_regex_pattern( $field['validation']['regex'] ) ) { $errors[] = __( 'Field validation regex must be a non-empty, bounded, non-pathological pattern.', 'chargeforms' ); return; } if ( ! empty( $field['children'] ) ) { if ( ! is_array( $field['children'] ) ) { $errors[] = __( 'Container children must be an array.', 'chargeforms' ); return; } foreach ( $field['children'] as $child ) { self::walk_field_tree( $child, $depth + 1, $field_count, $logic_rules, $logic_nodes, $errors ); if ( ! empty( $errors ) ) { return; } } } } /** * $field_context (e.g. "Email (email_1)") is threaded through every * error message here so a save-time rejection actually tells the * author which field's rule is broken — previously every message in * this method was field-agnostic, which meant a malformed rule on ANY * one field (often one the author had long forgotten about, or * inherited via Copy/Paste) surfaced as a bare, unattributed sentence, * routinely misread as being about whichever field was most recently * touched instead of the actual culprit. */ private static function validate_logic_rule( $rule, &$logic_nodes, array &$errors, $field_context ) { if ( ! is_array( $rule ) ) { /* translators: %s: the offending field's label and id, e.g. "Email (email_1)" */ $errors[] = sprintf( __( 'Conditional logic on field "%s" must be an array.', 'chargeforms' ), $field_context ); return; } // A disabled rule can't fire, so a structurally incomplete one // (e.g. a group whose only condition was just removed while // editing) is never a real save-blocking problem — only a rule // that's actually armed to run needs to be well-formed. if ( empty( $rule['enabled'] ) ) { return; } if ( ! empty( $rule['actions'] ) ) { if ( ! is_array( $rule['actions'] ) ) { /* translators: %s: the offending field's label and id */ $errors[] = sprintf( __( 'Conditional logic actions on field "%s" must be an array.', 'chargeforms' ), $field_context ); return; } if ( count( $rule['actions'] ) > self::MAX_RULE_ACTIONS ) { /* translators: 1: the offending field's label and id, 2: maximum number of actions allowed */ $errors[] = sprintf( __( 'Conditional logic on field "%1$s" can contain at most %2$d actions.', 'chargeforms' ), $field_context, self::MAX_RULE_ACTIONS ); return; } } if ( empty( $rule['trigger'] ) ) { /* translators: %s: the offending field's label and id */ $errors[] = sprintf( __( 'Conditional logic on field "%s" must have a trigger.', 'chargeforms' ), $field_context ); return; } self::walk_logic_node( $rule['trigger'], 1, $logic_nodes, $errors, $field_context ); } private static function walk_logic_node( $node, $depth, &$logic_nodes, array &$errors, $field_context ) { if ( ! is_array( $node ) ) { /* translators: %s: the offending field's label and id */ $errors[] = sprintf( __( 'Conditional logic nodes on field "%s" must be arrays.', 'chargeforms' ), $field_context ); return; } $logic_nodes++; if ( $logic_nodes > self::MAX_LOGIC_NODES ) { return; } if ( $depth > self::MAX_FIELD_DEPTH ) { /* translators: 1: the offending field's label and id, 2: maximum nesting depth allowed */ $errors[] = sprintf( __( 'Conditional logic on field "%1$s" cannot nest more than %2$d levels.', 'chargeforms' ), $field_context, self::MAX_FIELD_DEPTH ); return; } if ( empty( $node['type'] ) ) { /* translators: %s: the offending field's label and id */ $errors[] = sprintf( __( 'Conditional logic nodes on field "%s" must declare a type.', 'chargeforms' ), $field_context ); return; } if ( 'group' === $node['type'] ) { if ( empty( $node['children'] ) || ! is_array( $node['children'] ) ) { /* translators: %s: the offending field's label and id */ $errors[] = sprintf( __( 'Conditional logic on field "%s" has a group with no conditions — add one or remove the group in the Rule Builder.', 'chargeforms' ), $field_context ); return; } foreach ( $node['children'] as $child ) { self::walk_logic_node( $child, $depth + 1, $logic_nodes, $errors, $field_context ); if ( ! empty( $errors ) ) { return; } } return; } if ( 'condition' !== $node['type'] ) { /* translators: %s: the offending field's label and id */ $errors[] = sprintf( __( 'Conditional logic nodes on field "%s" must be conditions or groups.', 'chargeforms' ), $field_context ); return; } foreach ( [ 'source', 'ref', 'operator' ] as $required_key ) { if ( empty( $node[ $required_key ] ) && 'ref' !== $required_key ) { /* translators: 1: the offending field's label and id, 2: the missing condition key (e.g. "source", "operator") */ $errors[] = sprintf( __( 'Conditional logic on field "%1$s" must have a %2$s.', 'chargeforms' ), $field_context, $required_key ); return; } } if ( ! array_key_exists( 'ref', $node ) || '' === (string) $node['ref'] ) { /* translators: %s: the offending field's label and id */ $errors[] = sprintf( __( 'Conditional logic on field "%s" must have a reference.', 'chargeforms' ), $field_context ); return; } if ( 'regex' === ( $node['operator'] ?? '' ) && ! self::is_safe_regex_pattern( $node['value'] ?? '' ) ) { /* translators: %s: the offending field's label and id */ $errors[] = sprintf( __( 'Regex conditions on field "%s" must use a non-empty, bounded, non-pathological pattern.', 'chargeforms' ), $field_context ); return; } } private static function is_safe_regex_pattern( $pattern ) { $pattern = (string) $pattern; if ( '' === $pattern || strlen( $pattern ) > self::MAX_REGEX_LENGTH ) { return false; } return ! preg_match( '/\([^)]*[+*][^)]*\)\s*[+*{]/', $pattern ); } }