public_url; $media->update([ 'is_active' => true, 'user_id' => get_current_user_id(), 'object_source' => 'general' ]); } else { $item['icon_image'] = sanitize_url($item['icon_image']); } } if (Arr::get($item, 'privacy') == 'members_only') { $item['membership_ids'] = array_map('sanitize_text_field', (array)Arr::get($item, 'membership_ids', [])); } return $item; } public static function sanitizeSvg($svg_content) { if (!$svg_content) { return ''; } if (current_user_can('unfiltered_html')) { return $svg_content; } // Remove any comments $svg_content = preg_replace('//', '', $svg_content); // Remove XML or DOCTYPE declarations $svg_content = preg_replace('/<\?xml(.|\s)*?\?>/', '', $svg_content); $svg_content = preg_replace('//i', '', $svg_content); // Remove embedded scripts, iframes, or event handlers $svg_content = preg_replace('/]*>(.*?)<\/script>/is', '', $svg_content); $svg_content = preg_replace('/]*>(.*?)<\/iframe>/is', '', $svg_content); $svg_content = preg_replace('/on\w+="[^"]*"/i', '', $svg_content); $allowed_tags = [ 'svg' => ['width' => true, 'height' => true, 'viewBox' => true, 'version' => true, 'xmlns' => true, 'xmlns:xlink' => true, 'xml:space' => true, 'preserveAspectRatio' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'color' => true], 'g' => ['fill' => true, 'fill-rule' => true, 'stroke' => true, 'stroke-width' => true, 'clip-path' => true, 'transform' => true], 'path' => ['d' => true, 'opacity' => true, 'stroke-linecap' => true, 'fill' => true, 'fill-rule' => true, 'stroke' => true, 'stroke-width' => true, 'transform' => true], 'rect' => ['width' => true, 'height' => true, 'x' => true, 'y' => true, 'rx' => true, 'ry' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'transform' => true], 'circle' => ['cx' => true, 'cy' => true, 'r' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'transform' => true], 'ellipse' => ['cx' => true, 'cy' => true, 'rx' => true, 'ry' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'transform' => true], 'line' => ['x1' => true, 'x2' => true, 'y1' => true, 'y2' => true, 'stroke' => true, 'stroke-width' => true, 'transform' => true], 'polyline' => ['points' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'transform' => true], 'polygon' => ['points' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'transform' => true], 'text' => ['x' => true, 'y' => true, 'font-size' => true, 'font-family' => true, 'text-anchor' => true, 'fill' => true, 'transform' => true], 'tspan' => ['x' => true, 'y' => true, 'font-size' => true, 'font-family' => true, 'text-anchor' => true, 'fill' => true], 'defs' => [], 'clipPath' => ['id' => true, 'clipPathUnits' => true], 'stop' => ['offset' => true, 'stop-color' => true, 'stop-opacity' => true], 'linearGradient' => ['id' => true, 'x1' => true, 'y1' => true, 'x2' => true, 'y2' => true, 'gradientUnits' => true, 'gradientTransform' => true], 'radialGradient' => ['id' => true, 'cx' => true, 'cy' => true, 'r' => true, 'fx' => true, 'fy' => true, 'gradientUnits' => true, 'gradientTransform' => true], 'mask' => ['id' => true, 'maskUnits' => true, 'maskContentUnits' => true, 'x' => true, 'y' => true, 'width' => true, 'height' => true], 'use' => ['x' => true, 'y' => true, 'width' => true, 'height' => true], 'title' => [], 'desc' => [], ]; // Browsers accept bare "&" and HTML named entities in inline SVG, but strict XML parsing rejects them $svg_content = preg_replace('/&(?!#?[a-zA-Z0-9]+;)/', '&', $svg_content); $svg_content = preg_replace_callback('/&([a-zA-Z][a-zA-Z0-9]*);/', function ($matches) { $decoded = html_entity_decode($matches[0], ENT_QUOTES | ENT_HTML5, 'UTF-8'); if ($decoded === $matches[0]) { return ''; } return htmlspecialchars($decoded, ENT_QUOTES | ENT_XML1, 'UTF-8'); }, $svg_content); // Load the SVG string into a DOMDocument and discard errors for malformed XML $dom = new \DOMDocument(); libxml_use_internal_errors(true); $loaded = $dom->loadXML($svg_content); libxml_clear_errors(); if (!$loaded || !$dom->documentElement) { return ''; } // Sanitize by removing unwanted tags and attributes self::sanitizeNode($dom->documentElement, $allowed_tags); return $dom->saveXML($dom->documentElement); } private static function sanitizeNode(\DOMNode $node, array $allowed_tags) { if ($node->nodeType === XML_ELEMENT_NODE) { /** @var \DOMElement $node */ if (!isset($allowed_tags[$node->nodeName])) { $node->parentNode->removeChild($node); return; } // Check attributes $attributes = $node->attributes; $length = $attributes->length; for ($i = $length - 1; $i >= 0; $i--) { $attr = $attributes->item($i); $attr_name = $attr->nodeName; if (!isset($allowed_tags[$node->nodeName][$attr_name])) { $node->removeAttribute($attr_name); } else { // Sanitize attribute values // FILTER_SANITIZE_STRING is deprecated in PHP 8.1 $sanitized_value = htmlspecialchars($attr->nodeValue, ENT_QUOTES, 'UTF-8'); $node->setAttribute($attr_name, $sanitized_value); } } } // Recursively sanitize child nodes for ($i = $node->childNodes->length - 1; $i >= 0; $i--) { self::sanitizeNode($node->childNodes->item($i), $allowed_tags); } } public static function sanitizeEmoji($emoji, $single = true) { $emoji = (string)$emoji; $emoji = trim($emoji); if (!$emoji) { return ''; } if ($single && function_exists('\mb_substr')) { $emoji = \mb_substr($emoji, 0, 4, 'UTF-8'); } $isEmoji = preg_match('/[\x{1F600}-\x{1F64F}\x{1F300}-\x{1F5FF}\x{1F680}-\x{1F6FF}\x{1F700}-\x{1F77F}\x{1F780}-\x{1F7FF}\x{1F800}-\x{1F8FF}\x{1F900}-\x{1F9FF}\x{1FA00}-\x{1FA6F}\x{1FA70}-\x{1FAFF}\x{1F1E0}-\x{1F1FF}\x{2600}-\x{26FF}\x{2700}-\x{27BF}\x{2B50}\x{2B55}\x{2934}\x{2935}\x{3297}\x{3299}\x{20E3}\x{23E9}-\x{23FA}\x{25B6}\x{25C0}\x{FE0F}]/u', $emoji); if ($isEmoji) { return $emoji; } return ''; } public static function sanitizeWelcomeBannerSettings($settings, $views = ['login', 'logout']) { $views = array_intersect($views, ['login', 'logout', 'enrolled', 'not_enrolled']); $rules = [ 'title' => 'sanitize_text_field', 'description' => 'wp_kses_post', 'mediaType' => 'sanitize_text_field', 'allowClose' => 'sanitize_text_field', 'enabled' => 'sanitize_text_field', ]; $sanitizedSettings = []; foreach ($views as $type) { $typeSettings = Arr::get($settings, $type, []); if (empty($typeSettings)) { continue; } $bannerVideo = Arr::get($typeSettings, 'bannerVideo', []); $bannerImage = Arr::get($typeSettings, 'bannerImage', ''); $ctaButtons = Arr::get($typeSettings, 'ctaButtons', []); $sanitizedSettings[$type]['bannerVideo'] = self::sanitizeBannerVideo($bannerVideo); $sanitizedSettings[$type]['bannerImage'] = self::sanitizeBannerImage($bannerImage); $sanitizedSettings[$type]['ctaButtons'] = self::sanitizeCtaButtons($ctaButtons); $description = Arr::get($typeSettings, 'description'); if (!empty($description)) { $description = wp_kses_post(self::unslashMarkdown(wp_unslash($description))); } $sanitizedSettings[$type]['description'] = $description; foreach ($typeSettings as $key => $value) { if (isset($rules[$key]) && !in_array($key, ['bannerVideo', 'bannerImage', 'ctaButtons', 'description'])) { $sanitizedSettings[$type][$key] = call_user_func($rules[$key], $value); } } } return $sanitizedSettings; } private static function sanitizeBannerVideo($video) { if (empty($video)) { return []; } return array_filter([ 'type' => sanitize_text_field(Arr::get($video, 'type', '')), 'url' => sanitize_url(Arr::get($video, 'url', '')), 'content_type' => sanitize_text_field(Arr::get($video, 'content_type', '')), 'provider' => sanitize_text_field(Arr::get($video, 'provider', '')), 'title' => sanitize_text_field(Arr::get($video, 'title', '')), 'author_name' => sanitize_text_field(Arr::get($video, 'author_name', '')), 'html' => self::sanitizeRichText(Arr::get($video, 'html', '')), 'image' => sanitize_url(Arr::get($video, 'image', '')), ]); } private static function sanitizeBannerImage($imageUrl) { if (empty($imageUrl)) { return ''; } $media = Helper::getMediaFromUrl($imageUrl); if ($media) { $media->update([ 'is_active' => true, 'user_id' => get_current_user_id(), 'object_source' => 'general' ]); return $media->public_url; } return sanitize_url($imageUrl); } private static function sanitizeCtaButtons($ctaButtons) { if (empty($ctaButtons)) { return []; } $sanitizerMap = [ 'label' => 'sanitize_text_field', 'link' => function ($url) { return esc_url_raw($url, ['http', 'https', 'mailto']); }, 'type' => 'sanitize_text_field', 'newTab' => 'sanitize_text_field' ]; foreach ($ctaButtons as $btnKey => $btnValue) { foreach ($btnValue as $key => $value) { if (isset($sanitizerMap[$key])) { $ctaButtons[$btnKey][$key] = call_user_func($sanitizerMap[$key], $value); } } } return $ctaButtons; } public static function santizeLinkItem($item) { $validKeys = ['title', 'enabled', 'new_tab', 'emoji', 'icon_image', 'shape_svg', 'title', 'permalink', 'slug', 'privacy', 'membership_ids']; $item = array_filter(Arr::only($item, $validKeys)); $yesNoItems = ['enabled', 'new_tab', 'is_locked', 'is_unavailable']; foreach ($yesNoItems as $key) { if (isset($item[$key])) { $item[$key] = $item[$key] === 'yes' ? 'yes' : 'no'; } } $item['emoji'] = self::sanitizeEmoji(Arr::get($item, 'emoji')); if (empty($item['slug'])) { $item['slug'] = sanitize_title(Arr::get($item, 'title', '')); } else { $item['slug'] = sanitize_title($item['slug']); } $textTypes = ['title']; foreach ($textTypes as $key) { if (isset($item[$key])) { $item[$key] = sanitize_text_field($item[$key]); } } $item['permalink'] = sanitize_url(Arr::get($item, 'permalink', '')); if (!empty($item['icon_image'])) { $media = Helper::getMediaFromUrl($item['icon_image']); if ($media) { $media->update([ 'is_active' => true, 'user_id' => get_current_user_id(), 'object_source' => 'general' ]); $item['icon_image'] = $media->public_url; } else { $item['icon_image'] = sanitize_text_field($item['icon_image']); } } if (!empty($item['icon_svg'])) { $item['icon_svg'] = self::sanitizeSvg($item['icon_svg']); } if (!empty($item['shape_svg'])) { $item['shape_svg'] = self::sanitizeSvg($item['shape_svg']); } if (Arr::get($item, 'privacy') == 'members_only') { $item['membership_ids'] = array_map('sanitize_text_field', (array)Arr::get($item, 'membership_ids', [])); } return array_filter($item); } /** * @param array $items * @return array */ public static function sanitizeSpaceMenuItems($items) { $sanitized = []; $seen = []; foreach ((array)$items as $item) { $menuItem = self::sanitizeSpaceMenuItem($item); if (!$menuItem || isset($seen[$menuItem['slug']])) { continue; } $seen[$menuItem['slug']] = true; $sanitized[] = $menuItem; } return $sanitized; } /** * One row of a space's primary menu. Returns null for a row that cannot be rendered — no * slug, a custom row with no label, or a destination that survived neither the protocol * allowlist nor the page lookup. * * @param array $item * @return array|null */ public static function sanitizeSpaceMenuItem($item) { // `parent` is accepted and stored but nothing renders it yet. It holds a sibling row's // slug for the one-level sub-menu, and keeping it on the write path now means that // feature is additive rather than a migration of everyone's stored menu. $validKeys = [ 'slug', 'title', 'enabled', 'new_tab', 'emoji', 'icon_image', 'shape_svg', 'permalink', 'page_slug', 'link_type', 'privacy', 'membership_ids', 'is_custom', 'parent', ]; $item = Arr::only((array)$item, $validKeys); $isCustom = Arr::get($item, 'is_custom') === 'yes'; $slug = Utility::slugify(Arr::get($item, 'slug', '')); if ($isCustom) { // Force the prefix so a custom row's slug can never hijack a real tab's slug. if (strpos($slug, 'fcom_custom_') !== 0) { $slug = 'fcom_custom_' . ($slug ?: substr(md5(wp_generate_password(12, false)), 0, 10)); } } elseif (!$slug) { return null; } $sanitized = [ 'slug' => $slug, 'title' => sanitize_text_field(Arr::get($item, 'title', '')), 'enabled' => Arr::get($item, 'enabled') === 'no' ? 'no' : 'yes', 'is_custom' => $isCustom ? 'yes' : 'no', 'parent' => sanitize_title(Arr::get($item, 'parent', '')), ]; $emoji = self::sanitizeEmoji(Arr::get($item, 'emoji', '')); if ($emoji) { $sanitized['emoji'] = $emoji; } $shapeSvg = self::sanitizeSvg(Arr::get($item, 'shape_svg', '')); if ($shapeSvg) { $sanitized['shape_svg'] = $shapeSvg; } $iconImage = Arr::get($item, 'icon_image'); if ($iconImage) { $media = Helper::getMediaFromUrl($iconImage); if ($media) { $media->update([ 'is_active' => true, 'user_id' => get_current_user_id(), 'object_source' => 'general', ]); $sanitized['icon_image'] = $media->public_url; } else { $sanitized['icon_image'] = sanitize_url($iconImage); } } $privacy = Arr::get($item, 'privacy'); if (!in_array($privacy, ['public', 'logged_in', 'logged_out_only', 'members_only'], true)) { $privacy = 'public'; } $sanitized['privacy'] = $privacy; if ($privacy === 'members_only') { $membershipIds = array_map('intval', (array)Arr::get($item, 'membership_ids', [])); $sanitized['membership_ids'] = array_values(array_filter($membershipIds)); } if (!$isCustom) { return $sanitized; } if (!$sanitized['title']) { return null; } $linkType = Arr::get($item, 'link_type') === 'space_page' ? 'space_page' : 'url'; $sanitized['link_type'] = $linkType; if ($linkType === 'space_page') { $pageSlug = sanitize_title(Arr::get($item, 'page_slug', '')); if (!$pageSlug) { return null; } $sanitized['page_slug'] = $pageSlug; $sanitized['new_tab'] = 'no'; return $sanitized; } // sanitize_url drops everything outside WordPress's protocol allowlist, so a // javascript: destination comes back empty and the row is discarded. $permalink = sanitize_url(Arr::get($item, 'permalink', '')); if (!$permalink) { return null; } $sanitized['permalink'] = $permalink; $sanitized['new_tab'] = Arr::get($item, 'new_tab') === 'yes' ? 'yes' : 'no'; return $sanitized; } public static function sanitizeRichText($content, $print = false) { if ($print) { echo self::sanitizeHtml($content); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } return self::sanitizeHtml($content); } public static function sanitizeHtml($html) { if (current_user_can('unfiltered_html')) { return $html; } if (!$html) { return $html; } // Return $html if it's just a plain text if (!preg_match('/<[^>]*>/', $html)) { return $html; } $tags = wp_kses_allowed_html('post'); // No