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)
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/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/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 );
+ }
+}
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() );
+ }
+}