Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
32 changes: 26 additions & 6 deletions includes/create-theme/theme-locale.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@

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 text for localization.
*
Expand All @@ -29,18 +39,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 +63,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 +88,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 @@ -292,6 +304,13 @@ function ( $matches ) {
return $matches[0];
}

$placeholders = array();
$next_placeholder = static function ( $raw ) use ( &$placeholders ) {
$placeholder = '__CBT_LOCALIZED_ATTRIBUTE_' . count( $placeholders ) . '__';
$placeholders[ $placeholder ] = $raw;
return $placeholder;
};

// Process each localizable attribute.
$modified = false;
foreach ( $localizable_attrs as $attr_name ) {
Expand All @@ -302,14 +321,15 @@ function ( $matches ) {
}

// Escape the attribute value.
$attrs[ $attr_name ] = self::escape_attribute( $attrs[ $attr_name ] );
$attrs[ $attr_name ] = $next_placeholder( self::escape_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 );
$new_attrs_json = strtr( $new_attrs_json, $placeholders );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an edge case, but this strstr replaces __CBT_LOCALIZED_ATTRIBUTE_ across the content. Instead we could save the placeholders in an array and only replace them. I've added a commit to do this.

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
13 changes: 13 additions & 0 deletions tests/CbtThemeLocale/escapeBlockAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,19 @@ 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.' );
}

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( 'esc_attr_e', $escaped_markup );
$this->assert_php_code_does_not_call_function( 'system', $escaped_markup );
}

public function data_test_escape_block_attributes() {
return array(

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
68 changes: 68 additions & 0 deletions tests/test-theme-patterns.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,28 @@ private function make_wp_block_post( $content, $title = 'Test Pattern' ) {
return get_post( $post_id );
}

/**
* 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.
*/
private 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 );
}

public function test_pattern_from_wp_block_strips_php_open_tag() {
$post = $this->make_wp_block_post( '<p>safe</p><?php phpinfo(); ?>' );
$pattern = CBT_Theme_Patterns::pattern_from_wp_block( $post );
Expand Down Expand Up @@ -329,6 +351,52 @@ public function test_add_patterns_to_theme_writes_sanitised_body_to_disk() {
$this->uninstall_theme( $test_theme_slug );
}

public function test_add_patterns_to_theme_localizes_backslash_quote_text() {
$admin = $this->factory->user->create( array( 'role' => 'administrator' ) );
wp_set_current_user( $admin );

$test_theme_slug = $this->create_blank_theme();

$expected_pattern_path = get_stylesheet_directory() . '/patterns/cbt-localize-text-export-probe.php';
$marker = '/tmp/cbt_localize_text_export_marker.txt';

if ( file_exists( $marker ) ) {
unlink( $marker );
}

$payload = chr( 92 ) . '\'); file_put_contents("' . $marker . '", "unexpected"); //';

$pattern_post = $this->make_wp_block_post(
wp_slash( '<!-- wp:paragraph --><p>' . $payload . '</p><!-- /wp:paragraph -->' ),
'CBT Localize Text Export Probe'
);
$this->assertStringContainsString( $payload, $pattern_post->post_content );

CBT_Theme_Patterns::add_patterns_to_theme(
array(
'localizeText' => true,
'localizeImages' => false,
'removeNavRefs' => false,
)
);

$this->assertFileExists( $expected_pattern_path, 'Pattern file should have been written to the active theme' );

$contents = file_get_contents( $expected_pattern_path );

$this->assertStringContainsString( 'esc_html_e', $contents, 'Pattern text should be localized' );
$this->assertStringContainsString( addcslashes( $payload, "\\'" ), $contents, 'Pattern text should keep the escaped backslash and quote sequence' );
$this->assert_php_code_does_not_call_function( 'file_put_contents', $contents );

ob_start();
include $expected_pattern_path;
ob_end_clean();

$this->assertFileDoesNotExist( $marker );

$this->uninstall_theme( $test_theme_slug );
}

/**
* Create a fresh test theme via the plugin's REST endpoint and activate it.
*
Expand Down
Loading