'Missing credential: no Authorization header and no token query parameter.', AuthorizationFailureReason::TOKEN_INVALID => 'Invalid or expired token.', AuthorizationFailureReason::ISSUER_INVALID => 'Invalid token issuer.', AuthorizationFailureReason::BODY_HASH_MISMATCH => 'Body hash mismatch.', ); /** * Registers the REST API route on the rest_api_init hook. * * @return void * * @since 10.7.0 */ public function register(): void { add_action( 'rest_api_init', array( $this, 'register_routes' ) ); } /** * Registers the send route. * * @return void * * @since 10.7.0 */ public function register_routes(): void { register_rest_route( self::ROUTE_NAMESPACE, 'send', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create' ), 'permission_callback' => array( $this, 'authorize' ), ) ); } /** * Processes the send request by delegating each notification to the * processor. * * @param WP_REST_Request $request The request object. * @phpstan-param WP_REST_Request> $request * @return WP_REST_Response|WP_Error * * @since 10.7.0 */ public function create( WP_REST_Request $request ) { wc_set_time_limit( 30 ); $body = json_decode( $request->get_body(), true ); $notifications = is_array( $body ) ? ( $body['notifications'] ?? array() ) : array(); $success_response = new WP_REST_Response( array( 'success' => true ), WP_Http::OK ); $step_logger = wc_get_container()->get( NotificationStepLogger::class ); if ( empty( $notifications ) || ! is_array( $notifications ) ) { $step_logger->log_unattributed_failure( 'loopback_started', 'malformed_body', 'warning', 'Loopback endpoint received empty or missing notifications array.' ); return $success_response; } $processor = wc_get_container()->get( NotificationProcessor::class ); foreach ( $notifications as $data ) { try { $notification = Notification::from_array( $data ); } catch ( Exception $e ) { $step_logger->log_unattributed_failure( 'loopback_started', 'invalid_notification', 'error', sprintf( 'Failed to process notification: %s', $e->getMessage() ), array( 'type' => is_array( $data ) ? (string) ( $data['type'] ?? '' ) : '', 'resource_id' => is_array( $data ) ? (int) ( $data['resource_id'] ?? 0 ) : 0, ) ); continue; } $step_logger->log_notification_step( $notification, 'loopback_started', 'ok' ); try { $processor->process( $notification ); } catch ( Exception $e ) { $step_logger->log_failure( $notification, 'processing', 'exception', 'error', sprintf( 'Failed to process notification: %s', $e->getMessage() ) ); } } return $success_response; } /** * Validates the JWT from the Authorization header, falling back to the * token query parameter on hosts that strip the header before it reaches * PHP (see {@see InternalNotificationDispatcher::TOKEN_QUERY_PARAM}). * * @param WP_REST_Request $request The request object. * @phpstan-param WP_REST_Request> $request * @return true|WP_Error * * @since 10.7.0 */ public function authorize( WP_REST_Request $request ) { $reason = $this->find_authorization_failure( $request ); if ( null === $reason ) { return true; } $this->log_authorization_failure( $request, $reason ); return new WP_Error( 'woocommerce_rest_unauthorized', self::AUTH_FAILURE_MESSAGES[ $reason ], array( 'status' => WP_Http::UNAUTHORIZED ) ); } /** * Checks the JWT from the Authorization header against the request. * * @param WP_REST_Request $request The request object. * @phpstan-param WP_REST_Request> $request * @return string|null One of the AuthorizationFailureReason constants, or null when authorized. */ private function find_authorization_failure( WP_REST_Request $request ): ?string { $header = trim( (string) $request->get_header( 'authorization' ) ); if ( '' !== $header ) { $token = strncasecmp( $header, 'Bearer ', 7 ) === 0 ? substr( $header, 7 ) : $header; } else { $token = $this->get_token_from_query( $request ); } if ( '' === $token ) { return AuthorizationFailureReason::CREDENTIAL_MISSING; } if ( ! JsonWebToken::validate( $token, wp_salt( 'auth' ) ) ) { return AuthorizationFailureReason::TOKEN_INVALID; } $parts = JsonWebToken::get_parts( $token ); if ( ! isset( $parts->payload->iss ) || get_site_url() !== $parts->payload->iss ) { return AuthorizationFailureReason::ISSUER_INVALID; } $body_hash = hash( 'sha256', $request->get_body() ); if ( ! isset( $parts->payload->body_hash ) || ! hash_equals( (string) $parts->payload->body_hash, $body_hash ) ) { return AuthorizationFailureReason::BODY_HASH_MISMATCH; } return null; } /** * Records a refused loopback request, but only when the body looks like * one of ours. * * The route is public until authorization passes, so logging every refused * request would let anyone write to the store's log by POSTing here. A * stripped header or an expired token still arrives with our body; a * scanner never does. * * The body check alone is not enough, since the source is public and the * route is unauthenticated, so the line is also rate limited store-wide. A * genuine incident still records a line a minute and a flood costs one. * * @param WP_REST_Request $request The request object. * @phpstan-param WP_REST_Request> $request * @param string $reason One of the AuthorizationFailureReason constants. * @return void */ private function log_authorization_failure( WP_REST_Request $request, string $reason ): void { $body = json_decode( $request->get_body(), true ); if ( ! is_array( $body ) || ! array_key_exists( 'notifications', $body ) ) { return; } if ( WC_Rate_Limiter::retried_too_soon( self::AUTH_FAILURE_LOG_RATE_LIMIT_ID ) ) { return; } WC_Rate_Limiter::set_rate_limit( self::AUTH_FAILURE_LOG_RATE_LIMIT_ID, self::AUTH_FAILURE_LOG_RATE_LIMIT_SECONDS ); wc_get_container()->get( NotificationStepLogger::class )->log_unattributed_failure( 'loopback_started', 'auth_failed', 'warning', sprintf( 'Loopback request refused: %s', self::AUTH_FAILURE_MESSAGES[ $reason ] ), array( 'reason' => $reason, 'notifications' => is_array( $body['notifications'] ) ? count( $body['notifications'] ) : 0, ) ); } /** * Reads the credential from the query string. * * Reads the query parameters directly rather than through * {@see WP_REST_Request::get_param()}, which searches the JSON body and the * POST body first and is reorderable by the `rest_request_parameter_order` * filter. The credential is sent in the URL, so that is the only place it * should be read from. * * @param WP_REST_Request $request The request object. * @phpstan-param WP_REST_Request> $request * @return string The token, or an empty string when absent or not a string. */ private function get_token_from_query( WP_REST_Request $request ): string { $params = $request->get_query_params(); $token = $params[ InternalNotificationDispatcher::TOKEN_QUERY_PARAM ] ?? ''; return is_string( $token ) ? trim( $token ) : ''; } }