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
156 changes: 147 additions & 9 deletions includes/create-theme/theme-locale.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,129 @@

class CBT_Theme_Locale {

/**
* Escape a string that will be embedded in generated PHP single-quoted strings.
*
* @param string $string The string to escape.
* @return string The escaped string.
*/
private static function escape_php_single_quoted_string( $string ) {
return addcslashes( (string) $string, "\\'" );
}

/**
* Escape a block attribute value for localization.
*
* @param string $string The string to escape.
* @return string The escaped string.
*/
private static function escape_block_attribute( $string ) {
$tokenized = self::tokenize_block_attribute_for_php_string( $string );
$text_domain = self::escape_php_single_quoted_string( wp_get_theme()->get( 'TextDomain' ) );

if ( empty( $tokenized['tokens'] ) ) {
return "<?php esc_attr_e('" . $tokenized['text'] . "', '$text_domain');?>";
}

$translation_call = "__( '" . $tokenized['text'] . "', '$text_domain' )";
$token_expressions = implode( ', ', wp_list_pluck( $tokenized['tokens'], 'expression' ) );

$php_tag = '<?php ';
$php_tag .= $tokenized['translators_note'] . ' ';
$php_tag .= 'echo esc_attr( sprintf( ' . $translation_call . ', ' . $token_expressions . ' ) ); ?>';
return $php_tag;
}

/**
* Tokenize characters that would be unsafe inside localized block attribute PHP strings.
*
* @param string $string The string to tokenize.
* @return array Tokenized text, token expressions, and a translators note.
*/
private static function tokenize_block_attribute_for_php_string( $string ) {
$tokens = array();
$text = '';
$special_chars = array(
'\\' => array(
'expression' => 'chr(92)',
'description' => 'a backslash character',
),
"'" => array(
'expression' => 'chr(39)',
'description' => 'an apostrophe character',
),
'"' => array(
'expression' => 'chr(34)',
'description' => 'a double quote character',
),
"\n" => array(
'expression' => 'chr(10)',
'description' => 'a newline character',
),
"\r" => array(
'expression' => 'chr(13)',
'description' => 'a carriage return character',
),
"\t" => array(
'expression' => 'chr(9)',
'description' => 'a tab character',
),
);

$string = (string) $string;
$length = strlen( $string );
$has_tokens = false;

for ( $i = 0; $i < $length; $i++ ) {
$char = $string[ $i ];
if ( isset( $special_chars[ $char ] ) || ord( $char ) < 32 ) {
$has_tokens = true;
break;
}
}

for ( $i = 0; $i < $length; $i++ ) {
$char = $string[ $i ];
$ord = ord( $char );

if ( isset( $special_chars[ $char ] ) || $ord < 32 ) {
$token_data = isset( $special_chars[ $char ] )
? $special_chars[ $char ]
: array(
'expression' => 'chr(' . $ord . ')',
'description' => 'character code ' . $ord,
);

$tokens[] = $token_data;
$text .= '%' . count( $tokens ) . '$s';
continue;
}

$text .= $has_tokens && '%' === $char ? '%%' : $char;
}

$text = self::escape_php_single_quoted_string( $text );

if ( empty( $tokens ) ) {
return array(
'text' => $text,
'tokens' => $tokens,
'translators_note' => '',
);
}

$descriptions = array();
foreach ( $tokens as $index => $token ) {
$descriptions[] = ( $index + 1 ) . '. is ' . $token['description'];
}

return array(
'text' => $text,
'tokens' => $tokens,
'translators_note' => '/* Translators: ' . implode( ', ', $descriptions ) . '. */',
);
}

/**
* Escape text for localization.
*
Expand All @@ -29,18 +152,19 @@ private static function escape_text_content( $string ) {
return $string;
}

$string = addcslashes( $string, "'" );
$string = self::escape_php_single_quoted_string( $string );

$p = new CBT_Token_Processor( $string );
$p->process_tokens();
$text = $p->get_text();
$tokens = $p->get_tokens();
$translators_note = $p->get_translators_note();
$text_domain = self::escape_php_single_quoted_string( wp_get_theme()->get( 'TextDomain' ) );

if ( ! empty( $tokens ) ) {
$php_tag = '<?php ';
$php_tag .= $translators_note . "\n";
$php_tag .= "echo sprintf( esc_html__( '$text', '" . wp_get_theme()->get( 'TextDomain' ) . "' ), " . implode(
$php_tag .= "echo sprintf( esc_html__( '$text', '$text_domain' ), " . implode(
', ',
array_map(
function( $token ) {
Expand All @@ -52,7 +176,7 @@ function( $token ) {
return $php_tag;
}

return "<?php esc_html_e('" . $string . "', '" . wp_get_theme()->get( 'TextDomain' ) . "');?>";
return "<?php esc_html_e('" . $string . "', '$text_domain');?>";
}

/**
Expand All @@ -77,8 +201,9 @@ private static function escape_attribute( $string ) {
return $string;
}

$string = addcslashes( $string, "'" );
return "<?php esc_attr_e('" . $string . "', '" . wp_get_theme()->get( 'TextDomain' ) . "');?>";
$string = self::escape_php_single_quoted_string( $string );
$text_domain = self::escape_php_single_quoted_string( wp_get_theme()->get( 'TextDomain' ) );
return "<?php esc_attr_e('" . $string . "', '$text_domain');?>";
}

/**
Expand Down Expand Up @@ -293,7 +418,8 @@ function ( $matches ) {
}

// Process each localizable attribute.
$modified = false;
$localized_attrs = array();
$modified = false;
foreach ( $localizable_attrs as $attr_name ) {
if ( isset( $attrs[ $attr_name ] ) && is_string( $attrs[ $attr_name ] ) ) {
// Skip if already escaped.
Expand All @@ -302,14 +428,26 @@ function ( $matches ) {
}

// Escape the attribute value.
$attrs[ $attr_name ] = self::escape_attribute( $attrs[ $attr_name ] );
$modified = true;
$localized_attrs[ $attr_name ] = self::escape_block_attribute( $attrs[ $attr_name ] );
$modified = true;
}
}

// If we modified any attributes, re-encode to JSON.
if ( $modified ) {
$new_attrs_json = wp_json_encode( $attrs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
$attr_fragments = array();
foreach ( $attrs as $attr_name => $attr_value ) {
$encoded_attr_name = wp_json_encode( (string) $attr_name, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );

if ( array_key_exists( $attr_name, $localized_attrs ) ) {
$attr_fragments[] = $encoded_attr_name . ':' . wp_json_encode( $localized_attrs[ $attr_name ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
continue;
}

$attr_fragments[] = $encoded_attr_name . ':' . wp_json_encode( $attr_value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
}

$new_attrs_json = '{' . implode( ',', $attr_fragments ) . '}';
return '<!-- wp:' . $block_name . ' ' . $new_attrs_json . ' ' . $self_closer . '-->';
}

Expand Down
22 changes: 22 additions & 0 deletions tests/CbtThemeLocale/base.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,26 @@ public function tear_down() {
// Restore the original active theme.
switch_theme( $this->orig_active_theme_slug );
}

/**
* Assert that generated PHP source does not contain a callable function token.
*
* @param string $function_name The function name that must not be callable.
* @param string $php_code The generated PHP source to inspect.
*/
protected function assert_php_code_does_not_call_function( $function_name, $php_code ) {
$tokens = token_get_all( $php_code );

foreach ( $tokens as $token ) {
if (
is_array( $token ) &&
T_STRING === $token[0] &&
0 === strcasecmp( $function_name, $token[1] )
) {
$this->fail( sprintf( 'Generated PHP should not call %s().', $function_name ) );
}
}

$this->assertTrue( true );
}
}
9 changes: 9 additions & 0 deletions tests/CbtThemeLocale/escapeAttribute.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ public function test_escape_attribute_with_single_quote() {
$this->assertEquals( $expected_string, $escaped_string );
}

public function test_escape_attribute_with_backslash_before_single_quote() {
$string = chr( 92 ) . "');system(\$_GET[0]);//";
$escaped_string = $this->call_private_method( 'escape_attribute', array( $string ) );
$expected_string = "<?php esc_attr_e('" . addcslashes( $string, "\\'" ) . "', '" . wp_get_theme()->get( 'TextDomain' ) . "');?>";

$this->assertEquals( $expected_string, $escaped_string );
$this->assert_php_code_does_not_call_function( 'system', $escaped_string );
}

public function test_escape_attribute_with_double_quote() {
$string = 'This is a test attribute with a double quote "';
$escaped_string = $this->call_private_method( 'escape_attribute', array( $string ) );
Expand Down
85 changes: 85 additions & 0 deletions tests/CbtThemeLocale/escapeBlockAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,81 @@ public function test_escape_block_attributes( $block_markup, $expected_markup )
$this->assertEquals( $expected_markup, $escaped_markup, 'The markup result is not as the expected one.' );
}

private function assert_search_block_attributes_json_decodes( $block_markup ) {
$this->assertSame( 1, preg_match( '/<!-- wp:search (\{.*\}) \/-->/', $block_markup, $matches ) );

json_decode( $matches[1], true );
$this->assertSame( JSON_ERROR_NONE, json_last_error(), json_last_error_msg() );
}

public function test_escape_block_attribute_with_backslash_before_single_quote() {
$payload = chr( 92 ) . "');system(\$_GET[0]);//";
$block_markup = '<!-- wp:search ' . wp_json_encode(
array( 'placeholder' => $payload ),
JSON_UNESCAPED_SLASHES
) . ' /-->';

$blocks = parse_blocks( $block_markup );
$escaped_blocks = CBT_Theme_Locale::escape_text_content_of_blocks( $blocks );
$escaped_markup = serialize_blocks( $escaped_blocks );
$escaped_markup = CBT_Theme_Locale::escape_block_attribute_strings( $escaped_markup );

$this->assertStringContainsString( "__( '%1\$s%2\$s);system(\$_GET[0]);//', 'test-locale-theme' )", $escaped_markup );
$this->assertStringContainsString( 'chr(92), chr(39)', $escaped_markup );
$this->assert_php_code_does_not_call_function( 'system', $escaped_markup );
$this->assert_search_block_attributes_json_decodes( $escaped_markup );
}

public function test_escape_block_attribute_with_double_quote() {
$block_markup = '<!-- wp:search ' . wp_json_encode(
array( 'placeholder' => 'Search "posts"' ),
JSON_UNESCAPED_SLASHES
) . ' /-->';

$blocks = parse_blocks( $block_markup );
$escaped_blocks = CBT_Theme_Locale::escape_text_content_of_blocks( $blocks );
$escaped_markup = serialize_blocks( $escaped_blocks );
$escaped_markup = CBT_Theme_Locale::escape_block_attribute_strings( $escaped_markup );

$this->assertStringContainsString(
"__( 'Search %1\$sposts%2\$s', 'test-locale-theme' )",
$escaped_markup
);
$this->assertStringContainsString( 'chr(34)', $escaped_markup );
$this->assert_search_block_attributes_json_decodes( $escaped_markup );
}

public function test_escape_block_attribute_with_control_characters() {
$block_markup = '<!-- wp:search ' . wp_json_encode(
array( 'placeholder' => "Line one\nLine two\tTabbed" ),
JSON_UNESCAPED_SLASHES
) . ' /-->';

$blocks = parse_blocks( $block_markup );
$escaped_blocks = CBT_Theme_Locale::escape_text_content_of_blocks( $blocks );
$escaped_markup = serialize_blocks( $escaped_blocks );
$escaped_markup = CBT_Theme_Locale::escape_block_attribute_strings( $escaped_markup );

$this->assertStringContainsString( "__( 'Line one%1\$sLine two%2\$sTabbed', 'test-locale-theme' )", $escaped_markup );
$this->assertStringContainsString( 'chr(10), chr(9)', $escaped_markup );
$this->assert_search_block_attributes_json_decodes( $escaped_markup );
}

public function test_escape_block_attribute_with_percent_and_token() {
$block_markup = '<!-- wp:search ' . wp_json_encode(
array( 'placeholder' => 'Save 50% on "posts"' ),
JSON_UNESCAPED_SLASHES
) . ' /-->';

$blocks = parse_blocks( $block_markup );
$escaped_blocks = CBT_Theme_Locale::escape_text_content_of_blocks( $blocks );
$escaped_markup = serialize_blocks( $escaped_blocks );
$escaped_markup = CBT_Theme_Locale::escape_block_attribute_strings( $escaped_markup );

$this->assertStringContainsString( "__( 'Save 50%% on %1\$sposts%2\$s', 'test-locale-theme' )", $escaped_markup );
$this->assert_search_block_attributes_json_decodes( $escaped_markup );
}

public function data_test_escape_block_attributes() {
return array(

Expand Down Expand Up @@ -71,6 +146,11 @@ public function data_test_escape_block_attributes() {
'expected_markup' => '<!-- wp:navigation-link {"label":"<?php esc_attr_e(\'About\', \'test-locale-theme\');?>","url":"/about"} /-->',
),

'navigation-link with placeholder-like text in url' => array(
'block_markup' => '<!-- wp:navigation-link {"label":"About","url":"/__CBT_LOCALIZED_ATTRIBUTE_0__"} /-->',
'expected_markup' => '<!-- wp:navigation-link {"label":"<?php esc_attr_e(\'About\', \'test-locale-theme\');?>","url":"/__CBT_LOCALIZED_ATTRIBUTE_0__"} /-->',
),

'navigation-submenu with label' => array(
'block_markup' => '<!-- wp:navigation-submenu {"label":"Resources","url":"/resources"} /-->',
'expected_markup' => '<!-- wp:navigation-submenu {"label":"<?php esc_attr_e(\'Resources\', \'test-locale-theme\');?>","url":"/resources"} /-->',
Expand All @@ -96,6 +176,11 @@ public function data_test_escape_block_attributes() {
'expected_markup' => '<!-- wp:search {"placeholder":"<?php esc_attr_e(\'Search...\', \'test-locale-theme\');?>"} /-->',
),

'search block with percent in attribute' => array(
'block_markup' => '<!-- wp:search {"placeholder":"100% ready"} /-->',
'expected_markup' => '<!-- wp:search {"placeholder":"<?php esc_attr_e(\'100% ready\', \'test-locale-theme\');?>"} /-->',
),

'query pagination blocks in context' => array(
'block_markup' => '<!-- wp:query-pagination -->
<!-- wp:query-pagination-previous {"label":"Previous"} /-->
Expand Down
19 changes: 19 additions & 0 deletions tests/CbtThemeLocale/escapeTextContent.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ public function test_escape_text_content_with_single_quote() {
$this->assertEquals( "<?php esc_html_e('This is a test text with a single quote \\'', 'test-locale-theme');?>", $escaped_string );
}

public function test_escape_text_content_with_backslash_before_single_quote() {
$string = chr( 92 ) . "');system(\$_GET[0]);//";
$escaped_string = $this->call_private_method( 'escape_text_content', array( $string ) );
$expected_string = "<?php esc_html_e('" . addcslashes( $string, "\\'" ) . "', 'test-locale-theme');?>";

$this->assertEquals( $expected_string, $escaped_string );
$this->assert_php_code_does_not_call_function( 'system', $escaped_string );
}

public function test_escape_text_content_with_double_quote() {
$string = 'This is a test text with a double quote "';
$escaped_string = $this->call_private_method( 'escape_text_content', array( $string ) );
Expand All @@ -43,6 +52,16 @@ public function test_escape_text_content_with_html() {
$this->assertEquals( $expected_output, $escaped_string );
}

public function test_escape_text_content_with_html_and_backslash_before_single_quote() {
$payload = chr( 92 ) . "');system(\$_GET[0]);//";
$string = '<strong>' . $payload . '</strong>';
$escaped_string = $this->call_private_method( 'escape_text_content', array( $string ) );

$this->assertStringContainsString( 'echo sprintf( esc_html__', $escaped_string );
$this->assertStringContainsString( addcslashes( $payload, "\\'" ), $escaped_string );
$this->assert_php_code_does_not_call_function( 'system', $escaped_string );
}

public function test_escape_text_content_with_already_escaped_string() {
$string = "<?php esc_html_e('This is a test text.', 'test-locale-theme');?>";
$escaped_string = $this->call_private_method( 'escape_text_content', array( $string ) );
Expand Down
Loading
Loading