diff --git a/packages/reprint-client/src/lib/url-rewrite/class-base64-value-scanner.php b/packages/reprint-client/src/lib/url-rewrite/class-base64-value-scanner.php index 716fbc03..e11c5428 100644 --- a/packages/reprint-client/src/lib/url-rewrite/class-base64-value-scanner.php +++ b/packages/reprint-client/src/lib/url-rewrite/class-base64-value-scanner.php @@ -318,7 +318,14 @@ private function scan_tokens(array $tokens): void } } - private static function encoded_payload_could_decode_to_http_scheme(string $payload): bool + /** + * Return whether a Base64 payload could decode to an HTTP(S) URL. + * + * A Base64 group begins at one of three alignments relative to the scheme, + * so the encoded payload has one of four markers. The caller remains + * responsible for Base64 validation and URL rewriting. + */ + public static function encoded_payload_could_decode_to_http_scheme(string $payload): bool { return strpos($payload, 'aHR0') !== false || strpos($payload, 'dHA6') !== false diff --git a/packages/reprint-client/src/lib/url-rewrite/class-cautious-text-block-markup-url-processor.php b/packages/reprint-client/src/lib/url-rewrite/class-cautious-text-block-markup-url-processor.php index 4d89a723..3da501ef 100644 --- a/packages/reprint-client/src/lib/url-rewrite/class-cautious-text-block-markup-url-processor.php +++ b/packages/reprint-client/src/lib/url-rewrite/class-cautious-text-block-markup-url-processor.php @@ -59,7 +59,7 @@ public function replace_raw_current_text(string $updated_text): bool } /** - * Replace configured URL bases in the current raw text token. + * Replace configured URL bases in the current raw modifiable text. * * WP_HTML_Tag_Processor exposes decoded text through get_modifiable_text() * and HTML-encodes the complete replacement in set_modifiable_text(). The @@ -70,7 +70,7 @@ public function replace_raw_current_text(string $updated_text): bool */ public function replace_url_bases_in_current_text(array $url_mapping): bool { - if ('#text' !== $this->get_token_type()) { + if ($this->get_token_type() !== '#text' && $this->get_token_type() !== '#tag') { return false; } diff --git a/packages/reprint-client/src/lib/url-rewrite/class-php-serialization-processor.php b/packages/reprint-client/src/lib/url-rewrite/class-php-serialization-processor.php index 9c1bf411..bfb50e06 100644 --- a/packages/reprint-client/src/lib/url-rewrite/class-php-serialization-processor.php +++ b/packages/reprint-client/src/lib/url-rewrite/class-php-serialization-processor.php @@ -27,6 +27,330 @@ class PhpSerializationProcessor { private const DIGITS = '0123456789'; + /** + * Return whether the first $bytes could be the prefix of a serialized + * value accepted by this processor. + * + * The prefix may stop inside a token, a byte-counted string, or an open + * array/object. Bytes after the prefix are deliberately not inspected. + */ + public static function is_valid_prefix(string $serialized, int $bytes = 100): bool + { + $length = min(strlen($serialized), $bytes); + if ($length === 0) { + return true; + } + + $pos = 0; + $parse_value = null; + + $expect = static function (string $expected) use (&$serialized, &$pos, $length): int { + if ($pos === $length) { + return 0; + } + + $available = min(strlen($expected), $length - $pos); + if (substr_compare($serialized, $expected, $pos, $available) !== 0) { + return -1; + } + + if ($available < strlen($expected)) { + $pos = $length; + return 0; + } + + $pos += $available; + return 1; + }; + + $parse_count = static function () use (&$serialized, &$pos, $length): array { + if ($pos === $length) { + return [0, 0]; + } + + $digits = strspn($serialized, self::DIGITS, $pos, $length - $pos); + if ($digits === 0) { + return [-1, 0]; + } + + $count = 0; + for ($offset = 0; $offset < $digits; ++$offset) { + $digit = ord($serialized[$pos + $offset]) - ord('0'); + if ($count > intdiv(PHP_INT_MAX - $digit, 10)) { + return [-1, 0]; + } + $count = $count * 10 + $digit; + } + + $pos += $digits; + return [1, $count]; + }; + + $parse_string = static function () use (&$serialized, &$pos, $length, $expect, $parse_count): int { + $status = $expect('s:'); + if ($status !== 1) { + return $status; + } + + [$status, $byte_length] = $parse_count(); + if ($status !== 1) { + return $status; + } + + $status = $expect(':"'); + if ($status !== 1) { + return $status; + } + + if ($byte_length > $length - $pos) { + $pos = $length; + return 0; + } + + $pos += $byte_length; + return $expect('";'); + }; + + $parse_integer = static function () use (&$serialized, &$pos, $length, $expect): int { + $status = $expect('i:'); + if ($status !== 1) { + return $status; + } + + if ($pos === $length) { + return 0; + } + + if ($serialized[$pos] === '-') { + ++$pos; + if ($pos === $length) { + return 0; + } + } + + $digits = strspn($serialized, self::DIGITS, $pos, $length - $pos); + if ($digits === 0) { + return -1; + } + + $pos += $digits; + return $expect(';'); + }; + + $parse_double = static function () use (&$serialized, &$pos, $length, $expect): int { + $status = $expect('d:'); + if ($status !== 1) { + return $status; + } + + $span = strcspn($serialized, ';', $pos, $length - $pos); + if ($span === 0) { + return $pos === $length ? 0 : -1; + } + + $pos += $span; + return $expect(';'); + }; + + $parse_boolean = static function () use (&$serialized, &$pos, $length, $expect): int { + $status = $expect('b:'); + if ($status !== 1) { + return $status; + } + + if ($pos === $length) { + return 0; + } + if ($serialized[$pos] !== '0' && $serialized[$pos] !== '1') { + return -1; + } + + ++$pos; + return $expect(';'); + }; + + $parse_reference = static function () use (&$serialized, &$pos, $length, $expect, $parse_count): int { + ++$pos; + $status = $expect(':'); + if ($status !== 1) { + return $status; + } + + [$status] = $parse_count(); + if ($status !== 1) { + return $status; + } + + return $expect(';'); + }; + + $parse_array = null; + $parse_object = null; + $parse_custom = null; + + $parse_array = function () use (&$parse_value, &$serialized, &$pos, $length, $expect, $parse_count): int { + $status = $expect('a:'); + if ($status !== 1) { + return $status; + } + + [$status, $count] = $parse_count(); + if ($status !== 1) { + return $status; + } + + $status = $expect(':{'); + if ($status !== 1) { + return $status; + } + + for ($entry = 0; $entry < $count; ++$entry) { + $status = $parse_value(); + if ($status !== 1) { + return $status; + } + + $status = $parse_value(); + if ($status !== 1) { + return $status; + } + } + + return $expect('}'); + }; + + $parse_object = function () use (&$parse_value, &$serialized, &$pos, $length, $expect, $parse_count): int { + $status = $expect('O:'); + if ($status !== 1) { + return $status; + } + + [$status, $class_name_length] = $parse_count(); + if ($status !== 1) { + return $status; + } + + $status = $expect(':"'); + if ($status !== 1) { + return $status; + } + + if ($class_name_length > $length - $pos) { + $pos = $length; + return 0; + } + + $pos += $class_name_length; + $status = $expect('":'); + if ($status !== 1) { + return $status; + } + + [$status, $property_count] = $parse_count(); + if ($status !== 1) { + return $status; + } + + $status = $expect(':{'); + if ($status !== 1) { + return $status; + } + + for ($property = 0; $property < $property_count; ++$property) { + $status = $parse_value(); + if ($status !== 1) { + return $status; + } + + $status = $parse_value(); + if ($status !== 1) { + return $status; + } + } + + return $expect('}'); + }; + + $parse_custom = static function () use (&$serialized, &$pos, $length, $expect, $parse_count): int { + $status = $expect('C:'); + if ($status !== 1) { + return $status; + } + + [$status, $class_name_length] = $parse_count(); + if ($status !== 1) { + return $status; + } + + $status = $expect(':"'); + if ($status !== 1) { + return $status; + } + + if ($class_name_length > $length - $pos) { + $pos = $length; + return 0; + } + + $pos += $class_name_length; + $status = $expect('":'); + if ($status !== 1) { + return $status; + } + + [$status, $payload_length] = $parse_count(); + if ($status !== 1) { + return $status; + } + + $status = $expect(':{'); + if ($status !== 1) { + return $status; + } + + if ($payload_length > $length - $pos) { + $pos = $length; + return 0; + } + + $pos += $payload_length; + return $expect('}'); + }; + + $parse_value = function () use (&$serialized, &$pos, $length, $parse_string, $parse_integer, $parse_double, $parse_boolean, $expect, $parse_array, $parse_object, $parse_custom, $parse_reference): int { + if ($pos === $length) { + return 0; + } + + switch ($serialized[$pos]) { + case 's': + return $parse_string(); + case 'i': + return $parse_integer(); + case 'd': + return $parse_double(); + case 'b': + return $parse_boolean(); + case 'N': + return $expect('N;'); + case 'a': + return $parse_array(); + case 'O': + return $parse_object(); + case 'C': + return $parse_custom(); + case 'r': + case 'R': + return $parse_reference(); + default: + return -1; + } + }; + + $status = $parse_value(); + return $status !== -1 && ( $status === 0 || $pos === $length ); + } + /** @var string The original serialized data. */ private $data; diff --git a/packages/reprint-client/src/lib/url-rewrite/class-structured-data-url-rewriter.php b/packages/reprint-client/src/lib/url-rewrite/class-structured-data-url-rewriter.php index 721cc25d..8da0fd1d 100644 --- a/packages/reprint-client/src/lib/url-rewrite/class-structured-data-url-rewriter.php +++ b/packages/reprint-client/src/lib/url-rewrite/class-structured-data-url-rewriter.php @@ -158,7 +158,7 @@ public function rewrite(string $value, ?string $content_type = null): string !$this->maybe_contains_rewritable_urls($value) && ( $content_type !== self::BLOCK_MARKUP || - !$this->might_contain_base64_shortcode_body($value) + !Base64ValueScanner::encoded_payload_could_decode_to_http_scheme($value) ) ) { return $value; @@ -235,36 +235,20 @@ private function maybe_contains_rewritable_urls(string $value): bool return false; } - /** - * Return whether a block-markup value may contain a Base64 shortcode body. - * The later decoder remains the authority on Base64 validity and whether - * the decoded value actually contains a mapped URL. - */ - private function might_contain_base64_shortcode_body(string $value): bool - { - return preg_match( - '/\[[A-Za-z][A-Za-z0-9_-]*(?:\s+[^\]]*)?\][A-Za-z0-9+\/=]+\[\/[A-Za-z][A-Za-z0-9_-]*\]/', - $value - ) === 1; - } - /** * Return whether the value starts with a PHP serialization token that may * expose string values to rewrite. * * This is a speed guard before constructing PhpSerializationProcessor. It * deliberately omits scalar serialized types such as i:, d:, b:, N;, r:, - * and R: because they cannot contain string leaves. The processor remains - * responsible for full validation once this coarse first-byte check passes. + * and R: because they cannot contain string leaves. The prefix validator + * only rejects syntax that is already impossible in the first 100 bytes; + * the processor remains responsible for full validation. */ private function could_be_php_serialization_with_strings(string $value): bool { - $first_byte = $value[0] ?? ''; - - return $first_byte === 'a' - || $first_byte === 's' - || $first_byte === 'O' - || $first_byte === 'C'; + return preg_match('/^(?:a|s|O|C)(?::|$)/', $value) === 1 + && PhpSerializationProcessor::is_valid_prefix($value); } /** @@ -274,22 +258,12 @@ private function could_be_php_serialization_with_strings(string $value): bool * This is a speed guard before constructing JsonStringIterator, whose * constructor calls json_decode(). Objects and arrays can contain nested * string leaves, and JSON string scalars can themselves be rewritten. The - * iterator remains responsible for full JSON validation after this coarse - * first-byte check passes. + * prefix parser only rejects syntax that is already impossible in the + * first 100 bytes. The iterator remains responsible for full validation. */ private function could_be_json_with_strings(string $value): bool { - $length = strlen($value); - for ($i = 0; $i < $length; $i++) { - $byte = $value[$i]; - if ($byte === ' ' || $byte === "\n" || $byte === "\r" || $byte === "\t") { - continue; - } - - return $byte === '{' || $byte === '[' || $byte === '"'; - } - - return false; + return \Reprint\Importer\UrlRewrite\is_valid_json_prefix($value); } /** @@ -481,6 +455,13 @@ private function rewrite_urls( string $content, string $content_type ): string { continue; } + if ($this->token_has_css_or_json_text($p)) { + $text = $p->get_modifiable_text(); + if ($this->maybe_contains_rewritable_urls($text)) { + $p->replace_url_bases_in_current_text($this->url_mapping); + } + } + while ( $p->next_url_in_current_token() ) { $raw_url = $p->get_raw_url(); $cache_key = $this->mapping_cache_key . "\0" . self::BLOCK_MARKUP . "\0" . $token_type . "\0" . $raw_url; @@ -572,6 +553,32 @@ private function rewrite_urls( string $content, string $content_type ): string { } } + /** + * Return whether the current tag owns CSS or JSON text. Those values are + * raw text in the HTML tokenizer, not ordinary HTML text nodes. Keep their + * original escaping while the cautious processor replaces only a mapped + * URL base. + */ + private function token_has_css_or_json_text(CautiousTextBlockMarkupUrlProcessor $processor): bool + { + $tag = strtolower($processor->get_tag() ?? ''); + if ($tag === 'style') { + return true; + } + + if ($tag !== 'script') { + return false; + } + + $type = $processor->get_attribute('type'); + if (!is_string($type)) { + return false; + } + + $mime_type = strtolower(trim(explode(';', $type, 2)[0])); + return $mime_type === 'application/ld+json' || $mime_type === 'application/json'; + } + /** * Rewrite a Base64 payload which is the complete body of a shortcode. * diff --git a/packages/reprint-client/src/lib/url-rewrite/json-prefix.php b/packages/reprint-client/src/lib/url-rewrite/json-prefix.php new file mode 100644 index 00000000..fa26d28c --- /dev/null +++ b/packages/reprint-client/src/lib/url-rewrite/json-prefix.php @@ -0,0 +1,208 @@ +assertTrue(Base64ValueScanner::encoded_payload_could_decode_to_http_scheme($payload)); + } + } + + public function testEncodedPayloadWithoutHttpSchemeIsRejected(): void + { + $payload = base64_encode('mailto:hello@example.com'); + + $this->assertFalse(Base64ValueScanner::encoded_payload_could_decode_to_http_scheme($payload)); + } + /** * Collect all decoded values from the scanner without modifying them. * diff --git a/tests/UrlRewriting/JsonPrefixTest.php b/tests/UrlRewriting/JsonPrefixTest.php new file mode 100644 index 00000000..12f4333c --- /dev/null +++ b/tests/UrlRewriting/JsonPrefixTest.php @@ -0,0 +1,107 @@ + + */ + public static function validDocuments(): array + { + return [ + 'object with nested arrays' => ['{"site":{"pages":["home",{"title":"About"}]},"enabled":true}'], + 'escaped string' => ['{"quote":"\\\"","slash":"\\/","unicode":"\\u20ac"}'], + 'numbers' => ['[-0,0,12,-12.5,6e3,-4.2E-8]'], + 'whitespace' => [" \t\n{\r\n \"name\" : \"value\" \n}\t"], + 'scalar strings' => ['"https:\/\/old-site.com\/wp-content\/uploads"'], + 'literals' => ['[true,false,null]'], + ]; + } + + /** + * @dataProvider validDocuments + */ + public function testAcceptsEveryPrefixOfValidJson(string $document): void + { + $length = strlen($document); + for ($bytes = 0; $bytes <= $length; ++$bytes) { + $this->assertTrue(is_valid_json_prefix($document, $bytes), "Failed at byte {$bytes}: {$document}"); + } + } + + /** + * @return array + */ + public static function invalidPrefixes(): array + { + return [ + 'non-json root' => ['[et_pb_section]'], + 'unmatched closing delimiter' => [']'], + 'unquoted object key' => ['{title:"value"}'], + 'object key lacks colon' => ['{"title" "value"}'], + 'object value is missing' => ['{"title":}'], + 'trailing object comma' => ['{"title":"value",}'], + 'trailing array comma' => ['["title",]'], + 'array values lack comma' => ['["title" "value"]'], + 'array uses colon' => ['["title":"value"]'], + 'invalid string escape' => ['"\\x"'], + 'invalid unicode escape' => ['"\\u12x4"'], + 'control byte in string' => ["\"line\nbreak\""], + 'invalid literal' => ['truX'], + 'literal followed by token' => ['true false'], + 'leading plus number' => ['+1'], + 'leading zero number' => ['01'], + 'number has no integer digits' => ['-.1'], + 'number has no fractional digits' => ['1.e2'], + 'number has invalid exponent' => ['1eX'], + 'number has invalid signed exponent' => ['1e+X'], + 'two decimal points' => ['1.2.3'], + 'text after complete string' => ['"title"x'], + 'text after complete container' => ['{}[]'], + 'wrong closer for object' => ['{"title":"value"]'], + 'wrong closer for array' => ['["value"}'], + ]; + } + + /** + * @dataProvider invalidPrefixes + */ + public function testRejectsSyntaxAlreadyImpossibleInThePrefix(string $document): void + { + $this->assertFalse(is_valid_json_prefix($document, strlen($document))); + } + + public function testIgnoresBytesAfterTheLimit(): void + { + $document = '{"content":"' . str_repeat('a', 100) . '" invalid}'; + + $this->assertTrue(is_valid_json_prefix($document, 100)); + $this->assertFalse(is_valid_json_prefix($document, strlen($document))); + } + + public function testDoesNotLookPastACompleteJsonPrefix(): void + { + $document = '{"title":"value"} shortcode'; + + $this->assertTrue(is_valid_json_prefix($document, strlen('{"title":"value"}'))); + $this->assertFalse(is_valid_json_prefix($document, strlen($document))); + } + + public function testRejectsAnInvalidByteAtTheLimit(): void + { + $document = '{"title":"\\u12x4"}'; + $invalid_byte = strpos($document, 'x'); + + $this->assertNotFalse($invalid_byte); + $this->assertTrue(is_valid_json_prefix($document, $invalid_byte)); + $this->assertFalse(is_valid_json_prefix($document, $invalid_byte + 1)); + } +} diff --git a/tests/UrlRewriting/PhpSerializationPrefixTest.php b/tests/UrlRewriting/PhpSerializationPrefixTest.php new file mode 100644 index 00000000..f90da5c8 --- /dev/null +++ b/tests/UrlRewriting/PhpSerializationPrefixTest.php @@ -0,0 +1,103 @@ + + */ + public static function serializedValues(): array + { + $object = new stdClass(); + $object->url = 'https://old-site.com'; + $object->settings = ['enabled' => true]; + + $recursive = []; + $recursive['self'] = &$recursive; + + return [ + 'null' => [serialize(null)], + 'boolean' => [serialize(true)], + 'integer' => [serialize(-123)], + 'float' => [serialize(-4.2e-8)], + 'binary string' => [serialize("quote\";\0bytes")], + 'nested arrays' => [serialize(['url' => 'https://old-site.com', 'items' => ['one', 'two']])], + 'object' => [serialize($object)], + 'reference' => [serialize($recursive)], + 'nan' => [serialize(NAN)], + ]; + } + + /** + * @dataProvider serializedValues + */ + public function testAcceptsEveryPrefixOfSerializedValues(string $serialized): void + { + $length = strlen($serialized); + for ($bytes = 0; $bytes <= $length; ++$bytes) { + $this->assertTrue( + PhpSerializationProcessor::is_valid_prefix($serialized, $bytes), + "Failed at byte {$bytes}: {$serialized}" + ); + } + } + + /** + * @return array + */ + public static function invalidPrefixes(): array + { + return [ + 'unknown type' => ['x:1;'], + 'string length lacks colon' => ['s:3x"foo";'], + 'string is shorter than declared' => ['s:3:"ab";'], + 'string closing quote is missing' => ['s:3:"abc;'], + 'string closing semicolon is missing' => ['s:3:"abc"x'], + 'integer has no digits' => ['i:;'], + 'integer is not terminated' => ['i:12x'], + 'boolean has an invalid value' => ['b:2;'], + 'boolean is not terminated' => ['b:1x'], + 'null is not terminated' => ['Nx'], + 'array length lacks opener' => ['a:1:x'], + 'array ends before its entry' => ['a:1:{}'], + 'array has no closing brace' => ['a:0:{x'], + 'object class name length is wrong' => ['O:3:"No":0:{}'], + 'object property count lacks opener' => ['O:8:"stdClass":1:x'], + 'object ends before its property' => ['O:8:"stdClass":1:{}'], + 'custom payload closing brace is wrong' => ['C:3:"Foo":3:{abcx'], + 'reference has no number' => ['R:;'], + 'reference is not terminated' => ['r:2x'], + 'trailing data' => ['i:1;shortcode'], + ]; + } + + /** + * @dataProvider invalidPrefixes + */ + public function testRejectsSyntaxAlreadyImpossibleInThePrefix(string $serialized): void + { + $this->assertFalse( + PhpSerializationProcessor::is_valid_prefix($serialized, strlen($serialized)) + ); + } + + public function testIgnoresMalformedBytesAfterTheLimit(): void + { + $serialized = 's:120:"' . str_repeat('a', 120) . '"x'; + + $this->assertTrue(PhpSerializationProcessor::is_valid_prefix($serialized, 100)); + $this->assertFalse(PhpSerializationProcessor::is_valid_prefix($serialized, strlen($serialized))); + } + + public function testRejectsAnInvalidByteAtTheLimit(): void + { + $serialized = 's:3:"abcx'; + $invalid_byte = strpos($serialized, 'x'); + + $this->assertNotFalse($invalid_byte); + $this->assertTrue(PhpSerializationProcessor::is_valid_prefix($serialized, $invalid_byte)); + $this->assertFalse(PhpSerializationProcessor::is_valid_prefix($serialized, $invalid_byte + 1)); + } +} diff --git a/tests/UrlRewriting/SiteBuilderPostContentClassificationTest.php b/tests/UrlRewriting/SiteBuilderPostContentClassificationTest.php new file mode 100644 index 00000000..106d5a60 --- /dev/null +++ b/tests/UrlRewriting/SiteBuilderPostContentClassificationTest.php @@ -0,0 +1,242 @@ + 'https://new-site.com', + ]) + ); + $encoded_input = base64_encode($input); + $sql = "INSERT INTO `wp_posts` (`ID`, `post_content`) VALUES (1, FROM_BASE64('{$encoded_input}'));"; + + $scanner = new Base64ValueScanner($rewriter->rewrite($sql)); + $this->assertTrue($scanner->next_value()); + $this->assertSame($expected, $scanner->get_value()); + } + + /** + * @return array + */ + public static function site_builder_post_content_cases(): array + { + $rewrite_domain = static function (string $input): array { + return [$input, str_replace('old-site.com', 'new-site.com', $input)]; + }; + + return [ + 'HTML image attribute' => $rewrite_domain( + '' + ), + 'block comment image attribute' => $rewrite_domain( + '' + ), + 'Divi 4 background image shortcode' => $rewrite_domain( + '[et_pb_section background_image="https://old-site.com/uploads/hero.jpg"][/et_pb_section]' + ), + 'WPBakery escaped video shortcode' => $rewrite_domain( + '[vc_video link="https:\\/\\/old-site.com\\/uploads\\/tour.mp4"]' + ), + 'Elementor JSON document' => $rewrite_domain( + '{"version":"0.4","content":[{"elType":"widget","settings":{"image":{"url":"https://old-site.com/uploads/logo.png"}}}]}' + ), + 'serialized PHP array' => $rewrite_domain( + 'a:1:{s:5:"image";s:37:"https://old-site.com/uploads/logo.png";}' + ), + 'SiteOrigin JSON in an HTML input value' => $rewrite_domain( + '' + ), + 'Elementor JSON in a data-settings attribute' => $rewrite_domain( + '
' + ), + 'Beaver Builder JSON in a data-node attribute' => $rewrite_domain( + '
' + ), + 'Divi shortcode in a block attribute' => $rewrite_domain( + '' + ), + 'WPBakery shortcode in a block attribute' => $rewrite_domain( + '' + ), + 'Kadence block attribute containing a shortcode' => $rewrite_domain( + '' + ), + 'Spectra block attribute with percent-encoded URL' => $rewrite_domain( + '' + ), + 'Divi CSS URL with percent-encoded separators' => $rewrite_domain( + '[et_pb_section custom_css_main_element="background:url(https%3A%2F%2Fold-site.com%2Fuploads%2Fhero.jpg)"][/et_pb_section]' + ), + 'Divi CSS URL with hexadecimal escapes' => $rewrite_domain( + '[et_pb_section custom_css_main_element="background:url(https\\3a \\2f \\2f old-site.com\\2f uploads\\2f hero.jpg)"][/et_pb_section]' + ), + 'Divi CSS URL with HTML entities' => $rewrite_domain( + '[et_pb_section custom_css_main_element="background:url(https://old-site.com/uploads/hero.jpg)"][/et_pb_section]' + ), + 'WPBakery CSS URL with hexadecimal escapes' => $rewrite_domain( + '[vc_column css=".vc_custom{background-image:url(https\\3a \\2f \\2f old-site.com\\2f uploads\\2f hero.jpg)}"]' + ), + 'WPBakery raw HTML Base64 body' => [ + '[vc_raw_html]' . base64_encode('Manual') . '[/vc_raw_html]', + '[vc_raw_html]' . base64_encode('Manual') . '[/vc_raw_html]', + ], + 'Divi Base64 module payload' => [ + '[et_pb_code]' . base64_encode('{"url":"https://old-site.com/uploads/hero.jpg"}') . '[/et_pb_code]', + '[et_pb_code]' . base64_encode('{"url":"https://new-site.com/uploads/hero.jpg"}') . '[/et_pb_code]', + ], + 'Elementor Base64 document in an attribute' => [ + '
', + '
', + ], + 'serialized PHP string containing a Base64 document' => [ + serialize(['builder' => base64_encode('{"url":"https://old-site.com/uploads/hero.jpg"}')]), + serialize(['builder' => base64_encode('{"url":"https://new-site.com/uploads/hero.jpg"}')]), + ], + 'JSON document containing a Base64 shortcode' => [ + json_encode(['content' => base64_encode('[et_pb_image src="https://old-site.com/uploads/logo.png"]')]), + json_encode(['content' => base64_encode('[et_pb_image src="https://new-site.com/uploads/logo.png"]')]), + ], + 'Avada shortcode JSON attribute' => $rewrite_domain( + '[fusion_builder_container settings="{"background_image":"https:\\/\\/old-site.com\\/uploads\\/hero.jpg"}"][/fusion_builder_container]' + ), + 'Themify JSON shortcode attribute' => $rewrite_domain( + '[themify_box settings="{"image":"https://old-site.com/uploads/hero.jpg"}"]content[/themify_box]' + ), + 'Oxygen shortcode JSON attribute' => $rewrite_domain( + '[ct_section options="{"background-image":"https:\\/\\/old-site.com\\/uploads\\/hero.jpg"}"][/ct_section]' + ), + 'Brizy compact JSON document' => $rewrite_domain( + '{"data":[{"type":"image","value":"https:\\/\\/old-site.com\\/uploads\\/hero.jpg"}]}' + ), + 'serialized Elementor document with a shortcode leaf' => $rewrite_domain( + 'a:1:{s:7:"content";s:62:"[et_pb_image src=\"https:\\/\\/old-site.com\\/uploads\\/logo.png\"]";}' + ), + 'JSON document with a shortcode leaf' => $rewrite_domain( + '{"content":"[vc_video link=\\\"https:\\/\\/old-site.com\\/uploads\\/tour.mp4\\\"]"}' + ), + 'serialized PHP object with a URL property' => $rewrite_domain( + 'O:8:"stdClass":1:{s:3:"url";s:37:"https://old-site.com/uploads/logo.png";}' + ), + 'serialized PHP object with private builder data' => $rewrite_domain( + 'O:11:"BuilderState":1:{s:16:"\\0BuilderState\\0url";s:37:"https://old-site.com/uploads/logo.png";}' + ), + 'JSON document with Unicode URL separators' => $rewrite_domain( + '{"url":"https\\u003A\\u002F\\u002Fold-site.com\\u002Fuploads\\u002Flogo.png"}' + ), + 'JSON document with an escaped source host' => $rewrite_domain( + '{"url":"https://old\\u002dsite\\u002ecom/uploads/logo.png"}' + ), + 'Gutenberg HTML comment containing entity-quoted JSON' => $rewrite_domain( + '
' + ), + 'shortcode body with a percent-encoded HTML link' => $rewrite_domain( + '[vc_column_text]%3Ca%20href%3D%22https%3A%2F%2Fold-site.com%2Fmanual.pdf%22%3EManual%3C%2Fa%3E[/vc_column_text]' + ), + 'shortcode body with a Base64 HTML link' => [ + '[vc_column_text]' . base64_encode('Manual') . '[/vc_column_text]', + '[vc_column_text]' . base64_encode('Manual') . '[/vc_column_text]', + ], + 'HTML data URI containing a Base64 SVG link' => [ + '') . '">', + '') . '">', + ], + 'JSON string whose URL is percent encoded' => $rewrite_domain( + '{"url":"https%3A%2F%2Fold-site.com%2Fuploads%2Flogo.png"}' + ), + 'serialized PHP URL with HTML entities' => $rewrite_domain( + 'a:1:{s:3:"url";s:46:"https://old-site.com/uploads/logo.png";}' + ), + 'Elementor style attribute with a hexadecimal escaped URL' => $rewrite_domain( + '
' + ), + 'Divi module URL in an HTML comment' => $rewrite_domain( + '' + ), + 'Base64 shortcode body containing block markup' => [ + '[et_pb_code]' . base64_encode('') . '[/et_pb_code]', + '[et_pb_code]' . base64_encode('') . '[/et_pb_code]', + ], + 'Base64 shortcode body containing CSS' => [ + '[vc_raw_html]' . base64_encode('.hero{background:url(https://old-site.com/uploads/hero.jpg)}') . '[/vc_raw_html]', + '[vc_raw_html]' . base64_encode('.hero{background:url(https://new-site.com/uploads/hero.jpg)}') . '[/vc_raw_html]', + ], + 'Base64 shortcode body containing JSON-LD' => [ + '[et_pb_code]' . base64_encode('{"@context":"https://schema.org","image":"https://old-site.com/uploads/logo.png"}') . '[/et_pb_code]', + '[et_pb_code]' . base64_encode('{"@context":"https://schema.org","image":"https://new-site.com/uploads/logo.png"}') . '[/et_pb_code]', + ], + 'JSON document containing block markup' => $rewrite_domain( + '{"content":""}' + ), + 'JSON document containing HTML and a shortcode' => $rewrite_domain( + '{"html":"

[et_pb_image src=\\"https:\\/\\/old-site.com\\/uploads\\/logo.png\\"]

"}' + ), + 'serialized PHP array containing block markup' => [ + serialize(['content' => '']), + serialize(['content' => '']), + ], + 'serialized PHP array containing HTML and a shortcode' => [ + serialize(['content' => '

[vc_video link="https://old-site.com/uploads/tour.mp4"]

']), + serialize(['content' => '

[vc_video link="https://new-site.com/uploads/tour.mp4"]

']), + ], + 'block markup with shortcode CSS and JSON-LD' => $rewrite_domain( + '

[et_pb_image src="https:\\/\\/old-site.com\\/uploads\\/logo.png"]

' + ), + 'CSS between Divi opener and closer' => $rewrite_domain( + '[et_pb_section] .hero{background:url(https:\\/\\/old-site.com\\/uploads\\/hero.jpg) no-repeat center} [/et_pb_section]' + ), + 'CSS between WPBakery opener and closer' => $rewrite_domain( + '[vc_column_text].hero{background:url(https:\\/\\/old-site.com\\/uploads\\/hero.jpg)}[/vc_column_text]' + ), + 'Divi CSS preserves Unicode string escapes' => $rewrite_domain( + '[et_pb_section custom_css_main_element=\'font-family:"R\\00fc bik";content:"\\1f6a4 ";background:url(https:\\/\\/old-site.com\\/uploads\\/hero.jpg) no-repeat center center fixed;\'][/et_pb_section]' + ), + 'Divi CSS preserves a literal Unicode character' => $rewrite_domain( + '[et_pb_section custom_css_main_element=\'font-family:"Rubik 🚤";background:url(https:\\/\\/old-site.com\\/uploads\\/hero.jpg)\'][/et_pb_section]' + ), + 'Divi CSS URL with six-digit Unicode escapes' => $rewrite_domain( + '[et_pb_section custom_css_main_element="background:url(https\\00003a\\00002f\\00002fold-site.com\\00002fuploads\\00002fhero.jpg)"][/et_pb_section]' + ), + 'Divi CSS URL with uppercase Unicode escapes' => $rewrite_domain( + '[et_pb_section custom_css_main_element="background:url(https\\3A \\2F \\2F old-site.com\\2F uploads\\2F hero.jpg)"][/et_pb_section]' + ), + 'Divi CSS URL with an escaped source host' => [ + '[et_pb_section custom_css_main_element="background:url(https\\3a \\2f \\2f old\\2dsite\\2ecom\\2f uploads\\2f hero.jpg)"][/et_pb_section]', + '[et_pb_section custom_css_main_element="background:url(https\\3a \\2f \\2f new-site.com\\2f uploads\\2f hero.jpg)"][/et_pb_section]', + ], + 'Divi CSS URL with Unicode escapes in its path' => $rewrite_domain( + '[et_pb_section custom_css_main_element="background:url(https:\\/\\/old-site.com\\/uploads\\/hero\\00002ejpg)"][/et_pb_section]' + ), + 'Divi CSS URL with a Unicode-escaped query value' => $rewrite_domain( + '[et_pb_section custom_css_main_element="background:url(https:\\/\\/old-site.com\\/uploads\\/hero.jpg?caption=\\1f6a4 )"][/et_pb_section]' + ), + 'Divi CSS comment beside a rewritten URL' => $rewrite_domain( + '[et_pb_section custom_css_main_element="/* fallback \\1f6a4 */ background:url(https:\\/\\/old-site.com\\/uploads\\/hero.jpg)"][/et_pb_section]' + ), + 'HTML style CSS with a literal URL and Unicode escapes' => $rewrite_domain( + '
' + ), + 'JSON-LD script beside a shortcode in block markup' => $rewrite_domain( + '[vc_video link="https:\\/\\/old-site.com\\/uploads\\/tour.mp4"]' + ), + ]; + } +} diff --git a/tests/UrlRewriting/StructuredDataUrlRewriterTest.php b/tests/UrlRewriting/StructuredDataUrlRewriterTest.php index 873a1b96..84ededb8 100644 --- a/tests/UrlRewriting/StructuredDataUrlRewriterTest.php +++ b/tests/UrlRewriting/StructuredDataUrlRewriterTest.php @@ -377,6 +377,24 @@ public function testBlockMarkupRewritesBase64ShortcodeBody(): void $this->assertSame($expected, $rewriter->rewrite($input, 'block_markup')); } + public function testBlockMarkupUsesCautiousReplacementInStyleTagText(): void + { + $rewriter = $this->createRewriter(); + $input = ''; + $expected = ''; + + $this->assertSame($expected, $rewriter->rewrite($input, 'block_markup')); + } + + public function testBlockMarkupUsesCautiousReplacementInJsonScriptText(): void + { + $rewriter = $this->createRewriter(); + $input = ''; + $expected = ''; + + $this->assertSame($expected, $rewriter->rewrite($input, 'block_markup')); + } + /** * @return array */