Skip to content

Commit 7502fbf

Browse files
committed
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.
1 parent 0eec284 commit 7502fbf

File tree

3 files changed

+339
-0
lines changed

3 files changed

+339
-0
lines changed

projects/plugins/jetpack/_inc/lib/class-jetpack-application-password-extras.php

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,23 @@
1616
* Extends Application Password functionality beyond the REST API.
1717
*/
1818
class Jetpack_Application_Password_Extras {
19+
/**
20+
* Allowed CORS origins for AJAX requests
21+
*/
22+
const ALLOWED_AJAX_CORS_ORIGINS = array(
23+
'https://appassets.androidplatform.net', // Android WebView
24+
);
1925

2026
/**
2127
* Initialize the main hooks.
2228
*/
2329
public static function init() {
2430
add_filter( 'application_password_is_api_request', array( __CLASS__, 'application_password_extras' ) );
31+
// Use a hook that runs early, before send_origin_headers, which exits before
32+
// the `send_origin_headers` function is called.
33+
// https://github.com/WordPress/wordpress-develop/blob/3c3852e8a2a70c4f09233ffe5bce03576a687130/src/wp-includes/http.php#L525-L527
34+
add_action( 'wp_loaded', array( __CLASS__, 'add_ajax_preflight_headers' ), 5 );
35+
add_filter( 'allowed_http_origins', array( __CLASS__, 'allow_ajax_cors_origins' ) );
2536
}
2637

2738
/**
@@ -64,4 +75,93 @@ public static function get_abilities() {
6475
'post-previews' => true,
6576
);
6677
}
78+
79+
/**
80+
* Add CORS headers for OPTIONS preflight requests
81+
*/
82+
public static function add_ajax_preflight_headers() {
83+
$origin = get_http_origin();
84+
if ( ! self::is_ajax_preflight_request_allowed( $origin ) ) {
85+
return;
86+
}
87+
88+
header( 'Access-Control-Allow-Headers: Authorization, Content-Type, X-WP-Nonce' );
89+
header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );
90+
header( 'Access-Control-Max-Age: 86400' );
91+
}
92+
93+
/**
94+
* Allow CORS origins for authorized admin-ajax requests
95+
*
96+
* @param array $allowed_origins Array of allowed origin URLs.
97+
* @return array Array of allowed origin URLs.
98+
*/
99+
public static function allow_ajax_cors_origins( $allowed_origins ) {
100+
$has_auth_header = ! empty( $_SERVER['HTTP_AUTHORIZATION'] );
101+
$is_auth_preflight = self::is_auth_preflight_request();
102+
$is_admin_ajax = self::is_admin_ajax_request();
103+
104+
// Only allow CORS for admin-ajax.php requests that have authorization or are authorization preflights
105+
if ( $is_admin_ajax && ( $has_auth_header || $is_auth_preflight ) ) {
106+
$origin = get_http_origin();
107+
// Only allow whitelisted origins
108+
if ( $origin && self::is_origin_in_ajax_allowed_list( $origin ) && ! in_array( $origin, $allowed_origins, true ) ) {
109+
$allowed_origins[] = $origin;
110+
}
111+
}
112+
113+
return $allowed_origins;
114+
}
115+
116+
/**
117+
* Check if AJAX preflight request should be allowed for the given origin
118+
*
119+
* @param string $origin The origin to check.
120+
* @return bool Whether the preflight request should be allowed.
121+
*/
122+
private static function is_ajax_preflight_request_allowed( $origin ) {
123+
$is_origin_in_allowed_list = self::is_origin_in_ajax_allowed_list( $origin );
124+
$is_auth_preflight = self::is_auth_preflight_request();
125+
$is_admin_ajax = self::is_admin_ajax_request();
126+
127+
return $is_origin_in_allowed_list && $is_auth_preflight && $is_admin_ajax;
128+
}
129+
130+
/**
131+
* Check if an origin is in the AJAX CORS allowed list
132+
*
133+
* @param string $origin The origin to check.
134+
* @return bool Whether the origin should be allowed.
135+
*/
136+
private static function is_origin_in_ajax_allowed_list( $origin ) {
137+
/**
138+
* Filter the allowed AJAX CORS origins
139+
*
140+
* @param array $allowed_origins Array of allowed origin URLs
141+
*/
142+
$allowed_origins = apply_filters( 'ajax_allowed_cors_origins', self::ALLOWED_AJAX_CORS_ORIGINS );
143+
144+
return in_array( $origin, $allowed_origins, true );
145+
}
146+
147+
/**
148+
* Check if the request is an authorization preflight request
149+
*
150+
* @return bool Whether the request is an authorization preflight request.
151+
*/
152+
private static function is_auth_preflight_request() {
153+
$request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '';
154+
$request_headers = isset( $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'] ) ) : '';
155+
156+
return 'OPTIONS' === $request_method && false !== stripos( $request_headers, 'authorization' );
157+
}
158+
159+
/**
160+
* Check if the current request is an admin AJAX request
161+
*
162+
* @return bool Whether this is an admin-ajax.php request
163+
*/
164+
private static function is_admin_ajax_request() {
165+
return is_admin() && wp_doing_ajax();
166+
}
67167
}
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
<?php
2+
/**
3+
* Test Jetpack Application Password Android CORS functionality.
4+
*
5+
* @package jetpack
6+
*/
7+
8+
use PHPUnit\Framework\Attributes\CoversClass;
9+
10+
require_once JETPACK__PLUGIN_DIR . '/tests/php/lib/Jetpack_REST_TestCase.php';
11+
require_once JETPACK__PLUGIN_DIR . '/_inc/lib/class-jetpack-application-password-extras.php';
12+
13+
/**
14+
* Test class for Android CORS headers.
15+
*
16+
* @covers \Jetpack_Application_Password_Extras
17+
*/
18+
#[CoversClass( Jetpack_Application_Password_Extras::class )]
19+
class Jetpack_Application_Password_Android_CORS_Test extends Jetpack_REST_TestCase {
20+
21+
/**
22+
* Mock user ID.
23+
*
24+
* @var int
25+
*/
26+
private static $user_id = 0;
27+
28+
/**
29+
* Original server globals backup.
30+
*
31+
* @var array
32+
*/
33+
private $server_backup = array();
34+
35+
/**
36+
* Create shared database fixtures.
37+
*
38+
* @param WP_UnitTest_Factory $factory Fixture factory.
39+
*/
40+
public static function wpSetUpBeforeClass( $factory ) {
41+
static::$user_id = $factory->user->create( array( 'role' => 'administrator' ) );
42+
}
43+
44+
/**
45+
* Setup the environment for a test.
46+
*/
47+
public function set_up() {
48+
parent::set_up();
49+
wp_set_current_user( static::$user_id );
50+
$this->server_backup = $_SERVER;
51+
Jetpack_Application_Password_Extras::init();
52+
}
53+
54+
/**
55+
* Tear down the environment after a test.
56+
*/
57+
public function tear_down() {
58+
parent::tear_down();
59+
$_SERVER = $this->server_backup;
60+
remove_all_filters( 'wp_doing_ajax' );
61+
remove_all_filters( 'ajax_allowed_cors_origins' );
62+
}
63+
64+
/**
65+
* Test that CORS origin is added with authorization header.
66+
*/
67+
public function test_cors_origin_added_with_authorization() {
68+
$_SERVER['HTTP_ORIGIN'] = 'https://appassets.androidplatform.net';
69+
$_SERVER['HTTP_AUTHORIZATION'] = 'Basic xxxxx';
70+
set_current_screen( 'admin-ajax' );
71+
add_filter( 'wp_doing_ajax', '__return_true' );
72+
73+
$result = Jetpack_Application_Password_Extras::allow_ajax_cors_origins( array() );
74+
75+
$this->assertContains( 'https://appassets.androidplatform.net', $result, 'Android origin should be added when authorization is present' );
76+
}
77+
78+
/**
79+
* Test that CORS origin is not added without authorization.
80+
*/
81+
public function test_cors_origin_not_added_without_authorization() {
82+
$_SERVER['HTTP_ORIGIN'] = 'https://appassets.androidplatform.net';
83+
unset( $_SERVER['HTTP_AUTHORIZATION'] );
84+
set_current_screen( 'admin-ajax' );
85+
add_filter( 'wp_doing_ajax', '__return_true' );
86+
87+
$result = Jetpack_Application_Password_Extras::allow_ajax_cors_origins( array() );
88+
89+
$this->assertNotContains( 'https://appassets.androidplatform.net', $result, 'Android origin should not be added without authorization' );
90+
}
91+
92+
/**
93+
* Test that CORS origin is added for preflight requests with Authorization header.
94+
*/
95+
public function test_cors_origin_added_for_preflight_with_auth() {
96+
$_SERVER['HTTP_ORIGIN'] = 'https://appassets.androidplatform.net';
97+
$_SERVER['REQUEST_METHOD'] = 'OPTIONS';
98+
$_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'] = 'Authorization, Content-Type';
99+
unset( $_SERVER['HTTP_AUTHORIZATION'] );
100+
set_current_screen( 'admin-ajax' );
101+
add_filter( 'wp_doing_ajax', '__return_true' );
102+
103+
$result = Jetpack_Application_Password_Extras::allow_ajax_cors_origins( array() );
104+
105+
$this->assertContains( 'https://appassets.androidplatform.net', $result, 'Android origin should be added for preflight requests with Authorization header' );
106+
}
107+
108+
/**
109+
* Test that CORS is not added for preflight requests without Authorization header.
110+
*/
111+
public function test_cors_not_added_for_preflight_without_auth() {
112+
$_SERVER['HTTP_ORIGIN'] = 'https://appassets.androidplatform.net';
113+
$_SERVER['REQUEST_METHOD'] = 'OPTIONS';
114+
$_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'] = 'Content-Type, X-Requested-With';
115+
unset( $_SERVER['HTTP_AUTHORIZATION'] );
116+
set_current_screen( 'admin-ajax' );
117+
add_filter( 'wp_doing_ajax', '__return_true' );
118+
119+
$result = Jetpack_Application_Password_Extras::allow_ajax_cors_origins( array() );
120+
121+
$this->assertNotContains( 'https://appassets.androidplatform.net', $result, 'Android origin should not be added for preflight without Authorization header' );
122+
}
123+
124+
/**
125+
* Test that CORS is not added outside admin-ajax context.
126+
*/
127+
public function test_cors_not_added_outside_admin_ajax() {
128+
$_SERVER['HTTP_ORIGIN'] = 'https://appassets.androidplatform.net';
129+
$_SERVER['HTTP_AUTHORIZATION'] = 'Basic xxxxx';
130+
// Not setting admin-ajax context
131+
132+
$result = Jetpack_Application_Password_Extras::allow_ajax_cors_origins( array() );
133+
134+
$this->assertEmpty( $result, 'CORS should not be added outside admin-ajax context' );
135+
}
136+
137+
/**
138+
* Test that CORS is not added for non-admin context even with wp_doing_ajax.
139+
*/
140+
public function test_cors_not_added_for_non_admin_context() {
141+
$_SERVER['HTTP_ORIGIN'] = 'https://appassets.androidplatform.net';
142+
$_SERVER['HTTP_AUTHORIZATION'] = 'Basic xxxxx';
143+
add_filter( 'wp_doing_ajax', '__return_true' );
144+
// Not setting admin screen
145+
146+
$result = Jetpack_Application_Password_Extras::allow_ajax_cors_origins( array() );
147+
148+
$this->assertEmpty( $result, 'CORS should not be added when not in admin context' );
149+
}
150+
151+
/**
152+
* Test that CORS is not added for different origin.
153+
*/
154+
public function test_cors_not_added_for_different_origin() {
155+
$_SERVER['HTTP_ORIGIN'] = 'https://evil.com';
156+
$_SERVER['HTTP_AUTHORIZATION'] = 'Basic xxxxx';
157+
set_current_screen( 'admin-ajax' );
158+
add_filter( 'wp_doing_ajax', '__return_true' );
159+
160+
$result = Jetpack_Application_Password_Extras::allow_ajax_cors_origins( array() );
161+
162+
$this->assertNotContains( 'https://evil.com', $result, 'Non-allowed origins should not be added' );
163+
}
164+
165+
/**
166+
* Test that existing allowed origins are preserved.
167+
*/
168+
public function test_preserves_existing_allowed_origins() {
169+
$_SERVER['HTTP_ORIGIN'] = 'https://appassets.androidplatform.net';
170+
$_SERVER['HTTP_AUTHORIZATION'] = 'Basic xxxxx';
171+
set_current_screen( 'admin-ajax' );
172+
add_filter( 'wp_doing_ajax', '__return_true' );
173+
174+
$existing = array( 'https://example.com', 'https://test.com' );
175+
$result = Jetpack_Application_Password_Extras::allow_ajax_cors_origins( $existing );
176+
177+
$this->assertContains( 'https://example.com', $result, 'Existing origins should be preserved' );
178+
$this->assertContains( 'https://test.com', $result, 'Existing origins should be preserved' );
179+
$this->assertContains( 'https://appassets.androidplatform.net', $result, 'Android origin should be added' );
180+
}
181+
182+
/**
183+
* Test that duplicate origins are not added.
184+
*/
185+
public function test_no_duplicate_origins() {
186+
$_SERVER['HTTP_ORIGIN'] = 'https://appassets.androidplatform.net';
187+
$_SERVER['HTTP_AUTHORIZATION'] = 'Basic xxxxx';
188+
set_current_screen( 'admin-ajax' );
189+
add_filter( 'wp_doing_ajax', '__return_true' );
190+
191+
$existing = array( 'https://appassets.androidplatform.net' );
192+
$result = Jetpack_Application_Password_Extras::allow_ajax_cors_origins( $existing );
193+
194+
$this->assertCount( 1, $result, 'Should not add duplicate origins' );
195+
$this->assertEquals( array( 'https://appassets.androidplatform.net' ), $result );
196+
}
197+
198+
/**
199+
* Test ajax_allowed_cors_origins filter extensibility.
200+
*/
201+
public function test_ajax_allowed_cors_origins_filter_extensibility() {
202+
add_filter(
203+
'ajax_allowed_cors_origins',
204+
function ( $origins ) {
205+
$origins[] = 'https://custom.origin.com';
206+
return $origins;
207+
}
208+
);
209+
210+
$_SERVER['HTTP_ORIGIN'] = 'https://custom.origin.com';
211+
$_SERVER['HTTP_AUTHORIZATION'] = 'Basic xxxxx';
212+
set_current_screen( 'admin-ajax' );
213+
add_filter( 'wp_doing_ajax', '__return_true' );
214+
215+
$result = Jetpack_Application_Password_Extras::allow_ajax_cors_origins( array() );
216+
217+
$this->assertContains( 'https://custom.origin.com', $result, 'Custom origins added via filter should be allowed' );
218+
}
219+
}

projects/plugins/jetpack/tests/php/_inc/lib/Jetpack_Application_Password_Extras_Test.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,26 @@ public function test_init_registers_hook() {
6565
);
6666
}
6767

68+
/**
69+
* Test that init method registers CORS-related hooks.
70+
*/
71+
public function test_init_registers_cors_hooks() {
72+
remove_all_filters( 'allowed_http_origins' );
73+
remove_all_actions( 'wp_loaded' );
74+
75+
Jetpack_Application_Password_Extras::init();
76+
77+
$this->assertNotFalse(
78+
has_filter( 'allowed_http_origins', array( 'Jetpack_Application_Password_Extras', 'allow_ajax_cors_origins' ) ),
79+
'CORS origins filter should be registered'
80+
);
81+
82+
$this->assertNotFalse(
83+
has_action( 'wp_loaded', array( 'Jetpack_Application_Password_Extras', 'add_ajax_preflight_headers' ) ),
84+
'Preflight headers action should be registered'
85+
);
86+
}
87+
6888
/**
6989
* Test that non-matching requests preserve original false value.
7090
*/

0 commit comments

Comments
 (0)