Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
66 changes: 57 additions & 9 deletions includes/class-create-block-theme-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public function register_rest_routes() {
'methods' => 'POST',
'callback' => array( $this, 'rest_export_theme' ),
'permission_callback' => function () {
return current_user_can( 'edit_theme_options' );
return $this->can_modify_theme();
},
)
);
Expand All @@ -54,7 +54,7 @@ public function register_rest_routes() {
'methods' => 'POST',
'callback' => array( $this, 'rest_update_theme' ),
'permission_callback' => function () {
return current_user_can( 'edit_theme_options' );
return $this->can_modify_theme();
},
)
);
Expand All @@ -65,7 +65,7 @@ public function register_rest_routes() {
'methods' => 'POST',
'callback' => array( $this, 'rest_save_theme' ),
'permission_callback' => function () {
return current_user_can( 'edit_theme_options' );
return $this->can_modify_theme();
},
)
);
Expand All @@ -76,7 +76,7 @@ public function register_rest_routes() {
'methods' => 'POST',
'callback' => array( $this, 'rest_save_theme_settings' ),
'permission_callback' => function () {
return current_user_can( 'edit_theme_options' );
return $this->can_modify_theme();
},
)
);
Expand All @@ -87,7 +87,7 @@ public function register_rest_routes() {
'methods' => 'POST',
'callback' => array( $this, 'rest_clone_theme' ),
'permission_callback' => function () {
return current_user_can( 'edit_theme_options' );
return $this->can_modify_theme();
},
)
);
Expand All @@ -98,7 +98,7 @@ public function register_rest_routes() {
'methods' => 'POST',
'callback' => array( $this, 'rest_create_variation' ),
'permission_callback' => function () {
return current_user_can( 'edit_theme_options' );
return $this->can_modify_theme();
},
)
);
Expand All @@ -109,7 +109,7 @@ public function register_rest_routes() {
'methods' => 'POST',
'callback' => array( $this, 'rest_create_blank_theme' ),
'permission_callback' => function () {
return current_user_can( 'edit_theme_options' );
return $this->can_modify_theme();
},
)
);
Expand All @@ -120,7 +120,7 @@ public function register_rest_routes() {
'methods' => 'POST',
'callback' => array( $this, 'rest_create_child_theme' ),
'permission_callback' => function () {
return current_user_can( 'edit_theme_options' );
return $this->can_modify_theme();
},
)
);
Expand All @@ -142,7 +142,7 @@ public function register_rest_routes() {
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'rest_reset_theme' ),
'permission_callback' => function () {
return current_user_can( 'edit_theme_options' );
return $this->can_modify_theme();
Comment thread
scruffian marked this conversation as resolved.
Outdated
},
),
);
Expand Down Expand Up @@ -504,4 +504,52 @@ private function sanitize_theme_data( $theme ) {
$sanitized_theme['text_domain'] = $sanitized_theme['slug'];
return $sanitized_theme;
}

/**
* Permission check for filesystem-mutating REST routes.
*
* On multisite, themes are network-shared. Require super-admin to prevent
* sub-site administrators from crossing the tenant boundary into the
* shared `wp-content/themes/` directory.
*
* Honors the WP hardening constants `DISALLOW_FILE_EDIT` and
* `DISALLOW_FILE_MODS` via `file_mods_allowed()`, matching the behaviour
* the plugin had pre-v2.1.2 and aligning with WP Core's theme-editor.
*
* @return bool
*/
private function can_modify_theme() {
if ( ! $this->file_mods_allowed() ) {
return false;
}
if ( is_multisite() ) {
return is_super_admin();
}
return current_user_can( 'edit_theme_options' );
}

/**
* Whether theme-file modifications are permitted by site configuration.
*
* Delegates the `DISALLOW_FILE_MODS` check (and the canonical
* `file_mod_allowed` filter that hosts and security plugins use to disable
* file modifications globally) to WordPress Core's `wp_is_file_mod_allowed()`.
* `DISALLOW_FILE_EDIT` is checked explicitly because it is a separate
* constant that Core's helper does NOT cover.
*
* The `cbt_file_mods_allowed` filter remains as a test seam — it can only
* further restrict, never re-enable, the policy decided by core / the
* constants.
*
* @return bool True when file modifications are allowed by site configuration.
*/
private function file_mods_allowed() {
if ( defined( 'DISALLOW_FILE_EDIT' ) && DISALLOW_FILE_EDIT ) {
$allowed = false;
} else {
$allowed = wp_is_file_mod_allowed( 'create_block_theme_modify_theme' );
Comment thread
mikachan marked this conversation as resolved.
Outdated
}
$filtered = (bool) apply_filters( 'cbt_file_mods_allowed', $allowed );
return $allowed && $filtered;
}
}
169 changes: 169 additions & 0 deletions tests/test-class-create-block-theme-api.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
<?php
/**
* @package Create_Block_Theme
*/
class Test_Create_Block_Theme_Api extends WP_UnitTestCase {

/**
* Helper: invoke a private method on a CBT_Theme_API instance.
*/
private function invoke_private( $method ) {
$ref_class = new ReflectionClass( CBT_Theme_API::class );
$instance = $ref_class->newInstanceWithoutConstructor();
$ref = new ReflectionMethod( $instance, $method );
$ref->setAccessible( true );
return $ref->invoke( $instance );
}

public function test_admin_can_modify_theme_on_single_site() {
if ( is_multisite() ) {
$this->markTestSkipped( 'single-site only' );
}
$admin = $this->factory->user->create( array( 'role' => 'administrator' ) );
wp_set_current_user( $admin );

$this->assertTrue( $this->invoke_private( 'can_modify_theme' ) );
}

public function test_editor_cannot_modify_theme_on_single_site() {
if ( is_multisite() ) {
$this->markTestSkipped( 'single-site only' );
}
$editor = $this->factory->user->create( array( 'role' => 'editor' ) );
wp_set_current_user( $editor );

$this->assertFalse( $this->invoke_private( 'can_modify_theme' ) );
}

public function test_anonymous_cannot_modify_theme() {
wp_set_current_user( 0 );
$this->assertFalse( $this->invoke_private( 'can_modify_theme' ) );
}

public function test_admin_blocked_when_file_mods_disallowed() {
if ( is_multisite() ) {
$this->markTestSkipped( 'single-site only' );
}
$admin = $this->factory->user->create( array( 'role' => 'administrator' ) );
wp_set_current_user( $admin );

add_filter( 'cbt_file_mods_allowed', '__return_false' );
$result = $this->invoke_private( 'can_modify_theme' );
remove_filter( 'cbt_file_mods_allowed', '__return_false' );

$this->assertFalse( $result );
}

public function test_file_mods_allowed_returns_true_by_default() {
$this->assertTrue( $this->invoke_private( 'file_mods_allowed' ) );
}

public function test_file_mods_allowed_filter_can_disable() {
add_filter( 'cbt_file_mods_allowed', '__return_false' );
$result = $this->invoke_private( 'file_mods_allowed' );
remove_filter( 'cbt_file_mods_allowed', '__return_false' );

$this->assertFalse( $result );
}

public function test_file_mods_allowed_respects_core_file_mod_allowed_filter() {
// WordPress Core's canonical `file_mod_allowed` filter is the
// mechanism hosts and security plugins use to disable file mods
// globally. file_mods_allowed() must honour it.
add_filter( 'file_mod_allowed', '__return_false' );
$result = $this->invoke_private( 'file_mods_allowed' );
remove_filter( 'file_mod_allowed', '__return_false' );

$this->assertFalse( $result );
}
Comment thread
Copilot marked this conversation as resolved.
Outdated

public function test_file_mods_allowed_filter_cannot_reenable_core_disallow() {
add_filter( 'file_mod_allowed', '__return_false' );
add_filter( 'cbt_file_mods_allowed', '__return_true' );
$result = $this->invoke_private( 'file_mods_allowed' );
remove_filter( 'cbt_file_mods_allowed', '__return_true' );
remove_filter( 'file_mod_allowed', '__return_false' );

$this->assertFalse( $result );
}

public function test_save_endpoint_rejects_when_file_mods_disallowed() {
$admin = $this->factory->user->create( array( 'role' => 'administrator' ) );
wp_set_current_user( $admin );
if ( is_multisite() ) {
grant_super_admin( $admin );
}

add_filter( 'cbt_file_mods_allowed', '__return_false' );
$request = new WP_REST_Request( 'POST', '/create-block-theme/v1/save' );
$response = rest_get_server()->dispatch( $request );
remove_filter( 'cbt_file_mods_allowed', '__return_false' );

Comment thread
Copilot marked this conversation as resolved.
if ( is_multisite() ) {
revoke_super_admin( $admin );
}
$this->assertSame( 403, $response->get_status() );
$this->assertSame( 'rest_forbidden', $response->get_data()['code'] );
}

public function test_save_endpoint_rejects_anonymous() {
wp_set_current_user( 0 );
$request = new WP_REST_Request( 'POST', '/create-block-theme/v1/save' );
$response = rest_get_server()->dispatch( $request );

$this->assertSame( 401, $response->get_status() );
}

public function test_font_families_endpoint_still_accessible_to_admin() {
$admin = $this->factory->user->create( array( 'role' => 'administrator' ) );
wp_set_current_user( $admin );

// /font-families is a GET and is NOT gated by can_modify_theme().
// Even with file mods disallowed, it should still respond (not 403).
add_filter( 'cbt_file_mods_allowed', '__return_false' );
$request = new WP_REST_Request( 'GET', '/create-block-theme/v1/font-families' );
$response = rest_get_server()->dispatch( $request );
remove_filter( 'cbt_file_mods_allowed', '__return_false' );

$this->assertSame( 200, $response->get_status() );
$this->assertSame( 'SUCCESS', $response->get_data()['status'] );
}

public function test_super_admin_can_modify_theme_on_multisite() {
if ( ! is_multisite() ) {
$this->markTestSkipped( 'requires multisite — set WP_TESTS_MULTISITE=1' );
}
$super = $this->factory->user->create( array( 'role' => 'administrator' ) );
grant_super_admin( $super );
wp_set_current_user( $super );

$this->assertTrue( $this->invoke_private( 'can_modify_theme' ) );

revoke_super_admin( $super );
}

public function test_subsite_admin_cannot_modify_theme_on_multisite() {
if ( ! is_multisite() ) {
$this->markTestSkipped( 'requires multisite — set WP_TESTS_MULTISITE=1' );
}
$admin = $this->factory->user->create( array( 'role' => 'administrator' ) );
// Explicitly NOT a super-admin.
wp_set_current_user( $admin );

$this->assertFalse( $this->invoke_private( 'can_modify_theme' ) );
}

public function test_save_endpoint_rejects_subsite_admin_on_multisite() {
if ( ! is_multisite() ) {
$this->markTestSkipped( 'requires multisite — set WP_TESTS_MULTISITE=1' );
}
$admin = $this->factory->user->create( array( 'role' => 'administrator' ) );
wp_set_current_user( $admin );

$request = new WP_REST_Request( 'POST', '/create-block-theme/v1/save' );
$response = rest_get_server()->dispatch( $request );

$this->assertSame( 403, $response->get_status() );
$this->assertSame( 'rest_forbidden', $response->get_data()['code'] );
}
}
Loading