diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 12ca0045b98b4..d16979c9c8fb5 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -545,6 +545,7 @@ add_action( 'init', 'rest_api_init' ); add_action( 'rest_api_init', 'rest_api_default_filters', 10, 1 ); add_action( 'rest_api_init', 'register_initial_settings', 10 ); +add_filter( 'rest_pre_update_setting', 'rest_restrict_privacy_policy_page_setting_update', 10, 2 ); add_action( 'rest_api_init', 'create_initial_rest_routes', 99 ); add_action( 'parse_request', 'rest_api_loaded' ); diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index fb808b7ee4df1..edef0894bc650 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -2739,6 +2739,7 @@ function set_site_transient( $transient, $value, $expiration = 0 ) { * * @since 4.7.0 * @since 6.0.1 The `show_on_front`, `page_on_front`, and `page_for_posts` options were added. + * @since 7.2.0 The `wp_page_for_privacy_policy` option was registered, exposed as `page_for_privacy_policy`. */ function register_initial_settings() { register_setting( @@ -2931,6 +2932,18 @@ function register_initial_settings() { ) ); + register_setting( + 'reading', + 'wp_page_for_privacy_policy', + array( + 'show_in_rest' => array( + 'name' => 'page_for_privacy_policy', + ), + 'type' => 'integer', + 'description' => __( 'The ID of the page that should be displayed as the privacy policy page' ), + ) + ); + register_setting( 'discussion', 'default_ping_status', diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 892716b5d69e0..4f3e0a1348e06 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -3447,6 +3447,27 @@ function rest_get_endpoint_args_for_schema( $schema, $method = WP_REST_Server::C return $endpoint_args; } +/** + * Prevents users without the `manage_privacy_options` capability from + * changing the privacy policy page through the REST API. + * + * The settings endpoint only checks `manage_options`. On multisite the + * `manage_privacy_options` capability maps to `manage_network`, so a site + * administrator can read the setting but must not change it, matching the + * Settings > Privacy screen. + * + * @since 7.2.0 + * + * @param bool $updated Whether the setting update has already been handled. + * @param string $name Setting name (as shown in REST API responses). + * @return bool Whether to short-circuit the update. + */ +function rest_restrict_privacy_policy_page_setting_update( $updated, $name ) { + if ( 'page_for_privacy_policy' === $name && ! current_user_can( 'manage_privacy_options' ) ) { + return true; + } + return $updated; +} /** * Converts an error to a response object. diff --git a/tests/phpunit/tests/rest-api/rest-settings-controller.php b/tests/phpunit/tests/rest-api/rest-settings-controller.php index 981ca3dc684b6..3662e680863e0 100644 --- a/tests/phpunit/tests/rest-api/rest-settings-controller.php +++ b/tests/phpunit/tests/rest-api/rest-settings-controller.php @@ -57,9 +57,26 @@ public function tear_down() { } } + remove_filter( 'map_meta_cap', array( $this, 'deny_manage_privacy_options' ), 10 ); + parent::tear_down(); } + /** + * Maps `manage_privacy_options` to `do_not_allow`, as happens for a site + * administrator on multisite. + * + * @param string[] $caps Primitive capabilities required. + * @param string $cap Capability being checked. + * @return string[] Primitive capabilities required. + */ + public function deny_manage_privacy_options( $caps, $cap ) { + if ( 'manage_privacy_options' === $cap ) { + return array( 'do_not_allow' ); + } + return $caps; + } + public function test_register_routes() { $routes = rest_get_server()->get_routes(); $this->assertArrayHasKey( '/wp/v2/settings', $routes ); @@ -116,6 +133,7 @@ public function test_get_items() { 'show_on_front', 'page_on_front', 'page_for_posts', + 'page_for_privacy_policy', 'default_ping_status', 'default_comment_status', 'site_icon', // Registered in wp-includes/blocks/site-logo.php @@ -403,6 +421,37 @@ public function test_update_item() { $this->assertSame( get_option( 'blogname' ), $data['title'] ); } + public function test_update_item_privacy_policy_page() { + wp_set_current_user( self::$administrator ); + $page_id = self::factory()->post->create( array( 'post_type' => 'page' ) ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/settings' ); + $request->set_param( 'page_for_privacy_policy', $page_id ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( $page_id, $data['page_for_privacy_policy'] ); + $this->assertSame( $page_id, (int) get_option( 'wp_page_for_privacy_policy' ) ); + } + + public function test_update_item_privacy_policy_page_without_capability() { + wp_set_current_user( self::$administrator ); + $page_id = self::factory()->post->create( array( 'post_type' => 'page' ) ); + update_option( 'wp_page_for_privacy_policy', $page_id ); + $other_page_id = self::factory()->post->create( array( 'post_type' => 'page' ) ); + add_filter( 'map_meta_cap', array( $this, 'deny_manage_privacy_options' ), 10, 2 ); + + $request = new WP_REST_Request( 'PUT', '/wp/v2/settings' ); + $request->set_param( 'page_for_privacy_policy', $other_page_id ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( $page_id, $data['page_for_privacy_policy'], 'The response should still report the previous page.' ); + $this->assertSame( $page_id, (int) get_option( 'wp_page_for_privacy_policy' ), 'The option should not change.' ); + } + public function update_setting_custom_callback( $result, $name, $value, $args ) { if ( 'title' === $name && 'The new title!' === $value ) { // Do not allow changing the title in this case. diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 4a2d5a3ac7ea8..5d209f1c79820 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -11237,6 +11237,12 @@ mockedApiResponse.Schema = { "type": "integer", "required": false }, + "page_for_privacy_policy": { + "title": "", + "description": "The ID of the page that should be displayed as the privacy policy page", + "type": "integer", + "required": false + }, "default_ping_status": { "title": "", "description": "Allow link notifications from other blogs (pingbacks and trackbacks) on new articles.", @@ -14932,6 +14938,7 @@ mockedApiResponse.settings = { "show_on_front": "posts", "page_on_front": 0, "page_for_posts": 0, + "page_for_privacy_policy": 0, "default_ping_status": "open", "default_comment_status": "open", "site_logo": null,