110 lines
3.2 KiB
PHP
110 lines
3.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Esun\Translation\Model\Html;
|
|
|
|
use DOMDocument;
|
|
use DOMElement;
|
|
use DOMXPath;
|
|
|
|
class HtmlTranslationValidator
|
|
{
|
|
/**
|
|
* DOMDocument::loadHTML uses an HTML4 parser and reports safe HTML5/Page Builder tags as invalid.
|
|
*/
|
|
private const HTML5_ALLOWED_INVALID_TAGS = [
|
|
'article',
|
|
'aside',
|
|
'audio',
|
|
'canvas',
|
|
'figcaption',
|
|
'figure',
|
|
'footer',
|
|
'header',
|
|
'main',
|
|
'nav',
|
|
'picture',
|
|
'section',
|
|
'source',
|
|
'summary',
|
|
'template',
|
|
'time',
|
|
'video',
|
|
];
|
|
|
|
/**
|
|
* @return string[]
|
|
*/
|
|
public function validate(string $html): array
|
|
{
|
|
$errors = [];
|
|
|
|
if (substr_count($html, '{{') !== substr_count($html, '}}')) {
|
|
$errors[] = 'Magento directive braces are unbalanced.';
|
|
}
|
|
|
|
$document = new DOMDocument('1.0', 'UTF-8');
|
|
$previous = libxml_use_internal_errors(true);
|
|
$loaded = $document->loadHTML(
|
|
'<?xml encoding="UTF-8"><div>' . $html . '</div>',
|
|
LIBXML_HTML_NODEFDTD | LIBXML_HTML_NOIMPLIED
|
|
);
|
|
$libxmlErrors = libxml_get_errors();
|
|
libxml_clear_errors();
|
|
libxml_use_internal_errors($previous);
|
|
|
|
if (!$loaded) {
|
|
return ['HTML cannot be parsed.'];
|
|
}
|
|
|
|
foreach ($libxmlErrors as $error) {
|
|
if ($error->level >= LIBXML_ERR_ERROR) {
|
|
if ($this->isAllowedHtml5InvalidTagMessage(trim($error->message))) {
|
|
continue;
|
|
}
|
|
$errors[] = trim($error->message);
|
|
}
|
|
}
|
|
|
|
$xpath = new DOMXPath($document);
|
|
foreach (['script', 'iframe', 'object', 'embed'] as $tag) {
|
|
$nodes = $xpath->query('//' . $tag);
|
|
if ($nodes !== false && $nodes->length > 0) {
|
|
$errors[] = sprintf('Disallowed <%s> tag found.', $tag);
|
|
}
|
|
}
|
|
|
|
$allNodes = $xpath->query('//*');
|
|
if ($allNodes !== false) {
|
|
foreach ($allNodes as $node) {
|
|
if (!$node instanceof DOMElement || !$node->hasAttributes()) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($node->attributes as $attribute) {
|
|
$name = strtolower($attribute->nodeName);
|
|
$value = strtolower(trim($attribute->nodeValue ?? ''));
|
|
if (str_starts_with($name, 'on')) {
|
|
$errors[] = sprintf('Disallowed event attribute "%s" found.', $name);
|
|
}
|
|
if (in_array($name, ['href', 'src'], true) && str_starts_with($value, 'javascript:')) {
|
|
$errors[] = sprintf('Disallowed javascript URL in "%s" found.', $name);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique(array_filter($errors)));
|
|
}
|
|
|
|
private function isAllowedHtml5InvalidTagMessage(string $message): bool
|
|
{
|
|
if (!preg_match('/^Tag ([a-z0-9:-]+) invalid$/i', $message, $matches)) {
|
|
return false;
|
|
}
|
|
|
|
return in_array(strtolower($matches[1]), self::HTML5_ALLOWED_INVALID_TAGS, true);
|
|
}
|
|
}
|