true, 'minSubmitTimeEnabled' => true, 'minSubmitTimeSeconds' => 3, 'rateLimitEnabled' => false, 'rateLimitMax' => 5, 'rateLimitWindowMinutes' => 10, 'blockedMessage' => '', 'rateLimitMessage' => '', ); } public function sanitize_settings( $raw ) { $defaults = $this->get_defaults(); $raw = is_array( $raw ) ? $raw : array(); return array( 'honeypotEnabled' => $this->to_bool( $raw['honeypotEnabled'] ?? $defaults['honeypotEnabled'] ), 'minSubmitTimeEnabled' => $this->to_bool( $raw['minSubmitTimeEnabled'] ?? $defaults['minSubmitTimeEnabled'] ), 'minSubmitTimeSeconds' => max( 1, min( 30, absint( $raw['minSubmitTimeSeconds'] ?? $defaults['minSubmitTimeSeconds'] ) ) ), 'rateLimitEnabled' => $this->to_bool( $raw['rateLimitEnabled'] ?? $defaults['rateLimitEnabled'] ), 'rateLimitMax' => max( 1, min( 100, absint( $raw['rateLimitMax'] ?? $defaults['rateLimitMax'] ) ) ), 'rateLimitWindowMinutes' => max( 1, min( 1440, absint( $raw['rateLimitWindowMinutes'] ?? $defaults['rateLimitWindowMinutes'] ) ) ), 'blockedMessage' => sanitize_text_field( (string) ( $raw['blockedMessage'] ?? $defaults['blockedMessage'] ) ), 'rateLimitMessage' => sanitize_text_field( (string) ( $raw['rateLimitMessage'] ?? $defaults['rateLimitMessage'] ) ), ); } public function get_form_settings( $form_id ) { $attrs = function_exists( 'stacklar_get_submit_block_attributes' ) ? stacklar_get_submit_block_attributes( $form_id ) : array(); $raw = isset( $attrs['protection'] ) && is_array( $attrs['protection'] ) ? $attrs['protection'] : array(); return $this->sanitize_settings( $raw ); } public function get_public_settings( $form_id ) { return $this->get_form_settings( $form_id ); } public function is_enabled( array $settings ) { return ! empty( $settings['honeypotEnabled'] ) || ! empty( $settings['minSubmitTimeEnabled'] ) || ! empty( $settings['rateLimitEnabled'] ); } public function get_render_config( $form_id ) { if ( ! $form_id ) { return array( 'enabled' => false, 'settings' => $this->sanitize_settings( array() ), ); } $settings = $this->get_form_settings( $form_id ); if ( ! $this->is_enabled( $settings ) ) { return array( 'enabled' => false, 'settings' => $settings, ); } $issued_at = time(); return array( 'enabled' => true, 'settings' => $settings, 'honeypot' => ! empty( $settings['honeypotEnabled'] ), 'issued_at' => ! empty( $settings['minSubmitTimeEnabled'] ) ? $issued_at : 0, 'signature' => ! empty( $settings['minSubmitTimeEnabled'] ) ? $this->sign_issued_at( $form_id, $issued_at ) : '', ); } public function enforce( $form_id, array $args = array() ) { $form_id = absint( $form_id ); $settings = isset( $args['settings'] ) && is_array( $args['settings'] ) ? $this->sanitize_settings( $args['settings'] ) : $this->get_form_settings( $form_id ); $token = $this->sanitize_token( $args['submission_token'] ?? ( isset( $_POST[ self::TOKEN_FIELD ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::TOKEN_FIELD ] ) ) : '' ) ); if ( ! $this->is_enabled( $settings ) ) { return array( 'passed' => true, 'status' => 'disabled', ); } if ( '' === $token ) { return new WP_Error( 'spam_submission_token_missing', __( 'Submission token is missing.', 'stacklar-forms' ) ); } $action = $this->get_request_action( $args ); if ( '' === $action ) { return new WP_Error( 'spam_submission_action_missing', __( 'Submission action is missing.', 'stacklar-forms' ) ); } $lock = $this->acquire_attempt_lock( $form_id, $token ); if ( is_wp_error( $lock ) ) { return $lock; } try { $payload_hash = $this->get_request_payload_hash(); $state = $this->get_attempt_state( $form_id, $token ); if ( ! empty( $state['status'] ) ) { if ( 'passed' === $state['status'] ) { return $this->consume_action( $form_id, $token, $action, $payload_hash, $state, true ); } return new WP_Error( ! empty( $state['code'] ) ? sanitize_key( (string) $state['code'] ) : 'spam_blocked', ! empty( $state['message'] ) ? sanitize_text_field( (string) $state['message'] ) : $this->default_blocked_message( $settings ) ); } if ( ! empty( $settings['honeypotEnabled'] ) ) { $honeypot = isset( $_POST[ self::HONEYPOT_FIELD ] ) ? trim( sanitize_text_field( wp_unslash( $_POST[ self::HONEYPOT_FIELD ] ) ) ) : ''; if ( '' !== $honeypot ) { return $this->reject_attempt( $form_id, $token, 'spam_honeypot_triggered', $this->default_blocked_message( $settings ) ); } } if ( ! empty( $settings['minSubmitTimeEnabled'] ) ) { $timing = $this->validate_submit_timing( $form_id, $settings ); if ( is_wp_error( $timing ) ) { return $this->reject_attempt( $form_id, $token, 'spam_min_time_failed', $this->default_blocked_message( $settings ) ); } } if ( ! empty( $settings['rateLimitEnabled'] ) ) { $rate = $this->consume_rate_limit( $form_id, $token, $settings ); if ( is_wp_error( $rate ) ) { return $this->reject_attempt( $form_id, $token, 'spam_rate_limited', $this->default_rate_limit_message( $settings ) ); } } $state = array( 'status' => 'passed', 'token' => $token, 'checked_at' => time(), 'payload_hash' => $payload_hash, 'consumed_actions' => array(), ); return $this->consume_action( $form_id, $token, $action, $payload_hash, $state, true ); } finally { $this->release_attempt_lock( $lock ); } } public function assert_passed( $form_id, $token ) { $form_id = absint( $form_id ); $token = $this->sanitize_token( $token ); $settings = $this->get_form_settings( $form_id ); if ( ! $this->is_enabled( $settings ) ) { return array( 'status' => 'disabled', 'token' => $token, ); } if ( '' === $token ) { return new WP_Error( 'spam_submission_token_missing', __( 'Submission token is missing.', 'stacklar-forms' ) ); } $action = $this->get_request_action(); if ( '' === $action ) { return new WP_Error( 'spam_submission_action_missing', __( 'Submission action is missing.', 'stacklar-forms' ) ); } $lock = $this->acquire_attempt_lock( $form_id, $token ); if ( is_wp_error( $lock ) ) { return $lock; } try { $state = $this->get_attempt_state( $form_id, $token ); if ( ! empty( $state['status'] ) && 'passed' === $state['status'] ) { return $this->consume_action( $form_id, $token, $action, '', $state, false ); } if ( ! empty( $state['status'] ) && 'failed' === $state['status'] ) { return new WP_Error( ! empty( $state['code'] ) ? sanitize_key( (string) $state['code'] ) : 'spam_blocked', ! empty( $state['message'] ) ? sanitize_text_field( (string) $state['message'] ) : __( 'Submission blocked.', 'stacklar-forms' ) ); } return new WP_Error( 'spam_attempt_missing', __( 'Submission protection state is missing. Please try again.', 'stacklar-forms' ) ); } finally { $this->release_attempt_lock( $lock ); } } public function sign_issued_at( $form_id, $issued_at ) { return hash_hmac( 'sha256', absint( $form_id ) . '|' . absint( $issued_at ), wp_salt( 'nonce' ) ); } public function detect_ip_address() { /** * Opt in to proxy headers only when the site is behind a trusted proxy/CDN. * Trusted headers take precedence over REMOTE_ADDR, which is then the * proxy's own address. * * @param string[] $headers Server header keys such as HTTP_X_FORWARDED_FOR. */ $headers = apply_filters( 'stacklar_trusted_proxy_ip_headers', array() ); $headers = is_array( $headers ) ? $headers : array(); foreach ( $headers as $header ) { $header = is_string( $header ) ? strtoupper( preg_replace( '/[^A-Z0-9_]+/', '', $header ) ) : ''; if ( '' === $header ) { continue; } if ( empty( $_SERVER[ $header ] ) ) { continue; } // The trusted proxy appends the address it saw; earlier entries are // client-supplied, so read a list from the right. $candidates = array_reverse( explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ) ) ); foreach ( $candidates as $candidate ) { $candidate = trim( $candidate ); if ( filter_var( $candidate, FILTER_VALIDATE_IP ) ) { return $candidate; } } } $remote_addr = isset( $_SERVER['REMOTE_ADDR'] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) ) : ''; return filter_var( $remote_addr, FILTER_VALIDATE_IP ) ? $remote_addr : ''; } protected function validate_submit_timing( $form_id, array $settings ) { $issued_at = isset( $_POST[ self::ISSUED_AT_FIELD ] ) ? absint( wp_unslash( $_POST[ self::ISSUED_AT_FIELD ] ) ) : 0; $signature = isset( $_POST[ self::SIGNATURE_FIELD ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::SIGNATURE_FIELD ] ) ) : ''; if ( ! $issued_at || '' === $signature ) { return new WP_Error( 'spam_min_time_failed', __( 'Submission timing data is missing.', 'stacklar-forms' ) ); } $expected = $this->sign_issued_at( $form_id, $issued_at ); if ( ! hash_equals( $expected, $signature ) ) { return new WP_Error( 'spam_min_time_failed', __( 'Submission timing data is invalid.', 'stacklar-forms' ) ); } $now = time(); if ( $issued_at > $now + 60 || $issued_at < $now - DAY_IN_SECONDS ) { return new WP_Error( 'spam_min_time_failed', __( 'Submission timing data is invalid.', 'stacklar-forms' ) ); } $minimum = max( 1, absint( $settings['minSubmitTimeSeconds'] ?? 3 ) ); if ( ( $now - $issued_at ) < $minimum ) { return new WP_Error( 'spam_min_time_failed', __( 'Form submitted too quickly.', 'stacklar-forms' ) ); } return true; } protected function consume_rate_limit( $form_id, $token, array $settings ) { $ip = $this->detect_ip_address(); if ( '' === $ip ) { return true; } $max = max( 1, absint( $settings['rateLimitMax'] ?? 5 ) ); $window_min = max( 1, absint( $settings['rateLimitWindowMinutes'] ?? 10 ) ); $window_sec = $window_min * MINUTE_IN_SECONDS; $key = $this->rate_limit_key( $form_id, $ip ); $payload = get_transient( $key ); $payload = is_array( $payload ) ? $payload : array(); $count = isset( $payload['count'] ) ? absint( $payload['count'] ) : 0; $expires_at = isset( $payload['expires_at'] ) ? absint( $payload['expires_at'] ) : 0; $now = time(); if ( $expires_at <= $now ) { $count = 0; $expires_at = $now + $window_sec; } if ( $count >= $max ) { return new WP_Error( 'spam_rate_limited', __( 'Too many submissions.', 'stacklar-forms' ) ); } $payload = array( 'count' => $count + 1, 'expires_at' => $expires_at, 'last_token' => $token, ); set_transient( $key, $payload, max( 60, $expires_at - $now ) ); return true; } protected function reject_attempt( $form_id, $token, $code, $message ) { $this->store_attempt_state( $form_id, $token, array( 'status' => 'failed', 'token' => $token, 'code' => sanitize_key( (string) $code ), 'message' => sanitize_text_field( (string) $message ), 'checked_at' => time(), ) ); return new WP_Error( sanitize_key( (string) $code ), sanitize_text_field( (string) $message ) ); } protected function get_attempt_state( $form_id, $token ) { $token = $this->sanitize_token( $token ); if ( '' === $token ) { return array(); } $state = get_transient( $this->state_key( $form_id, $token ) ); return is_array( $state ) ? $state : array(); } protected function store_attempt_state( $form_id, $token, array $state ) { $token = $this->sanitize_token( $token ); if ( '' === $token ) { return false; } return set_transient( $this->state_key( $form_id, $token ), $state, self::TOKEN_TTL ); } protected function consume_action( $form_id, $token, $action, $payload_hash, array $state, $verify_payload ) { $action = sanitize_key( (string) $action ); if ( '' === $action ) { return new WP_Error( 'spam_submission_action_missing', __( 'Submission action is missing.', 'stacklar-forms' ) ); } $stored_hash = isset( $state['payload_hash'] ) ? (string) $state['payload_hash'] : ''; if ( $verify_payload && '' !== $stored_hash && ( '' === $payload_hash || ! hash_equals( $stored_hash, (string) $payload_hash ) ) ) { return new WP_Error( 'spam_attempt_mismatch', __( 'Submission data changed during processing. Please submit the form again.', 'stacklar-forms' ) ); } $consumed = isset( $state['consumed_actions'] ) && is_array( $state['consumed_actions'] ) ? array_values( array_unique( array_filter( array_map( 'sanitize_key', $state['consumed_actions'] ) ) ) ) : array(); if ( in_array( $action, $consumed, true ) ) { return new WP_Error( 'spam_submission_replayed', __( 'This submission action has already been processed. Please submit the form again.', 'stacklar-forms' ) ); } $consumed[] = $action; $state['payload_hash'] = '' !== $stored_hash ? $stored_hash : (string) $payload_hash; $state['consumed_actions'] = $consumed; $state['last_action'] = $action; $state['last_consumed_at'] = time(); if ( ! $this->store_attempt_state( $form_id, $token, $state ) ) { return new WP_Error( 'spam_state_store_failed', __( 'Submission protection state could not be saved. Please try again.', 'stacklar-forms' ) ); } return $state; } protected function acquire_attempt_lock( $form_id, $token ) { $key = $this->lock_key( $form_id, $token ); $expires_at = time() + self::LOCK_TTL; if ( add_option( $key, $expires_at, '', false ) ) { return $key; } $current_expiry = absint( get_option( $key, 0 ) ); if ( $current_expiry && $current_expiry < time() ) { delete_option( $key ); if ( add_option( $key, $expires_at, '', false ) ) { return $key; } } return new WP_Error( 'spam_submission_in_progress', __( 'This submission is already being processed. Please wait and try again.', 'stacklar-forms' ) ); } protected function release_attempt_lock( $key ) { $key = is_string( $key ) ? $key : ''; if ( '' !== $key && 0 === strpos( $key, self::LOCK_PREFIX ) ) { delete_option( $key ); } } protected function get_request_action( array $args = array() ) { $action = isset( $args['action'] ) ? $args['action'] : ( isset( $_POST['action'] ) ? sanitize_key( wp_unslash( $_POST['action'] ) ) : '' ); return substr( sanitize_key( (string) $action ), 0, 96 ); } protected function get_request_payload_hash() { // Only hashed, to tell a resubmission of the same values from a new one. $post = isset( $_POST ) && is_array( $_POST ) ? wp_unslash( $_POST ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $action_transport_fields = array( 'action', '_stacklar_nonce', '_wp_http_referer', 'stacklar_entry_id', 'stacklar_entry_token', 'entry_id', // Payment start/resume endpoints add these values between actions. // Provider-specific handlers authenticate them independently against // the server-created payment attempt and provider metadata. 'provider', 'operation', 'flow', 'payment_token', '__stacklar_payment_token', 'paypal_order_id', 'paypal_subscription_id', 'paypal_flow', 'paypal_status', 'paypal_currency', 'paypal_amount', 'paypal_mode', 'stripe_request_token', 'stripe_return_url', 'stripe_pending_token', 'stripe_payment_intent_id', 'stripe_flow', 'stripe_checkout_session_id', 'stripe_subscription_id', 'stripe_price_id', 'stripe_status', 'stripe_currency', 'stripe_amount', 'stripe_mode', 'woocommerce_return_url', 'woocommerce_pending_token', 'woocommerce_order_id', 'woocommerce_order_key', 'woocommerce_product_id', 'woocommerce_quantity', 'woocommerce_status', 'woocommerce_return_behavior', 'woocommerce_last_update', ); foreach ( array_keys( $post ) as $key ) { $key = (string) $key; if ( in_array( $key, $action_transport_fields, true ) || 0 === strpos( $key, '__stacklar_booking_context_' ) ) { unset( $post[ $key ] ); } } $packet = array( 'post' => $this->canonicalize_value( $post ), 'files' => $this->canonicalize_files( isset( $_FILES ) && is_array( $_FILES ) ? $_FILES : array() ), ); $json = wp_json_encode( $packet ); if ( false === $json ) { $json = serialize( $packet ); } return hash_hmac( 'sha256', (string) $json, wp_salt( 'auth' ) ); } protected function canonicalize_value( $value ) { if ( ! is_array( $value ) ) { if ( is_bool( $value ) ) { return $value ? '1' : '0'; } return is_scalar( $value ) || null === $value ? (string) $value : ''; } $is_list = array_keys( $value ) === range( 0, count( $value ) - 1 ); if ( ! $is_list ) { ksort( $value, SORT_STRING ); } $normalized = array(); foreach ( $value as $key => $item ) { $normalized[ $key ] = $this->canonicalize_value( $item ); } return $normalized; } protected function canonicalize_files( array $files ) { $normalized = array(); ksort( $files, SORT_STRING ); foreach ( $files as $field => $file ) { if ( ! is_array( $file ) ) { continue; } $normalized[ (string) $field ] = array( 'name' => $this->canonicalize_value( $file['name'] ?? '' ), 'type' => $this->canonicalize_value( $file['type'] ?? '' ), 'size' => $this->canonicalize_value( $file['size'] ?? '' ), 'error' => $this->canonicalize_value( $file['error'] ?? '' ), ); } return $normalized; } protected function state_key( $form_id, $token ) { return self::STATE_PREFIX . md5( absint( $form_id ) . '|' . $token ); } protected function lock_key( $form_id, $token ) { return self::LOCK_PREFIX . md5( absint( $form_id ) . '|' . $token ); } protected function rate_limit_key( $form_id, $ip ) { return self::RATE_LIMIT_PREFIX . md5( absint( $form_id ) . '|' . trim( (string) $ip ) ); } protected function sanitize_token( $token ) { $token = preg_replace( '/[^A-Za-z0-9_-]+/', '', (string) $token ); return substr( (string) $token, 0, 96 ); } protected function to_bool( $value ) { if ( function_exists( 'stacklar_is_truthy' ) ) { return stacklar_is_truthy( $value ); } if ( is_bool( $value ) ) { return $value; } return in_array( strtolower( trim( (string) $value ) ), array( '1', 'true', 'yes', 'on' ), true ); } protected function default_blocked_message( array $settings ) { if ( ! empty( $settings['blockedMessage'] ) ) { return sanitize_text_field( (string) $settings['blockedMessage'] ); } return __( 'Your submission could not be processed. Please try again.', 'stacklar-forms' ); } protected function default_rate_limit_message( array $settings ) { if ( ! empty( $settings['rateLimitMessage'] ) ) { return sanitize_text_field( (string) $settings['rateLimitMessage'] ); } return __( 'Too many submissions. Please wait and try again.', 'stacklar-forms' ); } } if ( ! function_exists( 'stacklar_submission_protection_service' ) ) { function stacklar_submission_protection_service() { static $instance = null; if ( null === $instance ) { $instance = new Stacklar_Submission_Protection_Service(); } return $instance; } }