• File: PushNotificationRestController.php
  • Full Path: /home/slvgwrnz/public_html/wp-content/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushNotificationRestController.php
  • Date Modified: 10/26/2026 10:38 AM
  • File size: 8.9 KB
  • MIME-type: text/x-php
  • Charset: utf-8
<?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\PushNotifications\Controllers;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\InternalNotificationDispatcher;
use Automattic\WooCommerce\Internal\PushNotifications\Enums\AuthorizationFailureReason;
use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationStepLogger;
use Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken;
use Exception;
use WC_Rate_Limiter;
use WP_Error;
use WP_Http;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;

/**
 * REST controller for the internal loopback send endpoint.
 *
 * Receives JWT-signed notification data from InternalNotificationDispatcher
 * and delegates each notification to NotificationProcessor.
 *
 * @since 10.7.0
 */
class PushNotificationRestController {

	/**
	 * The route namespace, shared with PushTokenRestController.
	 */
	const ROUTE_NAMESPACE = 'wc-push-notifications';

	/**
	 * Rate limit key for the refused loopback line. One key for the whole store,
	 * since an unauthenticated caller can vary anything of its own for free.
	 */
	const AUTH_FAILURE_LOG_RATE_LIMIT_ID = 'push_notification_auth_failure_log';

	/**
	 * Seconds between refused loopback lines.
	 */
	const AUTH_FAILURE_LOG_RATE_LIMIT_SECONDS = 60;

	/**
	 * The response message for each authorization failure.
	 */
	const AUTH_FAILURE_MESSAGES = array(
		AuthorizationFailureReason::CREDENTIAL_MISSING => '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<array<string, mixed>> $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<array<string, mixed>> $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<array<string, mixed>> $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<array<string, mixed>> $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<array<string, mixed>> $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 ) : '';
	}
}