$atts Array or shortcode attributes.
* @param string $content Content inside shortcode.
*
@@ -86,16 +91,19 @@ public function content_control( $atts, $content = '' ) {
$classes[] = 'content-control-accessible';
// @deprecated 2.0.0
$classes[] = 'jp-cc-accessible';
- $output = do_shortcode( $content );
+ // Keep nested shortcode output intact; KSES here would strip functional rendered markup.
+ $output = do_shortcode( $content );
} else {
$classes[] = 'content-control-not-accessible';
// @deprecated 2.0.0
$classes[] = 'jp-cc-not-accessible';
- $output = wp_kses_post( do_shortcode( $atts['message'] ) );
+ // Denial messages are shortcode attributes and intentionally limited to post-safe HTML.
+ $output = wp_kses_post( do_shortcode( $atts['message'] ) );
}
$classes = implode( ' ', $classes );
+ // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Contains intentional rendered nested-shortcode HTML.
return sprintf(
'<%1$s class="%2$s">%3$s%1$s>',
$tag,
diff --git a/classes/Plugin/License.php b/classes/Plugin/License.php
index 047bac42..4a1a70da 100644
--- a/classes/Plugin/License.php
+++ b/classes/Plugin/License.php
@@ -14,15 +14,24 @@
/**
* Temporary license provider for active Pro releases older than 1.3.0.
*
+ * Note for WordPress.org Plugin Review Team: Core registers this deprecated
+ * bridge only when it detects an active Content Control Pro version below
+ * 1.3.0. It preserves licensing and update access while that already-installed
+ * add-on is upgraded. Core alone and Pro 1.3.0 or later never instantiate it,
+ * and this class cannot install plugins.
+ *
+ * @see \ContentControl\Plugin\Core::is_legacy_pro_active()
+ *
* @package ContentControl
* @deprecated 2.7.0 Content Control Pro 1.3.0+ owns licensing.
*/
class License {
/**
- * EDD API URL.
+ * Legacy EDD API URL used only for active Pro releases older than 1.3.0.
*
* @var string
+ * @deprecated 2.7.0 Content Control Pro 1.3.0+ owns licensing.
*/
const API_URL = 'https://contentcontrolplugin.com/edd-sl-api/';
diff --git a/classes/Plugin/Prerequisites.php b/classes/Plugin/Prerequisites.php
index ac4aa458..3b4b5c28 100644
--- a/classes/Plugin/Prerequisites.php
+++ b/classes/Plugin/Prerequisites.php
@@ -311,6 +311,7 @@ public function get_php_message( $failed_check_args ) {
$message = __( 'This plugin requires %1$s %2$s or higher in order to run.', 'content-control' );
return sprintf(
$message,
+ // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- Reuse the WordPress Core translation.
__( 'PHP', 'default' ),
$failed_check_args['version'] );
}
@@ -327,6 +328,7 @@ public function get_wp_message( $failed_check_args ) {
$message = __( 'This plugin requires %1$s %2$s or higher in order to run.', 'content-control' );
return sprintf(
$message,
+ // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- Reuse the WordPress Core translation.
__( 'WordPress', 'default' ),
$failed_check_args['version']
);
@@ -396,8 +398,8 @@ public function render_notices() {
$class = 'notice notice-error';
$message = method_exists( $this, 'get_' . $failure['type'] . '_message' ) ? $this->{'get_' . $failure['type'] . '_message'}( $failure ) : false;
- // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
- printf( '', esc_attr( $class ), $message );
+ // Requirement messages contain only the post-safe links and emphasis generated above.
+ printf( '', esc_attr( $class ), wp_kses_post( $message ) );
}
}
}
diff --git a/classes/QueryMonitor/Output.php b/classes/QueryMonitor/Output.php
index 0470553b..da8228fd 100644
--- a/classes/QueryMonitor/Output.php
+++ b/classes/QueryMonitor/Output.php
@@ -172,7 +172,7 @@ public function output_main_query_restrictions() {
// Main query restriction.
echo '';
echo '| Main Query Restriction | ';
- echo '' . ( $main_query_restrictions ? '' . esc_html( $main_query_restrictions->title ) . '' : 'None' ) . ' | ';
+ echo '' . ( $main_query_restrictions ? '' . esc_html( $main_query_restrictions->title ) . '' : 'None' ) . ' | ';
echo '
';
echo '';
@@ -235,7 +235,7 @@ public function output_post_restrictions() {
$restrictions_html = [];
foreach ( $restrictions as $restriction ) {
- $restrictions_html[] = '' . esc_html( $restriction->title ) . '';
+ $restrictions_html[] = '' . esc_html( $restriction->title ) . '';
}
echo '' . wp_kses( join( ', ', $restrictions_html ), [
diff --git a/classes/RestAPI/License.php b/classes/RestAPI/License.php
index ac327549..fa1ef44b 100644
--- a/classes/RestAPI/License.php
+++ b/classes/RestAPI/License.php
@@ -17,6 +17,14 @@
/**
* Temporary REST bridge for Pro releases older than 1.3.0.
*
+ * Note for WordPress.org Plugin Review Team: these routes are registered only
+ * while Core's deprecated old-Pro license bridge is active. Every route also
+ * requires Content Control's manage-settings capability. Core-only sites and
+ * sites running Pro 1.3.0 or later do not register these routes.
+ *
+ * @see \ContentControl\Plugin\Core::is_legacy_pro_active()
+ * @see \ContentControl\Controllers\RestAPI::register_routes()
+ *
* @deprecated 2.7.0 Content Control Pro 1.3.0+ owns license REST routes.
*/
class License extends WP_REST_Controller {
diff --git a/classes/RestAPI/ObjectSearch.php b/classes/RestAPI/ObjectSearch.php
index 6bb12490..0f54e6bb 100644
--- a/classes/RestAPI/ObjectSearch.php
+++ b/classes/RestAPI/ObjectSearch.php
@@ -10,6 +10,8 @@
use WP_User_Query, WP_REST_Controller, WP_REST_Response, WP_REST_Server, WP_Error;
+use function ContentControl\plugin;
+
defined( 'ABSPATH' ) || exit;
/**
@@ -44,7 +46,7 @@ public function register_routes() {
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'object_search' ],
- 'permission_callback' => '__return_true', // Read only, so anyone can view.
+ 'permission_callback' => [ $this, 'object_search_permissions' ],
'args' => [
'nonce' => [
'description' => __( 'Nonce', 'content-control' ),
@@ -92,6 +94,15 @@ public function register_routes() {
);
}
+ /**
+ * Check whether the current user may search objects used by plugin settings.
+ *
+ * @return bool
+ */
+ public function object_search_permissions() {
+ return current_user_can( plugin()->get_permission( 'manage_settings' ) );
+ }
+
/**
* Get block type list.
*
@@ -103,7 +114,7 @@ public function object_search( $request ) {
$nonce = $request->get_param( 'nonce' );
$params = $request->get_params();
- if ( ! isset( $nonce ) || ! wp_verify_nonce( sanitize_key( wp_unslash( $nonce ) ), 'content_control_object_search_nonce' ) ) {
+ if ( ! isset( $nonce ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $nonce ) ), 'content_control_object_search_nonce' ) ) {
wp_send_json_error();
}
diff --git a/classes/Services/UpgradeStream.php b/classes/Services/UpgradeStream.php
index 565ba384..06bca540 100644
--- a/classes/Services/UpgradeStream.php
+++ b/classes/Services/UpgradeStream.php
@@ -98,15 +98,7 @@ public function send_event( $event, $data = [] ) {
plugin( 'logging' )->log( $data['message'] );
}
- $data = \wp_json_encode( $data );
-
- // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
- echo "event: {$event}" . PHP_EOL;
- // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
- echo "data: {$data}" . PHP_EOL;
- echo PHP_EOL;
-
- $this->flush_buffers();
+ parent::send_event( $event, $data );
}
/**
diff --git a/inc/functions/content.php b/inc/functions/content.php
index 2b366b30..a4ead181 100644
--- a/inc/functions/content.php
+++ b/inc/functions/content.php
@@ -119,8 +119,16 @@ function get_current_page_url() {
global $wp;
$current_page = trailingslashit( home_url( $wp->request ) );
+ $query_args = [];
+
+ if ( isset( $_SERVER['QUERY_STRING'] ) && is_string( $_SERVER['QUERY_STRING'] ) ) {
+ // Parse before sanitizing decoded values; sanitizing the raw string removes percent encoding.
+ // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ $query_string = wp_unslash( $_SERVER['QUERY_STRING'] );
+ wp_parse_str( $query_string, $query_args );
+ $query_args = map_deep( $query_args, 'sanitize_text_field' );
+ $query_args = map_deep( $query_args, 'rawurlencode' );
+ }
- /* phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash */
- return add_query_arg( $_SERVER['QUERY_STRING'], '', $current_page );
- /* phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash */
+ return add_query_arg( $query_args, $current_page );
}
diff --git a/inc/functions/query.php b/inc/functions/query.php
index f11a636d..ce4291df 100644
--- a/inc/functions/query.php
+++ b/inc/functions/query.php
@@ -266,7 +266,19 @@ function setup_post_globals( $post_id = null ) {
* @since 2.4.0 - Added support for `terms` context.
*/
function setup_term_globals( $term_id = null ) {
- global $cc_term; // Backward compatibility.
+ /**
+ * Legacy term context global retained for backward compatibility.
+ *
+ * `$cc_term` predates the managed term-context service. It remains
+ * synchronized so existing integrations do not break, but Content Control
+ * itself reads the managed `term` value and new integrations should do the
+ * same.
+ *
+ * @deprecated 2.7.0 Use get_global( 'term' ) instead.
+ * @var \WP_Term|\WP_Error|false|null $cc_term
+ */
+ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- Deprecated compatibility global.
+ global $cc_term;
$current_term = get_global( 'term' ); // Used instead of global $cc_term.
@@ -354,7 +366,19 @@ function reset_term_globals() {
return;
}
- global $cc_term; // Backward compatibility.
+ /**
+ * Legacy term context global retained for backward compatibility.
+ *
+ * `$cc_term` predates the managed term-context service. It remains
+ * synchronized so existing integrations do not break, but Content Control
+ * itself reads the managed `term` value and new integrations should do the
+ * same.
+ *
+ * @deprecated 2.7.0 Use get_global( 'term' ) instead.
+ * @var \WP_Term|\WP_Error|false|null $cc_term
+ */
+ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- Deprecated compatibility global.
+ global $cc_term;
$stored_term_id = pop_from_global( 'overloaded_terms' );
// Reset global post object.
diff --git a/package.json b/package.json
index 342cb34c..dd849fcf 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,7 @@
"vendor/composer/*.php",
"vendor/composer/*.json",
"vendor-prefixed/**/*",
+ "composer.json",
"readme.txt",
"index.php",
"content-control.php",
diff --git a/packages/settings-page/src/upgrades-view/index.tsx b/packages/settings-page/src/upgrades-view/index.tsx
index 76100b0f..2faea971 100644
--- a/packages/settings-page/src/upgrades-view/index.tsx
+++ b/packages/settings-page/src/upgrades-view/index.tsx
@@ -93,7 +93,9 @@ const UpgradeView = () => {
( { type, data } ) => {
const eventData = JSON.parse( data ) as SSEvent[ 'data' ];
- const { message = '', status: eventStatus } = eventData;
+ const { status: eventStatus } = eventData;
+ const message =
+ typeof eventData.message === 'string' ? eventData.message : '';
const newState = {
...upgradeState,
@@ -345,6 +347,7 @@ const UpgradeView = () => {
{ showLogs && (
+ { /* Keep SSE messages in React text nodes so markup is escaped. */ }
{ logs.map( ( log, index ) => (
{ log }
) ) }
diff --git a/tests/unit/Classes/Blocks.php b/tests/unit/Classes/Blocks.php
new file mode 100644
index 00000000..becf7dfd
--- /dev/null
+++ b/tests/unit/Classes/Blocks.php
@@ -0,0 +1,47 @@
+enqueue_block_styles( $styles );
+ }
+ };
+ $styles = '.icon { background-image: url("data:image/svg+xml, "); }';
+
+ Functions\expect( 'wp_register_style' )
+ ->once()
+ ->with( 'content-control-block-styles', false, [], '2.7.0' );
+ Functions\expect( 'wp_enqueue_style' )
+ ->once()
+ ->with( 'content-control-block-styles' );
+ Functions\expect( 'wp_add_inline_style' )
+ ->once()
+ ->with( 'content-control-block-styles', $styles );
+
+ $controller->enqueue_styles( $styles );
+ }
+}
diff --git a/tests/unit/Classes/Shortcodes.php b/tests/unit/Classes/Shortcodes.php
index 25c73362..6748b65e 100644
--- a/tests/unit/Classes/Shortcodes.php
+++ b/tests/unit/Classes/Shortcodes.php
@@ -39,7 +39,8 @@ static function ( $value ) {
return $value;
} );
Functions\when( 'wp_kses_post' )->alias( static function ( $value ) {
- return $value;
+ unset( $value );
+ return '[sanitized]';
} );
Functions\when( 'esc_attr' )->alias( static function ( $value ) {
return $value;
@@ -72,4 +73,21 @@ public function get_option( $key, $fallback = '' ) {
$this->assertStringStartsWith( ' content_control( [ 'inline' => 'true' ], 'Visible' ) );
$this->assertStringStartsWith( 'content_control( [ 'inline' => 'false' ], 'Visible' ) );
}
+
+ /**
+ * Allowed nested shortcode output is not passed through post KSES.
+ */
+ public function test_allowed_nested_shortcode_html_is_preserved() {
+ $controller = new ShortcodesController(
+ new class() {
+ public function get_option( $key, $fallback = '' ) {
+ unset( $key );
+ return $fallback;
+ }
+ }
+ );
+ $content = ' ';
+
+ $this->assertStringContainsString( $content, $controller->content_control( [], $content ) );
+ }
}
diff --git a/tests/unit/Classes/Stream.php b/tests/unit/Classes/Stream.php
new file mode 100644
index 00000000..6282fbc1
--- /dev/null
+++ b/tests/unit/Classes/Stream.php
@@ -0,0 +1,50 @@
+format_data( $data );
+ }
+ };
+
+ $payload = '  ' . "\r\nevent: injected\n\ndata: forged";
+
+ $this->assertSame(
+ 'data:  ' . PHP_EOL .
+ 'data: event: injected' . PHP_EOL .
+ 'data: ' . PHP_EOL .
+ 'data: data: forged' . PHP_EOL,
+ $controller->format( $payload )
+ );
+ }
+}
diff --git a/tests/unit/Functions/Content.php b/tests/unit/Functions/Content.php
new file mode 100644
index 00000000..cd8897af
--- /dev/null
+++ b/tests/unit/Functions/Content.php
@@ -0,0 +1,72 @@
+ 'search' ];
+ $_SERVER['QUERY_STRING'] = 's=hello%20world&filter%5Bstatus%5D=published';
+
+ Functions\when( 'home_url' )->alias( static function ( $path ) {
+ return 'https://example.com/' . ltrim( $path, '/' );
+ } );
+ Functions\when( 'trailingslashit' )->alias( static function ( $url ) {
+ return rtrim( $url, '/' ) . '/';
+ } );
+ Functions\when( 'wp_unslash' )->returnArg();
+ Functions\when( 'sanitize_text_field' )->alias( static function ( $value ) {
+ return preg_replace( '/%[a-f0-9]{2}/i', '', $value );
+ } );
+ Functions\expect( 'wp_parse_str' )
+ ->once()
+ ->with(
+ 's=hello%20world&filter%5Bstatus%5D=published',
+ Mockery::type( 'array' )
+ );
+ Functions\when( 'map_deep' )->alias( static function ( $values, $callback ) {
+ $sanitize = static function ( $value ) use ( &$sanitize, $callback ) {
+ if ( is_array( $value ) ) {
+ return array_map( $sanitize, $value );
+ }
+
+ return $callback( $value );
+ };
+
+ return $sanitize( $values );
+ } );
+ Functions\when( 'add_query_arg' )->alias( static function ( $args, $url ) {
+ return $url . '?' . http_build_query( $args );
+ } );
+
+ $this->assertSame( 'https://example.com/search/?', get_current_page_url() );
+ }
+
+ /**
+ * Clear request globals changed by the test.
+ */
+ protected function tearDown(): void {
+ unset( $_SERVER['QUERY_STRING'] );
+ parent::tearDown();
+ }
+}
|