151 lines
7.9 KiB
PHP
151 lines
7.9 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace FDMTech;
|
|
|
|
/** Defense in depth for the restricted CMS publication dialect, not arbitrary web content. */
|
|
final class Content
|
|
{
|
|
private const PROPERTIES = 'color background-color font-family font-size font-weight font-style line-height letter-spacing text-align text-decoration text-transform white-space word-break overflow-wrap margin margin-top margin-right margin-bottom margin-left padding padding-top padding-right padding-bottom padding-left width min-width max-width height min-height max-height display flex flex-direction flex-wrap flex-grow flex-shrink flex-basis align-items align-self justify-content gap row-gap column-gap grid-template-columns grid-template-rows grid-column grid-row border border-width border-style border-color border-radius border-top border-bottom border-left border-right box-sizing box-shadow opacity overflow object-fit object-position list-style-type vertical-align';
|
|
|
|
public static function safeUrl(string $url, bool $navigation = false): bool
|
|
{
|
|
// Decode only for validation, so encoded protocol-relative paths cannot bypass the guard.
|
|
$decoded = rawurldecode($url);
|
|
if ($url === '' || preg_match('/[\x00-\x20\x7f\\\\<>"\']/', $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('<?xml encoding="UTF-8"><html><body>' . $html . '</body></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;
|
|
}
|
|
}
|