';
}
// 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'] );
}
}