From 854c1ec183e876d7276b3b5ea7ee41891b279159 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 24 Sep 2025 12:58:25 -0400 Subject: [PATCH 1/3] feat: Allow admin-ajax CORS requests for Android WebViews The Jetpack mobile app block editor relies upon Android WebViews for serving local Gutenberg files. Editor requests then originate from the platform-default `https://appassets.androidplatform.net` origin. To enable `admin-ajax.php` requests from the Android app, we must allow CORS requests from this origin. --- ...ss-jetpack-application-password-extras.php | 100 ++++++++ ...Application_Password_Android_CORS_Test.php | 219 ++++++++++++++++++ ...tpack_Application_Password_Extras_Test.php | 20 ++ 3 files changed, 339 insertions(+) create mode 100644 projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_Application_Password_Android_CORS_Test.php diff --git a/projects/plugins/jetpack/_inc/lib/class-jetpack-application-password-extras.php b/projects/plugins/jetpack/_inc/lib/class-jetpack-application-password-extras.php index 6df97691fa6cb..ab62dd97e3481 100644 --- a/projects/plugins/jetpack/_inc/lib/class-jetpack-application-password-extras.php +++ b/projects/plugins/jetpack/_inc/lib/class-jetpack-application-password-extras.php @@ -16,12 +16,23 @@ * Extends Application Password functionality beyond the REST API. */ class Jetpack_Application_Password_Extras { + /** + * Allowed CORS origins for AJAX requests + */ + const ALLOWED_AJAX_CORS_ORIGINS = array( + 'https://appassets.androidplatform.net', // Android WebView + ); /** * Initialize the main hooks. */ public static function init() { add_filter( 'application_password_is_api_request', array( __CLASS__, 'application_password_extras' ) ); + // Use a hook that runs early, before send_origin_headers, which exits before + // the `send_origin_headers` function is called. + // https://github.com/WordPress/wordpress-develop/blob/3c3852e8a2a70c4f09233ffe5bce03576a687130/src/wp-includes/http.php#L525-L527 + add_action( 'wp_loaded', array( __CLASS__, 'add_ajax_preflight_headers' ), 5 ); + add_filter( 'allowed_http_origins', array( __CLASS__, 'allow_ajax_cors_origins' ) ); } /** @@ -59,4 +70,93 @@ public static function get_abilities() { 'post-previews' => true, ); } + + /** + * Add CORS headers for OPTIONS preflight requests + */ + public static function add_ajax_preflight_headers() { + $origin = get_http_origin(); + if ( ! self::is_ajax_preflight_request_allowed( $origin ) ) { + return; + } + + header( 'Access-Control-Allow-Headers: Authorization, Content-Type, X-WP-Nonce' ); + header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' ); + header( 'Access-Control-Max-Age: 86400' ); + } + + /** + * Allow CORS origins for authorized admin-ajax requests + * + * @param array $allowed_origins Array of allowed origin URLs. + * @return array Array of allowed origin URLs. + */ + public static function allow_ajax_cors_origins( $allowed_origins ) { + $has_auth_header = ! empty( $_SERVER['HTTP_AUTHORIZATION'] ); + $is_auth_preflight = self::is_auth_preflight_request(); + $is_admin_ajax = self::is_admin_ajax_request(); + + // Only allow CORS for admin-ajax.php requests that have authorization or are authorization preflights + if ( $is_admin_ajax && ( $has_auth_header || $is_auth_preflight ) ) { + $origin = get_http_origin(); + // Only allow whitelisted origins + if ( $origin && self::is_origin_in_ajax_allowed_list( $origin ) && ! in_array( $origin, $allowed_origins, true ) ) { + $allowed_origins[] = $origin; + } + } + + return $allowed_origins; + } + + /** + * Check if AJAX preflight request should be allowed for the given origin + * + * @param string $origin The origin to check. + * @return bool Whether the preflight request should be allowed. + */ + private static function is_ajax_preflight_request_allowed( $origin ) { + $is_origin_in_allowed_list = self::is_origin_in_ajax_allowed_list( $origin ); + $is_auth_preflight = self::is_auth_preflight_request(); + $is_admin_ajax = self::is_admin_ajax_request(); + + return $is_origin_in_allowed_list && $is_auth_preflight && $is_admin_ajax; + } + + /** + * Check if an origin is in the AJAX CORS allowed list + * + * @param string $origin The origin to check. + * @return bool Whether the origin should be allowed. + */ + private static function is_origin_in_ajax_allowed_list( $origin ) { + /** + * Filter the allowed AJAX CORS origins + * + * @param array $allowed_origins Array of allowed origin URLs + */ + $allowed_origins = apply_filters( 'ajax_allowed_cors_origins', self::ALLOWED_AJAX_CORS_ORIGINS ); + + return in_array( $origin, $allowed_origins, true ); + } + + /** + * Check if the request is an authorization preflight request + * + * @return bool Whether the request is an authorization preflight request. + */ + private static function is_auth_preflight_request() { + $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : ''; + $request_headers = isset( $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'] ) ) : ''; + + return 'OPTIONS' === $request_method && false !== stripos( $request_headers, 'authorization' ); + } + + /** + * Check if the current request is an admin AJAX request + * + * @return bool Whether this is an admin-ajax.php request + */ + private static function is_admin_ajax_request() { + return is_admin() && wp_doing_ajax(); + } } diff --git a/projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_Application_Password_Android_CORS_Test.php b/projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_Application_Password_Android_CORS_Test.php new file mode 100644 index 0000000000000..3bb754606e1c1 --- /dev/null +++ b/projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_Application_Password_Android_CORS_Test.php @@ -0,0 +1,219 @@ +user->create( array( 'role' => 'administrator' ) ); + } + + /** + * Setup the environment for a test. + */ + public function set_up() { + parent::set_up(); + wp_set_current_user( static::$user_id ); + $this->server_backup = $_SERVER; + Jetpack_Application_Password_Extras::init(); + } + + /** + * Tear down the environment after a test. + */ + public function tear_down() { + parent::tear_down(); + $_SERVER = $this->server_backup; + remove_all_filters( 'wp_doing_ajax' ); + remove_all_filters( 'ajax_allowed_cors_origins' ); + } + + /** + * Test that CORS origin is added with authorization header. + */ + public function test_cors_origin_added_with_authorization() { + $_SERVER['HTTP_ORIGIN'] = 'https://appassets.androidplatform.net'; + $_SERVER['HTTP_AUTHORIZATION'] = 'Basic xxxxx'; + set_current_screen( 'admin-ajax' ); + add_filter( 'wp_doing_ajax', '__return_true' ); + + $result = Jetpack_Application_Password_Extras::allow_ajax_cors_origins( array() ); + + $this->assertContains( 'https://appassets.androidplatform.net', $result, 'Android origin should be added when authorization is present' ); + } + + /** + * Test that CORS origin is not added without authorization. + */ + public function test_cors_origin_not_added_without_authorization() { + $_SERVER['HTTP_ORIGIN'] = 'https://appassets.androidplatform.net'; + unset( $_SERVER['HTTP_AUTHORIZATION'] ); + set_current_screen( 'admin-ajax' ); + add_filter( 'wp_doing_ajax', '__return_true' ); + + $result = Jetpack_Application_Password_Extras::allow_ajax_cors_origins( array() ); + + $this->assertNotContains( 'https://appassets.androidplatform.net', $result, 'Android origin should not be added without authorization' ); + } + + /** + * Test that CORS origin is added for preflight requests with Authorization header. + */ + public function test_cors_origin_added_for_preflight_with_auth() { + $_SERVER['HTTP_ORIGIN'] = 'https://appassets.androidplatform.net'; + $_SERVER['REQUEST_METHOD'] = 'OPTIONS'; + $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'] = 'Authorization, Content-Type'; + unset( $_SERVER['HTTP_AUTHORIZATION'] ); + set_current_screen( 'admin-ajax' ); + add_filter( 'wp_doing_ajax', '__return_true' ); + + $result = Jetpack_Application_Password_Extras::allow_ajax_cors_origins( array() ); + + $this->assertContains( 'https://appassets.androidplatform.net', $result, 'Android origin should be added for preflight requests with Authorization header' ); + } + + /** + * Test that CORS is not added for preflight requests without Authorization header. + */ + public function test_cors_not_added_for_preflight_without_auth() { + $_SERVER['HTTP_ORIGIN'] = 'https://appassets.androidplatform.net'; + $_SERVER['REQUEST_METHOD'] = 'OPTIONS'; + $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'] = 'Content-Type, X-Requested-With'; + unset( $_SERVER['HTTP_AUTHORIZATION'] ); + set_current_screen( 'admin-ajax' ); + add_filter( 'wp_doing_ajax', '__return_true' ); + + $result = Jetpack_Application_Password_Extras::allow_ajax_cors_origins( array() ); + + $this->assertNotContains( 'https://appassets.androidplatform.net', $result, 'Android origin should not be added for preflight without Authorization header' ); + } + + /** + * Test that CORS is not added outside admin-ajax context. + */ + public function test_cors_not_added_outside_admin_ajax() { + $_SERVER['HTTP_ORIGIN'] = 'https://appassets.androidplatform.net'; + $_SERVER['HTTP_AUTHORIZATION'] = 'Basic xxxxx'; + // Not setting admin-ajax context + + $result = Jetpack_Application_Password_Extras::allow_ajax_cors_origins( array() ); + + $this->assertEmpty( $result, 'CORS should not be added outside admin-ajax context' ); + } + + /** + * Test that CORS is not added for non-admin context even with wp_doing_ajax. + */ + public function test_cors_not_added_for_non_admin_context() { + $_SERVER['HTTP_ORIGIN'] = 'https://appassets.androidplatform.net'; + $_SERVER['HTTP_AUTHORIZATION'] = 'Basic xxxxx'; + add_filter( 'wp_doing_ajax', '__return_true' ); + // Not setting admin screen + + $result = Jetpack_Application_Password_Extras::allow_ajax_cors_origins( array() ); + + $this->assertEmpty( $result, 'CORS should not be added when not in admin context' ); + } + + /** + * Test that CORS is not added for different origin. + */ + public function test_cors_not_added_for_different_origin() { + $_SERVER['HTTP_ORIGIN'] = 'https://evil.com'; + $_SERVER['HTTP_AUTHORIZATION'] = 'Basic xxxxx'; + set_current_screen( 'admin-ajax' ); + add_filter( 'wp_doing_ajax', '__return_true' ); + + $result = Jetpack_Application_Password_Extras::allow_ajax_cors_origins( array() ); + + $this->assertNotContains( 'https://evil.com', $result, 'Non-allowed origins should not be added' ); + } + + /** + * Test that existing allowed origins are preserved. + */ + public function test_preserves_existing_allowed_origins() { + $_SERVER['HTTP_ORIGIN'] = 'https://appassets.androidplatform.net'; + $_SERVER['HTTP_AUTHORIZATION'] = 'Basic xxxxx'; + set_current_screen( 'admin-ajax' ); + add_filter( 'wp_doing_ajax', '__return_true' ); + + $existing = array( 'https://example.com', 'https://test.com' ); + $result = Jetpack_Application_Password_Extras::allow_ajax_cors_origins( $existing ); + + $this->assertContains( 'https://example.com', $result, 'Existing origins should be preserved' ); + $this->assertContains( 'https://test.com', $result, 'Existing origins should be preserved' ); + $this->assertContains( 'https://appassets.androidplatform.net', $result, 'Android origin should be added' ); + } + + /** + * Test that duplicate origins are not added. + */ + public function test_no_duplicate_origins() { + $_SERVER['HTTP_ORIGIN'] = 'https://appassets.androidplatform.net'; + $_SERVER['HTTP_AUTHORIZATION'] = 'Basic xxxxx'; + set_current_screen( 'admin-ajax' ); + add_filter( 'wp_doing_ajax', '__return_true' ); + + $existing = array( 'https://appassets.androidplatform.net' ); + $result = Jetpack_Application_Password_Extras::allow_ajax_cors_origins( $existing ); + + $this->assertCount( 1, $result, 'Should not add duplicate origins' ); + $this->assertEquals( array( 'https://appassets.androidplatform.net' ), $result ); + } + + /** + * Test ajax_allowed_cors_origins filter extensibility. + */ + public function test_ajax_allowed_cors_origins_filter_extensibility() { + add_filter( + 'ajax_allowed_cors_origins', + function ( $origins ) { + $origins[] = 'https://custom.origin.com'; + return $origins; + } + ); + + $_SERVER['HTTP_ORIGIN'] = 'https://custom.origin.com'; + $_SERVER['HTTP_AUTHORIZATION'] = 'Basic xxxxx'; + set_current_screen( 'admin-ajax' ); + add_filter( 'wp_doing_ajax', '__return_true' ); + + $result = Jetpack_Application_Password_Extras::allow_ajax_cors_origins( array() ); + + $this->assertContains( 'https://custom.origin.com', $result, 'Custom origins added via filter should be allowed' ); + } +} diff --git a/projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_Application_Password_Extras_Test.php b/projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_Application_Password_Extras_Test.php index 4b1bfe1af344e..96496c6346fa7 100644 --- a/projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_Application_Password_Extras_Test.php +++ b/projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_Application_Password_Extras_Test.php @@ -65,6 +65,26 @@ public function test_init_registers_hook() { ); } + /** + * Test that init method registers CORS-related hooks. + */ + public function test_init_registers_cors_hooks() { + remove_all_filters( 'allowed_http_origins' ); + remove_all_actions( 'wp_loaded' ); + + Jetpack_Application_Password_Extras::init(); + + $this->assertNotFalse( + has_filter( 'allowed_http_origins', array( 'Jetpack_Application_Password_Extras', 'allow_ajax_cors_origins' ) ), + 'CORS origins filter should be registered' + ); + + $this->assertNotFalse( + has_action( 'wp_loaded', array( 'Jetpack_Application_Password_Extras', 'add_ajax_preflight_headers' ) ), + 'Preflight headers action should be registered' + ); + } + /** * Test that non-matching requests preserve original false value. */ From e8e58d6420709daef189ed380b38a49d8828d712 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Wed, 24 Sep 2025 15:49:12 -0400 Subject: [PATCH 2/3] changelog --- .../changelog/feat-allow-android-web-view-ajax-cors-requests | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 projects/plugins/jetpack/changelog/feat-allow-android-web-view-ajax-cors-requests diff --git a/projects/plugins/jetpack/changelog/feat-allow-android-web-view-ajax-cors-requests b/projects/plugins/jetpack/changelog/feat-allow-android-web-view-ajax-cors-requests new file mode 100644 index 0000000000000..b6fe924a4f807 --- /dev/null +++ b/projects/plugins/jetpack/changelog/feat-allow-android-web-view-ajax-cors-requests @@ -0,0 +1,4 @@ +Significance: minor +Type: other + +Mobile editor: allow admin-ajax.php CORS requests for Android WebViews From 1e87e88b34f8d38cad9e447046ee8dbabb0f8eb2 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Thu, 2 Oct 2025 14:58:34 -0400 Subject: [PATCH 3/3] feat: Update allowed Android CORS domain to Jetpack-owned domain Use a domain owned by the Jetpack project. --- .../_inc/lib/class-jetpack-application-password-extras.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/plugins/jetpack/_inc/lib/class-jetpack-application-password-extras.php b/projects/plugins/jetpack/_inc/lib/class-jetpack-application-password-extras.php index ab62dd97e3481..7c1857337c3d0 100644 --- a/projects/plugins/jetpack/_inc/lib/class-jetpack-application-password-extras.php +++ b/projects/plugins/jetpack/_inc/lib/class-jetpack-application-password-extras.php @@ -20,7 +20,7 @@ class Jetpack_Application_Password_Extras { * Allowed CORS origins for AJAX requests */ const ALLOWED_AJAX_CORS_ORIGINS = array( - 'https://appassets.androidplatform.net', // Android WebView + 'https://android-app-assets.jetpack.com', // Jetpack Android mobile app WebView ); /**