Orders that got their hold note in this request. */ private static $noted = array(); /** @var bool|null Whether this request is a verified agent and signed agents skip the captcha. */ private static $fast = null; /** * @return array */ public static function defaults() { return array( 'fast_lane' => false, 'unattended' => 'allow', 'likely' => 'allow', ); } /** * @param array $input * @return array */ public static function sanitize( array $input ) { return array( 'fast_lane' => ! empty( $input['fast_lane'] ), 'unattended' => isset( $input['unattended'] ) && in_array( $input['unattended'], array( 'allow', 'hold', 'block' ), true ) ? $input['unattended'] : 'allow', 'likely' => isset( $input['likely'] ) && in_array( $input['likely'], array( 'allow', 'hold' ), true ) ? $input['likely'] : 'allow', ); } /** * @return array */ public static function rules() { $saved = get_option( self::OPTION, array() ); return self::sanitize( is_array( $saved ) ? $saved : array() ); } /** * What to do with a checkout. * * @param array{verdict:string,confidence:string} $result The stamp the order would get. * @param array $rules * @return array{action:string,rule:string,message:string} */ public static function decide( array $result, array $rules ) { $allow = array( 'action' => 'allow', 'rule' => '', 'message' => '', ); if ( 'agent' === $result['verdict'] && 'signed' === $result['confidence'] ) { return $allow; } if ( 'unattended' === $result['verdict'] && in_array( $rules['unattended'], array( 'hold', 'block' ), true ) ) { return array( 'action' => $rules['unattended'], 'rule' => 'unattended', 'message' => 'block' === $rules['unattended'] ? 'This store does not take a checkout with no person and no agent name. If you are a shopping agent, sign your requests with Web Bot Auth or enter your product name in the agent name field.' : '', ); } if ( 'likely' === $result['verdict'] && 'hold' === $rules['likely'] ) { return array( 'action' => 'hold', 'rule' => 'likely', 'message' => '', ); } return $allow; } /** * Hooks that must exist before other plugins read them. hCaptcha reads its allow list * at plugins_loaded, so this runs when the plugin file loads. Nothing is checked until * a captcha plugin asks. */ public static function early() { add_filter( 'hcap_whitelist_ip', array( __CLASS__, 'allow_captcha' ), PHP_INT_MAX ); add_filter( 'hcap_protect_form', array( __CLASS__, 'protect_form' ), PHP_INT_MAX ); add_filter( 'cfturnstile_whitelisted', array( __CLASS__, 'allow_captcha' ), PHP_INT_MAX ); } /** * @param mixed $allowed * @return bool */ public static function allow_captcha( $allowed ) { return (bool) $allowed || self::fast_lane_applies(); } /** * @param mixed $protect * @return mixed */ public static function protect_form( $protect ) { return self::fast_lane_applies() ? false : $protect; } /** * Is this a verified agent, and do signed agents skip the captcha? Checked once per request. * Only a known operator's signature is checked here, so a made-up Signature-Agent costs nothing. * * @return bool */ public static function fast_lane_applies() { if ( null !== self::$fast ) { return self::$fast; } self::$fast = false; $saved = get_option( self::OPTION, array() ); if ( ! is_array( $saved ) || empty( $saved['fast_lane'] ) || ! AgentStamp_Sensor::has_signature() || '' === AgentStamp_Sensor::signer() ) { return false; } $result = AgentStamp_Sensor::classification(); self::$fast = 'agent' === $result['verdict'] && 'signed' === $result['confidence']; if ( self::$fast ) { do_action( 'agent_stamp_fast_lane', $result ); } return self::$fast; } public static function init() { add_action( 'woocommerce_checkout_process', array( __CLASS__, 'enforce_classic' ), 5 ); add_action( 'woocommerce_store_api_checkout_update_order_from_request', array( __CLASS__, 'enforce_store_api' ), 5, 2 ); add_action( 'woocommerce_pre_payment_complete', array( __CLASS__, 'payment_starts' ) ); add_filter( 'woocommerce_payment_complete_order_status', array( __CLASS__, 'hold_status' ), 20, 3 ); add_filter( 'woocommerce_cod_process_payment_order_status', array( __CLASS__, 'hold_cod_status' ), 20, 2 ); } /** * The stamp this checkout request would get. * * @return array{verdict:string,name:string,confidence:string,detail:string} */ public static function checkout_result() { $request = AgentStamp_Sensor::request(); // WooCommerce checks its checkout nonce before order creation. These fields only label and route the order. $client = AgentStamp_Witness::read_client( isset( $_POST ) && is_array( $_POST ) ? $_POST : array(), // phpcs:ignore WordPress.Security.NonceVerification.Missing isset( $_COOKIE ) && is_array( $_COOKIE ) ? $_COOKIE : array(), isset( $request->headers['cf-bot-score'] ) ? $request->headers['cf-bot-score'] : null ); return AgentStamp_Witness::merge( AgentStamp_Sensor::classification(), $client ); } /** * @return array{action:string,rule:string,message:string} */ private static function checkout_decision() { $rules = self::rules(); $allow = array( 'action' => 'allow', 'rule' => '', 'message' => '', ); if ( 'block' !== $rules['unattended'] && ! apply_filters( 'agent_stamp_checkout_rules_active', false ) ) { return $allow; } $result = self::checkout_result(); $decision = (array) apply_filters( 'agent_stamp_checkout_decision', self::decide( $result, $rules ), $result ); return self::final_decision( $decision, $result ); } /** * Whatever another plugin decided, a signed agent goes through and a malformed answer allows. * * @param array $decision * @param array $result * @return array{action:string,rule:string,message:string} */ public static function final_decision( array $decision, array $result ) { $allow = array( 'action' => 'allow', 'rule' => '', 'message' => '', ); if ( 'agent' === $result['verdict'] && 'signed' === $result['confidence'] ) { return $allow; } if ( ! isset( $decision['action'] ) || ! in_array( $decision['action'], array( 'allow', 'hold', 'block' ), true ) ) { return $allow; } return array( 'action' => $decision['action'], 'rule' => isset( $decision['rule'] ) ? sanitize_key( (string) $decision['rule'] ) : '', 'message' => isset( $decision['message'] ) && '' !== (string) $decision['message'] ? (string) $decision['message'] : 'This checkout could not be completed. Please contact the store.', ); } public static function enforce_classic() { $decision = self::checkout_decision(); if ( 'block' !== $decision['action'] ) { return; } AgentStamp_Sensor::note_checkout( self::checkout_result(), 'policy', $decision['message'] ); wc_add_notice( $decision['message'], 'error', array( 'id' => 'agent_stamp_' . $decision['rule'] ) ); } /** * @param WC_Order $order * @param WP_REST_Request $request */ public static function enforce_store_api( $order, $request = null ) { $decision = self::checkout_decision(); if ( 'block' !== $decision['action'] ) { return; } AgentStamp_Sensor::note_checkout( self::checkout_result(), 'policy', $decision['message'] ); if ( class_exists( '\Automattic\WooCommerce\StoreApi\Exceptions\RouteException' ) ) { throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException( esc_html( 'agent_stamp_' . $decision['rule'] ), esc_html( $decision['message'] ), 403 ); } throw new Exception( esc_html( $decision['message'] ) ); } /** * Mark an order that a rule holds, or that waits for the buyer's answer. * * @param WC_Order $order * @param array $result * @param string $via */ public static function mark_order( $order, array $result, $via ) { $rule = self::decide( $result, self::rules() ); if ( 'hold' === $rule['action'] ) { $order->update_meta_data( '_agent_stamp_hold', $rule['rule'] ); } do_action( 'agent_stamp_order_marked', $order, $result, $via, $rule['action'] ); } /** * @param int $order_id */ public static function payment_starts( $order_id ) { self::$completing = (int) $order_id; } /** * Paid orders a rule holds go to on-hold instead of processing. * * WooCommerce also asks this filter as a question on every status change ("would a paid * order have this status?") to decide whether to set the paid date. Only the call inside * payment_complete() is a payment, so only that one is answered with a hold. * * @param string $status * @param int $order_id * @param WC_Order|null $order * @return string */ public static function hold_status( $status, $order_id = 0, $order = null ) { if ( 0 === self::$completing || (int) $order_id !== self::$completing ) { return $status; } self::$completing = 0; if ( ! is_object( $order ) && function_exists( 'wc_get_order' ) ) { $order = wc_get_order( $order_id ); } return self::apply_hold( $status, $order ); } /** * Cash on delivery sets its status itself, once, when the order is placed. * * @param string $status * @param WC_Order|null $order * @return string */ public static function hold_cod_status( $status, $order = null ) { return self::apply_hold( $status, $order ); } /** * @param string $status * @param WC_Order|null $order * @return string */ public static function apply_hold( $status, $order ) { if ( ! is_object( $order ) || ! method_exists( $order, 'get_meta' ) ) { return $status; } $hold = (string) $order->get_meta( '_agent_stamp_hold' ); if ( '' === $hold || ! in_array( $status, array( 'processing', 'completed' ), true ) ) { return $status; } // Another plugin that set its own hold can let the payment through, for example once a buyer answered. if ( apply_filters( 'agent_stamp_hold_released', false, $hold, $order ) ) { return $status; } // WooCommerce does not count on-hold as paid. Remember that this one was, so a later release can restore it. $order->update_meta_data( '_agent_stamp_held_paid', $status ); $id = (int) $order->get_id(); if ( empty( self::$noted[ $id ] ) ) { self::$noted[ $id ] = true; $order->add_order_note( self::hold_note( $hold ) ); } return 'on-hold'; } /** * @param string $hold * @return string */ public static function hold_note( $hold ) { if ( 'likely' === $hold ) { $note = __( 'Agent Stamp: on hold for review because your rule holds Likely agent orders.', 'agent-stamp' ); } elseif ( 'unattended' === $hold ) { $note = __( 'Agent Stamp: on hold for review because your rule holds Unattended orders.', 'agent-stamp' ); } else { $note = __( 'Agent Stamp: on hold for review.', 'agent-stamp' ); } return (string) apply_filters( 'agent_stamp_hold_note', $note, $hold ); } /** * The address WooCommerce records on the order, so the guard counts what the order shows. * WooCommerce reads X-Real-IP and X-Forwarded-For first, which a client can set when no proxy * overwrites them. Behind a proxy that you trust, filter agent_stamp_client_ip to its header. * * @return string */ public static function client_ip() { $ip = class_exists( 'WC_Geolocation' ) ? (string) WC_Geolocation::get_ip_address() : ( isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '' ); return (string) apply_filters( 'agent_stamp_client_ip', $ip ); } }