|false|null */ private static $state = null; /** * Hook registration. Runs on plugins_loaded, before WooCommerce creates its * session and before WordPress decides the current user. */ public static function init(): void { add_filter( 'determine_current_user', array( __CLASS__, 'current_user' ), 30 ); add_filter( 'woocommerce_session_handler', array( __CLASS__, 'session_handler' ), PHP_INT_MAX ); add_filter( 'woocommerce_persistent_cart_enabled', static fn( $on ) => self::active() ? false : $on ); add_action( 'wp_loaded', array( __CLASS__, 'guard_request' ), 1 ); add_action( 'template_redirect', array( __CLASS__, 'guard_pages' ), 0 ); add_action( 'admin_init', array( __CLASS__, 'guard_admin' ), 0 ); add_action( 'login_init', array( __CLASS__, 'guard_login' ), 0 ); add_filter( 'rest_pre_dispatch', array( __CLASS__, 'guard_rest' ), 0, 3 ); add_action( 'send_headers', array( __CLASS__, 'no_cache' ) ); // If something decided the current user before this plugin booted, // correct it now, or a login cookie in the agent's browser would win. if ( did_action( 'set_current_user' ) && self::active() ) { wp_set_current_user( (int) self::state()['user_id'] ); } } /** * Whether this request carries a valid agent Session cookie. */ public static function active(): bool { return is_array( self::state() ); } /** * The Session behind the cookie, resolved once per request. * * @return array|false */ public static function state() { if ( null !== self::$state ) { return self::$state; } self::$state = false; $token = isset( $_COOKIE[ AgentAdapt_Browser_Session::COOKIE ] ) ? sanitize_text_field( wp_unslash( $_COOKIE[ AgentAdapt_Browser_Session::COOKIE ] ) ) : ''; if ( '' === $token ) { return false; } global $wpdb; $row = str_starts_with( $token, AgentAdapt_Browser_Session::PREFIX ) && strlen( $token ) < 100 ? $wpdb->get_row( $wpdb->prepare( "SELECT session_id, expires FROM %i WHERE token_hash = %s AND kind = 'browser'", AgentAdapt_Schema::table( 'tokens' ), hash( 'sha256', $token ) ), ARRAY_A ) // phpcs:ignore WordPress.DB.DirectDatabaseQuery : null; $session = $row && (int) $row['expires'] >= time() ? AgentAdapt_Sessions::get( (string) $row['session_id'] ) : null; $agent = $session ? $wpdb->get_row( $wpdb->prepare( 'SELECT client_id, client_hash, name, status FROM %i WHERE client_hash = %s', AgentAdapt_Schema::table( 'agents' ), $session['client_hash'] ), ARRAY_A ) : null; // phpcs:ignore WordPress.DB.DirectDatabaseQuery if ( ! $session || ! $agent || ! AgentAdapt_Agents::accepted( (string) $agent['client_hash'], (string) $agent['status'] ) ) { // Ended Session or blocked agent: the cookie stops working now. AgentAdapt_Browser_Session::clear_cookie(); return false; } $user_id = (int) $session['user_id']; $scopes = array(); if ( $user_id > 0 ) { $grant = '' !== (string) $session['grant_id'] ? AgentAdapt_Grants::get( (string) $session['grant_id'] ) : null; if ( ! $grant || (int) $grant['revoked'] || (int) $grant['expires'] < time() ) { $user_id = 0; } elseif ( ! AgentAdapt_Eligibility::eligible( $user_id ) ) { AgentAdapt_Grants::revoke_user( $user_id, 'no_longer_eligible' ); $user_id = 0; } else { $scopes = array_values( array_filter( explode( ' ', (string) $grant['scope'] ) ) ); } } self::$state = array( 'session_id' => (string) $session['session_id'], 'client_hash' => (string) $session['client_hash'], 'subject_id' => (int) $session['subject_id'], 'wc_session_key' => (string) $session['wc_session_key'], 'user_id' => $user_id, 'scopes' => $scopes, 'agent' => (string) $agent['name'], ); return self::$state; } /** * The browser is the Session: its account, or a guest. Overrides any other * login cookie in the same browser. * * @param int|false $user_id What WordPress determined so far. * @return int|false */ public static function current_user( $user_id ) { $s = self::state(); return is_array( $s ) ? (int) $s['user_id'] : $user_id; } /** * The Session's WooCommerce cart is the browser's cart. * * @param string $class Session handler class. */ public static function session_handler( $class ) { $s = self::state(); if ( ! is_array( $s ) ) { return $class; } AgentAdapt_WC_Session::$next_key = $s['wc_session_key']; AgentAdapt_WC_Session::$save_on_shutdown = true; return 'AgentAdapt_WC_Session'; } /** * Never cache a page served to an agent's browser. */ public static function no_cache(): void { if ( ! self::active() ) { return; } nocache_headers(); if ( ! defined( 'DONOTCACHEPAGE' ) ) { define( 'DONOTCACHEPAGE', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- the cache plugins' shared convention. } } /** * The decision, from what the request is. * * @param array{always_deny: bool, cart: bool, write: bool, method: string} $req Classified request. * @return true|string True, or the reason it is refused. */ public static function decide( array $req ) { $s = self::state(); if ( ! is_array( $s ) ) { return true; } if ( $req['always_deny'] ) { return 'not_allowed_for_agents'; } if ( $req['cart'] ) { return true; } $reading = in_array( $req['method'], array( 'GET', 'HEAD' ), true ) && ! $req['write']; if ( $reading || 0 === (int) $s['user_id'] ) { // Reads are fine; a signed-out Session is just a guest. return true; } return in_array( AgentAdapt_Signin::WRITE, $s['scopes'], true ) ? true : 'needs_write_access'; } /** * Form posts and action links, before WooCommerce's form handler runs * (it acts on wp_loaded at priority 20). */ public static function guard_request(): void { if ( ! self::active() || is_admin() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { return; } // phpcs:disable WordPress.Security.NonceVerification -- classification only; WooCommerce verifies its own nonces for whatever we let through. $post = $_POST; $get = $_GET; $method = AgentAdapt_Http::method(); $action = isset( $post['action'] ) ? sanitize_key( wp_unslash( $post['action'] ) ) : ''; $wc_ajax = isset( $get['wc-ajax'] ) ? sanitize_key( wp_unslash( $get['wc-ajax'] ) ) : ''; // phpcs:enable $always = 'save_account_details' === $action || isset( $post['woocommerce-login-nonce'] ) || isset( $post['login'] ) || isset( $post['woocommerce-register-nonce'] ) || isset( $post['register'] ) || isset( $post['woocommerce-lost-password-nonce'] ) || isset( $post['wc_reset_password'] ) || isset( $get['delete-payment-method'] ) || isset( $get['set-default-payment-method'] ) || isset( $post['woocommerce_add_payment_method'] ) || isset( $post['woocommerce_checkout_place_order'] ) || isset( $post['woocommerce-process-checkout-nonce'] ) || 'checkout' === $wc_ajax; $cart = isset( $get['add-to-cart'] ) || isset( $post['add-to-cart'] ) || isset( $post['update_cart'] ) || isset( $post['apply_coupon'] ) || isset( $get['remove_item'] ) || isset( $get['undo_item'] ) || isset( $get['order_again'] ) || in_array( $wc_ajax, array( 'add_to_cart', 'remove_from_cart', 'apply_coupon', 'remove_coupon', 'get_refreshed_fragments', 'update_order_review', 'update_shipping_method', 'get_cart_totals' ), true ); $write = isset( $get['cancel_order'] ) || 'edit_address' === $action; self::enforce( self::decide( array( 'always_deny' => $always, 'cart' => $cart, 'write' => $write, 'method' => $method ) ), false ); } /** * Account screens an agent never uses, even to read. */ public static function guard_pages(): void { if ( ! self::active() || ! function_exists( 'is_wc_endpoint_url' ) ) { return; } foreach ( array( 'edit-account', 'payment-methods', 'add-payment-method', 'delete-payment-method', 'set-default-payment-method', 'lost-password' ) as $endpoint ) { if ( is_wc_endpoint_url( $endpoint ) ) { self::enforce( 'not_allowed_for_agents', false ); } } } /** * wp-admin is never an agent's. admin-ajax follows the form rules. */ public static function guard_admin(): void { if ( ! self::active() ) { return; } if ( ! wp_doing_ajax() ) { self::enforce( 'not_allowed_for_agents', false ); } self::enforce( self::decide( array( 'always_deny' => false, 'cart' => false, 'write' => true, 'method' => AgentAdapt_Http::method() ) ), false ); } /** * An agent never types credentials into the website (Guides). */ public static function guard_login(): void { if ( self::active() ) { self::enforce( 'not_allowed_for_agents', false ); } } /** * REST from an agent's browser. * * @param mixed $result Pre-dispatch result. * @param WP_REST_Server $server Server. * @param WP_REST_Request $request Request. * @return mixed */ public static function guard_rest( $result, $server, $request ) { if ( null !== $result || ! self::active() ) { return $result; } $route = (string) $request->get_route(); $method = $request->get_method(); if ( str_starts_with( $route, '/' . AgentAdapt_Rest::NS . '/' ) ) { return $result; // Our API authenticates by Session Token, not this cookie. } $checkout = str_starts_with( $route, '/wc/store/v1/checkout' ) && 'GET' !== $method; $cart = str_starts_with( $route, '/wc/store/v1/cart' ) || str_starts_with( $route, '/wc/store/v1/batch' ); $verdict = self::decide( array( 'always_deny' => $checkout, 'cart' => $cart && ! $checkout, 'write' => ! in_array( $method, array( 'GET', 'HEAD' ), true ), 'method' => $method ) ); if ( true !== $verdict ) { self::record_denial( $verdict, $route ); return new WP_Error( 'agentadapt_web_forbidden', self::message( $verdict ), array( 'status' => 403 ) ); } // Writes outside the cart are refused even with write access: through // REST they could reach user and settings routes a form never would. if ( ! $cart && ! in_array( $method, array( 'GET', 'HEAD' ), true ) ) { self::record_denial( 'not_allowed_for_agents', $route ); return new WP_Error( 'agentadapt_web_forbidden', self::message( 'not_allowed_for_agents' ), array( 'status' => 403 ) ); } return $result; } /** * Refuse a page request with an explanation. * * @param true|string $verdict Decision. * @param bool $json Answer JSON instead of a page. */ private static function enforce( $verdict, bool $json ): void { if ( true === $verdict ) { return; } self::record_denial( $verdict, AgentAdapt_Router::request_path() ); if ( $json || wp_doing_ajax() ) { wp_send_json_error( array( 'error' => 'agentadapt_web_forbidden', 'error_description' => self::message( $verdict ) ), 403 ); } AgentAdapt_Pages::send( 403, __( 'Your assistant can\'t do this here', 'agent-adapt' ), '

' . esc_html( self::message( $verdict ) ) . '

' ); } /** * Plain words for a refusal. * * @param string $verdict Reason code. */ private static function message( string $verdict ): string { return 'needs_write_access' === $verdict ? __( 'The customer has allowed this assistant to view their account, not to make changes.', 'agent-adapt' ) : __( 'Personal assistants can\'t use this part of the store. To check out, the assistant can give the customer a checkout link.', 'agent-adapt' ); } /** * Event for a refusal (the "where agents get stuck" signal, PLAN §12.1). * * @param string $verdict Reason. * @param string $where Path or route. */ private static function record_denial( string $verdict, string $where ): void { $s = self::state(); if ( is_array( $s ) ) { AgentAdapt_Events::record( 'web.denied', null, array( 'client_hash' => $s['client_hash'], 'subject_id' => $s['subject_id'], 'session_id' => $s['session_id'], 'channel' => 'web', 'outcome' => $verdict, 'meta' => array( 'where' => substr( $where, 0, 120 ) ) ) ); } } }