From 1fa70f9bd5233f988008bb9a16dbf32b992177e1 Mon Sep 17 00:00:00 2001 From: MaggieCabrera Date: Mon, 22 Jun 2026 12:17:48 +0200 Subject: [PATCH 1/4] i18n: Handle backslashes in localized template text --- includes/create-theme/theme-locale.php | 32 +++++++++++++++---- tests/CbtThemeLocale/base.php | 22 +++++++++++++ tests/CbtThemeLocale/escapeAttribute.php | 9 ++++++ .../CbtThemeLocale/escapeBlockAttributes.php | 13 ++++++++ tests/CbtThemeLocale/escapeTextContent.php | 19 +++++++++++ 5 files changed, 89 insertions(+), 6 deletions(-) diff --git a/includes/create-theme/theme-locale.php b/includes/create-theme/theme-locale.php index faeed03e..7cf27382 100644 --- a/includes/create-theme/theme-locale.php +++ b/includes/create-theme/theme-locale.php @@ -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. * @@ -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 = 'get( 'TextDomain' ) . "' ), " . implode( + $php_tag .= "echo sprintf( esc_html__( '$text', '$text_domain' ), " . implode( ', ', array_map( function( $token ) { @@ -52,7 +63,7 @@ function( $token ) { return $php_tag; } - return "get( 'TextDomain' ) . "');?>"; + return ""; } /** @@ -77,8 +88,9 @@ private static function escape_attribute( $string ) { return $string; } - $string = addcslashes( $string, "'" ); - return "get( 'TextDomain' ) . "');?>"; + $string = self::escape_php_single_quoted_string( $string ); + $text_domain = self::escape_php_single_quoted_string( wp_get_theme()->get( 'TextDomain' ) ); + return ""; } /** @@ -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 ) { @@ -302,7 +321,7 @@ 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; } } @@ -310,6 +329,7 @@ function ( $matches ) { // 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 ); return ''; } diff --git a/tests/CbtThemeLocale/base.php b/tests/CbtThemeLocale/base.php index bf86d292..c1c6ea33 100644 --- a/tests/CbtThemeLocale/base.php +++ b/tests/CbtThemeLocale/base.php @@ -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 ); + } } diff --git a/tests/CbtThemeLocale/escapeAttribute.php b/tests/CbtThemeLocale/escapeAttribute.php index 9f511d06..b133fb4f 100644 --- a/tests/CbtThemeLocale/escapeAttribute.php +++ b/tests/CbtThemeLocale/escapeAttribute.php @@ -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 = "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 ) ); diff --git a/tests/CbtThemeLocale/escapeBlockAttributes.php b/tests/CbtThemeLocale/escapeBlockAttributes.php index 1b28682e..f85ffd69 100644 --- a/tests/CbtThemeLocale/escapeBlockAttributes.php +++ b/tests/CbtThemeLocale/escapeBlockAttributes.php @@ -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 = ''; + + $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( diff --git a/tests/CbtThemeLocale/escapeTextContent.php b/tests/CbtThemeLocale/escapeTextContent.php index 82e6a08b..dbeb1ad6 100644 --- a/tests/CbtThemeLocale/escapeTextContent.php +++ b/tests/CbtThemeLocale/escapeTextContent.php @@ -30,6 +30,15 @@ public function test_escape_text_content_with_single_quote() { $this->assertEquals( "", $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 = ""; + + $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 ) ); @@ -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 = '' . $payload . ''; + $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 = ""; $escaped_string = $this->call_private_method( 'escape_text_content', array( $string ) ); From e0259b492d2747773158339f2957a43ec38ca943 Mon Sep 17 00:00:00 2001 From: MaggieCabrera Date: Mon, 22 Jun 2026 13:14:05 +0200 Subject: [PATCH 2/4] Add regression test for localized pattern export --- tests/test-theme-patterns.php | 68 +++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/tests/test-theme-patterns.php b/tests/test-theme-patterns.php index 460cbd5f..87badf6a 100644 --- a/tests/test-theme-patterns.php +++ b/tests/test-theme-patterns.php @@ -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( '

safe

' ); $pattern = CBT_Theme_Patterns::pattern_from_wp_block( $post ); @@ -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( '

' . $payload . '

' ), + '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. * From 2a948a177749423551500cfbecabf5acaf92529f Mon Sep 17 00:00:00 2001 From: Ben Dwyer Date: Wed, 24 Jun 2026 11:07:49 +0100 Subject: [PATCH 3/4] Avoid localized attribute placeholder collisions --- includes/create-theme/theme-locale.php | 29 +++++++++++-------- .../CbtThemeLocale/escapeBlockAttributes.php | 5 ++++ 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/includes/create-theme/theme-locale.php b/includes/create-theme/theme-locale.php index 7cf27382..ead2361f 100644 --- a/includes/create-theme/theme-locale.php +++ b/includes/create-theme/theme-locale.php @@ -304,15 +304,9 @@ 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; + $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. @@ -321,15 +315,26 @@ function ( $matches ) { } // Escape the attribute value. - $attrs[ $attr_name ] = $next_placeholder( self::escape_attribute( $attrs[ $attr_name ] ) ); - $modified = true; + $localized_attrs[ $attr_name ] = 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 ); + $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 . ':"' . $localized_attrs[ $attr_name ] . '"'; + continue; + } + + $attr_fragments[] = $encoded_attr_name . ':' . wp_json_encode( $attr_value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); + } + + $new_attrs_json = '{' . implode( ',', $attr_fragments ) . '}'; return ''; } diff --git a/tests/CbtThemeLocale/escapeBlockAttributes.php b/tests/CbtThemeLocale/escapeBlockAttributes.php index f85ffd69..9c3b78a1 100644 --- a/tests/CbtThemeLocale/escapeBlockAttributes.php +++ b/tests/CbtThemeLocale/escapeBlockAttributes.php @@ -84,6 +84,11 @@ public function data_test_escape_block_attributes() { 'expected_markup' => '', ), + 'navigation-link with placeholder-like text in url' => array( + 'block_markup' => '', + 'expected_markup' => '', + ), + 'navigation-submenu with label' => array( 'block_markup' => '', 'expected_markup' => '', From 4d7834829fb6999690a07e571866150a044b4f53 Mon Sep 17 00:00:00 2001 From: Ben Dwyer Date: Wed, 24 Jun 2026 12:37:00 +0100 Subject: [PATCH 4/4] Fix localized block attribute escaping --- includes/create-theme/theme-locale.php | 117 +++++++++++++++++- .../CbtThemeLocale/escapeBlockAttributes.php | 71 ++++++++++- 2 files changed, 184 insertions(+), 4 deletions(-) diff --git a/includes/create-theme/theme-locale.php b/includes/create-theme/theme-locale.php index ead2361f..91375ca8 100644 --- a/includes/create-theme/theme-locale.php +++ b/includes/create-theme/theme-locale.php @@ -17,6 +17,119 @@ 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 ""; + } + + $translation_call = "__( '" . $tokenized['text'] . "', '$text_domain' )"; + $token_expressions = implode( ', ', wp_list_pluck( $tokenized['tokens'], 'expression' ) ); + + $php_tag = ''; + 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. * @@ -315,7 +428,7 @@ function ( $matches ) { } // Escape the attribute value. - $localized_attrs[ $attr_name ] = self::escape_attribute( $attrs[ $attr_name ] ); + $localized_attrs[ $attr_name ] = self::escape_block_attribute( $attrs[ $attr_name ] ); $modified = true; } } @@ -327,7 +440,7 @@ function ( $matches ) { $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 . ':"' . $localized_attrs[ $attr_name ] . '"'; + $attr_fragments[] = $encoded_attr_name . ':' . wp_json_encode( $localized_attrs[ $attr_name ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); continue; } diff --git a/tests/CbtThemeLocale/escapeBlockAttributes.php b/tests/CbtThemeLocale/escapeBlockAttributes.php index 9c3b78a1..fece6908 100644 --- a/tests/CbtThemeLocale/escapeBlockAttributes.php +++ b/tests/CbtThemeLocale/escapeBlockAttributes.php @@ -28,17 +28,79 @@ 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( '//', $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 = ''; + $block_markup = ''; $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->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 = ''; + + $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 = ''; + + $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 = ''; + + $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() { @@ -114,6 +176,11 @@ public function data_test_escape_block_attributes() { 'expected_markup' => '', ), + 'search block with percent in attribute' => array( + 'block_markup' => '', + 'expected_markup' => '', + ), + 'query pagination blocks in context' => array( 'block_markup' => '