From 7bb9de14bde72f91e2caa88cdefb3ba80c204ae3 Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Thu, 29 Oct 2026 21:42:05 +0000 Subject: [PATCH 1/3] fix: require the network capability for network snippet imports --- .../Import/File_Import_REST_Controller.php | 30 +++- .../Import/Plugins_Import_REST_Controller.php | 7 +- src/php/REST_API/Network_Scope.php | 41 +++++ .../Snippets/Snippets_REST_Controller.php | 32 +--- .../File_Import_REST_Controller_Test.php | 147 ++++++++++++++++++ .../Plugins_Import_REST_Controller_Test.php | 111 +++++++++++++ 6 files changed, 337 insertions(+), 31 deletions(-) create mode 100644 src/php/REST_API/Network_Scope.php create mode 100644 tests/unit/REST_API/Import/Plugins_Import_REST_Controller_Test.php diff --git a/src/php/REST_API/Import/File_Import_REST_Controller.php b/src/php/REST_API/Import/File_Import_REST_Controller.php index 9686d6fc5..2387d6197 100644 --- a/src/php/REST_API/Import/File_Import_REST_Controller.php +++ b/src/php/REST_API/Import/File_Import_REST_Controller.php @@ -3,6 +3,7 @@ namespace Code_Snippets\REST_API\Import; use Code_Snippets\Model\Snippet; +use Code_Snippets\REST_API\Network_Scope; use Code_Snippets\REST_API\REST_Controller; use DOMDocument; use WP_Error; @@ -17,6 +18,8 @@ * Manages the import of code snippets from uploaded files via REST API. */ class File_Import_REST_Controller extends REST_Controller { + use Network_Scope; + /** * Current API version. @@ -79,7 +82,32 @@ public function register_routes() { * @return bool */ public function permission_callback( WP_REST_Request $request ): bool { - return code_snippets()->current_user_can(); + return $this->is_network_scoped_request( $request ) || $this->has_shared_network_snippets( $request ) + ? code_snippets()->user_can_manage_network_snippets() + : code_snippets()->current_user_can(); + } + + /** + * Determine whether any snippet in the request is marked as a shared network snippet. + * + * @param WP_REST_Request $request Incoming HTTP request. + * + * @return bool + */ + private function has_shared_network_snippets( WP_REST_Request $request ): bool { + $snippets = $request->get_param( 'snippets' ); + + if ( ! is_multisite() || ! is_array( $snippets ) ) { + return false; + } + + foreach ( $snippets as $snippet ) { + if ( is_array( $snippet ) && ! empty( $snippet['shared_network'] ) ) { + return true; + } + } + + return false; } /** diff --git a/src/php/REST_API/Import/Plugins_Import_REST_Controller.php b/src/php/REST_API/Import/Plugins_Import_REST_Controller.php index c868d3c77..e7a5107f6 100644 --- a/src/php/REST_API/Import/Plugins_Import_REST_Controller.php +++ b/src/php/REST_API/Import/Plugins_Import_REST_Controller.php @@ -6,6 +6,7 @@ use Code_Snippets\REST_API\Import\Plugins\Insert_Headers_And_Footers_Plugin_Importer; use Code_Snippets\REST_API\Import\Plugins\Insert_PHP_Code_Snippet_Plugin_Importer; use Code_Snippets\REST_API\Import\Plugins\Plugin_Importer; +use Code_Snippets\REST_API\Network_Scope; use Code_Snippets\REST_API\REST_Controller; use WP_REST_Request; use WP_REST_Response; @@ -18,6 +19,8 @@ * Handles registering REST API routes and providing information about available plugin importers. */ class Plugins_Import_REST_Controller extends REST_Controller { + use Network_Scope; + /** * Current API version. @@ -119,6 +122,8 @@ public function register_routes() { * @return bool */ public function permission_callback( WP_REST_Request $request ): bool { - return code_snippets()->current_user_can(); + return $this->is_network_scoped_request( $request ) + ? code_snippets()->user_can_manage_network_snippets() + : code_snippets()->current_user_can(); } } diff --git a/src/php/REST_API/Network_Scope.php b/src/php/REST_API/Network_Scope.php new file mode 100644 index 000000000..3657720f2 --- /dev/null +++ b/src/php/REST_API/Network_Scope.php @@ -0,0 +1,41 @@ +has_param( 'network' ) ) { + return false; + } + + $network = $request->get_param( 'network' ); + + if ( is_bool( $network ) ) { + return $network; + } + + if ( is_string( $network ) ) { + return ! in_array( strtolower( $network ), [ '0', 'false', 'no', '' ], true ); + } + + return (bool) $network; + } +} diff --git a/src/php/REST_API/Snippets/Snippets_REST_Controller.php b/src/php/REST_API/Snippets/Snippets_REST_Controller.php index c7a5fa4ce..9d96122d7 100644 --- a/src/php/REST_API/Snippets/Snippets_REST_Controller.php +++ b/src/php/REST_API/Snippets/Snippets_REST_Controller.php @@ -6,6 +6,7 @@ use Code_Snippets\Migration\Export\Export_Code; use Code_Snippets\Migration\Export\Export_JSON; use Code_Snippets\Model\Snippet; +use Code_Snippets\REST_API\Network_Scope; use Code_Snippets\REST_API\REST_Collection_Controller; use WP_Error; use WP_REST_Request; @@ -30,6 +31,8 @@ * @package Code_Snippets */ final class Snippets_REST_Controller extends REST_Collection_Controller { + use Network_Scope; + /** * Current API version. @@ -219,35 +222,6 @@ public function permission_callback( WP_REST_Request $request ): bool { return code_snippets()->current_user_can(); } - /** - * Determine whether a request targets network-scoped snippets. - * - * Only the literal boolean `true` (or its common string/integer equivalents) - * is treated as a network-scoped request. A missing or null `network` param - * means "site-scoped", and must not be escalated to the network capability. - * - * @param WP_REST_Request|WP_Error $request Full data about the request. - * - * @return bool - */ - private function is_network_scoped_request( $request ): bool { - if ( ! is_multisite() || ! $request instanceof WP_REST_Request || ! $request->has_param( 'network' ) ) { - return false; - } - - $network = $request->get_param( 'network' ); - - if ( is_bool( $network ) ) { - return $network; - } - - if ( is_string( $network ) ) { - return ! in_array( strtolower( $network ), [ '0', 'false', 'no', '' ], true ); - } - - return (bool) $network; - } - /** * Verify the current user has permission for the scope implied by the request. * diff --git a/tests/unit/REST_API/Import/File_Import_REST_Controller_Test.php b/tests/unit/REST_API/Import/File_Import_REST_Controller_Test.php index 92c7bda5d..e4cb9f8cb 100644 --- a/tests/unit/REST_API/Import/File_Import_REST_Controller_Test.php +++ b/tests/unit/REST_API/Import/File_Import_REST_Controller_Test.php @@ -5,6 +5,8 @@ use Code_Snippets\AdminUnitTestCase; use WP_REST_Request; use WP_REST_Response; +use WP_UnitTest_Factory; +use function Code_Snippets\get_snippets; /** * Tests for parsing uploaded snippet export files. @@ -21,6 +23,30 @@ class File_Import_REST_Controller_Test extends AdminUnitTestCase { */ private array $files = []; + /** + * Super administrator user ID. + * + * @var int + */ + protected static int $super_admin_id; + + /** + * Create the fixture users. + * + * @param WP_UnitTest_Factory $factory Factory object. + * + * @return void + */ + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { + parent::wpSetUpBeforeClass( $factory ); + + self::$super_admin_id = $factory->user->create( [ 'role' => 'administrator' ] ); + + if ( is_multisite() ) { + grant_super_admin( self::$super_admin_id ); + } + } + /** * Remove the export files. * @@ -104,4 +130,125 @@ public function test_snippets_without_conditions_are_not_flagged(): void { $this->assertArrayNotHasKey( 'warnings', $response->get_data() ); } + + /** + * Import the given snippets. + * + * @param array $params Request parameters. + * + * @return WP_REST_Response + */ + private function import( array $params ): WP_REST_Response { + $request = new WP_REST_Request( 'POST', '/code-snippets/v1/import/file-upload/import' ); + + foreach ( $params as $key => $value ) { + $request->set_param( $key, $value ); + } + + return rest_do_request( $request ); + } + + /** + * Skip the current test when not running on multisite. + * + * @return void + */ + private function require_multisite(): void { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network scope only exists on multisite installs.' ); + } + } + + /** + * A site administrator can import into the site table. + * + * @return void + */ + public function test_site_admin_can_import_site_snippets(): void { + $response = $this->import( + [ + 'snippets' => [ + [ + 'name' => 'Site import', + 'code' => '// site', + ], + ], + ] + ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 1, $response->get_data()['imported'] ); + } + + /** + * A site administrator without the network capability cannot import into the network table. + * + * @return void + */ + public function test_site_admin_cannot_import_network_snippets(): void { + $this->require_multisite(); + + $response = $this->import( + [ + 'network' => true, + 'snippets' => [ + [ + 'name' => 'Network import', + 'code' => '// network', + ], + ], + ] + ); + + $this->assertSame( 403, $response->get_status() ); + $this->assertEmpty( get_snippets( [], true ) ); + } + + /** + * A site administrator without the network capability cannot import shared network snippets. + * + * @return void + */ + public function test_site_admin_cannot_import_shared_network_snippets(): void { + $this->require_multisite(); + + $response = $this->import( + [ + 'snippets' => [ + [ + 'name' => 'Shared import', + 'code' => '// shared', + 'shared_network' => true, + ], + ], + ] + ); + + $this->assertSame( 403, $response->get_status() ); + } + + /** + * A network administrator can import into the network table. + * + * @return void + */ + public function test_super_admin_can_import_network_snippets(): void { + $this->require_multisite(); + wp_set_current_user( self::$super_admin_id ); + + $response = $this->import( + [ + 'network' => true, + 'snippets' => [ + [ + 'name' => 'Network import', + 'code' => '// network', + ], + ], + ] + ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertCount( 1, get_snippets( [], true ) ); + } } diff --git a/tests/unit/REST_API/Import/Plugins_Import_REST_Controller_Test.php b/tests/unit/REST_API/Import/Plugins_Import_REST_Controller_Test.php new file mode 100644 index 000000000..6a185c4b4 --- /dev/null +++ b/tests/unit/REST_API/Import/Plugins_Import_REST_Controller_Test.php @@ -0,0 +1,111 @@ +user->create( [ 'role' => 'administrator' ] ); + + if ( is_multisite() ) { + grant_super_admin( self::$super_admin_id ); + } + } + + /** + * Request an import from the Insert PHP Code Snippet importer. + * + * @param array $params Request parameters. + * + * @return WP_REST_Response + */ + private function import( array $params ): WP_REST_Response { + $request = new WP_REST_Request( 'POST', '/code-snippets/v1/import/plugins/insert-php-code-snippet/import' ); + + foreach ( $params as $key => $value ) { + $request->set_param( $key, $value ); + } + + return rest_do_request( $request ); + } + + /** + * A site administrator can import into the site table. + * + * @return void + */ + public function test_site_admin_can_import_site_snippets(): void { + $response = $this->import( [ 'ids' => [] ] ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * A site administrator without the network capability cannot import into the network table. + * + * @return void + */ + public function test_site_admin_cannot_import_network_snippets(): void { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network scope only exists on multisite installs.' ); + } + + $response = $this->import( + [ + 'ids' => [], + 'network' => true, + ] + ); + + $this->assertSame( 403, $response->get_status() ); + } + + /** + * A network administrator can import into the network table. + * + * @return void + */ + public function test_super_admin_can_import_network_snippets(): void { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network scope only exists on multisite installs.' ); + } + + wp_set_current_user( self::$super_admin_id ); + + $response = $this->import( + [ + 'ids' => [], + 'network' => true, + ] + ); + + $this->assertSame( 200, $response->get_status() ); + } +} From 7ccc7f1e7cc81b74dfab0447b93e19f667c54156 Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Thu, 29 Oct 2026 21:42:09 +0000 Subject: [PATCH 2/3] fix: only render snippet source for post authors who can manage snippets --- src/php/Integration/Shortcodes.php | 35 +++- src/php/Plugin.php | 16 ++ tests/unit/Integration/Shortcodes_Test.php | 191 +++++++++++++++++++++ 3 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 tests/unit/Integration/Shortcodes_Test.php diff --git a/src/php/Integration/Shortcodes.php b/src/php/Integration/Shortcodes.php index 708d00b81..9bcc30265 100644 --- a/src/php/Integration/Shortcodes.php +++ b/src/php/Integration/Shortcodes.php @@ -93,6 +93,26 @@ protected function invalid_id_warning( int $id ): string { return current_user_can( 'edit_posts' ) ? sprintf( $text, $id ) : ''; } + /** + * Determine whether the author of the current post may reveal the given snippet. + * + * The author is checked rather than the viewer, as published posts are viewed anonymously. + * + * @param Snippet $snippet Snippet object. + * @param bool $network Whether the snippet was requested from the network table. + * + * @return bool + */ + protected function can_render_snippet_source( Snippet $snippet, bool $network ): bool { + $post = get_post(); + $allowed = $post && code_snippets()->user_can_manage_snippets( + (int) $post->post_author, + validate_network_param( $network ) + ); + + return (bool) apply_filters( 'code_snippets/shortcode_can_render_source', $allowed, $snippet, $post ); + } + /** * Allow boolean attributes to be provided without a value, similar to how React works. * @@ -277,12 +297,18 @@ public function render_content_shortcode( array $atts ): string { // Render the source code if this is not a shortcode snippet. if ( 'content' !== $snippet->scope ) { - return $snippet->id ? $this->render_snippet_source( $snippet ) : $this->invalid_id_warning( $snippet->id ); + if ( ! $snippet->id ) { + return $this->invalid_id_warning( $snippet->id ); + } + + return $this->can_render_snippet_source( $snippet, (bool) $atts['network'] ) + ? $this->render_snippet_source( $snippet ) + : ''; } // If the snippet is inactive, either display a message or render nothing. if ( ! $snippet->active ) { - if ( $atts['debug'] ) { + if ( $atts['debug'] && $this->can_render_snippet_source( $snippet, (bool) $atts['network'] ) ) { /* translators: 1: snippet name, 2: snippet edit link */ $text = __( '%1$s is currently inactive. You can edit this snippet to activate it and make it visible. This message will not appear in the published post.', 'code-snippets' ); $snippet_name = '' . $snippet->name . ''; @@ -418,6 +444,11 @@ public function render_source_shortcode( array $atts ): string { } $snippet = $this->get_snippet( $id, (bool) $atts['network'] ); + + if ( ! $this->can_render_snippet_source( $snippet, (bool) $atts['network'] ) ) { + return ''; + } + return $this->render_snippet_source( $snippet, $atts ); } } diff --git a/src/php/Plugin.php b/src/php/Plugin.php index ae9585da9..86626fa41 100644 --- a/src/php/Plugin.php +++ b/src/php/Plugin.php @@ -345,6 +345,22 @@ public function user_can_manage_network_snippets(): bool { return is_super_admin() || current_user_can( $this->get_network_cap_name() ); } + /** + * Determine if a given user has the capability to manage snippets at the given scope. + * + * @param int $user_id User ID. + * @param bool $network Whether to check for network snippets. + * + * @return bool + */ + public function user_can_manage_snippets( int $user_id, bool $network = false ): bool { + if ( $network || ( is_multisite() && ! $this->is_subsite_menu_enabled() ) ) { + return is_super_admin( $user_id ) || user_can( $user_id, $this->get_network_cap_name() ); + } + + return user_can( $user_id, $this->get_cap_name() ); + } + /** * Get the required capability to perform a certain action on snippets. * Does not check if the user has this capability or not. diff --git a/tests/unit/Integration/Shortcodes_Test.php b/tests/unit/Integration/Shortcodes_Test.php new file mode 100644 index 000000000..1711f58ba --- /dev/null +++ b/tests/unit/Integration/Shortcodes_Test.php @@ -0,0 +1,191 @@ +user->create( [ 'role' => 'administrator' ] ); + self::$contributor_id = $factory->user->create( [ 'role' => 'contributor' ] ); + + if ( is_multisite() ) { + grant_super_admin( self::$admin_id ); + } + } + + /** + * Save an inactive PHP snippet containing the marker string. + * + * @param bool $network Whether to save the snippet to the network table. + * + * @return int Snippet ID. + */ + private function create_snippet( bool $network = false ): int { + $snippet = new Snippet( + [ + 'name' => 'Private snippet', + 'code' => '// ' . self::MARKER, + 'scope' => 'global', + 'active' => false, + 'network' => $network, + ] + ); + + return save_snippet( $snippet )->id; + } + + /** + * Render shortcode content as part of a post written by the given author. + * + * @param string $content Post content. + * @param int $author_id Post author ID. + * + * @return string Rendered content. + */ + private function render_as_post( string $content, int $author_id ): string { + $post_id = self::factory()->post->create( + [ + 'post_author' => $author_id, + 'post_content' => $content, + 'post_status' => 'draft', + ] + ); + + $GLOBALS['post'] = get_post( $post_id ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + + return do_shortcode( $content ); + } + + /** + * Data provider for the shortcode tags that can render snippet source. + * + * @return array> + */ + public function source_shortcode_tags(): array { + return [ + 'source shortcode' => [ Shortcodes::SOURCE_SHORTCODE ], + 'content shortcode' => [ Shortcodes::CONTENT_SHORTCODE ], + ]; + } + + /** + * A post written by an administrator renders the snippet source. + * + * @dataProvider source_shortcode_tags + * + * @param string $tag Shortcode tag. + * + * @return void + */ + public function test_admin_post_renders_source( string $tag ): void { + $id = $this->create_snippet(); + + $output = $this->render_as_post( "[$tag id=$id]", self::$admin_id ); + + $this->assertStringContainsString( self::MARKER, $output ); + } + + /** + * A post written by a contributor does not render the snippet source. + * + * @dataProvider source_shortcode_tags + * + * @param string $tag Shortcode tag. + * + * @return void + */ + public function test_contributor_post_does_not_render_source( string $tag ): void { + $id = $this->create_snippet(); + + $output = $this->render_as_post( "[$tag id=$id]", self::$contributor_id ); + + $this->assertStringNotContainsString( self::MARKER, $output ); + } + + /** + * Source rendered outside a post is not shown. + * + * @return void + */ + public function test_source_outside_post_is_not_rendered(): void { + $id = $this->create_snippet(); + unset( $GLOBALS['post'] ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + + $output = do_shortcode( '[' . Shortcodes::SOURCE_SHORTCODE . " id=$id]" ); + + $this->assertStringNotContainsString( self::MARKER, $output ); + } + + /** + * A post written by a site administrator does not render network snippet source. + * + * @return void + */ + public function test_site_admin_post_does_not_render_network_source(): void { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network scope only exists on multisite installs.' ); + } + + wp_set_current_user( self::$admin_id ); + $id = $this->create_snippet( true ); + $site_admin_id = self::factory()->user->create( [ 'role' => 'administrator' ] ); + + $output = $this->render_as_post( '[' . Shortcodes::SOURCE_SHORTCODE . " id=$id network=1]", $site_admin_id ); + + $this->assertStringNotContainsString( self::MARKER, $output ); + } + + /** + * A post written by a network administrator renders network snippet source. + * + * @return void + */ + public function test_super_admin_post_renders_network_source(): void { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network scope only exists on multisite installs.' ); + } + + wp_set_current_user( self::$admin_id ); + $id = $this->create_snippet( true ); + + $output = $this->render_as_post( '[' . Shortcodes::SOURCE_SHORTCODE . " id=$id network=1]", self::$admin_id ); + + $this->assertStringContainsString( self::MARKER, $output ); + } +} From 44d55df31aaf5fb8a52939d6d30c9acabc89323d Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Thu, 29 Oct 2026 21:42:14 +0000 Subject: [PATCH 3/3] docs: add changelog entries for snippet capability fixes --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 301d7e66b..0808a94a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,10 @@ * Fixed the version list refresh reporting success when the versions could not be fetched. * Fixed safe mode failing on sites running a plugin that builds URLs while WordPress is identifying the current user, and breaking REST API URLs. Safe mode is now carried on admin links only. +* Fixed the snippet import routes allowing a subsite administrator on multisite to import network snippets without the + network capability. Thanks to Artus KG for responsibly disclosing this issue. +* Fixed the snippet source shortcodes revealing snippet code in posts written by users who cannot manage snippets. + Thanks to Het Kalariya for responsibly disclosing this issue. ## [4.0.0] (2026-10-12)