'; } // The review step carries the original token forward, so "Confirm // withdrawal" isn't refused as too fast. $html .= ''; if ( self::turnstile_on() ) { $html .= '
'; wp_enqueue_script( 'rtkt-turnstile', self::SCRIPT_URL, array(), null, array( 'strategy' => 'defer' ) ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- versionless by Cloudflare's design. } return $html; } /** * Is Turnstile configured and on? * * @return bool */ public static function turnstile_on() { return Settings::get( 'turnstile_enabled' ) && '' !== Settings::get( 'turnstile_site_key' ) && '' !== Settings::get( 'turnstile_secret' ); } /** * A signed timestamp token. * * @param int|null $time Time (tests). * @return string */ public static function token( $time = null ) { $time = null === $time ? time() : (int) $time; return $time . '.' . substr( hash_hmac( 'sha256', 'rtkt-form|' . $time, wp_salt( 'nonce' ) ), 0, 20 ); } /** * Cloudflare edge ranges (https://www.cloudflare.com/ips/). * * @var string[] */ const CLOUDFLARE = array( '173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22', '141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20', '197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13', '104.24.0.0/14', '172.64.0.0/13', '131.0.72.0/22', '2400:cb00::/32', '2606:4700::/32', '2803:f800::/32', '2405:b500::/32', '2405:8100::/32', '2a06:98c0::/29', '2c0f:f248::/32' ); /** * A $_SERVER value, sanitized. * * @param string $key Key. * @return string */ private static function server( $key ) { return isset( $_SERVER[ $key ] ) ? trim( sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated } /** * Is an IP inside any of the CIDR ranges? * * @param string $ip IP. * @param string[] $ranges CIDR ranges. * @return bool */ public static function in_ranges( $ip, array $ranges ) { $bin = filter_var( $ip, FILTER_VALIDATE_IP ) ? inet_pton( $ip ) : false; if ( false === $bin ) { return false; } foreach ( $ranges as $range ) { list( $net, $bits ) = explode( '/', $range, 2 ); $net_bin = inet_pton( $net ); if ( false === $net_bin || strlen( $net_bin ) !== strlen( $bin ) ) { continue; } $bits = (int) $bits; $bytes = intdiv( $bits, 8 ); if ( substr( $bin, 0, $bytes ) !== substr( $net_bin, 0, $bytes ) ) { continue; } $rest = $bits % 8; if ( 0 === $rest ) { return true; } $mask = ( 0xFF << ( 8 - $rest ) ) & 0xFF; if ( ( ord( $bin[ $bytes ] ) & $mask ) === ( ord( $net_bin[ $bytes ] ) & $mask ) ) { return true; } } return false; } /** * A genuine token, at most 12 hours old. * * @param string $token Token. * @return bool */ public static function valid_token( $token ) { $parts = explode( '.', (string) $token ); if ( 2 !== count( $parts ) || ! ctype_digit( $parts[0] ) || ! hash_equals( self::token( (int) $parts[0] ), (string) $token ) ) { return false; } return time() - (int) $parts[0] <= 12 * HOUR_IN_SECONDS; } /** * The visitor's IP address, from the source chosen in the settings. * Proxy headers are only trusted when the store says it's behind one. * * @return string */ public static function client_ip() { $remote = self::server( 'REMOTE_ADDR' ); $raw = $remote; switch ( (string) Settings::get( 'ip_source' ) ) { case 'cloudflare': // Only when the connection really comes from Cloudflare. $cf = self::server( 'HTTP_CF_CONNECTING_IP' ); if ( '' !== $cf && '' !== $remote && self::in_ranges( $remote, self::CLOUDFLARE ) ) { $raw = $cf; } break; case 'forwarded': // The right-most entry is the one the proxy in front of the site // added; everything left of it is whatever the visitor sent. $parts = array_map( 'trim', explode( ',', self::server( 'HTTP_X_FORWARDED_FOR' ) ) ); $last = (string) end( $parts ); if ( filter_var( $last, FILTER_VALIDATE_IP ) ) { $raw = $last; } break; case 'real_ip': $real = self::server( 'HTTP_X_REAL_IP' ); if ( '' !== $real ) { $raw = $real; } break; } $ip = filter_var( $raw, FILTER_VALIDATE_IP ) ? $raw : ''; /** * Filters the client IP ReturnKit uses for rate limits and records. * * @param string $ip IP address. */ return (string) apply_filters( 'rtkt_client_ip', $ip ); } /** * Check a submission. Returns '' when it passes, or a message. * * @param string $bucket Rate-limit bucket (lookup, withdrawal …). * @return string */ public static function check( $bucket ) { // phpcs:disable WordPress.Security.NonceVerification.Missing -- only called from Forms, after Forms::handle() has verified the form nonce. if ( Settings::get( 'honeypot' ) && ! empty( $_POST[ self::HONEYPOT ] ) ) { return __( 'Your submission could not be accepted. Please try again.', 'returnkit' ); } $token = isset( $_POST[ self::TOKEN ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::TOKEN ] ) ) : ''; if ( ! self::valid_token( $token ) ) { return __( 'The form expired. Please reload the page and try again.', 'returnkit' ); } $parts = explode( '.', $token ); if ( time() - (int) $parts[0] < (int) Settings::get( 'min_seconds' ) ) { return __( 'That was quick! Please check your details and submit again.', 'returnkit' ); } if ( self::turnstile_on() ) { $response = isset( $_POST['cf-turnstile-response'] ) ? sanitize_text_field( wp_unslash( $_POST['cf-turnstile-response'] ) ) : ''; if ( ! self::verify_turnstile( $response ) ) { return __( 'Please complete the security check.', 'returnkit' ); } } // phpcs:enable if ( ! self::within_rate_limit( $bucket ) ) { return __( 'Too many submissions from your connection. Please wait an hour and try again, or contact us.', 'returnkit' ); } return ''; } /** * Count a submission against the IP's hourly limit. * * @param string $bucket Bucket. * @return bool False when over the limit. */ public static function within_rate_limit( $bucket ) { $ip = self::client_ip(); if ( '' === $ip ) { return true; } $key = 'rtkt_rl_' . sanitize_key( $bucket ) . '_' . md5( $ip ); $count = (int) get_transient( $key ); if ( $count >= (int) Settings::get( 'rate_limit' ) ) { return false; } set_transient( $key, $count + 1, HOUR_IN_SECONDS ); return true; } /** * Verify a Turnstile response with Cloudflare. * * @param string $response Widget response. * @return bool */ public static function verify_turnstile( $response ) { if ( '' === $response ) { return false; } $result = wp_remote_post( self::VERIFY_URL, array( 'timeout' => 8, 'body' => array( 'secret' => Settings::get( 'turnstile_secret' ), 'response' => $response, 'remoteip' => self::client_ip(), ), ) ); if ( is_wp_error( $result ) ) { // Cloudflare unreachable: don't block a legal statement on it. return true; } $body = json_decode( (string) wp_remote_retrieve_body( $result ), true ); return is_array( $body ) && ! empty( $body['success'] ); } }