diff --git a/public_html/wp-content/plugins/pattern-directory/includes/pattern-post-type.php b/public_html/wp-content/plugins/pattern-directory/includes/pattern-post-type.php index 54737d8a..dc36a4bd 100644 --- a/public_html/wp-content/plugins/pattern-directory/includes/pattern-post-type.php +++ b/public_html/wp-content/plugins/pattern-directory/includes/pattern-post-type.php @@ -343,6 +343,12 @@ function register_rest_fields() { array( 'get_callback' => function ( $response_data ) { $pattern = get_post( $response_data['id'] ); + + // Only ever expose pattern content; a mismatched type means the id resolved in the wrong context. + if ( ! $pattern || POST_TYPE !== $pattern->post_type ) { + return ''; + } + return decode_pattern_content( $pattern->post_content ); }, @@ -595,6 +601,44 @@ function enqueue_editor_assets() { ); } +/** + * Core block types that don't belong in shared patterns. + * + * Shared by the editor's `allowed_block_types_all` filter and the REST validator, so the two reject the + * same block types instead of drifting apart. + */ +const DISALLOWED_BLOCK_TYPES = array( + 'core/freeform', // Classic block. + 'core/legacy-widget', + 'core/more', + 'core/nextpage', + 'core/block', // Reusable blocks. + 'core/pattern', // Splices in another registered pattern by slug on render. + 'core/shortcode', + 'core/template-part', +); + +/** + * Whether a block type may be used in a submitted pattern. + * + * `wporg/*` blocks (Global Header & Footer and the like) and the disallowed core blocks are removed by + * the editor's `allowed_block_types_all` filter. A registered block is not an authorised one, so the REST + * validator applies this same predicate to reject them on the server too. + * + * @param string $block_type The block type name. + * + * @return bool Whether the block is allowed in patterns. + */ +function is_block_allowed_in_pattern( $block_type ) { + $block_type = (string) $block_type; + + if ( str_starts_with( $block_type, 'wporg/' ) ) { + return false; + } + + return ! in_array( $block_type, DISALLOWED_BLOCK_TYPES, true ); +} + /** * Restrict the set of blocks allowed in block patterns. * @@ -604,32 +648,13 @@ function enqueue_editor_assets() { * @return bool|array A (possibly) filtered list of block types. */ function remove_disallowed_blocks( $allowed_block_types, $block_editor_context ) { - $disallowed_block_types = array( - // Remove blocks that don't make sense in Block Patterns - 'core/freeform', // Classic block - 'core/legacy-widget', - 'core/more', - 'core/nextpage', - 'core/block', // Reusable blocks - 'core/shortcode', - 'core/template-part', - ); - if ( isset( $block_editor_context->post ) && POST_TYPE === $block_editor_context->post->post_type ) { - // This can be true if all block types are allowed, so to filter them we - // need to get the list of all registered blocks first. + // `true` means every registered block is allowed, so expand it before filtering. if ( true === $allowed_block_types ) { $allowed_block_types = array_keys( WP_Block_Type_Registry::get_instance()->get_all_registered() ); } - $allowed_block_types = array_diff( $allowed_block_types, $disallowed_block_types ); - // Remove the "WordPress.org" blocks, like Global Header & Global Footer. - $allowed_block_types = array_filter( - $allowed_block_types, - function ( $block_type ) { - return 'wporg/' !== substr( $block_type, 0, 6 ); - } - ); + $allowed_block_types = array_filter( $allowed_block_types, __NAMESPACE__ . '\is_block_allowed_in_pattern' ); } return is_array( $allowed_block_types ) ? array_values( $allowed_block_types ) : $allowed_block_types; diff --git a/public_html/wp-content/plugins/pattern-directory/includes/pattern-validation.php b/public_html/wp-content/plugins/pattern-directory/includes/pattern-validation.php index f0e6a28f..c0aa736b 100644 --- a/public_html/wp-content/plugins/pattern-directory/includes/pattern-validation.php +++ b/public_html/wp-content/plugins/pattern-directory/includes/pattern-validation.php @@ -4,11 +4,13 @@ use WordPressdotorg\Pattern_Translations\Pattern as Translations_Pattern; use WordPressdotorg\Pattern_Translations\PatternParser as Translations_PatternParser; +use function WordPressdotorg\Pattern_Directory\Pattern_Post_Type\is_block_allowed_in_pattern; use const WordPressdotorg\Pattern_Directory\Pattern_Post_Type\{ POST_TYPE, UNLISTED_STATUS, SPAM_STATUS }; add_filter( 'rest_pre_insert_' . POST_TYPE, __NAMESPACE__ . '\validate_content', 10, 2 ); add_filter( 'rest_pre_insert_' . POST_TYPE, __NAMESPACE__ . '\validate_block_context', 10, 2 ); add_filter( 'rest_pre_insert_' . POST_TYPE, __NAMESPACE__ . '\validate_block_attributes', 10, 2 ); +add_filter( 'rest_pre_insert_' . POST_TYPE, __NAMESPACE__ . '\validate_block_directives', 10, 2 ); add_filter( 'rest_pre_insert_' . POST_TYPE, __NAMESPACE__ . '\validate_title', 11, 2 ); add_filter( 'rest_pre_insert_' . POST_TYPE, __NAMESPACE__ . '\validate_status', 11, 2 ); add_filter( 'rest_pre_insert_' . POST_TYPE, __NAMESPACE__ . '\validate_parent', 11, 2 ); @@ -109,8 +111,7 @@ function validate_content( $prepared_post, $request ) { ); } - // The editor adds in linebreaks between blocks, but parse_blocks thinks those are invalid blocks. - $content = str_replace( "\n\n", '', $content ); + // Parse the exact content that will be stored: normalising it first could hide a block from validation. $blocks = parse_blocks( $content ); $blocks_queue = $blocks; $all_blocks = array(); @@ -118,6 +119,12 @@ function validate_content( $prepared_post, $request ) { // Loop over all the nested blocks to flatten the block list into 1 dimension. while ( count( $blocks_queue ) > 0 ) { // phpcs:ignore -- inline count OK. $block = array_shift( $blocks_queue ); + + // The editor's linebreaks between blocks parse as nameless whitespace-only blocks: separators, not content. + if ( is_null( $block['blockName'] ) && '' === trim( $block['innerHTML'] ) ) { + continue; + } + array_push( $all_blocks, $block ); if ( ! empty( $block['innerBlocks'] ) ) { foreach ( $block['innerBlocks'] as $inner_block ) { @@ -141,6 +148,22 @@ function validate_content( $prepared_post, $request ) { ); } + // The editor hiding a block is a UI affordance, not a boundary; enforce the same policy server-side. + $disallowed_blocks = array_filter( + $all_blocks, + function ( $block ) { + return ! is_null( $block['blockName'] ) && ! is_block_allowed_in_pattern( $block['blockName'] ); + } + ); + + if ( count( $disallowed_blocks ) ) { + return new \WP_Error( + 'rest_pattern_disallowed_blocks', + __( 'Pattern content contains blocks that are not allowed. Patterns shared on the Pattern Directory can only use core blocks.', 'wporg-patterns' ), + array( 'status' => 400 ) + ); + } + // Next, filter out any empty blocks $real_blocks = array_filter( $all_blocks, __NAMESPACE__ . '\is_not_empty_block' ); @@ -359,6 +382,60 @@ static function ( $matches ) { return ! in_array( $scheme, wp_allowed_protocols(), true ); } +/** + * Reject Interactivity API `data-wp-*` directives carried in a block's HTML. + * + * KSES preserves them and they sit in inner HTML, so neither core sanitisation nor the attribute check + * above catches them. + * + * @param object $prepared_post The post object about to be inserted. + * @param \WP_REST_Request $request The request. + * + * @return object|\WP_Error The post object, or an error if the content carries a directive. + */ +function validate_block_directives( $prepared_post, $request ) { + if ( is_wp_error( $prepared_post ) ) { + return $prepared_post; + } + + if ( ! isset( $prepared_post->post_content ) ) { + return $prepared_post; + } + + if ( content_has_block_directives( $prepared_post->post_content ) ) { + return new \WP_Error( + 'rest_pattern_interactivity_directive', + __( 'Pattern content contains interactivity directives, which are not allowed.', 'wporg-patterns' ), + array( 'status' => 400 ) + ); + } + + return $prepared_post; +} + +/** + * Whether any tag in the HTML carries an Interactivity API `data-wp-*` attribute. + * + * @param string $html The HTML to scan. + * + * @return bool Whether a directive is present. + */ +function content_has_block_directives( $html ) { + // Directives are rare; don't tokenize the whole document when the marker can't be present. + if ( false === stripos( $html, 'data-wp-' ) ) { + return false; + } + + $tags = new \WP_HTML_Tag_Processor( $html ); + while ( $tags->next_tag() ) { + if ( $tags->get_attribute_names_with_prefix( 'data-wp-' ) ) { + return true; + } + } + + return false; +} + /** * Validate the pattern title. */ diff --git a/public_html/wp-content/plugins/pattern-directory/tests/phpunit/pattern-content-validation-test.php b/public_html/wp-content/plugins/pattern-directory/tests/phpunit/pattern-content-validation-test.php index c78f6f6f..3372f0ff 100644 --- a/public_html/wp-content/plugins/pattern-directory/tests/phpunit/pattern-content-validation-test.php +++ b/public_html/wp-content/plugins/pattern-directory/tests/phpunit/pattern-content-validation-test.php @@ -11,6 +11,11 @@ * @group content-validation */ class Pattern_Content_Validation_Test extends WP_UnitTestCase { + /** + * Two valid paragraph blocks, the base fixture the data providers and tests build on. + */ + private const TWO_PARAGRAPHS = "\n

One.

\n\n\n\n

Two.

\n"; + protected static $pattern_id; protected static $user; @@ -63,7 +68,7 @@ public function test_valid_content( $content ) { * @return array */ public function data_valid_content() { - $two_paragraphs = "\n

One.

\n\n\n\n

Two.

\n"; + $two_paragraphs = self::TWO_PARAGRAPHS; $three_paragraphs = "$two_paragraphs\n\n\n

Three.

\n"; return array( @@ -75,7 +80,6 @@ public function data_valid_content() { array( "\n
\n
\n

Heading

\n\n\n\n

Paragraph

\n
\n\n\n\n
\"\"/
\n
\n" ), array( "\n
\n
\n
\n
\n\n\n\n
\n
\n\n\n\n

One

\n
\n
\n" ), array( "\n\n\n\n\n\n" ), - array( "$three_paragraphs\n\n\n\n" ), array( "\n
$three_paragraphs
\n" ), // A `mailto:` URL is an allowed protocol, and a relative path whose colon follows a non-scheme segment is not a scheme at all. array( "$two_paragraphs\n\n\n
\n
Mail
\n\n\n\n
Report
\n
\n" ), @@ -108,7 +112,7 @@ public function test_invalid_empty_content( $expected_error_code, $content ) { * @return array */ public function data_invalid_content() { - $two_paragraphs = "\n

One.

\n\n\n\n

Two.

\n"; + $two_paragraphs = self::TWO_PARAGRAPHS; $three_paragraphs = "$two_paragraphs\n\n\n

Three.

\n"; return array( @@ -129,6 +133,19 @@ public function data_invalid_content() { array( 'rest_pattern_invalid_blocks', "\n

This is some content.

\n" ), array( 'rest_pattern_invalid_blocks', "\n
\n

Fake nested block.

\n
\n" ), + // Registered core blocks the editor hides from the inserter must be rejected on the server too. + array( 'rest_pattern_disallowed_blocks', "$three_paragraphs\n\n\n\n" ), + array( 'rest_pattern_disallowed_blocks', "$three_paragraphs\n\n[gallery]" ), + array( 'rest_pattern_disallowed_blocks', "\n
[gallery]
\n" ), + // `core/pattern` splices in another pattern by slug on render, the indirection `core/block` is blocked for. + array( 'rest_pattern_disallowed_blocks', "$three_paragraphs\n\n" ), + // A `\n\n` inside a delimiter still parses in stored content; normalising it away must not hide the block. + array( 'rest_pattern_disallowed_blocks', "$three_paragraphs\n\n\n
[gallery]
\n" ), + + // Interactivity directives in a block's HTML would drive a trusted store from submitted markup. + array( 'rest_pattern_interactivity_directive', "$two_paragraphs\n\n\n

x

\n" ), + array( 'rest_pattern_interactivity_directive', "$two_paragraphs\n\n\n
\"\"/
\n" ), + // A parent-only block (`core/page-list-item` belongs to `core/page-list`) used standalone is out // of context. The second also carries a script URL, but the context check rejects it first. array( 'rest_pattern_invalid_block_context', "$two_paragraphs\n\n" ), @@ -160,6 +177,33 @@ public function data_invalid_content() { ); } + /** + * A `wporg/*` block is registered globally but must never be accepted in a pattern. + * + * This is the entry point the reported moderator-XSS and cross-blog-disclosure chains relied on: + * the editor hides `wporg/*` blocks, but the server accepted any registered block. + */ + public function test_wporg_blocks_are_disallowed() { + register_block_type( 'wporg/test-block', array( 'apiVersion' => 2 ) ); + + try { + wp_set_current_user( self::$user ); + + $content = self::TWO_PARAGRAPHS . "\n\n"; + + $request = new WP_REST_Request( 'POST', '/wp/v2/wporg-pattern/' . self::$pattern_id ); + $request->set_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'content' => $content ) ) ); + + $response = rest_do_request( $request ); + + $this->assertTrue( $response->is_error() ); + $this->assertSame( 'rest_pattern_disallowed_blocks', $response->get_data()['code'] ); + } finally { + unregister_block_type( 'wporg/test-block' ); + } + } + /** * Test a block that's detected as spam should be pending. */ diff --git a/public_html/wp-content/plugins/pattern-translations/includes/cron.php b/public_html/wp-content/plugins/pattern-translations/includes/cron.php index 5889285a..1c7f6e41 100644 --- a/public_html/wp-content/plugins/pattern-translations/includes/cron.php +++ b/public_html/wp-content/plugins/pattern-translations/includes/cron.php @@ -95,7 +95,13 @@ function () { if ( $translated ) { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped echo "\t{$locale} - " . ( $translated->ID ? 'Updating' : 'Creating' ) . " Translated pattern.\n"; - create_or_update_translated_pattern( $translated ); + $result = create_or_update_translated_pattern( $translated ); + if ( is_wp_error( $result ) ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- cron output isn't reliably captured; the failure has to reach the server log. + error_log( "Pattern translation import failed for {$pattern->name} ({$locale}): " . $result->get_error_message() ); + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo "\t{$locale} - ERROR: {$result->get_error_message()}\n"; + } } else { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped echo "\t{$locale} - No Translations exist yet.\n"; diff --git a/public_html/wp-content/plugins/pattern-translations/pattern-translations.php b/public_html/wp-content/plugins/pattern-translations/pattern-translations.php index 1cb0d917..8cc03d2f 100644 --- a/public_html/wp-content/plugins/pattern-translations/pattern-translations.php +++ b/public_html/wp-content/plugins/pattern-translations/pattern-translations.php @@ -7,6 +7,8 @@ */ namespace WordPressdotorg\Pattern_Translations; +use function WordPressdotorg\Pattern_Directory\Pattern_Post_Type\is_block_allowed_in_pattern; +use function WordPressdotorg\Pattern_Directory\Pattern_Validation\content_has_block_directives; use const WordPressdotorg\Pattern_Directory\Pattern_Post_Type\POST_TYPE; const GLOTPRESS_PROJECT = 'patterns/core'; @@ -27,10 +29,48 @@ require __DIR__ . '/includes/cli-commands.php'; } +/** + * Whether translated pattern HTML stays within what the directory accepts from direct submissions. + * + * Translator-supplied strings are assembled into stored markup without passing through the REST + * validators, so the block allowlist and Interactivity-directive checks (the two a translated string + * could realistically violate) run here. The remaining REST checks still apply to the English parent. + * + * @param string $html The assembled, translated pattern HTML. + * @return bool Whether the HTML is safe to store as a pattern. + */ +function is_translated_content_allowed( $html ) { + $blocks = parse_blocks( $html ); + while ( count( $blocks ) > 0 ) { // phpcs:ignore -- inline count OK. + $block = array_shift( $blocks ); + + if ( ! is_null( $block['blockName'] ) && ! is_block_allowed_in_pattern( $block['blockName'] ) ) { + return false; + } + + if ( ! empty( $block['innerBlocks'] ) ) { + $blocks = array_merge( $blocks, $block['innerBlocks'] ); + } + } + + return ! content_has_block_directives( $html ); +} + /** * Creates or updates a localised pattern. + * + * @param Pattern $pattern The translated pattern to store. + * + * @return int|\WP_Error The pattern post ID, or an error if the content is refused or the write fails. */ function create_or_update_translated_pattern( Pattern $pattern ) { + if ( ! is_translated_content_allowed( $pattern->html ) ) { + return new \WP_Error( + 'pattern_translation_disallowed_content', + 'Translated pattern content contains disallowed blocks or interactivity directives.' + ); + } + $parent = false; if ( $pattern->parent ) { $parent = get_post( $pattern->parent->ID );