Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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://android-app-assets.jetpack.com', // Jetpack Android mobile app 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' ) );
}

/**
Expand Down Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: other

Mobile editor: allow admin-ajax.php CORS requests for Android WebViews
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
<?php
/**
* Test Jetpack Application Password Android CORS functionality.
*
* @package jetpack
*/

use PHPUnit\Framework\Attributes\CoversClass;

require_once JETPACK__PLUGIN_DIR . '/tests/php/lib/Jetpack_REST_TestCase.php';
require_once JETPACK__PLUGIN_DIR . '/_inc/lib/class-jetpack-application-password-extras.php';

/**
* Test class for Android CORS headers.
*
* @covers \Jetpack_Application_Password_Extras
*/
#[CoversClass( Jetpack_Application_Password_Extras::class )]
class Jetpack_Application_Password_Android_CORS_Test extends Jetpack_REST_TestCase {

/**
* Mock user ID.
*
* @var int
*/
private static $user_id = 0;

/**
* Original server globals backup.
*
* @var array
*/
private $server_backup = array();

/**
* Create shared database fixtures.
*
* @param WP_UnitTest_Factory $factory Fixture factory.
*/
public static function wpSetUpBeforeClass( $factory ) {
static::$user_id = $factory->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' );
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Loading