">`; the boundary escapes * everything else and restore() swaps the tokens back (clean() does both; echo boundaries wrap * `echo wp_kses(...)` in begin() / end()). Keys are random per request, so saved content cannot forge a token; * unknown tokens are removed. * * Filter `zadex/kses/allowed_html` (array $allowed) adjusts the list. */ final class Kses { /** @var array>|null */ private static ?array $allowed = null; /** True while the current wp_kses() call uses allowed_html(). */ private static bool $active = false; /** @var array token key => protected third-party HTML */ private static array $raw = []; private const TOKEN = '#(?:\s*)?#i'; /** Escapes rendered document HTML for output (protected third-party fragments restored). */ public static function clean(string $html): string { if ($html === '') { return ''; } return self::restore(wp_kses(self::strip_code($html), self::allowed_html())); } /** * Third-party HTML (shortcodes, widget areas, comments, another post's the_content, WooCommerce templates): * kept as WordPress prints it. Returns a token the output boundary replaces with the fragment. */ public static function protect(string $html): string { if ($html === '') { return ''; } $key = bin2hex(random_bytes(16)); self::$raw[$key] = $html; return ''; } /** Swaps protect() tokens back; unknown (forged) tokens are removed. */ public static function restore(string $html): string { if (stripos($html, ' self::$raw[strtolower($m[1])] ?? '', $html); } /** Starts an output buffer that restore()s what is echoed until end() (echo boundaries). */ public static function begin(): void { ob_start([self::class, 'restore']); } public static function end(): void { ob_end_flush(); } /** Removes script / style elements with their content. */ public static function strip_code(string $html): string { if (!preg_match('#<(?:script|style)\b#i', $html)) { return $html; } return (string) preg_replace('#<(script|style)\b[^>]*>.*?#is', '', $html); } /** * Filter `pre_kses`: turns the scoped extras on for a wp_kses() call made with allowed_html() and off for any * other call. `===` on the same array is a pointer comparison, so this is cheap for every kses call. * * @param mixed $content * @param mixed $allowed_html * @return mixed */ public static function pre_kses($content, $allowed_html = null, $allowed_protocols = null) { self::$active = self::$allowed !== null && is_array($allowed_html) && $allowed_html === self::$allowed; if (self::$active && is_string($content)) { return self::strip_code($content); } return $content; } /** Whether the scoped style rules apply right now (tests). */ public static function active(): bool { return self::$active; } /** @return array> */ public static function allowed_html(): array { if (self::$allowed !== null) { return self::$allowed; } if (function_exists('add_filter')) { add_filter('pre_kses', [self::class, 'pre_kses'], 1, 3); add_filter('safe_style_css', [self::class, 'safe_style_css'], 99); add_filter('safecss_filter_attr_allow_css', [self::class, 'allow_css'], 99, 2); } $post = function_exists('wp_kses_allowed_html') ? wp_kses_allowed_html('post') : []; $allowed = is_array($post) ? $post : []; $extra = [ // protect() tokens (restored after escaping). 'zx-raw' => ['data-k' => true], // Landmarks / sectioning / text. 'nav' => [], 'header' => [], 'footer' => [], 'main' => [], 'section' => [], 'article' => [], 'aside' => [], 'figure' => [], 'figcaption' => [], 'details' => ['open' => true, 'name' => true], 'summary' => [], 'dialog' => ['open' => true], 'time' => ['datetime' => true], 'template' => [], 'mark' => [], 'small' => [], 'meta' => ['content' => true, 'itemprop' => true], 'ol' => ['start' => true, 'reversed' => true, 'type' => true], 'td' => ['colspan' => true, 'rowspan' => true, 'headers' => true, 'scope' => true], 'th' => ['colspan' => true, 'rowspan' => true, 'headers' => true, 'scope' => true, 'abbr' => true], 'a' => ['href' => true, 'target' => true, 'rel' => true, 'download' => true, 'hreflang' => true, 'type' => true, 'name' => true], 'img' => [ 'src' => true, 'srcset' => true, 'sizes' => true, 'alt' => true, 'width' => true, 'height' => true, 'loading' => true, 'decoding' => true, 'fetchpriority' => true, 'usemap' => true, 'referrerpolicy' => true, 'crossorigin' => true, ], // Forms. 'form' => [ 'action' => true, 'method' => true, 'enctype' => true, 'novalidate' => true, 'target' => true, 'name' => true, 'autocomplete' => true, 'accept-charset' => true, ], 'input' => [ 'type' => true, 'name' => true, 'value' => true, 'placeholder' => true, 'required' => true, 'checked' => true, 'disabled' => true, 'readonly' => true, 'min' => true, 'max' => true, 'step' => true, 'minlength' => true, 'maxlength' => true, 'pattern' => true, 'autocomplete' => true, 'autocapitalize' => true, 'autofocus' => true, 'accept' => true, 'multiple' => true, 'size' => true, 'form' => true, 'list' => true, 'inputmode' => true, 'spellcheck' => true, 'enterkeyhint' => true, ], 'select' => ['name' => true, 'multiple' => true, 'required' => true, 'disabled' => true, 'size' => true, 'form' => true, 'autocomplete' => true], 'option' => ['value' => true, 'selected' => true, 'disabled' => true, 'label' => true], 'optgroup' => ['label' => true, 'disabled' => true], 'textarea' => [ 'name' => true, 'rows' => true, 'cols' => true, 'placeholder' => true, 'required' => true, 'disabled' => true, 'readonly' => true, 'minlength' => true, 'maxlength' => true, 'autocomplete' => true, 'form' => true, 'wrap' => true, 'spellcheck' => true, ], 'label' => ['for' => true, 'form' => true], 'button' => ['type' => true, 'name' => true, 'value' => true, 'disabled' => true, 'form' => true, 'popovertarget' => true], 'fieldset' => ['name' => true, 'disabled' => true, 'form' => true], 'legend' => [], 'progress' => ['value' => true, 'max' => true], 'meter' => ['value' => true, 'min' => true, 'max' => true, 'low' => true, 'high' => true, 'optimum' => true], 'output' => ['for' => true, 'name' => true, 'form' => true], 'datalist' => [], // Embeds and media. 'iframe' => [ 'src' => true, 'title' => true, 'allow' => true, 'allowfullscreen' => true, 'loading' => true, 'referrerpolicy' => true, 'frameborder' => true, 'width' => true, 'height' => true, 'scrolling' => true, 'name' => true, 'sandbox' => true, ], 'video' => [ 'src' => true, 'poster' => true, 'autoplay' => true, 'muted' => true, 'loop' => true, 'controls' => true, 'playsinline' => true, 'preload' => true, 'controlslist' => true, 'disablepictureinpicture' => true, 'width' => true, 'height' => true, 'crossorigin' => true, ], 'audio' => ['src' => true, 'autoplay' => true, 'muted' => true, 'loop' => true, 'controls' => true, 'preload' => true, 'controlslist' => true, 'crossorigin' => true], 'source' => ['src' => true, 'srcset' => true, 'sizes' => true, 'type' => true, 'media' => true, 'width' => true, 'height' => true], 'track' => ['src' => true, 'kind' => true, 'srclang' => true, 'label' => true, 'default' => true], 'picture' => [], 'canvas' => ['width' => true, 'height' => true], // SVG (icons, shape dividers, text path, rating stars, progress rings). 'svg' => [ 'xmlns' => true, 'xmlns:xlink' => true, 'viewbox' => true, 'width' => true, 'height' => true, 'preserveaspectratio' => true, 'focusable' => true, 'version' => true, 'x' => true, 'y' => true, ], 'g' => [], 'defs' => [], 'symbol' => ['viewbox' => true, 'preserveaspectratio' => true], 'title' => [], 'desc' => [], 'path' => ['d' => true, 'pathlength' => true], 'circle' => ['cx' => true, 'cy' => true, 'r' => true, 'pathlength' => true], 'ellipse' => ['cx' => true, 'cy' => true, 'rx' => true, 'ry' => true], 'rect' => ['x' => true, 'y' => true, 'width' => true, 'height' => true, 'rx' => true, 'ry' => true], 'line' => ['x1' => true, 'y1' => true, 'x2' => true, 'y2' => true], 'polyline' => ['points' => true], 'polygon' => ['points' => true], 'lineargradient' => ['x1' => true, 'y1' => true, 'x2' => true, 'y2' => true, 'gradientunits' => true, 'gradienttransform' => true, 'spreadmethod' => true, 'href' => true, 'xlink:href' => true], 'radialgradient' => ['cx' => true, 'cy' => true, 'r' => true, 'fx' => true, 'fy' => true, 'fr' => true, 'gradientunits' => true, 'gradienttransform' => true, 'spreadmethod' => true, 'href' => true, 'xlink:href' => true], 'stop' => ['offset' => true, 'stop-color' => true, 'stop-opacity' => true], 'use' => ['href' => true, 'xlink:href' => true, 'x' => true, 'y' => true, 'width' => true, 'height' => true], 'clippath' => ['clippathunits' => true], 'mask' => ['x' => true, 'y' => true, 'width' => true, 'height' => true, 'maskunits' => true, 'maskcontentunits' => true], 'pattern' => ['x' => true, 'y' => true, 'width' => true, 'height' => true, 'patternunits' => true, 'patterncontentunits' => true, 'patterntransform' => true, 'viewbox' => true], 'text' => ['x' => true, 'y' => true, 'dx' => true, 'dy' => true, 'rotate' => true, 'textlength' => true, 'lengthadjust' => true], 'tspan' => ['x' => true, 'y' => true, 'dx' => true, 'dy' => true, 'rotate' => true, 'textlength' => true, 'lengthadjust' => true], 'textpath' => ['href' => true, 'xlink:href' => true, 'startoffset' => true, 'method' => true, 'spacing' => true, 'side' => true, 'textlength' => true, 'lengthadjust' => true], ]; // Presentation attributes every SVG element may carry. $svg_presentation = [ 'fill' => true, 'fill-opacity' => true, 'fill-rule' => true, 'clip-rule' => true, 'clip-path' => true, 'mask' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true, 'stroke-miterlimit' => true, 'stroke-dasharray' => true, 'stroke-dashoffset' => true, 'stroke-opacity' => true, 'opacity' => true, 'transform' => true, 'transform-origin' => true, 'color' => true, 'display' => true, 'visibility' => true, 'vector-effect' => true, 'shape-rendering' => true, 'text-anchor' => true, 'dominant-baseline' => true, 'alignment-baseline' => true, 'unicode-bidi' => true, 'direction' => true, 'overflow' => true, 'font-family' => true, 'font-size' => true, 'font-weight' => true, 'font-style' => true, 'letter-spacing' => true, 'stop-color' => true, 'stop-opacity' => true, 'marker-start' => true, 'marker-mid' => true, 'marker-end' => true, 'paint-order' => true, ]; $svg_tags = ['svg', 'g', 'defs', 'symbol', 'path', 'circle', 'ellipse', 'rect', 'line', 'polyline', 'polygon', 'lineargradient', 'radialgradient', 'stop', 'use', 'clippath', 'mask', 'pattern', 'text', 'tspan', 'textpath']; foreach ($extra as $tag => $attrs) { $allowed[$tag] = array_merge($allowed[$tag] ?? [], array_filter($attrs)); } foreach ($svg_tags as $tag) { $allowed[$tag] = array_merge($allowed[$tag], $svg_presentation); } $global = self::global_attributes(); foreach ($allowed as $tag => $attrs) { $allowed[$tag] = array_merge(is_array($attrs) ? $attrs : [], $global); } $filtered = apply_filters('zadex/kses/allowed_html', $allowed); self::$allowed = is_array($filtered) ? $filtered : $allowed; return self::$allowed; } /** Attributes accepted on every element. @return array */ private static function global_attributes(): array { $names = [ 'id', 'class', 'style', 'title', 'lang', 'dir', 'role', 'tabindex', 'hidden', 'inert', 'data-*', 'loading', 'decoding', 'srcset', 'sizes', 'width', 'height', 'target', 'rel', 'download', 'translate', 'draggable', 'contenteditable', 'popover', 'accesskey', 'part', 'slot', 'is', 'itemscope', 'itemtype', 'itemprop', 'itemid', 'itemref', 'focusable', 'xml:lang', 'xml:space', ]; $aria = [ 'activedescendant', 'atomic', 'autocomplete', 'braillelabel', 'brailleroledescription', 'busy', 'checked', 'colcount', 'colindex', 'colindextext', 'colspan', 'controls', 'current', 'describedby', 'description', 'details', 'disabled', 'errormessage', 'expanded', 'flowto', 'haspopup', 'hidden', 'invalid', 'keyshortcuts', 'label', 'labelledby', 'level', 'live', 'modal', 'multiline', 'multiselectable', 'orientation', 'owns', 'placeholder', 'posinset', 'pressed', 'readonly', 'relevant', 'required', 'roledescription', 'rowcount', 'rowindex', 'rowindextext', 'rowspan', 'selected', 'setsize', 'sort', 'valuemax', 'valuemin', 'valuenow', 'valuetext', ]; foreach ($aria as $name) { $names[] = 'aria-' . $name; } return array_fill_keys($names, true); } /** * Filter `safe_style_css` (only while clean() runs): the CSS properties widgets write into inline `style` * attributes on top of core's list. * * @param mixed $properties * @return string[] */ public static function safe_style_css($properties): array { $properties = is_array($properties) ? $properties : []; if (!self::$active) { return $properties; } return array_values(array_unique(array_merge($properties, [ '--*', 'inset', 'inset-block', 'inset-inline', 'inset-block-start', 'inset-block-end', 'inset-inline-start', 'inset-inline-end', 'order', 'place-items', 'place-content', 'place-self', 'grid-area', 'grid-template-areas', 'grid-template', 'grid-auto-flow', 'overflow-x', 'overflow-y', 'overflow-wrap', 'word-break', 'hyphens', 'text-wrap', 'text-shadow', 'text-overflow', 'text-decoration-color', 'text-decoration-line', 'text-decoration-style', 'text-decoration-thickness', 'text-underline-offset', 'font-feature-settings', 'font-variation-settings', 'list-style', 'list-style-image', 'list-style-position', 'outline', 'outline-color', 'outline-offset', 'outline-style', 'outline-width', 'transition', 'transition-delay', 'transition-duration', 'transition-property', 'transition-timing-function', 'animation', 'animation-delay', 'animation-direction', 'animation-duration', 'animation-fill-mode', 'animation-iteration-count', 'animation-name', 'animation-play-state', 'animation-timing-function', 'translate', 'rotate', 'scale', 'perspective', 'perspective-origin', 'backface-visibility', 'transform-style', 'will-change', 'backdrop-filter', 'mix-blend-mode', 'isolation', 'mask-image', 'mask-size', 'mask-position', 'mask-repeat', 'mask-mode', 'clip', 'background-clip', 'background-origin', 'background-position-x', 'background-position-y', 'border-image', 'box-sizing', 'resize', 'user-select', 'touch-action', 'scroll-behavior', 'scroll-margin', 'scroll-margin-top', 'scroll-padding', 'scroll-padding-top', 'scroll-snap-type', 'scroll-snap-align', 'content-visibility', 'contain', 'container-name', 'accent-color', 'caret-color', 'tab-size', 'text-align-last', 'text-orientation', 'line-clamp', '-webkit-line-clamp', '-webkit-box-orient', '-webkit-text-stroke', '-webkit-text-fill-color', '-webkit-background-clip', '-webkit-mask-image', 'counter-reset', 'counter-increment', 'quotes', ]))); } /** * Filter `safecss_filter_attr_allow_css` (only while clean() runs). Core rejects every declaration whose * value still holds a `(` after its known functions were removed, which drops colours such as `rgba()` / * `hsl()`, `cubic-bezier()` easing and `attr()`. Those are accepted here as long as the rest of the * declaration carries none of the characters a style attribute could be abused with. * * @param mixed $allow * @param mixed $css_test_string */ public static function allow_css($allow, $css_test_string): bool { if ($allow || !self::$active) { return (bool) $allow; } $test = is_string($css_test_string) ? $css_test_string : ''; $test = (string) preg_replace('/\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color|color-mix|light-dark|cubic-bezier|steps|attr|env|fit-content|linear|radial|conic|repeating-linear|repeating-radial|drop-shadow|blur|brightness|contrast|grayscale|hue-rotate|invert|opacity|saturate|sepia|counter|counters|local|format|image-set|round|mod|rem|abs|sign|pow|sqrt|hypot|sin|cos|tan|asin|acos|atan|atan2|exp|log)(\((?:[^()]|(?1))*\))/i', '', $test); return 0 === preg_match('%[\\\\(&=}{<>]|/\*|expression|javascript:|vbscript:|url\s*\(%i', $test); } /** Resets the memoised list (tests / after filters changed). */ public static function reset(): void { self::$allowed = null; self::$active = false; self::$raw = []; } }