loggerFactory = $loggerFactory; $this->logger = $this->loggerFactory->create(); } public function execute(Observer $observer): void { self::LOG_EXECUTION && $this->logger->info( 'CK_LibxmlPainInTheAss observer FIRED. libxml=' . (defined('LIBXML_LOADED_VERSION') ? LIBXML_LOADED_VERSION : 'undefined') ); $loadedVersion = ''; // Only apply the workaround to libxml2 versions affected // by the changed HTML serialization behavior. if (!defined('LIBXML_LOADED_VERSION') || (int)LIBXML_LOADED_VERSION !== self::BUG_LIBXML_VERSION) { if (self::LOG_EXECUTION) { $loadedVersion = defined('LIBXML_LOADED_VERSION') ? (string)LIBXML_LOADED_VERSION : 'undefined'; $this->logger->info( sprintf( 'CK_LibxmlPainInTheAss bypassed for mismatched libxml version. ' . 'Loaded libxml2: %s; expected: %d', $loadedVersion, self::BUG_LIBXML_VERSION ) ); } return; } self::LOG_EXECUTION && $this->logger->warning( 'CK_LibxmlPainInTheAss detected bug version of libxml=' . $loadedVersion . '. Patching in progress.' ); // Work only on HTML responses. $response = $observer->getEvent()->getResponse(); if (!$response instanceof Http) { return; } // Check HTML body contains data-mage-init attribute $body = (string)$response->getContent(); if ($body === '' || stripos($body, 'data-mage-init=') === false) { return; } $repaired = $this->repairDataMageInitAttributes($body); if ($repaired === $body) { return; } $response->setContent($repaired); // Log repairs if asked to if (self::LOG_EXECUTION) { $this->logger->warning( sprintf( 'CK_LibxmlPainInTheAss repaired malformed data-mage-init attributes. ' . 'Loaded libxml2: %s', LIBXML_LOADED_VERSION ) ); } } /** * Repair only malformed data-mage-init attributes. * * Expected broken input: * * data-mage-init="{"key":"value"}" * * Preferred output: * * data-mage-init='{"key":"value"}' * * If the JSON itself contains a literal apostrophe, use: * * data-mage-init="{"..."}" * * instead. */ private function repairDataMageInitAttributes(string $html): string { $length = strlen($html); $offset = 0; $output = ''; $changed = false; while ($offset < $length) { $match = preg_match( '/data-mage-init\s*=\s*"/i', $html, $matches, PREG_OFFSET_CAPTURE, $offset ); if ($match !== 1) { $output .= substr($html, $offset); break; } $attributeOffset = $matches[0][1]; $attributeToken = $matches[0][0]; $attributeTokenLength = strlen($attributeToken); // Only process data-mage-init when it appears to be an actual attribute boundary, not as part of another token. // Example: foo-data-mage-init="..." if ($attributeOffset === 0) { $output .= substr($html, $offset, $attributeTokenLength); $offset = $attributeTokenLength; continue; } $previousCharacter = $html[$attributeOffset - 1]; if (!ctype_space($previousCharacter)) { $output .= substr( $html, $offset, ($attributeOffset + $attributeTokenLength) - $offset ); $offset = $attributeOffset + $attributeTokenLength; continue; } // Copy everything before data-mage-init="... $output .= substr( $html, $offset, $attributeOffset - $offset ); $jsonStart = $attributeOffset + $attributeTokenLength; // The malformed value must begin with a JSON object or array. if ($jsonStart >= $length || ($html[$jsonStart] !== '{' && $html[$jsonStart] !== '[')) { $output .= $attributeToken; $offset = $jsonStart; continue; } $jsonEnd = $this->findJsonEnd($html, $jsonStart); // Could not safely identify a JSON value. Leave this occurrence untouched. if ($jsonEnd === null || $jsonEnd >= $length) { $output .= $attributeToken; $offset = $jsonStart; continue; } // The malformed attribute should end with a literal ". // // Example: // data-mage-init="{"key":"value"}" // ^ if ($html[$jsonEnd] !== '"') { $output .= $attributeToken; $offset = $jsonStart; continue; } // Ensure it's the end of the HTML attribute. $afterJson = $jsonEnd + 1; if ($afterJson < $length) { $nextCharacter = $html[$afterJson]; if ($nextCharacter !== '>' && $nextCharacter !== '/' && !ctype_space($nextCharacter)) { $output .= $attributeToken; $offset = $jsonStart; continue; } } // Extract the JSON from $html $json = substr( $html, $jsonStart, $jsonEnd - $jsonStart ); // Validate that this really is JSON. try { json_decode($json, true, 512, JSON_THROW_ON_ERROR); } catch (\JsonException $e) { $output .= $attributeToken; $offset = $jsonStart; continue; } // JSON normally contains double quotes and no apostrophes, so single quotes are the safest HTML attribute delimiter. if (strpos($json, "'") === false) { $output .= 'data-mage-init=\'' . $json . '\''; } else { // The JSON contains a literal apostrophe, so use double quotes and HTML-escape its double quotes. $escapedJson = htmlspecialchars( $json, ENT_COMPAT | ENT_SUBSTITUTE, 'UTF-8', false ); $output .= 'data-mage-init="' . $escapedJson . '"'; } $changed = true; // Consume the final quote of the malformed attribute. $offset = $afterJson; } return $changed ? $output : $html; } /** * Find the end of a JSON object or array. * * Handles: * - nested objects * - nested arrays * - quoted strings * - escaped quotes * * Returns the position immediately AFTER the final } or ]. */ private function findJsonEnd(string $html, int $start): ?int { $length = strlen($html); $stack = []; $inString = false; $escaped = false; for ($i = $start; $i < $length; $i++) { $char = $html[$i]; if ($inString) { if ($escaped) { $escaped = false; continue; } if ($char === '\\') { $escaped = true; continue; } if ($char === '"') { $inString = false; } continue; } if ($char === '"') { $inString = true; continue; } if ($char === '{' || $char === '[') { $stack[] = $char; continue; } if ($char === '}' || $char === ']') { if (empty($stack)) { return null; } $open = array_pop($stack); if (($open === '{' && $char !== '}') || ($open === '[' && $char !== ']')) { return null; } if (empty($stack)) { return $i + 1; } } } return null; } }