Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
},

Expand Down Expand Up @@ -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.
*
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down Expand Up @@ -109,15 +111,20 @@ 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();

// 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 ) {
Expand All @@ -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' );

Expand Down Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<!-- wp:paragraph -->\n<p>One.</p>\n<!-- /wp:paragraph -->\n\n<!-- wp:paragraph -->\n<p>Two.</p>\n<!-- /wp:paragraph -->";

protected static $pattern_id;
protected static $user;

Expand Down Expand Up @@ -63,7 +68,7 @@ public function test_valid_content( $content ) {
* @return array
*/
public function data_valid_content() {
$two_paragraphs = "<!-- wp:paragraph -->\n<p>One.</p>\n<!-- /wp:paragraph -->\n\n<!-- wp:paragraph -->\n<p>Two.</p>\n<!-- /wp:paragraph -->";
$two_paragraphs = self::TWO_PARAGRAPHS;
$three_paragraphs = "$two_paragraphs\n\n<!-- wp:paragraph -->\n<p>Three.</p>\n<!-- /wp:paragraph -->";

return array(
Expand All @@ -75,7 +80,6 @@ public function data_valid_content() {
array( "<!-- wp:group {\"layout\":{\"type\":\"flex\",\"justifyContent\":\"space-between\"}} -->\n<div class=\"wp-block-group\"><!-- wp:group -->\n<div class=\"wp-block-group\"><!-- wp:heading -->\n<h2>Heading</h2>\n<!-- /wp:heading -->\n\n<!-- wp:paragraph -->\n<p>Paragraph</p>\n<!-- /wp:paragraph --></div>\n<!-- /wp:group -->\n\n<!-- wp:image {\"id\":null} -->\n<figure class=\"wp-block-image\"><img src=\"./pear.png\" alt=\"\"/></figure>\n<!-- /wp:image --></div>\n<!-- /wp:group -->" ),
array( "<!-- wp:columns -->\n<div class=\"wp-block-columns\"><!-- wp:column {\"width\":\"66.66%\"} -->\n<div class=\"wp-block-column\" style=\"flex-basis:66.66%\"><!-- wp:spacer -->\n<div style=\"height:100px\" aria-hidden=\"true\" class=\"wp-block-spacer\"></div>\n<!-- /wp:spacer --></div>\n<!-- /wp:column -->\n\n<!-- wp:column {\"width\":\"33.33%\"} -->\n<div class=\"wp-block-column\" style=\"flex-basis:33.33%\"><!-- wp:spacer {\"height\":\"51px\"} -->\n<div style=\"height:51px\" aria-hidden=\"true\" class=\"wp-block-spacer\"></div>\n<!-- /wp:spacer -->\n\n<!-- wp:paragraph -->\n<p>One</p>\n<!-- /wp:paragraph --></div>\n<!-- /wp:column --></div>\n<!-- /wp:columns -->" ),
array( "<!-- wp:navigation -->\n<!-- wp:navigation-link {\"label\":\"Home\",\"url\":\"https://example.com/\"} /-->\n\n<!-- wp:navigation-submenu {\"label\":\"About\",\"url\":\"https://example.com/about\"} -->\n<!-- wp:navigation-link {\"label\":\"Team\",\"url\":\"https://example.com/team\"} /-->\n<!-- /wp:navigation-submenu -->\n<!-- /wp:navigation -->" ),
array( "$three_paragraphs\n\n<!-- wp:nextpage -->\n<!--nextpage-->\n<!-- /wp:nextpage -->" ),
array( "<!-- wp:group {\"metadata\":{\"name\":\"JavaScript: hero section\"}} -->\n<div class=\"wp-block-group\">$three_paragraphs</div>\n<!-- /wp:group -->" ),
// 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<!-- wp:buttons -->\n<div class=\"wp-block-buttons\"><!-- wp:button {\"url\":\"mailto:hello@example.com\"} -->\n<div class=\"wp-block-button\"><a class=\"wp-block-button__link wp-element-button\">Mail</a></div>\n<!-- /wp:button -->\n\n<!-- wp:button {\"url\":\"/2024/report:final\"} -->\n<div class=\"wp-block-button\"><a class=\"wp-block-button__link wp-element-button\">Report</a></div>\n<!-- /wp:button --></div>\n<!-- /wp:buttons -->" ),
Expand Down Expand Up @@ -108,7 +112,7 @@ public function test_invalid_empty_content( $expected_error_code, $content ) {
* @return array
*/
public function data_invalid_content() {
$two_paragraphs = "<!-- wp:paragraph -->\n<p>One.</p>\n<!-- /wp:paragraph -->\n\n<!-- wp:paragraph -->\n<p>Two.</p>\n<!-- /wp:paragraph -->";
$two_paragraphs = self::TWO_PARAGRAPHS;
$three_paragraphs = "$two_paragraphs\n\n<!-- wp:paragraph -->\n<p>Three.</p>\n<!-- /wp:paragraph -->";

return array(
Expand All @@ -129,6 +133,19 @@ public function data_invalid_content() {
array( 'rest_pattern_invalid_blocks', "<!-- wp:plugin/fake -->\n<p>This is some content.</p>\n<!-- /wp:plugin/fake -->" ),
array( 'rest_pattern_invalid_blocks', "<!-- wp:group -->\n<div class=\"wp-block-group\"><!-- wp:plugin/fake -->\n<p>Fake nested block.</p>\n<!-- /wp:plugin/fake --></div>\n<!-- /wp:group -->" ),

// 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<!-- wp:nextpage -->\n<!--nextpage-->\n<!-- /wp:nextpage -->" ),
array( 'rest_pattern_disallowed_blocks', "$three_paragraphs\n\n<!-- wp:shortcode -->[gallery]<!-- /wp:shortcode -->" ),
array( 'rest_pattern_disallowed_blocks', "<!-- wp:group -->\n<div class=\"wp-block-group\"><!-- wp:shortcode -->[gallery]<!-- /wp:shortcode --></div>\n<!-- /wp:group -->" ),
// `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<!-- wp:pattern {\"slug\":\"core/example\"} /-->" ),
// 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<!-- wp:group -->\n<div class=\"wp-block-group\"><!-- wp:shortcode\n\n-->[gallery]<!-- /wp:shortcode\n\n--></div>\n<!-- /wp:group -->" ),

// Interactivity directives in a block's HTML would drive a trusted store from submitted markup.
array( 'rest_pattern_interactivity_directive', "$two_paragraphs\n\n<!-- wp:paragraph -->\n<p><span data-wp-interactive=\"wporg/patterns\" data-wp-init=\"actions.go\">x</span></p>\n<!-- /wp:paragraph -->" ),
array( 'rest_pattern_interactivity_directive', "$two_paragraphs\n\n<!-- wp:image -->\n<figure class=\"wp-block-image\"><img data-wp-bind--src=\"context.href\" alt=\"\"/></figure>\n<!-- /wp:image -->" ),

// 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<!-- wp:page-list-item {\"label\":\"Featured\"} /-->" ),
Expand Down Expand Up @@ -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<!-- wp:wporg/test-block /-->";

$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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 );
Expand Down
Loading