session ) { return; } if ( (int) WC()->session->get( self::SESSION_KEY, 0 ) > 0 ) { return; } WC()->session->set( self::SESSION_KEY, time() ); } /** * Record the time-trap start for a session whose cart already holds items, * e.g. one that predates plugin activation or a restored persistent cart. * Empty carts are skipped so no session is written for every visitor. * * @param \WC_Cart $cart Cart loaded from the session. * * @return void */ public static function mark_session_start_for_cart( $cart ) { if ( $cart instanceof WC_Cart && ! $cart->is_empty() ) { self::mark_session_start(); } } /** * Get the time-trap start from the WooCommerce session. * * @return int Unix timestamp, or 0 when unknown. */ private static function get_session_start() { if ( ! function_exists( 'WC' ) || ! WC()->session ) { return 0; } return max( 0, (int) WC()->session->get( self::SESSION_KEY, 0 ) ); } /** * Signature for a time-trap timestamp, bound to the current WooCommerce * session so a value scraped once cannot be replayed by other sessions. * * @param int $t0 Unix timestamp. * * @return string */ private static function timestamp_signature( $t0 ) { $session_id = ''; if ( function_exists( 'WC' ) && WC()->session && method_exists( WC()->session, 'get_customer_id' ) ) { $session_id = (string) WC()->session->get_customer_id(); } return wp_hash( 'ordersieve_t0|' . $t0 . '|' . $session_id, 'nonce' ); } /** * Sign a timestamp so the rendered time-trap field cannot be tampered with. * * @param int $t0 Unix timestamp. * * @return string "timestamp|signature". */ private static function sign_timestamp( $t0 ) { return $t0 . '|' . self::timestamp_signature( $t0 ); } /** * Verify a signed timestamp from the checkout form. * * @param string $value Posted "timestamp|signature" value. * * @return int Unix timestamp, or 0 when missing or tampered. */ private static function verify_timestamp( $value ) { $parts = explode( '|', (string) $value, 2 ); if ( 2 !== count( $parts ) || ! preg_match( '/^\d{1,12}$/', $parts[0] ) ) { return 0; } $t0 = (int) $parts[0]; if ( $t0 < 1 || ! hash_equals( self::timestamp_signature( $t0 ), $parts[1] ) ) { return 0; } return $t0; } /** * Render the hidden honeypot field and the signed time-trap timestamp. * Rendered off-screen with inline CSS: invisible to humans, visible to bots. * * @return void */ public static function render_fields() { if ( ! self::enabled() ) { return; } ?>
0 && apply_filters( 'ordersieve_run_time_trap', true ); } /** * Whether a checkout started at $t0 is too fast to be human. * * Unknown start (0) never blocks: it only happens for sessions that * predate plugin activation, and failing open is safer than losing a sale. * * @param int $t0 Start timestamp, 0 if unknown. * @param array $settings Plugin settings. * * @return bool */ private static function too_fast( $t0, $settings ) { if ( $t0 < 1 ) { return false; } return ( time() - $t0 ) < max( 0, (int) $settings['min_seconds'] ); } /** * Validate the classic checkout submission. * * @return void */ public static function validate() { if ( ! self::enabled() ) { return; } $settings = ordersieve_get_settings(); // Rule 1: honeypot. Any value means a bot filled the hidden field; an // array (field[]=x) counts too, since a browser never sends one. $honeypot_filled = isset( $_POST[ self::HONEYPOT_FIELD ] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce checkout nonce covers this request. && ( ! is_scalar( $_POST[ self::HONEYPOT_FIELD ] ) || '' !== self::get_posted( self::HONEYPOT_FIELD ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( $honeypot_filled ) { self::block( 'honeypot', $settings ); return; } // Rule 2: time trap. Prefer the signed form timestamp (time spent on the // checkout page); fall back to the session start when the field is // missing (custom checkout templates), tampered with or replayed from // another session. if ( self::time_trap_active( $settings ) ) { $t0 = self::verify_timestamp( self::get_posted( self::TIME_FIELD ) ); if ( $t0 < 1 ) { $t0 = self::get_session_start(); } if ( self::too_fast( $t0, $settings ) ) { self::block( 'time-trap', $settings ); return; } } // Rule 3: disposable email domain. if ( self::is_disposable_email( sanitize_email( self::get_posted( 'billing_email' ) ), $settings ) ) { self::block( 'disposable-email', $settings ); return; } } /** * Read a posted classic checkout field as a sanitized string. * * Non-scalar values (e.g. field[]=x) are returned as an empty string so * they cannot trigger type errors in the string sanitizers. * * @param string $key POST key. * * @return string */ private static function get_posted( $key ) { if ( ! isset( $_POST[ $key ] ) || ! is_scalar( $_POST[ $key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- WooCommerce checkout nonce covers this request. return ''; } return sanitize_text_field( wp_unslash( (string) $_POST[ $key ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing } /** * Reject the classic checkout and log the attempt. * * @param string $reason Machine-readable block reason. * @param array $settings Plugin settings. * * @return void */ private static function block( $reason, $settings ) { self::log_attempt( $reason, sanitize_email( self::get_posted( 'billing_email' ) ), $settings ); wc_add_notice( self::get_block_message( $settings ), 'error' ); } /** * Validate a block-based (Store API) checkout when the order is placed. * * Runs while the draft order is populated from the request, before * WooCommerce creates a customer account (and logs it in), holds coupons, * reserves stock or takes payment, so a blocked bot leaves none of that * behind. Since WooCommerce 10.8 the draft order is only created when the * customer places the order, so its creation time is useless as a * time-trap start; the WooCommerce session start recorded by * mark_session_start() is used instead. * * @throws \Automattic\WooCommerce\StoreApi\Exceptions\RouteException When the order is blocked. * * @param \WC_Order $order Order being processed. * @param \WP_REST_Request $request Store API request. * * @return void */ public static function validate_store_api( $order, $request = null ) { if ( ! self::enabled() || ! $order instanceof WC_Order || ! $request instanceof WP_REST_Request ) { return; } // The hook also fires for PUT/PATCH draft updates while the customer // fills in the form, and for POST /checkout/{id} when paying for an // existing order; only screen the request that places a new order. if ( 'POST' !== $request->get_method() || ! preg_match( '#/checkout/?$#', (string) $request->get_route() ) ) { return; } if ( 'store-api' !== $order->get_created_via() ) { return; } $settings = ordersieve_get_settings(); $reason = ''; // Rule 1: time trap, based on the session start. if ( self::time_trap_active( $settings ) && self::too_fast( self::get_session_start(), $settings ) ) { $reason = 'time-trap'; } // Rule 2: disposable email domain. if ( '' === $reason && self::is_disposable_email( $order->get_billing_email(), $settings ) ) { $reason = 'disposable-email'; } if ( '' === $reason ) { return; } self::log_attempt( $reason, sanitize_email( $order->get_billing_email() ), $settings ); // Flag and cancel so the daily cleanup can remove the junk order. The // flag also suppresses the "cancelled order" admin email, see // suppress_cancelled_order_email(). $order->update_meta_data( self::META_KEY, $reason ); $order->update_status( 'cancelled' ); // WooCommerce drops the status-change note for orders leaving // checkout-draft, so record the reason as a note of its own. $order->add_order_note( sprintf( /* translators: %s: block reason */ __( 'Blocked by OrderSieve (%s).', 'ordersieve' ), $reason ) ); throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException( 'ordersieve_order_blocked', esc_html( self::get_block_message( $settings ) ), 400 ); } /** * Disable the "cancelled order" admin email for orders blocked by OrderSieve. * * Checked when the email is sent rather than toggled around the status * change, so it also holds when WooCommerce defers transactional emails. * * @param bool $enabled Whether the email is enabled. * @param \WC_Order|null $order Order the email is about. * * @return bool */ public static function suppress_cancelled_order_email( $enabled, $order ) { if ( $order instanceof WC_Order && '' !== (string) $order->get_meta( self::META_KEY ) ) { return false; } return $enabled; } /** * Customer-facing rejection message. * * @param array $settings Plugin settings. * * @return string */ private static function get_block_message( $settings ) { $message = trim( (string) $settings['block_message'] ); if ( '' === $message ) { $message = __( 'Your order could not be placed. Please contact us if you believe this is a mistake.', 'ordersieve' ); } /** * Filter the message shown to a blocked customer. * * @since 1.0.0 * * @param string $message Rejection message. */ return (string) apply_filters( 'ordersieve_block_message', $message ); } /** * Get the full list of blocked email domains (built-in + custom + filter). * * @param array $settings Plugin settings. * * @return string[] Lowercase domains. */ public static function get_blocked_domains( $settings ) { $domains = self::DISPOSABLE_DOMAINS; $custom = isset( $settings['custom_domains'] ) ? (string) $settings['custom_domains'] : ''; if ( '' !== trim( $custom ) ) { foreach ( preg_split( '/[\s,;]+/', strtolower( $custom ) ) as $domain ) { $domain = ltrim( trim( $domain ), '@' ); if ( '' !== $domain && false !== strpos( $domain, '.' ) ) { $domains[] = $domain; } } } /** * Filter the list of blocked email domains. * * @since 1.0.0 * * @param string[] $domains Lowercase domains. Subdomains of these are blocked too. */ $domains = apply_filters( 'ordersieve_disposable_domains', $domains ); return array_values( array_unique( array_map( 'strtolower', array_filter( (array) $domains, 'is_string' ) ) ) ); } /** * Whether an email address uses a blocked (disposable) domain or a subdomain of one. * * @param string $email Email address. * @param array $settings Plugin settings. * * @return bool */ public static function is_disposable_email( $email, $settings ) { if ( 'yes' !== $settings['block_disposable'] ) { return false; } $email = sanitize_email( (string) $email ); $domain = strtolower( (string) substr( strrchr( $email, '@' ), 1 ) ); if ( '' === $domain ) { return false; } foreach ( self::get_blocked_domains( $settings ) as $blocked ) { if ( $domain === $blocked || substr( $domain, - ( strlen( $blocked ) + 1 ) ) === '.' . $blocked ) { return true; } } return false; } /** * Write a blocked attempt to the WooCommerce log. * * @param string $reason Machine-readable block reason. * @param string $email Customer email, if known. * @param array $settings Plugin settings. * * @return void */ private static function log_attempt( $reason, $email, $settings ) { /** * Fires whenever OrderSieve blocks a checkout attempt. * * @since 1.0.0 * * @param string $reason Block reason: honeypot, time-trap or disposable-email. * @param string $email Billing email, if known. */ do_action( 'ordersieve_blocked', $reason, $email ); if ( 'yes' !== $settings['logging'] ) { return; } $ip = class_exists( 'WC_Geolocation' ) ? WC_Geolocation::get_ip_address() : ''; wc_get_logger()->warning( sprintf( /* translators: 1: reason, 2: email, 3: ip address */ __( 'Blocked checkout attempt. Reason: %1$s. Email: %2$s. IP: %3$s', 'ordersieve' ), $reason, '' !== $email ? $email : '(none)', $ip ), array( 'source' => 'ordersieve' ) ); } }