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..7cd65e4b 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,7 @@ private function scan_tokens(array $tokens): void } } - private static function encoded_payload_could_decode_to_http_scheme(string $payload): bool + 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 1d91644c..244ba25a 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 @@ -34,12 +34,34 @@ * * @method string get_modifiable_text() * @method bool set_modifiable_text(string $plaintext_content) + * @method string|null get_tag() + * @method string|true|null get_attribute(string $name) * @property array $lexical_updates */ // phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound class CautiousTextBlockMarkupUrlProcessor extends BlockMarkupUrlProcessor { /** - * Replace configured URL bases in the current raw text token. + * Replace the current text token without passing it through the HTML + * encoder. The caller has already rewritten a nested data value and must + * preserve the surrounding shortcode or builder bytes verbatim. + */ + public function replace_raw_current_text(string $updated_text): bool + { + if ('#text' !== $this->get_token_type()) { + return false; + } + + $this->get_updated_html(); + if (!$this->set_modifiable_text('')) { + return false; + } + + $this->lexical_updates['modifiable text']->text = $updated_text; + return true; + } + + /** + * 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 @@ -50,7 +72,7 @@ class CautiousTextBlockMarkupUrlProcessor extends BlockMarkupUrlProcessor { */ 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-cautious-url-base-processor-in-text-with-mixed-unknown-escape-rules.php b/packages/reprint-client/src/lib/url-rewrite/class-cautious-url-base-processor-in-text-with-mixed-unknown-escape-rules.php index 44aa8774..57c379f6 100644 --- a/packages/reprint-client/src/lib/url-rewrite/class-cautious-url-base-processor-in-text-with-mixed-unknown-escape-rules.php +++ b/packages/reprint-client/src/lib/url-rewrite/class-cautious-url-base-processor-in-text-with-mixed-unknown-escape-rules.php @@ -287,34 +287,63 @@ private function create_url_candidate_pattern( string $source_path ): string { - $escaped_separator = '(?:\\\\{1}|\\\\{3})?'; + // A builder can store the URL syntax in JSON, HTML entities, percent + // escapes, or CSS hexadecimal escapes. The authority itself remains + // plain text, so replace only that raw span after recognizing these + // spelling variants of `:` and `/`. + $backslash = '(?:\\\\{1}|\\\\{3})?'; + $css_colon = '\\\\(?:0{0,5}3[aA])(?:[ \\t\\r\\n\\f])?'; + $css_slash = '\\\\(?:0{0,5}2[fF])(?:[ \\t\\r\\n\\f])?'; + $json_colon = '\\\\u00(?:3[aA])'; + $json_slash = '\\\\u00(?:2[fF])'; + $colon = '(?:' . $backslash . ':|(?i:%3a)|&\#(?:0*58|[xX]0*3[aA]);|' . $css_colon . '|' . $json_colon . ')'; + $slash = '(?:' . $backslash . '/|(?i:%2f)|&\#(?:0*47|[xX]0*2[fF]);|' . $css_slash . '|' . $json_slash . ')'; + $source_authority_pattern = $this->create_css_escaped_text_pattern($source_authority); $source_path_pattern = str_replace( '/', - $escaped_separator . '/', + $slash, preg_quote($source_path, '~') ); return '~ - (?@/\\\\]+@)? )? (? - (?(?i:' . preg_quote($source_authority, '~') . ')) + (?' . $source_authority_pattern . ') ' . $source_path_pattern . ' ) (?= $ - | ' . $escaped_separator . '/ + | ' . $slash . ' | [/?# \t\r\n,!;)\]}>"\'] ) ~x'; } + /** + * Match an ASCII authority as literal bytes or CSS hexadecimal escapes. + * The complete authority remains one captured span, allowing the caller + * to replace it without selecting an escape spelling for the target host. + */ + private function create_css_escaped_text_pattern(string $text): string + { + $pattern = ''; + $length = strlen($text); + for ($offset = 0; $offset < $length; ++$offset) { + $byte = $text[$offset]; + $hex = dechex(ord($byte)); + $pattern .= '(?:(?i:' . preg_quote($byte, '~') . ')|\\\\0{0,5}' . $hex . '(?:[ \\t\\r\\n\\f])?)'; + } + + return $pattern; + } + /** * @return array{ * source_authority: string, diff --git a/packages/reprint-client/src/lib/url-rewrite/class-json-string-iterator.php b/packages/reprint-client/src/lib/url-rewrite/class-json-string-iterator.php index fad2a810..46d182e5 100644 --- a/packages/reprint-client/src/lib/url-rewrite/class-json-string-iterator.php +++ b/packages/reprint-client/src/lib/url-rewrite/class-json-string-iterator.php @@ -1,46 +1,33 @@ next_value()) { - * $iter->set_value(str_replace('old', 'new', $iter->get_value())); - * } - * $result = $iter->get_result(); + * It validates and walks the JSON once without constructing a decoded object + * tree. Object keys are skipped; string values are exposed as decoded strings. + * Changed values are encoded individually, leaving every untouched byte in + * the enclosing JSON document intact. */ class JsonStringIterator { /** @var string The original JSON string. */ private string $original; - /** @var mixed The decoded JSON value (object decoded as array). */ - private $decoded; + /** @var int Length of the original JSON string. */ + private int $length; - /** @var bool Whether decoding succeeded. */ - private bool $valid; - - /** @var bool Whether any value has been modified via set_value(). */ - private bool $changed = false; + /** @var bool Whether parsing succeeded. */ + private bool $valid = false; /** - * Paths to all string leaf values in the decoded structure. - * Each path is an array of keys/indices leading to a string value. - * @var array> + * Raw spans for JSON string values, including their surrounding quotes. + * + * @var array */ - private array $paths = []; + private array $string_spans = []; + + /** @var array Changed decoded values by string-span index. */ + private array $replacements = []; /** @var int Current cursor position. -1 means before the first value. */ private int $cursor = -1; @@ -48,22 +35,28 @@ class JsonStringIterator public function __construct(string $json) { $this->original = $json; - $this->decoded = json_decode($json, true); + $this->length = strlen($json); + + if (preg_match('//u', $json) !== 1) { + return; + } - if (json_last_error() !== JSON_ERROR_NONE || (!is_array($this->decoded) && !is_string($this->decoded))) { - $this->valid = false; + $pos = 0; + $this->skip_whitespace($pos); + if ($pos === $this->length || !str_contains('"[{', $json[$pos])) { return; } - $this->valid = true; - $this->enumerate($this->decoded, []); + if (!$this->parse_value($pos, true)) { + return; + } + + $this->skip_whitespace($pos); + $this->valid = $pos === $this->length; } /** - * Whether the input was not valid JSON or had no string leaves. - * - * Mirrors PhpSerializationProcessor::is_malformed() so both iterators - * can be used with the same try-and-fail pattern. + * Whether the input was malformed or was not a JSON container/string. */ public function is_malformed(): bool { @@ -71,103 +64,347 @@ public function is_malformed(): bool } /** - * Advance to the next string value. - * - * @return bool True if there is another value, false if iteration is complete. + * Advance to the next JSON string value. */ public function next_value(): bool { if (!$this->valid) { return false; } - $this->cursor++; - return $this->cursor < count($this->paths); + + ++$this->cursor; + return $this->cursor < count($this->string_spans); } /** - * Get the current string value. - * - * Must only be called after next_value() returns true. + * Get the current decoded JSON string value. */ public function get_value(): string { - return $this->navigate($this->paths[$this->cursor]); + if (array_key_exists($this->cursor, $this->replacements)) { + return $this->replacements[$this->cursor]; + } + + $span = $this->string_spans[$this->cursor]; + $value = json_decode(substr($this->original, $span['start'], $span['length'])); + + return is_string($value) ? $value : ''; } /** - * Replace the current string value. - * - * Must only be called after next_value() returns true. + * Replace the current decoded JSON string value. */ public function set_value(string $new_value): void { - $path = $this->paths[$this->cursor]; - $this->setAtPath($path, $new_value); - $this->changed = true; + $this->replacements[$this->cursor] = $new_value; } /** - * Get the result JSON string. - * - * Returns the original string if nothing was modified, otherwise - * re-encodes the mutated structure. + * Return the original JSON with only changed string spans re-encoded. */ public function get_result(): string { - if (!$this->changed) { + if (count($this->replacements) === 0) { return $this->original; } - return json_encode($this->decoded, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $result = ''; + $last_end = 0; + + foreach ($this->replacements as $index => $replacement) { + $span = $this->string_spans[$index]; + $result .= substr($this->original, $last_end, $span['start'] - $last_end); + $result .= json_encode($replacement, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $last_end = $span['start'] + $span['length']; + } + + return $result . substr($this->original, $last_end); } /** - * Recursively enumerate all string leaf values and record their paths. - * - * @param mixed $data The current node in the decoded structure. - * @param array $path The path of keys leading to this node. + * Parse a JSON value at $pos and record it when it is a string value. */ - private function enumerate($data, array $path): void + private function parse_value(int &$pos, bool $is_string_value): bool { - if (is_string($data)) { - $this->paths[] = $path; - return; + $this->skip_whitespace($pos); + if ($pos === $this->length) { + return false; } - if (is_array($data)) { - foreach ($data as $key => $value) { - $this->enumerate($value, array_merge($path, [$key])); + switch ($this->original[$pos]) { + case '"': + return $this->parse_string($pos, $is_string_value); + case '{': + return $this->parse_object($pos); + case '[': + return $this->parse_array($pos); + case 't': + return $this->parse_literal($pos, 'true'); + case 'f': + return $this->parse_literal($pos, 'false'); + case 'n': + return $this->parse_literal($pos, 'null'); + default: + return $this->parse_number($pos); + } + } + + /** + * Parse a quoted JSON string and optionally record its raw span. + */ + private function parse_string(int &$pos, bool $is_string_value): bool + { + $start = $pos++; + + while ($pos < $this->length) { + $char = $this->original[$pos++]; + + if ($char === '"') { + if ($is_string_value) { + $this->string_spans[] = [ + 'start' => $start, + 'length' => $pos - $start, + ]; + } + + return true; + } + + if (ord($char) < 0x20) { + return false; + } + + if ($char !== '\\') { + continue; + } + + if ($pos === $this->length) { + return false; + } + + $escape = $this->original[$pos++]; + if (str_contains('"\\/bfnrt', $escape)) { + continue; + } + + if ($escape !== 'u' || !$this->parse_unicode_escape($pos)) { + return false; } } + + return false; } /** - * Navigate the decoded structure to the value at $path. - * - * @param array $path - * @return string + * Parse one Unicode escape, including a required low-surrogate escape. */ - private function navigate(array $path): string + private function parse_unicode_escape(int &$pos): bool { - $node = $this->decoded; - foreach ($path as $key) { - $node = $node[$key]; + $code_point = $this->read_unicode_code_point($pos); + if ($code_point === -1) { + return false; } - return $node; + + if ($code_point >= 0xDC00 && $code_point <= 0xDFFF) { + return false; + } + + if ($code_point < 0xD800 || $code_point > 0xDBFF) { + return true; + } + + if ($this->length - $pos < 6 || $this->original[$pos] !== '\\' || $this->original[$pos + 1] !== 'u') { + return false; + } + + $pos += 2; + $low_surrogate = $this->read_unicode_code_point($pos); + + return $low_surrogate >= 0xDC00 && $low_surrogate <= 0xDFFF; } /** - * Set the value at $path in the decoded structure. - * - * @param array $path - * @param string $value + * Read four hexadecimal digits from a JSON Unicode escape. */ - private function setAtPath(array $path, string $value): void + private function read_unicode_code_point(int &$pos): int { - $ref = &$this->decoded; - foreach ($path as $key) { - $ref = &$ref[$key]; + if ($this->length - $pos < 4) { + return -1; } - $ref = $value; + + $code_point = 0; + for ($offset = 0; $offset < 4; ++$offset) { + $byte = ord($this->original[$pos + $offset]); + if ($byte >= ord('0') && $byte <= ord('9')) { + $digit = $byte - ord('0'); + } elseif ($byte >= ord('a') && $byte <= ord('f')) { + $digit = $byte - ord('a') + 10; + } elseif ($byte >= ord('A') && $byte <= ord('F')) { + $digit = $byte - ord('A') + 10; + } else { + return -1; + } + + $code_point = $code_point * 16 + $digit; + } + + $pos += 4; + return $code_point; + } + + /** + * Parse an object and skip its string keys. + */ + private function parse_object(int &$pos): bool + { + ++$pos; + $this->skip_whitespace($pos); + + if ($pos < $this->length && $this->original[$pos] === '}') { + ++$pos; + return true; + } + + while (true) { + if (!$this->parse_string($pos, false)) { + return false; + } + + $this->skip_whitespace($pos); + if (!$this->consume($pos, ':') || !$this->parse_value($pos, true)) { + return false; + } + + $this->skip_whitespace($pos); + if ($this->consume($pos, '}')) { + return true; + } + if (!$this->consume($pos, ',')) { + return false; + } + + $this->skip_whitespace($pos); + if ($pos === $this->length) { + return false; + } + } + } + + /** + * Parse an array and record any string elements. + */ + private function parse_array(int &$pos): bool + { + ++$pos; + $this->skip_whitespace($pos); + + if ($pos < $this->length && $this->original[$pos] === ']') { + ++$pos; + return true; + } + + while (true) { + if (!$this->parse_value($pos, true)) { + return false; + } + + $this->skip_whitespace($pos); + if ($this->consume($pos, ']')) { + return true; + } + if (!$this->consume($pos, ',')) { + return false; + } + + $this->skip_whitespace($pos); + if ($pos === $this->length) { + return false; + } + } + } + + /** + * Parse a JSON literal. + */ + private function parse_literal(int &$pos, string $literal): bool + { + $literal_length = strlen($literal); + if ( + $this->length - $pos < $literal_length + || substr_compare($this->original, $literal, $pos, $literal_length) !== 0 + ) { + return false; + } + + $pos += $literal_length; + return true; + } + + /** + * Parse a JSON number. + */ + private function parse_number(int &$pos): bool + { + if ($this->original[$pos] === '-') { + ++$pos; + if ($pos === $this->length) { + return false; + } + } + + if ($this->original[$pos] === '0') { + ++$pos; + } elseif (strspn($this->original, '123456789', $pos, 1) === 1) { + $pos += strspn($this->original, '0123456789', $pos, $this->length - $pos); + } else { + return false; + } + + if ($pos < $this->length && $this->original[$pos] === '.') { + ++$pos; + $digits = strspn($this->original, '0123456789', $pos, $this->length - $pos); + if ($digits === 0) { + return false; + } + + $pos += $digits; + } + + if ($pos < $this->length && ( $this->original[$pos] === 'e' || $this->original[$pos] === 'E' )) { + ++$pos; + if ($pos < $this->length && str_contains('+-', $this->original[$pos])) { + ++$pos; + } + + $digits = strspn($this->original, '0123456789', $pos, $this->length - $pos); + if ($digits === 0) { + return false; + } + + $pos += $digits; + } + + return true; + } + + /** + * Skip JSON whitespace. + */ + private function skip_whitespace(int &$pos): void + { + $pos += strspn($this->original, " \t\r\n", $pos, $this->length - $pos); + } + + /** + * Consume $expected at $pos. + */ + private function consume(int &$pos, string $expected): bool + { + if ($pos === $this->length || $this->original[$pos] !== $expected) { + return false; + } + + ++$pos; + return true; } } diff --git a/packages/reprint-client/src/lib/url-rewrite/class-sql-statement-rewriter.php b/packages/reprint-client/src/lib/url-rewrite/class-sql-statement-rewriter.php index 47cf3c8f..18188b84 100644 --- a/packages/reprint-client/src/lib/url-rewrite/class-sql-statement-rewriter.php +++ b/packages/reprint-client/src/lib/url-rewrite/class-sql-statement-rewriter.php @@ -128,6 +128,7 @@ public function rewrite(string $sql): string && strpos($sql, 'dHA6') === false && strpos($sql, 'dHBz') === false && strpos($sql, 'dHRw') === false + && !$this->might_contain_nested_builder_data($sql) ) { return $sql; } @@ -156,6 +157,23 @@ public function rewrite(string $sql): string return $this->rewrite_with_scanner($scanner, $value_to_column_map); } + /** + * Return whether an INSERT names a WordPress content column which may hold + * a nested builder value. An outer Base64 payload cannot expose the HTTP + * prefix when a shortcode body contains a second Base64 payload, so those + * columns must reach the decoded-value classifier. + */ + private function might_contain_nested_builder_data(string $sql): bool + { + foreach (['post_content', 'post_content_filtered', 'post_excerpt', 'comment_content', 'description'] as $column) { + if (stripos($sql, $column) !== false) { + return true; + } + } + + return false; + } + /** * Build a SQLite prepared INSERT for producer-shaped statements. * @@ -185,18 +203,20 @@ function (string $value, string $table, ?string $column): string { private function rewrite_value_for_column(string $value, string $table, ?string $column): string { - if (strpos($value, 'http') === false) { - return $value; - } - - if (!$this->url_rewriter->value_might_contain_source_domain($value)) { - return $value; - } - $content_type = $column !== null ? $this->get_content_type($table, $column) : null; + if ( + $content_type !== StructuredDataUrlRewriter::BLOCK_MARKUP && + ( + strpos($value, 'http') === false || + !$this->url_rewriter->value_might_contain_source_domain($value) + ) + ) { + return $value; + } + // Rewrite URLs in the value. Known block-markup columns go through // the structured block parser so alternate URL spellings (for example // escaped JSON, uppercase schemes/hosts, and IDNs) are handled by the @@ -222,32 +242,40 @@ private function rewrite_value_for_column(string $value, string $table, ?string private function rewrite_with_scanner(Base64ValueScanner $scanner, ?array $value_to_column_map): string { while ($scanner->next_value()) { - if (!$scanner->encoded_payload_could_contain_http_scheme()) { - continue; - } - - $value = $scanner->get_value(); - - if (strpos($value, 'http') === false) { - continue; - } - - if (!$this->url_rewriter->value_might_contain_source_domain($value)) { - continue; - } - // Determine content type hint for this column. $column_name = null; + $table_name = ''; if ($value_to_column_map !== null) { + $table_name = $value_to_column_map['table']; $column_name = $this->find_column_at_offset( $value_to_column_map['column_map'], $scanner->get_match_offset() ); } + $content_type = $column_name !== null + ? $this->get_content_type($table_name, $column_name) + : null; + $nested_builder_data = $content_type === StructuredDataUrlRewriter::BLOCK_MARKUP; + if (!$nested_builder_data && !$scanner->encoded_payload_could_contain_http_scheme()) { + continue; + } + + $value = $scanner->get_value(); + + if ( + !$nested_builder_data && + ( + strpos($value, 'http') === false || + !$this->url_rewriter->value_might_contain_source_domain($value) + ) + ) { + continue; + } + $rewritten = $this->rewrite_value_for_column( $value, - $value_to_column_map['table'] ?? '', + $table_name, $column_name ); 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 68788d41..acc4e939 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 @@ -14,11 +14,14 @@ * the parsers themselves remain the authority on what's valid. * * 1. Serialized PHP → construct PhpSerializationProcessor, if not malformed, - * iterate string values and recurse on each - * 2. JSON → construct JsonStringIterator, if not malformed, iterate string + * iterate string values and recurse on each so byte lengths remain valid + * 2. Opaque fragments → replace only a configured URL authority in the raw + * bytes, preserving their enclosing format's escapes and quoting + * 3. Base64 → decode canonical payloads that might contain HTTP(S), recurse, + * and re-encode only when changed + * 4. JSON → construct JsonStringIterator, if not malformed, iterate string * values and recurse on each - * 3. Base64 → decode, recurse on decoded content, re-encode if changed - * 4. Leaf text → CautiousTextBlockMarkupUrlProcessor (block_markup hint) + * 5. Leaf text → CautiousTextBlockMarkupUrlProcessor (block_markup hint) * or URLInTextProcessor (default) * * HTML is never auto-detected — the caller must explicitly pass @@ -144,24 +147,16 @@ public function rewrite(string $value, ?string $content_type = null): string $content_type = self::PLAIN_TEXT; } + $input = $value; $structured_cache_key = sha1($content_type . "\0" . $value); $cached = $this->get_cached_structured_rewrite($structured_cache_key, $content_type, $value); if ($cached !== null) { return $cached; } - // Quick-reject: if the value doesn't contain href=", src=", or any - // source domain, there's nothing to rewrite. This avoids expensive - // parsing (serialized PHP, JSON, block markup) for the vast majority - // of values that don't contain any rewritable URLs. - if (!$this->maybe_contains_rewritable_urls($value)) { - return $value; - } - - // Performance guard: avoid constructing the serialized-PHP parser for - // ordinary URL strings and block markup. The parser still owns - // validation once entered; this gate only skips first-byte shapes that - // cannot expose serialized string values for rewriting. + // PHP serialization includes byte lengths for every string. Let its + // parser own the outer record before changing a leaf, so a mapped host + // whose length differs from the source also updates that framing. if ($this->could_be_php_serialization_with_strings($value)) { $p = new PhpSerializationProcessor($value); if (!$p->is_malformed()) { @@ -173,11 +168,32 @@ public function rewrite(string $value, ?string $content_type = null): string } } $rewritten_value = $p->get_updated_serialization(); - $this->set_cached_structured_rewrite($structured_cache_key, $content_type, $value, $rewritten_value); + $this->set_cached_structured_rewrite($structured_cache_key, $content_type, $input, $rewritten_value); return $rewritten_value; } } + // Rewrite the smallest unambiguous unit first: a configured URL base + // in the original bytes. This works equally in HTML attributes, + // shortcode attributes, CSS, and JSON without asking a parent format + // to encode its complete value again. + $value = $this->rewrite_url_bases_cautiously($value); + + // An opaque Base64 token has no readable host. Decode only canonical + // Base64 tokens whose bytes contain an HTTP(S) marker, then return the + // re-encoded token only when its decoded content changed. + if ($this->might_contain_base64_encoded_http($value)) { + $value = $this->rewrite_embedded_base64_values($value, $content_type); + } + + // Quick-reject after the two byte-preserving paths above. Structured + // parsers remain the authority on their syntax; this merely avoids + // opening them when there is no remaining mapped host to expose. + if (!$this->maybe_contains_rewritable_urls($value)) { + $this->set_cached_structured_rewrite($structured_cache_key, $content_type, $input, $value); + return $value; + } + // Performance guard: avoid calling json_decode() for ordinary URL // strings and block markup. JsonStringIterator still owns validation // once entered; this gate only skips first non-whitespace bytes that @@ -193,7 +209,7 @@ public function rewrite(string $value, ?string $content_type = null): string } } $rewritten_value = $iter->get_result(); - $this->set_cached_structured_rewrite($structured_cache_key, $content_type, $value, $rewritten_value); + $this->set_cached_structured_rewrite($structured_cache_key, $content_type, $input, $rewritten_value); return $rewritten_value; } } @@ -204,10 +220,58 @@ public function rewrite(string $value, ?string $content_type = null): string // was for base64-within-base64 nesting which is rare in practice. $rewritten_value = $this->rewrite_urls($value, $content_type); - $this->set_cached_structured_rewrite($structured_cache_key, $content_type, $value, $rewritten_value); + $this->set_cached_structured_rewrite($structured_cache_key, $content_type, $input, $rewritten_value); return $rewritten_value; } + /** + * Replace configured URL bases in an opaque fragment without decoding it. + * + * The fragment may be a complete post content value or a raw string leaf + * inside a recognized format. Valid PHP serialization is dispatched before + * this method so its byte lengths remain valid. The cautious processor only + * replaces the mapped authority and leaves enclosing quoting and escapes + * untouched. + */ + private function rewrite_url_bases_cautiously(string $value): string + { + $processor = new CautiousURLBaseProcessorInTextWithMixedUnknownEscapeRules( + $value, + $this->url_mapping + ); + while ($processor->next_url()) { + $processor->replace_url_base(); + } + + return $processor->get_updated_text(); + } + + /** + * Decode and recursively rewrite canonical Base64 tokens in a raw + * fragment. A replacement is limited to a token which decodes cleanly and + * whose decoded bytes actually change, so unrelated opaque text remains + * untouched. + */ + private function rewrite_embedded_base64_values(string $value, string $content_type): string + { + $rewritten = preg_replace_callback( + '/(?rewrite($decoded, $content_type); + return $updated === $decoded ? $encoded : base64_encode($updated); + }, + $value + ); + + return $rewritten ?? $value; + } + /** * Quick-reject check: returns false when the value certainly doesn't * contain any rewritable URLs, avoiding expensive parsing. @@ -229,6 +293,16 @@ private function maybe_contains_rewritable_urls(string $value): bool return false; } + /** + * Return whether a string might contain a Base64 payload which decodes to + * an HTTP(S) URL. Base64ValueScanner owns the alignment markers used by + * both SQL values and embedded payloads. + */ + private function might_contain_base64_encoded_http(string $value): bool + { + return Base64ValueScanner::encoded_payload_could_decode_to_http_scheme($value); + } + /** * Return whether the value starts with a PHP serialization token that may * expose string values to rewrite. @@ -450,12 +524,20 @@ private function rewrite_urls( string $content, string $content_type ): string { while ( $p->next_token() ) { $token_type = $p->get_token_type() ?? ''; if ( '#text' === $token_type ) { - if ($this->maybe_contains_rewritable_urls($p->get_modifiable_text())) { + $text = $p->get_modifiable_text(); + if ($this->maybe_contains_rewritable_urls($text)) { $p->replace_url_bases_in_current_text($this->url_mapping); } 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; @@ -546,4 +628,31 @@ private function rewrite_urls( string $content, string $content_type ): string { return ''; } } + + /** + * 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'; + } + } diff --git a/tests/UrlRewriting/CautiousURLBaseProcessorInTextWithMixedUnknownEscapeRulesTest.php b/tests/UrlRewriting/CautiousURLBaseProcessorInTextWithMixedUnknownEscapeRulesTest.php index 8f229d01..462428a3 100644 --- a/tests/UrlRewriting/CautiousURLBaseProcessorInTextWithMixedUnknownEscapeRulesTest.php +++ b/tests/UrlRewriting/CautiousURLBaseProcessorInTextWithMixedUnknownEscapeRulesTest.php @@ -96,9 +96,9 @@ public static function supported_cases(): array 'HTTPS://destination.example/media/logo.png', ['https://source.example' => 'https://destination.example'], ], - 'URL-valued query parameter' => [ + 'URL-valued query parameter remains opaque' => [ + 'https://archive.example/export?redirect=https://source.example/wp-content/uploads/2026/01/hero.jpg', 'https://archive.example/export?redirect=https://source.example/wp-content/uploads/2026/01/hero.jpg', - 'https://archive.example/export?redirect=https://destination.example/wp-content/uploads/2026/01/hero.jpg', ['https://source.example' => 'https://destination.example'], ], 'every configured occurrence in one text leaf' => [ @@ -196,9 +196,9 @@ public static function supported_cases(): array 'site.com/source.com/media/logo.png destination.com/media/logo.png', ['https://source.com' => 'https://destination.com'], ], - 'outer URL path stays unchanged while full query URL is rewritten' => [ + 'embedded URL in an outer query remains opaque' => [ + 'https://site.com/source.com/media?next=https://source.com/media/logo.png', 'https://site.com/source.com/media?next=https://source.com/media/logo.png', - 'https://site.com/source.com/media?next=https://destination.com/media/logo.png', ['https://source.com' => 'https://destination.com'], ], ]; @@ -305,9 +305,9 @@ public static function unsupported_cases(): array '-https://source.example/media/logo.png', ['https://source.example/media' => 'https://destination.example'], ], - 'CSS uses hexadecimal escapes' => [ - 'url(https\\3a \\2f \\2f source.example\\2f media\\2f logo.png)', + 'CSS hexadecimal escapes preserve their spelling' => [ 'url(https\\3a \\2f \\2f source.example\\2f media\\2f logo.png)', + 'url(https\\3a \\2f \\2f destination.example\\2f logo.png)', ['https://source.example/media' => 'https://destination.example'], ], 'source URL has an unconfigured port' => [ @@ -400,9 +400,9 @@ public static function unsupported_cases(): array 'https:\\\\/\\\\/source.example\\\\/media\\\\/logo.png', ['https://source.example' => 'https://destination.example'], ], - 'protocol separators are percent encoded' => [ - 'https%3A%2F%2Fsource.example%2Fmedia%2Flogo.png', + 'percent-encoded protocol separators preserve their spelling' => [ 'https%3A%2F%2Fsource.example%2Fmedia%2Flogo.png', + 'https%3A%2F%2Fdestination.example%2Fmedia%2Flogo.png', ['https://source.example' => 'https://destination.example'], ], 'protocol separators are HTML entities' => [ diff --git a/tests/UrlRewriting/JsonStringIteratorTest.php b/tests/UrlRewriting/JsonStringIteratorTest.php index 8909bcbc..82159e2f 100644 --- a/tests/UrlRewriting/JsonStringIteratorTest.php +++ b/tests/UrlRewriting/JsonStringIteratorTest.php @@ -89,11 +89,42 @@ public function testSetValueUpdatesJsonStringScalar(): void $this->assertSame('https://new-site.com/page', json_decode($iter->get_result(), true)); } + public function testPreservesUnchangedJsonBytesWhenReplacingAValue(): void + { + $json = "{\n \"url\" : \"https:\\/\\/old-site.com\\/page\", \"unicode\":\"\\u20ac\"\n}"; + $iter = new JsonStringIterator($json); + + $this->assertTrue($iter->next_value()); + $iter->set_value('https://new-site.com/page'); + + $this->assertSame( + "{\n \"url\" : \"https://new-site.com/page\", \"unicode\":\"\\u20ac\"\n}", + $iter->get_result() + ); + } + + public function testWalksLargeJsonWithoutDecodingItsObjectTree(): void + { + $json = '{"items":[' . str_repeat('{"title":"unchanged","count":1},', 10000) . '{"url":"https://old-site.com/page"}]}'; + $iter = new JsonStringIterator($json); + $last_value = null; + + while ($iter->next_value()) { + $last_value = $iter->get_value(); + } + + $this->assertFalse($iter->is_malformed()); + $this->assertSame('https://old-site.com/page', $last_value); + $this->assertSame($json, $iter->get_result()); + } + public function testMalformedJsonIsMalformed(): void { - $iter = new JsonStringIterator('{"broken":'); + foreach (['{"broken":', '"\\uD800"', '"\\uDC00"'] as $json) { + $iter = new JsonStringIterator($json); - $this->assertTrue($iter->is_malformed()); - $this->assertFalse($iter->next_value()); + $this->assertTrue($iter->is_malformed()); + $this->assertFalse($iter->next_value()); + } } } 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/SqlStatementRewriterPrefilterTest.php b/tests/UrlRewriting/SqlStatementRewriterPrefilterTest.php index c89adf59..ad617845 100644 --- a/tests/UrlRewriting/SqlStatementRewriterPrefilterTest.php +++ b/tests/UrlRewriting/SqlStatementRewriterPrefilterTest.php @@ -439,20 +439,23 @@ public function testFromBase64WithoutHttpIsReturnedUnchanged(): void } /** - * Uppercase HTTP encodes to `SFRU…` — none of our prefixes match. - * The leaf rewriter is also case-sensitive on "http", so this is - * preserved-behaviour, not a regression. + * Uppercase HTTP encodes to `SFRU…`, but the source-domain check still + * reaches the leaf rewriter. The cautious path preserves the scheme bytes + * while replacing the configured authority. */ - public function testUppercaseHttpIsLeftAlone(): void + public function testUppercaseHttpPreservesItsSchemeWhenRewritten(): void { $rewriter = $this->createRewriter(); $value = 'HTTP://old-site.com/page'; $sql = $this->buildInsertSql($value); - // Sanity: prefilter does not match. + // Sanity: the encoded HTTP marker does not match the prefilter. foreach (self::PREFIXES as $prefix) { $this->assertFalse(strpos($sql, $prefix), "Unexpected prefilter hit on '{$prefix}'"); } - $this->assertSame($sql, $rewriter->rewrite($sql)); + $this->assertSame( + $this->buildInsertSql('HTTP://new-site.com/page'), + $rewriter->rewrite($sql) + ); } /** diff --git a/tests/UrlRewriting/SqlStatementRewriterTest.php b/tests/UrlRewriting/SqlStatementRewriterTest.php index 142b3ab6..f88c29ef 100644 --- a/tests/UrlRewriting/SqlStatementRewriterTest.php +++ b/tests/UrlRewriting/SqlStatementRewriterTest.php @@ -193,6 +193,22 @@ public function testPostContentColumnUsesBlockMarkupRewriting(): void $this->assertStringNotContainsString('old-site.com', $values[0]); } + public function testPostContentRewritesBase64ShortcodeBodyWithoutAnOuterHttpPrefix(): void + { + $rewriter = $this->createRewriter(); + $value = '[vc_raw_html]' . base64_encode( + '' + ) . '[/vc_raw_html]'; + $expected = '[vc_raw_html]' . base64_encode( + '' + ) . '[/vc_raw_html]'; + $sql = "INSERT INTO `wp_posts` (`ID`, `post_content`) VALUES(1, FROM_BASE64('" . base64_encode($value) . "'));"; + + $values = $this->collectValues($rewriter->rewrite($sql)); + + $this->assertSame([$expected], $values); + } + public function testUnknownColumnUsesPlainTextUrlScanning(): void { $rewriter = $this->createRewriter(); @@ -223,7 +239,7 @@ public function testWpDefaultsWorkWithCustomTablePrefix(): void $this->assertStringContainsString('new-site.com/page', $values[0]); } - public function testPostContentUsesStructuredParserForMixedUrlSpellings(): void + public function testPostContentPreservesARewrittenCaseVariantScheme(): void { $rewriter = $this->createRewriter(); $markup = 'Literal' @@ -236,7 +252,7 @@ public function testPostContentUsesStructuredParserForMixedUrlSpellings(): void $values = $this->collectValues($result); $this->assertCount(1, $values); $this->assertStringContainsString('https://new-site.com/literal', $values[0]); - $this->assertStringContainsString('https://new-site.com/case-variant', $values[0]); + $this->assertStringContainsString('HTTPS://new-site.com/case-variant', $values[0]); $this->assertStringNotContainsString('old-site.com', strtolower($values[0])); } @@ -560,17 +576,17 @@ public function testBlockMarkupVsPlainTextDistinction(): void $encoded = base64_encode($block); // wp_posts.post_content → block_markup: rewrites both the JSON - // attribute and the src correctly. + // attribute and the src without changing the JSON spelling. $sql_real = "INSERT INTO `wp_posts` (`ID`, `post_content`) VALUES(1, FROM_BASE64('{$encoded}'));"; $result_real = $rewriter->rewrite($sql_real); $values_real = $this->collectValues($result_real); $this->assertStringContainsString('new-longer-domain-site.com/img.jpg', $values_real[0]); - // The JSON attribute should still be valid inside the block comment. - // The block parser JSON-encodes attribute values, so slashes are escaped. + // The JSON attribute remains valid inside the block comment and its + // unchanged bytes retain their original spelling. $this->assertStringContainsString( - '"url":"https:\/\/new-longer-domain-site.com\/img.jpg"', + '"url":"https://new-longer-domain-site.com/img.jpg"', $values_real[0], - 'block_markup should correctly rewrite the JSON attribute inside the block comment' + 'block_markup should rewrite the JSON attribute without re-encoding it' ); // spoofed_posts.post_content → auto-detect (not block_markup): the diff --git a/tests/UrlRewriting/StructuredDataUrlRewriterTest.php b/tests/UrlRewriting/StructuredDataUrlRewriterTest.php index 04d64673..a2a3c90b 100644 --- a/tests/UrlRewriting/StructuredDataUrlRewriterTest.php +++ b/tests/UrlRewriting/StructuredDataUrlRewriterTest.php @@ -164,6 +164,28 @@ public function testRewritesUrlInSerializedString(): void $this->assertSame('https://new-site.com/page', unserialize($result)); } + public function testSerializedPhpUpdatesAChangedStringLengthAfterCautiousRewrite(): void + { + $rewriter = $this->createRewriter([ + 'https://old-site.com' => 'https://a-much-longer-new-site.example', + ]); + $input = serialize(['url' => 'https://old-site.com/uploads/logo.png']); + $expected = serialize(['url' => 'https://a-much-longer-new-site.example/uploads/logo.png']); + + $this->assertSame($expected, $rewriter->rewrite($input)); + } + + public function testEmbeddedBase64ReencodesAChangedPayload(): void + { + $rewriter = $this->createRewriter([ + 'https://old-site.com' => 'https://a-much-longer-new-site.example', + ]); + $input = base64_encode('https://old-site.com/uploads/logo.png'); + $expected = base64_encode('https://a-much-longer-new-site.example/uploads/logo.png'); + + $this->assertSame($expected, $rewriter->rewrite($input, 'block_markup')); + } + public function testRewritesUrlsInDoubleSerializedPhp(): void { $rewriter = $this->createRewriter(); @@ -364,6 +386,37 @@ public function testBlockMarkupTextNodesUseCautiousUrlBaseReplacement( $this->assertSame($expected, $rewriter->rewrite($input, 'block_markup')); } + public function testBlockMarkupRewritesBase64ShortcodeBody(): void + { + $rewriter = $this->createRewriter(); + $input = '[vc_raw_html]' . base64_encode( + '' + ) . '[/vc_raw_html]'; + $expected = '[vc_raw_html]' . base64_encode( + '' + ) . '[/vc_raw_html]'; + + $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 */ @@ -454,12 +507,15 @@ public static function structured_target_base_cases(): array ]; } - public function testBlockMarkupLeavesEncodedSiteOriginInputValueUnchanged(): void + public function testBlockMarkupRewritesEncodedSiteOriginInputValueWithoutReencodingIt(): void { $rewriter = $this->createRewriter(); $input = ''; - $this->assertSame($input, $rewriter->rewrite($input, 'block_markup')); + $this->assertSame( + str_replace('old-site.com', 'new-site.com', $input), + $rewriter->rewrite($input, 'block_markup') + ); } public function testBlockMarkupTextOffsetFollowsAnEarlierStructuredReplacement(): void @@ -477,11 +533,11 @@ public function testBlockMarkupTextOffsetFollowsAnEarlierStructuredReplacement() $this->assertSame($expected, $rewriter->rewrite($input, 'block_markup')); } - public function testBlockMarkupStillUsesTheCssUrlProcessorForStyleAttributes(): void + public function testBlockMarkupPreservesStyleAttributeBytes(): void { $rewriter = $this->createRewriter(); $input = '
'; - $expected = '
'; + $expected = '
'; $this->assertSame($expected, $rewriter->rewrite($input, 'block_markup')); } @@ -508,7 +564,7 @@ public function testKnownBlockMarkupDoesNotRewriteEmbeddedQueryUrl(): void $this->assertSame($input, $rewriter->rewrite_known_block_markup_value($input)); } - public function testKnownBlockMarkupRewritesMixedLiteralAndCaseVariantUrls(): void + public function testKnownBlockMarkupPreservesARewrittenCaseVariantScheme(): void { $rewriter = $this->createRewriter(); $input = 'Literal' @@ -517,7 +573,7 @@ public function testKnownBlockMarkupRewritesMixedLiteralAndCaseVariantUrls(): vo $result = $rewriter->rewrite_known_block_markup_value($input); $this->assertStringContainsString('https://new-site.com/literal', $result); - $this->assertStringContainsString('https://new-site.com/case-variant', $result); + $this->assertStringContainsString('HTTPS://new-site.com/case-variant', $result); $this->assertStringNotContainsString('old-site.com', strtolower($result)); } @@ -561,7 +617,7 @@ public function testKnownBlockMarkupRewritesUnicodeHostInBlockCommentJson(): voi $this->assertStringNotContainsString('bücher.example', $result); } - public function testKnownBlockMarkupRewritesEscapedJsonAndCaseVariantHtmlTogether(): void + public function testKnownBlockMarkupPreservesARewrittenCaseVariantHtmlScheme(): void { $rewriter = $this->createRewriter(); $input = '' @@ -571,11 +627,11 @@ public function testKnownBlockMarkupRewritesEscapedJsonAndCaseVariantHtmlTogethe $result = $rewriter->rewrite_known_block_markup_value($input); $this->assertStringContainsString('https:\/\/new-site.com\/img.jpg', $result); - $this->assertStringContainsString('src="https://new-site.com/img.jpg"', $result); + $this->assertStringContainsString('src="HTTPS://new-site.com/img.jpg"', $result); $this->assertStringNotContainsString('old-site.com', strtolower($result)); } - public function testRewriteCacheSeparatesPlainTextAndBlockMarkupSemantics(): void + public function testBlockMarkupRewritesAUrlInAnArbitraryDataAttribute(): void { $rewriter = $this->createRewriter(); $input = '
Content
'; @@ -584,7 +640,7 @@ public function testRewriteCacheSeparatesPlainTextAndBlockMarkupSemantics(): voi $block_result = $rewriter->rewrite($input, 'block_markup'); $this->assertStringContainsString('https://new-site.com/not-a-url-attribute', $plain_result); - $this->assertSame($input, $block_result); + $this->assertStringContainsString('https://new-site.com/not-a-url-attribute', $block_result); } // --- Content type hint: null (default) uses plain text URL scanning ---