Commit ef3ca1c9282 for woocommerce

commit ef3ca1c92822223a82738fac740c4ca8dee6e61b
Author: Hannah Tinkler <hannah.tinkler@gmail.com>
Date:   Thu Sep 17 23:59:44 2026 +0300

    Accept the push notification send credential from the URL when hosts strip the Authorization header (#67577)

    * Send loopback push notification credential in URL as header fallback

    Add the send request's JWT as a query parameter alongside the
    Authorization header, and accept it at the receiving endpoint when the
    header is absent.

diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushNotificationRestController.php b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushNotificationRestController.php
index 265f9b92ebb..011ab031ed3 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushNotificationRestController.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushNotificationRestController.php
@@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Internal\PushNotifications\Controllers;

 defined( 'ABSPATH' ) || exit;

+use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\InternalNotificationDispatcher;
 use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
 use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
@@ -106,7 +107,9 @@ class PushNotificationRestController {
 	}

 	/**
-	 * Validates the JWT from the Authorization header.
+	 * 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
@@ -117,16 +120,20 @@ class PushNotificationRestController {
 	public function authorize( WP_REST_Request $request ) {
 		$header = trim( (string) $request->get_header( 'authorization' ) );

-		if ( empty( $header ) ) {
+		if ( '' !== $header ) {
+			$token = strncasecmp( $header, 'Bearer ', 7 ) === 0 ? substr( $header, 7 ) : $header;
+		} else {
+			$token = $this->get_token_from_query( $request );
+		}
+
+		if ( '' === $token ) {
 			return new WP_Error(
 				'woocommerce_rest_unauthorized',
-				'Missing authorization header.',
+				'Missing credential: no Authorization header and no token query parameter.',
 				array( 'status' => WP_Http::UNAUTHORIZED )
 			);
 		}

-		$token = strncasecmp( $header, 'Bearer ', 7 ) === 0 ? substr( $header, 7 ) : $header;
-
 		if ( ! JsonWebToken::validate( $token, wp_salt( 'auth' ) ) ) {
 			return new WP_Error(
 				'woocommerce_rest_unauthorized',
@@ -157,4 +164,24 @@ class PushNotificationRestController {

 		return true;
 	}
+
+	/**
+	 * 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 ) : '';
+	}
 }
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcher.php b/plugins/woocommerce/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcher.php
index 5cec21c20f9..b6e6ce6b220 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcher.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcher.php
@@ -26,6 +26,17 @@ class InternalNotificationDispatcher {
 	 */
 	const SEND_ENDPOINT = 'wc-push-notifications/send';

+	/**
+	 * Query parameter carrying the JWT as a fallback credential.
+	 *
+	 * Some hosting setups (e.g. nginx to PHP-FPM, or Plesk's nginx-to-Apache
+	 * proxying) do not pass the `Authorization` header through to PHP. On
+	 * those hosts a header-only credential silently fails and every
+	 * notification degrades to the slower ActionScheduler safety net. The
+	 * same token is therefore also sent in the URL, where nothing strips it.
+	 */
+	const TOKEN_QUERY_PARAM = 'wcpn_token';
+
 	/**
 	 * JWT expiry in seconds.
 	 */
@@ -69,9 +80,13 @@ class InternalNotificationDispatcher {
 		 * The request is non-blocking so the response is not handled anywhere.
 		 * If the request fails, the ActionScheduler safety net will pick up
 		 * unsent notifications after 60 seconds.
+		 *
+		 * The token travels both as an Authorization header and as a query
+		 * parameter: the receiver prefers the header and falls back to the
+		 * parameter on hosts that strip the header (see TOKEN_QUERY_PARAM).
 		 */
 		wp_remote_post(
-			rest_url( self::SEND_ENDPOINT ),
+			add_query_arg( self::TOKEN_QUERY_PARAM, $token, rest_url( self::SEND_ENDPOINT ) ),
 			array(
 				'blocking' => false,
 				'timeout'  => 1,
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushNotificationRestControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushNotificationRestControllerTest.php
index 52ae66a432f..2d136eedbe7 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushNotificationRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/PushNotificationRestControllerTest.php
@@ -5,6 +5,7 @@ declare( strict_types = 1 );
 namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Controllers;

 use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushNotificationRestController;
+use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\InternalNotificationDispatcher;
 use Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken;
 use WC_Unit_Test_Case;
 use WP_REST_Request;
@@ -66,9 +67,9 @@ class PushNotificationRestControllerTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Should reject requests without an authorization header.
+	 * @testdox Should reject requests with neither an authorization header nor a token query parameter.
 	 */
-	public function test_authorize_rejects_missing_header(): void {
+	public function test_authorize_rejects_missing_credential(): void {
 		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/send' );
 		$request->set_body( '{}' );

@@ -159,6 +160,123 @@ class PushNotificationRestControllerTest extends WC_Unit_Test_Case {
 		$this->assertTrue( $result );
 	}

+	/**
+	 * Builds a request shaped like the one the dispatcher sends: a JSON POST
+	 * whose credential is in the query string. The content type matters,
+	 * because {@see WP_REST_Request::set_param()} writes into whichever bucket
+	 * the parameter order puts first, so without it a parameter set here would
+	 * land in the POST body rather than the query string and the test would
+	 * pass without exercising the URL at all.
+	 *
+	 * @param string $body       The request body.
+	 * @param string $token      The credential, or an empty string to omit it.
+	 * @param string $auth_token Authorization header credential, or an empty string to omit the header.
+	 * @return WP_REST_Request
+	 */
+	private function build_request( string $body, string $token = '', string $auth_token = '' ): WP_REST_Request {
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/send' );
+		$request->set_header( 'Content-Type', 'application/json' );
+		$request->set_body( $body );
+
+		if ( '' !== $token ) {
+			$request->set_query_params( array( InternalNotificationDispatcher::TOKEN_QUERY_PARAM => $token ) );
+		}
+
+		if ( '' !== $auth_token ) {
+			$request->set_header( 'Authorization', 'Bearer ' . $auth_token );
+		}
+
+		return $request;
+	}
+
+	/**
+	 * Builds a token valid for the given body.
+	 *
+	 * @param string $body The body the token is signed over.
+	 * @return string
+	 */
+	private function build_token( string $body ): string {
+		return JsonWebToken::create(
+			array(
+				'iss'       => get_site_url(),
+				'exp'       => time() + 30,
+				'body_hash' => hash( 'sha256', $body ),
+			),
+			wp_salt( 'auth' )
+		);
+	}
+
+	/**
+	 * @testdox Should accept a valid JWT supplied via the token query parameter when the header is absent.
+	 */
+	public function test_authorize_accepts_valid_jwt_via_query_param(): void {
+		$body = '{"notifications":[]}';
+
+		$result = $this->sut->authorize( $this->build_request( $body, $this->build_token( $body ) ) );
+
+		$this->assertTrue( $result );
+	}
+
+	/**
+	 * @testdox Should reject an invalid JWT supplied via the token query parameter.
+	 */
+	public function test_authorize_rejects_invalid_jwt_via_query_param(): void {
+		$result = $this->sut->authorize( $this->build_request( '{}', 'invalid.token.here' ) );
+
+		$this->assertWPError( $result );
+	}
+
+	/**
+	 * The credential is sent in the URL, so the request body must never be
+	 * consulted for it. WP_REST_Request::get_param() searches the JSON body
+	 * before the query string, so reading the token that way would find this
+	 * one. Asserting on the missing-credential message rather than only on the
+	 * error proves the body was not read, since a body that was read would
+	 * produce a different rejection reason.
+	 *
+	 * @testdox Should not read the credential from the request body.
+	 */
+	public function test_authorize_does_not_read_credential_from_body(): void {
+		$body = (string) wp_json_encode(
+			array(
+				'notifications' => array(),
+				InternalNotificationDispatcher::TOKEN_QUERY_PARAM => $this->build_token( '{}' ),
+			)
+		);
+
+		$result = $this->sut->authorize( $this->build_request( $body ) );
+
+		$this->assertWPError( $result );
+		$this->assertStringContainsString( 'Missing credential', $result->get_error_message() );
+	}
+
+	/**
+	 * @testdox Should reject an array-valued token query parameter without a type error.
+	 */
+	public function test_authorize_rejects_array_token_query_param(): void {
+		$request = new WP_REST_Request( 'POST', '/wc-push-notifications/send' );
+		$request->set_header( 'Content-Type', 'application/json' );
+		$request->set_body( '{}' );
+		$request->set_query_params( array( InternalNotificationDispatcher::TOKEN_QUERY_PARAM => array( 'a', 'b' ) ) );
+
+		$result = $this->sut->authorize( $request );
+
+		$this->assertWPError( $result );
+	}
+
+	/**
+	 * @testdox Should prefer the Authorization header over the token query parameter.
+	 */
+	public function test_authorize_prefers_header_over_query_param(): void {
+		$body = '{"notifications":[]}';
+
+		$result = $this->sut->authorize(
+			$this->build_request( $body, $this->build_token( $body ), 'invalid.token.here' )
+		);
+
+		$this->assertWPError( $result );
+	}
+
 	/**
 	 * @testdox Should return success when no notifications are provided.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcherTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcherTest.php
index 5dccfce2da7..3bb04d41ed9 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcherTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Dispatchers/InternalNotificationDispatcherTest.php
@@ -80,6 +80,11 @@ class InternalNotificationDispatcherTest extends WC_Unit_Test_Case {
 	}

 	/**
+	 * The URL is decoded before asserting on it: on sites without pretty
+	 * permalinks rest_url() returns the `?rest_route=/...` form, and appending
+	 * the token with add_query_arg() re-encodes that existing query string, so
+	 * the raw URL carries `%2F` in place of the endpoint's slashes.
+	 *
 	 * @testdox Should fire a non-blocking POST to the send endpoint URL.
 	 */
 	public function test_dispatch_fires_non_blocking_post_to_send_endpoint(): void {
@@ -89,7 +94,7 @@ class InternalNotificationDispatcherTest extends WC_Unit_Test_Case {

 		$this->assertStringContainsString(
 			InternalNotificationDispatcher::SEND_ENDPOINT,
-			$this->captured_url,
+			urldecode( (string) $this->captured_url ),
 			'Request URL should contain the send endpoint'
 		);
 		$this->assertFalse(
@@ -136,6 +141,30 @@ class InternalNotificationDispatcherTest extends WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * The receiver prefers the Authorization header and falls back to the query
+	 * parameter on hosts that strip it, so the two credentials have to be the
+	 * same token - otherwise the fallback would validate against a different
+	 * body hash than the request it arrived with.
+	 *
+	 * @testdox Should repeat the Authorization header token in the URL query string.
+	 */
+	public function test_dispatch_repeats_token_in_url_query_string(): void {
+		$notifications = array( $this->create_order_mock( 1 ) );
+
+		$this->sut->dispatch( $notifications );
+
+		$header_token = str_replace( 'Bearer ', '', $this->captured_request['headers']['Authorization'] );
+		$query        = array();
+		wp_parse_str( (string) wp_parse_url( (string) $this->captured_url, PHP_URL_QUERY ), $query );
+
+		$this->assertSame(
+			$header_token,
+			$query[ InternalNotificationDispatcher::TOKEN_QUERY_PARAM ] ?? null,
+			'URL token should be the same token sent in the Authorization header'
+		);
+	}
+
 	/**
 	 * @testdox Should include encoded notifications in the request body.
 	 */