Publiczna aplikacja fdmtech.pl: renderer CMS, formularz audytu, szablony i logotypy
This commit is contained in:
+150
@@ -0,0 +1,150 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user