"\']/', $decoded) || str_contains($decoded, '%')) { return false; } if (str_starts_with($decoded, '/') && !str_starts_with($decoded, '//')) { return true; } if (!$navigation && preg_match('/^#[a-zA-Z][\w-]*$/D', $url)) { return true; } if (strtolower((string) parse_url($url, PHP_URL_SCHEME)) === 'https') { return filter_var($url, FILTER_VALIDATE_URL) !== false && !parse_url($url, PHP_URL_USER) && !parse_url($url, PHP_URL_PASS); } return !$navigation && (bool) preg_match('/^(?:mailto:[a-zA-Z0-9.!#$%&*+\/=^_`{|}~-]+@[a-zA-Z0-9.-]+|tel:\+?[0-9()-]+)$/D', $url); } public static function html(string $html): string { if (strlen($html) > 1000000) { throw new \RuntimeException('CMS HTML exceeds contract limit'); } if (trim($html) === '') { return ''; } $document = new \DOMDocument('1.0', 'UTF-8'); $previous = libxml_use_internal_errors(true); try { $document->loadHTML('' . $html . '', LIBXML_NONET); $allowed = ['div', 'p', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'a', 'img', 'ul', 'ol', 'li', 'strong', 'em', 'b', 'i', 'br', 'hr', 'blockquote', 'table', 'thead', 'tbody', 'tr', 'th', 'td']; $clean = static function (\DOMNode $parent) use (&$clean, $allowed): void { foreach (iterator_to_array($parent->childNodes) as $node) { if ($node instanceof \DOMElement) { if (!in_array($node->tagName, $allowed, true)) { $parent->removeChild($node); continue; } foreach (iterator_to_array($node->attributes) as $attribute) { $name = $attribute->name; $value = $attribute->value; $valid = match ($name) { 'class' => (bool) preg_match('/^[a-zA-Z0-9_ -]{0,500}$/D', $value), 'id' => $value !== 'cms-content' && (bool) preg_match('/^[a-zA-Z][a-zA-Z0-9_-]{0,159}$/D', $value), 'href' => $node->tagName === 'a' && self::safeUrl($value), 'src' => $node->tagName === 'img' && self::safeUrl($value, true), 'alt', 'title' => true, 'width', 'height', 'colspan', 'rowspan' => (bool) preg_match('/^[0-9]{1,4}$/D', $value), default => false, }; if (!$valid) { $node->removeAttribute($name); } } $clean($node); } elseif (!$node instanceof \DOMText) { $parent->removeChild($node); } } }; $body = $document->getElementsByTagName('body')->item(0); $clean($body); $result = ''; foreach ($body->childNodes as $node) { $result .= $document->saveHTML($node); } return $result; } finally { libxml_clear_errors(); libxml_use_internal_errors($previous); } } public static function css(string $css): string { if (strlen($css) > 200000 || preg_match('/[\\\\<>\x00-\x08\x0b\x0c\x0e-\x1f]|\/\*/', $css)) { throw new \RuntimeException('Invalid CMS stylesheet'); } $result = ''; $offset = 0; while ($offset < strlen($css)) { if (preg_match('/\G\s+/', $css, $space, 0, $offset)) { $offset += strlen($space[0]); continue; } if (preg_match('/\G@media\s+(?:only\s+)?(?:screen\s+and\s+)?\((max|min)-width:\s*(\d{1,4})px\)\s*\{((?:[^{}]*\{[^{}]*\}\s*)*)\}/i', $css, $media, 0, $offset)) { $result .= '@media (' . strtolower($media[1]) . '-width:' . $media[2] . 'px){' . self::css($media[3]) . '}'; $offset += strlen($media[0]); continue; } if (!preg_match('/\G([^{}]+)\{([^{}]*)\}/', $css, $rule, 0, $offset)) { throw new \RuntimeException('Unsupported CMS stylesheet syntax'); } $offset += strlen($rule[0]); $selectors = []; foreach (explode(',', $rule[1]) as $selector) { $selector = trim($selector); if (!preg_match('/^[a-zA-Z0-9_.#* >+~-]+(?::(?:hover|focus|focus-visible|first-child|last-child))?$/D', $selector)) { throw new \RuntimeException('Invalid CMS selector'); } if (in_array($selector, ['#cms-content', 'body', 'html', 'html body'], true)) { $selectors[] = '#cms-content'; continue; } if (str_starts_with($selector, '#cms-content')) { if (!str_starts_with($selector, '#cms-content ')) { throw new \RuntimeException('Invalid CMS root selector'); } $selector = ltrim(substr($selector, strlen('#cms-content'))); } // Root siblings escape the content boundary. Descendant siblings remain inside it. if (!preg_match('/^(?:>\s*)?[a-zA-Z0-9_.#*]/', $selector)) { throw new \RuntimeException('CMS selector escapes content scope'); } $selectors[] = '#cms-content ' . $selector; } $declarations = []; foreach (explode(';', $rule[2]) as $declaration) { $parts = explode(':', $declaration, 2); $property = strtolower(trim($parts[0])); $value = trim($parts[1] ?? ''); if (!in_array($property, explode(' ', self::PROPERTIES), true) || !preg_match('/^[a-zA-Z0-9\s#.,%()\-+\'"\/]+$/D', $value)) { continue; } preg_match_all('/([a-zA-Z-]+)\s*\(/', $value, $functions); if (array_diff(array_map('strtolower', $functions[1]), ['rgb', 'rgba', 'hsl', 'hsla', 'repeat', 'minmax', 'calc'])) { continue; } $declarations[] = $property . ':' . $value; } if ($declarations !== []) { $result .= implode(',', $selectors) . '{' . implode(';', $declarations) . '}'; } } return $result; } }