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.
*/