diff --git a/changelog/fix-network-license-key-permission.yaml b/changelog/fix-network-license-key-permission.yaml new file mode 100644 index 00000000..2c93b6ea --- /dev/null +++ b/changelog/fix-network-license-key-permission.yaml @@ -0,0 +1,3 @@ +significance: patch +type: fix +entry: On multisite, storing or deleting the network-wide license key through the REST API now requires the manage_network_options capability. A site administrator who sends network=true gets a 403 instead of changing the key for every site. diff --git a/docs/api/rest/license.md b/docs/api/rest/license.md index b29676f1..4a024623 100644 --- a/docs/api/rest/license.md +++ b/docs/api/rest/license.md @@ -1,6 +1,6 @@ # License Endpoints -All endpoints require the `manage_options` capability. +All endpoints require the `manage_options` capability. On multisite, storing or deleting the network-level key (`network=true`) also requires `manage_network_options`; a site administrator gets a 403. See [Licensing](../../subsystems/licensing.md) for the product entry data model, validation statuses, and key discovery workflows. diff --git a/src/Harbor/API/REST/V1/License_Controller.php b/src/Harbor/API/REST/V1/License_Controller.php index f35550ec..be807a88 100644 --- a/src/Harbor/API/REST/V1/License_Controller.php +++ b/src/Harbor/API/REST/V1/License_Controller.php @@ -100,13 +100,13 @@ public function register_routes(): void { [ 'methods' => WP_REST_Server::CREATABLE, 'callback' => [ $this, 'store_item' ], - 'permission_callback' => [ $this, 'check_permissions' ], + 'permission_callback' => [ $this, 'check_key_write_permissions' ], 'args' => $this->get_store_args(), ], [ 'methods' => WP_REST_Server::DELETABLE, 'callback' => [ $this, 'delete_item' ], - 'permission_callback' => [ $this, 'check_permissions' ], + 'permission_callback' => [ $this, 'check_key_write_permissions' ], 'args' => $this->get_network_args(), ], 'schema' => [ $this, 'get_public_item_schema' ], @@ -161,6 +161,30 @@ public function check_permissions(): bool { return current_user_can( 'manage_options' ); } + /** + * Permission callback for storing or deleting the key. + * + * The network-level key applies to every site, so changing it also needs + * network-level authority. + * + * @since 2.0.2 + * + * @param WP_REST_Request $request The request object. + * + * @return bool + */ + public function check_key_write_permissions( WP_REST_Request $request ): bool { + if ( ! $this->check_permissions() ) { + return false; + } + + if ( is_multisite() && (bool) $request->get_param( 'network' ) ) { + return current_user_can( 'manage_network_options' ); + } + + return true; + } + /** * Returns the current unified license key and its associated products. * diff --git a/src/Harbor/Harbor.php b/src/Harbor/Harbor.php index 50dfe787..0bc80add 100644 --- a/src/Harbor/Harbor.php +++ b/src/Harbor/Harbor.php @@ -15,7 +15,7 @@ class Harbor { * * @var string */ - public const VERSION = '2.0.1'; + public const VERSION = '2.0.2'; /** * Initializes the service provider. diff --git a/tests/muwpunit/API/REST/V1/License_ControllerTest.php b/tests/muwpunit/API/REST/V1/License_ControllerTest.php new file mode 100644 index 00000000..037406e0 --- /dev/null +++ b/tests/muwpunit/API/REST/V1/License_ControllerTest.php @@ -0,0 +1,126 @@ +server = $wp_rest_server; + + // Allow registering routes outside of the rest_api_init hook. + $this->set_fn_return( + 'did_action', + function ( $hook_name ) { + if ( $hook_name !== 'rest_api_init' ) { + return \did_action( $hook_name ); + } + return true; + }, + true + ); + + ( new License_Controller( $manager, new Data() ) )->register_routes(); + } + + protected function tearDown(): void { + global $wp_rest_server; + $wp_rest_server = null; + + delete_network_option( null, License_Repository::KEY_OPTION_NAME ); + delete_option( License_Repository::KEY_OPTION_NAME ); + delete_option( License_Repository::PRODUCTS_STATE_OPTION_NAME ); + delete_option( License_Repository::VALIDATION_STATE_OPTION_NAME ); + + parent::tearDown(); + } + + public function test_site_admin_cannot_store_the_network_key(): void { + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + + $request = new WP_REST_Request( 'POST', '/liquidweb/harbor/v1/license' ); + $request->set_param( 'key', 'LWSW-UNIFIED-PRO-2026' ); + $request->set_param( 'network', true ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 403, $response->get_status() ); + $this->assertEmpty( get_network_option( null, License_Repository::KEY_OPTION_NAME ) ); + } + + public function test_site_admin_cannot_delete_the_network_key(): void { + update_network_option( null, License_Repository::KEY_OPTION_NAME, 'LWSW-UNIFIED-PRO-2026' ); + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + + $request = new WP_REST_Request( 'DELETE', '/liquidweb/harbor/v1/license' ); + $request->set_param( 'network', true ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 403, $response->get_status() ); + $this->assertSame( 'LWSW-UNIFIED-PRO-2026', get_network_option( null, License_Repository::KEY_OPTION_NAME ) ); + } + + public function test_site_admin_can_still_store_a_site_key(): void { + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + + $request = new WP_REST_Request( 'POST', '/liquidweb/harbor/v1/license' ); + $request->set_param( 'key', 'LWSW-UNIFIED-PRO-2026' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'LWSW-UNIFIED-PRO-2026', get_option( License_Repository::KEY_OPTION_NAME ) ); + } + + public function test_super_admin_can_store_and_delete_the_network_key(): void { + $user_id = self::factory()->user->create( [ 'role' => 'administrator' ] ); + grant_super_admin( $user_id ); + wp_set_current_user( $user_id ); + + $store = new WP_REST_Request( 'POST', '/liquidweb/harbor/v1/license' ); + $store->set_param( 'key', 'LWSW-UNIFIED-PRO-2026' ); + $store->set_param( 'network', true ); + + $this->assertSame( 200, $this->server->dispatch( $store )->get_status() ); + $this->assertSame( 'LWSW-UNIFIED-PRO-2026', get_network_option( null, License_Repository::KEY_OPTION_NAME ) ); + + $delete = new WP_REST_Request( 'DELETE', '/liquidweb/harbor/v1/license' ); + $delete->set_param( 'network', true ); + + $this->assertSame( 204, $this->server->dispatch( $delete )->get_status() ); + $this->assertEmpty( get_network_option( null, License_Repository::KEY_OPTION_NAME ) ); + } +}