Files
ltsecurityinc/app/code/CK/LibxmlPainInTheAss/Observer/RepairMageInitAttributes.php
T

309 lines
7.9 KiB
PHP

<?php
declare(strict_types=1);
namespace CK\LibxmlPainInTheAss\Observer;
use Magento\Framework\App\Response\Http;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
class RepairMageInitAttributes implements ObserverInterface {
// libxml2 2.15.x changed HTML attribute serialization.
// 2.15.3 is the version currently exhibiting the problem.
private const BUG_LIBXML_VERSION = 21503;
// Set this to true to log every repaired response.
private const LOG_EXECUTION = false;
/**
* @var \CK\Logger\Logger
*/
protected $logger;
/**
* @var \CK\Logger\LoggerFactory
*/
protected $loggerFactory;
public function __construct(
\CK\Logger\LoggerFactory $loggerFactory
) {
$this->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="{&quot;...&quot;}"
*
* 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;
}
}