Skip to content

Commit 505dd6a

Browse files
committed
Prevent stored XSS in the block editor.
Brings r46896 to the 5.3 branch. Prevent escaped unicode characters become unescaped in unsafe HTML during JSON decoding. git-svn-id: https://develop.svn.wordpress.org/branches/5.3@46900 602fd350-edb4-49c9-b593-d223f7449a82
1 parent b197546 commit 505dd6a

File tree

4 files changed

+271
-3
lines changed

4 files changed

+271
-3
lines changed

Diff for: src/wp-includes/blocks.php

+227-3
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,11 @@ function has_blocks( $post = null ) {
7474
* @since 5.0.0
7575
* @see parse_blocks()
7676
*
77-
* @param string $block_type Full Block type to look for.
77+
* @param string $block_name Full Block type to look for.
7878
* @param int|string|WP_Post|null $post Optional. Post content, post ID, or post object. Defaults to global $post.
7979
* @return bool Whether the post content contains the specified block.
8080
*/
81-
function has_block( $block_type, $post = null ) {
81+
function has_block( $block_name, $post = null ) {
8282
if ( ! has_blocks( $post ) ) {
8383
return false;
8484
}
@@ -90,7 +90,30 @@ function has_block( $block_type, $post = null ) {
9090
}
9191
}
9292

93-
return false !== strpos( $post, '<!-- wp:' . $block_type . ' ' );
93+
/*
94+
* Normalize block name to include namespace, if provided as non-namespaced.
95+
* This matches behavior for WordPress 5.0.0 - 5.3.0 in matching blocks by
96+
* their serialized names.
97+
*/
98+
if ( false === strpos( $block_name, '/' ) ) {
99+
$block_name = 'core/' . $block_name;
100+
}
101+
102+
// Test for existence of block by its fully qualified name.
103+
$has_block = false !== strpos( $post, '<!-- wp:' . $block_name . ' ' );
104+
105+
if ( ! $has_block ) {
106+
/*
107+
* If the given block name would serialize to a different name, test for
108+
* existence by the serialized form.
109+
*/
110+
$serialized_block_name = strip_core_block_namespace( $block_name );
111+
if ( $serialized_block_name !== $block_name ) {
112+
$has_block = false !== strpos( $post, '<!-- wp:' . $serialized_block_name . ' ' );
113+
}
114+
}
115+
116+
return $has_block;
94117
}
95118

96119
/**
@@ -113,6 +136,207 @@ function get_dynamic_block_names() {
113136
return $dynamic_block_names;
114137
}
115138

139+
/**
140+
* Given an array of attributes, returns a string in the serialized attributes
141+
* format prepared for post content.
142+
*
143+
* The serialized result is a JSON-encoded string, with unicode escape sequence
144+
* substitution for characters which might otherwise interfere with embedding
145+
* the result in an HTML comment.
146+
*
147+
* @since 5.3.1
148+
*
149+
* @param array $attributes Attributes object.
150+
* @return string Serialized attributes.
151+
*/
152+
function serialize_block_attributes( $block_attributes ) {
153+
$encoded_attributes = json_encode( $block_attributes );
154+
$encoded_attributes = preg_replace( '/--/', '\\u002d\\u002d', $encoded_attributes );
155+
$encoded_attributes = preg_replace( '/</', '\\u003c', $encoded_attributes );
156+
$encoded_attributes = preg_replace( '/>/', '\\u003e', $encoded_attributes );
157+
$encoded_attributes = preg_replace( '/&/', '\\u0026', $encoded_attributes );
158+
// Regex: /\\"/
159+
$encoded_attributes = preg_replace( '/\\\\"/', '\\u0022', $encoded_attributes );
160+
161+
return $encoded_attributes;
162+
}
163+
164+
/**
165+
* Returns the block name to use for serialization. This will remove the default
166+
* "core/" namespace from a block name.
167+
*
168+
* @since 5.3.1
169+
*
170+
* @param string $block_name Original block name.
171+
* @return string Block name to use for serialization.
172+
*/
173+
function strip_core_block_namespace( $block_name = null ) {
174+
if ( is_string( $block_name ) && 0 === strpos( $block_name, 'core/' ) ) {
175+
return substr( $block_name, 5 );
176+
}
177+
178+
return $block_name;
179+
}
180+
181+
/**
182+
* Returns the content of a block, including comment delimiters.
183+
*
184+
* @since 5.3.1
185+
*
186+
* @param string $block_name Block name.
187+
* @param array $attributes Block attributes.
188+
* @param string $content Block save content.
189+
* @return string Comment-delimited block content.
190+
*/
191+
function get_comment_delimited_block_content( $block_name = null, $block_attributes, $block_content ) {
192+
if ( is_null( $block_name ) ) {
193+
return $block_content;
194+
}
195+
196+
$serialized_block_name = strip_core_block_namespace( $block_name );
197+
$serialized_attributes = empty( $block_attributes ) ? '' : serialize_block_attributes( $block_attributes ) . ' ';
198+
199+
if ( empty( $block_content ) ) {
200+
return sprintf( '<!-- wp:%s %s/-->', $serialized_block_name, $serialized_attributes );
201+
}
202+
203+
return sprintf(
204+
'<!-- wp:%s %s-->%s<!-- /wp:%s -->',
205+
$serialized_block_name,
206+
$serialized_attributes,
207+
$block_content,
208+
$serialized_block_name
209+
);
210+
}
211+
212+
/**
213+
* Returns the content of a block, including comment delimiters, serializing all
214+
* attributes from the given parsed block.
215+
*
216+
* This should be used when preparing a block to be saved to post content.
217+
* Prefer `render_block` when preparing a block for display. Unlike
218+
* `render_block`, this does not evaluate a block's `render_callback`, and will
219+
* instead preserve the markup as parsed.
220+
*
221+
* @since 5.3.1
222+
*
223+
* @param WP_Block_Parser_Block $block A single parsed block object.
224+
* @return string String of rendered HTML.
225+
*/
226+
function serialize_block( $block ) {
227+
$block_content = '';
228+
229+
$index = 0;
230+
foreach ( $block['innerContent'] as $chunk ) {
231+
$block_content .= is_string( $chunk ) ? $chunk : serialize_block( $block['innerBlocks'][ $index++ ] );
232+
}
233+
234+
if ( ! is_array( $block['attrs'] ) ) {
235+
$block['attrs'] = array();
236+
}
237+
238+
return get_comment_delimited_block_content(
239+
$block['blockName'],
240+
$block['attrs'],
241+
$block_content
242+
);
243+
}
244+
245+
/**
246+
* Returns a joined string of the aggregate serialization of the given parsed
247+
* blocks.
248+
*
249+
* @since 5.3.1
250+
*
251+
* @param WP_Block_Parser_Block[] $blocks Parsed block objects.
252+
* @return string String of rendered HTML.
253+
*/
254+
function serialize_blocks( $blocks ) {
255+
return implode( '', array_map( 'serialize_block', $blocks ) );
256+
}
257+
258+
/**
259+
* Filters and sanitizes block content to remove non-allowable HTML from
260+
* parsed block attribute values.
261+
*
262+
* @since 5.3.1
263+
*
264+
* @param string $text Text that may contain block content.
265+
* @param array[]|string $allowed_html An array of allowed HTML elements
266+
* and attributes, or a context name
267+
* such as 'post'.
268+
* @param string[] $allowed_protocols Array of allowed URL protocols.
269+
* @return string The filtered and sanitized content result.
270+
*/
271+
function filter_block_content( $text, $allowed_html = 'post', $allowed_protocols = array() ) {
272+
$result = '';
273+
274+
$blocks = parse_blocks( $text );
275+
foreach ( $blocks as $block ) {
276+
$block = filter_block_kses( $block, $allowed_html, $allowed_protocols );
277+
$result .= serialize_block( $block );
278+
}
279+
280+
return $result;
281+
}
282+
283+
/**
284+
* Filters and sanitizes a parsed block to remove non-allowable HTML from block
285+
* attribute values.
286+
*
287+
* @since 5.3.1
288+
*
289+
* @param WP_Block_Parser_Block $block The parsed block object.
290+
* @param array[]|string $allowed_html An array of allowed HTML
291+
* elements and attributes, or a
292+
* context name such as 'post'.
293+
* @param string[] $allowed_protocols Allowed URL protocols.
294+
* @return array The filtered and sanitized block object result.
295+
*/
296+
function filter_block_kses( $block, $allowed_html, $allowed_protocols = array() ) {
297+
$block['attrs'] = filter_block_kses_value( $block['attrs'], $allowed_html, $allowed_protocols );
298+
299+
if ( is_array( $block['innerBlocks'] ) ) {
300+
foreach ( $block['innerBlocks'] as $i => $inner_block ) {
301+
$block['innerBlocks'][ $i ] = filter_block_kses( $inner_block, $allowed_html, $allowed_protocols );
302+
}
303+
}
304+
305+
return $block;
306+
}
307+
308+
/**
309+
* Filters and sanitizes a parsed block attribute value to remove non-allowable
310+
* HTML.
311+
*
312+
* @since 5.3.1
313+
*
314+
* @param mixed $value The attribute value to filter.
315+
* @param array[]|string $allowed_html An array of allowed HTML elements
316+
* and attributes, or a context name
317+
* such as 'post'.
318+
* @param string[] $allowed_protocols Array of allowed URL protocols.
319+
* @return array The filtered and sanitized result.
320+
*/
321+
function filter_block_kses_value( $value, $allowed_html, $allowed_protocols = array() ) {
322+
if ( is_array( $value ) ) {
323+
foreach ( $value as $key => $inner_value ) {
324+
$filtered_key = filter_block_kses_value( $key, $allowed_html, $allowed_protocols );
325+
$filtered_value = filter_block_kses_value( $inner_value, $allowed_html, $allowed_protocols );
326+
327+
if ( $filtered_key !== $key ) {
328+
unset( $value[ $key ] );
329+
}
330+
331+
$value[ $filtered_key ] = $filtered_value;
332+
}
333+
} elseif ( is_string( $value ) ) {
334+
return wp_kses( $value, $allowed_html, $allowed_protocols );
335+
}
336+
337+
return $value;
338+
}
339+
116340
/**
117341
* Parses blocks out of a content string, and renders those appropriate for the excerpt.
118342
*

Diff for: src/wp-includes/default-filters.php

+1
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@
243243
add_filter( 'tiny_mce_before_init', '_mce_set_direction' );
244244
add_filter( 'teeny_mce_before_init', '_mce_set_direction' );
245245
add_filter( 'pre_kses', 'wp_pre_kses_less_than' );
246+
add_filter( 'pre_kses', 'wp_pre_kses_block_attributes', 10, 3 );
246247
add_filter( 'sanitize_title', 'sanitize_title_with_dashes', 10, 3 );
247248
add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 );
248249
add_filter( 'comment_flood_filter', 'wp_throttle_comment_flood', 10, 3 );

Diff for: src/wp-includes/formatting.php

+25
Original file line numberDiff line numberDiff line change
@@ -4902,6 +4902,31 @@ function wp_pre_kses_less_than_callback( $matches ) {
49024902
return $matches[0];
49034903
}
49044904

4905+
/**
4906+
* Remove non-allowable HTML from parsed block attribute values when filtering
4907+
* in the post context.
4908+
*
4909+
* @since 5.3.1
4910+
*
4911+
* @param string $string Content to be run through KSES.
4912+
* @param array[]|string $allowed_html An array of allowed HTML elements
4913+
* and attributes, or a context name
4914+
* such as 'post'.
4915+
* @param string[] $allowed_protocols Array of allowed URL protocols.
4916+
* @return string Filtered text to run through KSES.
4917+
*/
4918+
function wp_pre_kses_block_attributes( $string, $allowed_html, $allowed_protocols ) {
4919+
/*
4920+
* `filter_block_content` is expected to call `wp_kses`. Temporarily remove
4921+
* the filter to avoid recursion.
4922+
*/
4923+
remove_filter( 'pre_kses', 'wp_pre_kses_block_attributes', 10 );
4924+
$string = filter_block_content( $string, $allowed_html, $allowed_protocols );
4925+
add_filter( 'pre_kses', 'wp_pre_kses_block_attributes', 10, 3 );
4926+
4927+
return $string;
4928+
}
4929+
49054930
/**
49064931
* WordPress implementation of PHP sprintf() with filters.
49074932
*

Diff for: tests/phpunit/tests/blocks/block-type.php

+18
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,24 @@ public function test_post_has_block() {
304304
$this->assertFalse( has_block( 'core/fake' ) );
305305
}
306306

307+
public function test_post_has_block_serialized_name() {
308+
$content = '<!-- wp:serialized /--><!-- wp:core/normalized /--><!-- wp:plugin/third-party /-->';
309+
310+
$this->assertTrue( has_block( 'core/serialized', $content ) );
311+
312+
/*
313+
* Technically, `has_block` should receive a "full" (normalized, parsed)
314+
* block name. But this test conforms to expected pre-5.3.1 behavior.
315+
*/
316+
$this->assertTrue( has_block( 'serialized', $content ) );
317+
$this->assertTrue( has_block( 'core/normalized', $content ) );
318+
$this->assertTrue( has_block( 'normalized', $content ) );
319+
$this->assertFalse( has_block( 'plugin/normalized', $content ) );
320+
$this->assertFalse( has_block( 'plugin/serialized', $content ) );
321+
$this->assertFalse( has_block( 'third-party', $content ) );
322+
$this->assertFalse( has_block( 'core/third-party', $content ) );
323+
}
324+
307325
/**
308326
* Renders a test block without content.
309327
*

0 commit comments

Comments
 (0)