| @@ -2,8 +2,9 @@ | ||
| 2 | 2 | |
| 3 | 3 | namespace FluentCommunity\App\Services; |
| 4 | 4 | |
| 5 | 5 | use FluentCommunity\App\Functions\Utility; |
| 6 | +use FluentCommunity\App\Models\Space; | |
| 6 | 7 | use FluentCommunity\Framework\Support\Arr; |
| 7 | 8 | |
| 8 | 9 | class CustomSanitizer |
| 9 | 10 | { |
| @@ -8,9 +9,9 @@ | ||
| 8 | 9 | class CustomSanitizer |
| 9 | 10 | { |
| 10 | 11 | public static function sanitizeMenuLink($item) |
| 11 | 12 | { |
| 12 | - $validKeys = ['title', 'enabled', 'permalink', 'new_tab', 'link_classes', 'shape_svg', 'emoji', 'icon_image', 'is_custom', 'is_system', 'is_locked', 'is_unavailable', 'slug']; | |
| 13 | + $validKeys = ['title', 'enabled', 'permalink', 'new_tab', 'link_classes', 'shape_svg', 'emoji', 'icon_image', 'is_custom', 'is_system', 'is_locked', 'is_unavailable', 'slug', 'privacy', 'membership_ids']; | |
| 13 | 14 | $item = array_filter(Arr::only($item, $validKeys)); |
| 14 | 15 | |
| 15 | 16 | $yesNoItems = ['enabled', 'is_custom', 'is_system', 'is_locked', 'is_unavailable']; |
| 16 | 17 | foreach ($yesNoItems as $key) { |
| @@ -18,42 +19,41 @@ | ||
| 18 | 19 | $item[$key] = $item[$key] === 'yes' ? 'yes' : 'no'; |
| 19 | 20 | } |
| 20 | 21 | } |
| 21 | 22 | |
| 22 | - $textTypes = ['title', 'new_tab', 'link_classes', 'slug']; | |
| 23 | + $textTypes = ['title', 'new_tab', 'link_classes', 'slug', 'privacy']; | |
| 23 | 24 | foreach ($textTypes as $key) { |
| 24 | 25 | if (isset($item[$key])) { |
| 25 | 26 | $item[$key] = sanitize_text_field($item[$key]); |
| 26 | 27 | } |
| 27 | 28 | } |
| 28 | - $item['permalink'] = sanitize_url($item['permalink']); | |
| 29 | 29 | |
| 30 | - if (Arr::get($item, 'is_system') !== 'yes') { | |
| 31 | - if (!empty($item['shape_svg'])) { | |
| 32 | - $item['shape_svg'] = self::sanitizeSvg($item['shape_svg']); | |
| 33 | - } | |
| 30 | + $item['permalink'] = sanitize_url(Arr::get($item, 'permalink', '')); | |
| 34 | 31 | |
| 35 | - if (!empty($item['emoji'])) { | |
| 36 | - $item['emoji'] = self::sanitizeEmoji($item['emoji']); | |
| 32 | + if (!empty($item['shape_svg'])) { | |
| 33 | + $item['shape_svg'] = self::sanitizeSvg($item['shape_svg']); | |
| 34 | + } | |
| 35 | + | |
| 36 | + if (!empty($item['emoji'])) { | |
| 37 | + $item['emoji'] = self::sanitizeEmoji($item['emoji']); | |
| 38 | + } | |
| 39 | + | |
| 40 | + if (!empty($item['icon_image'])) { | |
| 41 | + $media = Helper::getMediaFromUrl($item['icon_image']); | |
| 42 | + if ($media) { | |
| 43 | + $item['icon_image'] = $media->public_url; | |
| 44 | + $media->update([ | |
| 45 | + 'is_active' => true, | |
| 46 | + 'user_id' => get_current_user_id(), | |
| 47 | + 'object_source' => 'general' | |
| 48 | + ]); | |
| 49 | + } else { | |
| 50 | + $item['icon_image'] = sanitize_url($item['icon_image']); | |
| 37 | 51 | } |
| 52 | + } | |
| 38 | 53 | |
| 39 | - if (!empty($item['icon_image'])) { | |
| 40 | - $media = Helper::getMediaFromUrl($item['icon_image']); | |
| 41 | - if ($media) { | |
| 42 | - $item['icon_image'] = $media->public_url; | |
| 43 | - $media->update([ | |
| 44 | - 'is_active' => true, | |
| 45 | - 'user_id' => get_current_user_id(), | |
| 46 | - 'object_source' => 'general' | |
| 47 | - ]); | |
| 48 | - } else { | |
| 49 | - $item['icon_image'] = sanitize_url($item['icon_image']); | |
| 50 | - } | |
| 51 | - } | |
| 52 | - } else { | |
| 53 | - $item['shape_svg'] = ''; | |
| 54 | - $item['emoji'] = ''; | |
| 55 | - $item['icon_image'] = ''; | |
| 54 | + if (Arr::get($item, 'privacy') == 'members_only') { | |
| 55 | + $item['membership_ids'] = array_map('sanitize_text_field', (array)Arr::get($item, 'membership_ids', [])); | |
| 56 | 56 | } |
| 57 | 57 | |
| 58 | 58 | return $item; |
| 59 | 59 | } |
| @@ -80,12 +80,12 @@ | ||
| 80 | 80 | $svg_content = preg_replace('/<iframe\b[^>]*>(.*?)<\/iframe>/is', '', $svg_content); |
| 81 | 81 | $svg_content = preg_replace('/on\w+="[^"]*"/i', '', $svg_content); |
| 82 | 82 | |
| 83 | 83 | $allowed_tags = [ |
| 84 | - 'svg' => ['width' => true, 'height' => true, 'viewBox' => true, 'version' => true, 'xmlns' => true, 'xmlns:xlink' => true, 'xml:space' => true, 'style' => true, 'preserveAspectRatio' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'color' => true], | |
| 84 | + '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], | |
| 85 | 85 | 'g' => ['fill' => true, 'fill-rule' => true, 'stroke' => true, 'stroke-width' => true, 'clip-path' => true, 'transform' => true], |
| 86 | - 'path' => ['d' => true, 'opacity' => true, 'stroke-linecap' => true, 'fill' => true, 'fill-rule' => true, 'stroke' => true, 'stroke-width' => true, 'style' => true, 'transform' => true], | |
| 87 | - 'rect' => ['width' => true, 'height' => true, 'x' => true, 'y' => true, 'rx' => true, 'ry' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'transform' => true, 'style' => true], | |
| 86 | + 'path' => ['d' => true, 'opacity' => true, 'stroke-linecap' => true, 'fill' => true, 'fill-rule' => true, 'stroke' => true, 'stroke-width' => true, 'transform' => true], | |
| 87 | + 'rect' => ['width' => true, 'height' => true, 'x' => true, 'y' => true, 'rx' => true, 'ry' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'transform' => true], | |
| 88 | 88 | 'circle' => ['cx' => true, 'cy' => true, 'r' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'transform' => true], |
| 89 | 89 | 'ellipse' => ['cx' => true, 'cy' => true, 'rx' => true, 'ry' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'transform' => true], |
| 90 | 90 | 'line' => ['x1' => true, 'x2' => true, 'y1' => true, 'y2' => true, 'stroke' => true, 'stroke-width' => true, 'transform' => true], |
| 91 | 91 | 'polyline' => ['points' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'transform' => true], |
| @@ -97,19 +97,35 @@ | ||
| 97 | 97 | 'stop' => ['offset' => true, 'stop-color' => true, 'stop-opacity' => true], |
| 98 | 98 | 'linearGradient' => ['id' => true, 'x1' => true, 'y1' => true, 'x2' => true, 'y2' => true, 'gradientUnits' => true, 'gradientTransform' => true], |
| 99 | 99 | 'radialGradient' => ['id' => true, 'cx' => true, 'cy' => true, 'r' => true, 'fx' => true, 'fy' => true, 'gradientUnits' => true, 'gradientTransform' => true], |
| 100 | 100 | 'mask' => ['id' => true, 'maskUnits' => true, 'maskContentUnits' => true, 'x' => true, 'y' => true, 'width' => true, 'height' => true], |
| 101 | - 'use' => ['xlink:href' => true, 'x' => true, 'y' => true, 'width' => true, 'height' => true], | |
| 101 | + 'use' => ['x' => true, 'y' => true, 'width' => true, 'height' => true], | |
| 102 | 102 | 'title' => [], |
| 103 | 103 | 'desc' => [], |
| 104 | 104 | ]; |
| 105 | 105 | |
| 106 | + // Browsers accept bare "&" and HTML named entities in inline SVG, but strict XML parsing rejects them | |
| 107 | + $svg_content = preg_replace('/&(?!#?[a-zA-Z0-9]+;)/', '&', $svg_content); | |
| 108 | + $svg_content = preg_replace_callback('/&([a-zA-Z][a-zA-Z0-9]*);/', function ($matches) { | |
| 109 | + $decoded = html_entity_decode($matches[0], ENT_QUOTES | ENT_HTML5, 'UTF-8'); | |
| 110 | + | |
| 111 | + if ($decoded === $matches[0]) { | |
| 112 | + return ''; | |
| 113 | + } | |
| 114 | + | |
| 115 | + return htmlspecialchars($decoded, ENT_QUOTES | ENT_XML1, 'UTF-8'); | |
| 116 | + }, $svg_content); | |
| 117 | + | |
| 106 | 118 | // Load the SVG string into a DOMDocument and discard errors for malformed XML |
| 107 | 119 | $dom = new \DOMDocument(); |
| 108 | 120 | libxml_use_internal_errors(true); |
| 109 | - $dom->loadXML($svg_content); | |
| 121 | + $loaded = $dom->loadXML($svg_content); | |
| 110 | 122 | libxml_clear_errors(); |
| 111 | 123 | |
| 124 | + if (!$loaded || !$dom->documentElement) { | |
| 125 | + return ''; | |
| 126 | + } | |
| 127 | + | |
| 112 | 128 | // Sanitize by removing unwanted tags and attributes |
| 113 | 129 | self::sanitizeNode($dom->documentElement, $allowed_tags); |
| 114 | 130 | |
| 115 | 131 | return $dom->saveXML($dom->documentElement); |
| @@ -117,8 +133,9 @@ | ||
| 117 | 133 | |
| 118 | 134 | private static function sanitizeNode(\DOMNode $node, array $allowed_tags) |
| 119 | 135 | { |
| 120 | 136 | if ($node->nodeType === XML_ELEMENT_NODE) { |
| 137 | + /** @var \DOMElement $node */ | |
| 121 | 138 | if (!isset($allowed_tags[$node->nodeName])) { |
| 122 | 139 | $node->parentNode->removeChild($node); |
| 123 | 140 | return; |
| 124 | 141 | } |
| @@ -155,12 +172,12 @@ | ||
| 155 | 172 | return ''; |
| 156 | 173 | } |
| 157 | 174 | |
| 158 | 175 | if ($single && function_exists('\mb_substr')) { |
| 159 | - $emoji = \mb_substr($emoji, 0, 1, 'UTF-8'); | |
| 176 | + $emoji = \mb_substr($emoji, 0, 4, 'UTF-8'); | |
| 160 | 177 | } |
| 161 | 178 | |
| 162 | - $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{2600}-\x{26FF}\x{2700}-\x{27BF}\x{2B50}\x{2B55}\x{2934}\x{2935}\x{3297}\x{3299}]/u', $emoji); | |
| 179 | + $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); | |
| 163 | 180 | |
| 164 | 181 | if ($isEmoji) { |
| 165 | 182 | return $emoji; |
| 166 | 183 | } |
| @@ -166,20 +183,22 @@ | ||
| 166 | 183 | } |
| 167 | 184 | return ''; |
| 168 | 185 | } |
| 169 | 186 | |
| 170 | - public static function sanitizeWelcomeBannerSettings($settings) | |
| 187 | + public static function sanitizeWelcomeBannerSettings($settings, $views = ['login', 'logout']) | |
| 171 | 188 | { |
| 189 | + $views = array_intersect($views, ['login', 'logout', 'enrolled', 'not_enrolled']); | |
| 190 | + | |
| 172 | 191 | $rules = [ |
| 173 | - 'title' => 'sanitize_text_field', | |
| 174 | - 'description' => 'wp_kses_post', | |
| 175 | - 'mediaType' => 'sanitize_text_field', | |
| 176 | - 'allowClose' => 'sanitize_text_field', | |
| 177 | - 'enabled' => 'sanitize_text_field', | |
| 192 | + 'title' => 'sanitize_text_field', | |
| 193 | + 'description' => 'wp_kses_post', | |
| 194 | + 'mediaType' => 'sanitize_text_field', | |
| 195 | + 'allowClose' => 'sanitize_text_field', | |
| 196 | + 'enabled' => 'sanitize_text_field', | |
| 178 | 197 | ]; |
| 179 | 198 | |
| 180 | 199 | $sanitizedSettings = []; |
| 181 | - foreach (['login', 'logout'] as $type) { | |
| 200 | + foreach ($views as $type) { | |
| 182 | 201 | $typeSettings = Arr::get($settings, $type, []); |
| 183 | 202 | if (empty($typeSettings)) { |
| 184 | 203 | continue; |
| 185 | 204 | } |
| @@ -190,12 +209,17 @@ | ||
| 190 | 209 | |
| 191 | 210 | $sanitizedSettings[$type]['bannerVideo'] = self::sanitizeBannerVideo($bannerVideo); |
| 192 | 211 | $sanitizedSettings[$type]['bannerImage'] = self::sanitizeBannerImage($bannerImage); |
| 193 | 212 | $sanitizedSettings[$type]['ctaButtons'] = self::sanitizeCtaButtons($ctaButtons); |
| 194 | - $sanitizedSettings[$type]['description'] = self::unslashMarkdown(Arr::get($typeSettings, 'description')); | |
| 195 | 213 | |
| 214 | + $description = Arr::get($typeSettings, 'description'); | |
| 215 | + if (!empty($description)) { | |
| 216 | + $description = wp_kses_post(self::unslashMarkdown(wp_unslash($description))); | |
| 217 | + } | |
| 218 | + $sanitizedSettings[$type]['description'] = $description; | |
| 219 | + | |
| 196 | 220 | foreach ($typeSettings as $key => $value) { |
| 197 | - if (isset($rules[$key]) && !in_array($key, ['bannerVideo', 'bannerImage', 'ctaButtons'])) { | |
| 221 | + if (isset($rules[$key]) && !in_array($key, ['bannerVideo', 'bannerImage', 'ctaButtons', 'description'])) { | |
| 198 | 222 | $sanitizedSettings[$type][$key] = call_user_func($rules[$key], $value); |
| 199 | 223 | } |
| 200 | 224 | } |
| 201 | 225 | } |
| @@ -212,12 +236,13 @@ | ||
| 212 | 236 | return array_filter([ |
| 213 | 237 | 'type' => sanitize_text_field(Arr::get($video, 'type', '')), |
| 214 | 238 | 'url' => sanitize_url(Arr::get($video, 'url', '')), |
| 215 | 239 | 'content_type' => sanitize_text_field(Arr::get($video, 'content_type', '')), |
| 216 | - 'provider' => sanitize_url(Arr::get($video, 'provider', '')), | |
| 240 | + 'provider' => sanitize_text_field(Arr::get($video, 'provider', '')), | |
| 217 | 241 | 'title' => sanitize_text_field(Arr::get($video, 'title', '')), |
| 218 | 242 | 'author_name' => sanitize_text_field(Arr::get($video, 'author_name', '')), |
| 219 | 243 | 'html' => self::sanitizeRichText(Arr::get($video, 'html', '')), |
| 244 | + 'image' => sanitize_url(Arr::get($video, 'image', '')), | |
| 220 | 245 | ]); |
| 221 | 246 | } |
| 222 | 247 | |
| 223 | 248 | private static function sanitizeBannerImage($imageUrl) |
| @@ -228,10 +253,10 @@ | ||
| 228 | 253 | |
| 229 | 254 | $media = Helper::getMediaFromUrl($imageUrl); |
| 230 | 255 | if ($media) { |
| 231 | 256 | $media->update([ |
| 232 | - 'is_active' => true, | |
| 233 | - 'user_id' => get_current_user_id(), | |
| 257 | + 'is_active' => true, | |
| 258 | + 'user_id' => get_current_user_id(), | |
| 234 | 259 | 'object_source' => 'general' |
| 235 | 260 | ]); |
| 236 | 261 | return $media->public_url; |
| 237 | 262 | } |
| @@ -246,9 +271,11 @@ | ||
| 246 | 271 | } |
| 247 | 272 | |
| 248 | 273 | $sanitizerMap = [ |
| 249 | 274 | 'label' => 'sanitize_text_field', |
| 250 | - 'link' => 'sanitize_url', | |
| 275 | + 'link' => function ($url) { | |
| 276 | + return esc_url_raw($url, ['http', 'https', 'mailto']); | |
| 277 | + }, | |
| 251 | 278 | 'type' => 'sanitize_text_field', |
| 252 | 279 | 'newTab' => 'sanitize_text_field' |
| 253 | 280 | ]; |
| 254 | 281 | |
| @@ -264,9 +291,9 @@ | ||
| 264 | 291 | } |
| 265 | 292 | |
| 266 | 293 | public static function santizeLinkItem($item) |
| 267 | 294 | { |
| 268 | - $validKeys = ['title', 'enabled', 'new_tab', 'emoji', 'icon_image', 'shape_svg', 'title', 'permalink', 'slug']; | |
| 295 | + $validKeys = ['title', 'enabled', 'new_tab', 'emoji', 'icon_image', 'shape_svg', 'title', 'permalink', 'slug', 'privacy', 'membership_ids']; | |
| 269 | 296 | $item = array_filter(Arr::only($item, $validKeys)); |
| 270 | 297 | |
| 271 | 298 | $yesNoItems = ['enabled', 'new_tab', 'is_locked', 'is_unavailable']; |
| 272 | 299 | foreach ($yesNoItems as $key) { |
| @@ -277,9 +304,9 @@ | ||
| 277 | 304 | |
| 278 | 305 | $item['emoji'] = self::sanitizeEmoji(Arr::get($item, 'emoji')); |
| 279 | 306 | |
| 280 | 307 | if (empty($item['slug'])) { |
| 281 | - $item['slug'] = sanitize_title($item['title']); | |
| 308 | + $item['slug'] = sanitize_title(Arr::get($item, 'title', '')); | |
| 282 | 309 | } else { |
| 283 | 310 | $item['slug'] = sanitize_title($item['slug']); |
| 284 | 311 | } |
| 285 | 312 | |
| @@ -288,9 +315,9 @@ | ||
| 288 | 315 | if (isset($item[$key])) { |
| 289 | 316 | $item[$key] = sanitize_text_field($item[$key]); |
| 290 | 317 | } |
| 291 | 318 | } |
| 292 | - $item['permalink'] = sanitize_url($item['permalink']); | |
| 319 | + $item['permalink'] = sanitize_url(Arr::get($item, 'permalink', '')); | |
| 293 | 320 | |
| 294 | 321 | |
| 295 | 322 | if (!empty($item['icon_image'])) { |
| 296 | 323 | $media = Helper::getMediaFromUrl($item['icon_image']); |
| @@ -309,11 +336,163 @@ | ||
| 309 | 336 | if (!empty($item['icon_svg'])) { |
| 310 | 337 | $item['icon_svg'] = self::sanitizeSvg($item['icon_svg']); |
| 311 | 338 | } |
| 312 | 339 | |
| 340 | + if (!empty($item['shape_svg'])) { | |
| 341 | + $item['shape_svg'] = self::sanitizeSvg($item['shape_svg']); | |
| 342 | + } | |
| 343 | + | |
| 344 | + if (Arr::get($item, 'privacy') == 'members_only') { | |
| 345 | + $item['membership_ids'] = array_map('sanitize_text_field', (array)Arr::get($item, 'membership_ids', [])); | |
| 346 | + } | |
| 347 | + | |
| 313 | 348 | return array_filter($item); |
| 314 | 349 | } |
| 315 | 350 | |
| 351 | + /** | |
| 352 | + * @param array $items | |
| 353 | + * @return array | |
| 354 | + */ | |
| 355 | + public static function sanitizeSpaceMenuItems($items) | |
| 356 | + { | |
| 357 | + $sanitized = []; | |
| 358 | + $seen = []; | |
| 359 | + | |
| 360 | + foreach ((array)$items as $item) { | |
| 361 | + $menuItem = self::sanitizeSpaceMenuItem($item); | |
| 362 | + | |
| 363 | + if (!$menuItem || isset($seen[$menuItem['slug']])) { | |
| 364 | + continue; | |
| 365 | + } | |
| 366 | + | |
| 367 | + $seen[$menuItem['slug']] = true; | |
| 368 | + $sanitized[] = $menuItem; | |
| 369 | + } | |
| 370 | + | |
| 371 | + return $sanitized; | |
| 372 | + } | |
| 373 | + | |
| 374 | + /** | |
| 375 | + * One row of a space's primary menu. Returns null for a row that cannot be rendered — no | |
| 376 | + * slug, a custom row with no label, or a destination that survived neither the protocol | |
| 377 | + * allowlist nor the page lookup. | |
| 378 | + * | |
| 379 | + * @param array $item | |
| 380 | + * @return array|null | |
| 381 | + */ | |
| 382 | + public static function sanitizeSpaceMenuItem($item) | |
| 383 | + { | |
| 384 | + // `parent` is accepted and stored but nothing renders it yet. It holds a sibling row's | |
| 385 | + // slug for the one-level sub-menu, and keeping it on the write path now means that | |
| 386 | + // feature is additive rather than a migration of everyone's stored menu. | |
| 387 | + $validKeys = [ | |
| 388 | + 'slug', 'title', 'enabled', 'new_tab', 'emoji', 'icon_image', 'shape_svg', | |
| 389 | + 'permalink', 'page_slug', 'link_type', 'privacy', 'membership_ids', 'is_custom', 'parent', | |
| 390 | + ]; | |
| 391 | + | |
| 392 | + $item = Arr::only((array)$item, $validKeys); | |
| 393 | + | |
| 394 | + $isCustom = Arr::get($item, 'is_custom') === 'yes'; | |
| 395 | + | |
| 396 | + $slug = Utility::slugify(Arr::get($item, 'slug', '')); | |
| 397 | + | |
| 398 | + if ($isCustom) { | |
| 399 | + // Force the prefix so a custom row's slug can never hijack a real tab's slug. | |
| 400 | + if (strpos($slug, 'fcom_custom_') !== 0) { | |
| 401 | + $slug = 'fcom_custom_' . ($slug ?: substr(md5(wp_generate_password(12, false)), 0, 10)); | |
| 402 | + } | |
| 403 | + } elseif (!$slug) { | |
| 404 | + return null; | |
| 405 | + } | |
| 406 | + | |
| 407 | + $sanitized = [ | |
| 408 | + 'slug' => $slug, | |
| 409 | + 'title' => sanitize_text_field(Arr::get($item, 'title', '')), | |
| 410 | + 'enabled' => Arr::get($item, 'enabled') === 'no' ? 'no' : 'yes', | |
| 411 | + 'is_custom' => $isCustom ? 'yes' : 'no', | |
| 412 | + 'parent' => sanitize_title(Arr::get($item, 'parent', '')), | |
| 413 | + ]; | |
| 414 | + | |
| 415 | + $emoji = self::sanitizeEmoji(Arr::get($item, 'emoji', '')); | |
| 416 | + | |
| 417 | + if ($emoji) { | |
| 418 | + $sanitized['emoji'] = $emoji; | |
| 419 | + } | |
| 420 | + | |
| 421 | + $shapeSvg = self::sanitizeSvg(Arr::get($item, 'shape_svg', '')); | |
| 422 | + | |
| 423 | + if ($shapeSvg) { | |
| 424 | + $sanitized['shape_svg'] = $shapeSvg; | |
| 425 | + } | |
| 426 | + | |
| 427 | + $iconImage = Arr::get($item, 'icon_image'); | |
| 428 | + | |
| 429 | + if ($iconImage) { | |
| 430 | + $media = Helper::getMediaFromUrl($iconImage); | |
| 431 | + | |
| 432 | + if ($media) { | |
| 433 | + $media->update([ | |
| 434 | + 'is_active' => true, | |
| 435 | + 'user_id' => get_current_user_id(), | |
| 436 | + 'object_source' => 'general', | |
| 437 | + ]); | |
| 438 | + $sanitized['icon_image'] = $media->public_url; | |
| 439 | + } else { | |
| 440 | + $sanitized['icon_image'] = sanitize_url($iconImage); | |
| 441 | + } | |
| 442 | + } | |
| 443 | + | |
| 444 | + $privacy = Arr::get($item, 'privacy'); | |
| 445 | + | |
| 446 | + if (!in_array($privacy, ['public', 'logged_in', 'logged_out_only', 'members_only'], true)) { | |
| 447 | + $privacy = 'public'; | |
| 448 | + } | |
| 449 | + | |
| 450 | + $sanitized['privacy'] = $privacy; | |
| 451 | + | |
| 452 | + if ($privacy === 'members_only') { | |
| 453 | + $membershipIds = array_map('intval', (array)Arr::get($item, 'membership_ids', [])); | |
| 454 | + $sanitized['membership_ids'] = array_values(array_filter($membershipIds)); | |
| 455 | + } | |
| 456 | + | |
| 457 | + if (!$isCustom) { | |
| 458 | + return $sanitized; | |
| 459 | + } | |
| 460 | + | |
| 461 | + if (!$sanitized['title']) { | |
| 462 | + return null; | |
| 463 | + } | |
| 464 | + | |
| 465 | + $linkType = Arr::get($item, 'link_type') === 'space_page' ? 'space_page' : 'url'; | |
| 466 | + $sanitized['link_type'] = $linkType; | |
| 467 | + | |
| 468 | + if ($linkType === 'space_page') { | |
| 469 | + $pageSlug = sanitize_title(Arr::get($item, 'page_slug', '')); | |
| 470 | + | |
| 471 | + if (!$pageSlug) { | |
| 472 | + return null; | |
| 473 | + } | |
| 474 | + | |
| 475 | + $sanitized['page_slug'] = $pageSlug; | |
| 476 | + $sanitized['new_tab'] = 'no'; | |
| 477 | + | |
| 478 | + return $sanitized; | |
| 479 | + } | |
| 480 | + | |
| 481 | + // sanitize_url drops everything outside WordPress's protocol allowlist, so a | |
| 482 | + // javascript: destination comes back empty and the row is discarded. | |
| 483 | + $permalink = sanitize_url(Arr::get($item, 'permalink', '')); | |
| 484 | + | |
| 485 | + if (!$permalink) { | |
| 486 | + return null; | |
| 487 | + } | |
| 488 | + | |
| 489 | + $sanitized['permalink'] = $permalink; | |
| 490 | + $sanitized['new_tab'] = Arr::get($item, 'new_tab') === 'yes' ? 'yes' : 'no'; | |
| 491 | + | |
| 492 | + return $sanitized; | |
| 493 | + } | |
| 494 | + | |
| 316 | 495 | public static function sanitizeRichText($content, $print = false) |
| 317 | 496 | { |
| 318 | 497 | if ($print) { |
| 319 | 498 | echo self::sanitizeHtml($content); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped |
| @@ -337,18 +516,22 @@ | ||
| 337 | 516 | return $html; |
| 338 | 517 | } |
| 339 | 518 | |
| 340 | 519 | $tags = wp_kses_allowed_html('post'); |
| 341 | - $tags['style'] = [ | |
| 342 | - 'types' => [], | |
| 343 | - ]; | |
| 344 | 520 | |
| 345 | - // iframe | |
| 521 | + // No <style> element: kses filters style="" attributes but never the text content | |
| 522 | + // of a <style> block, so allowing it would let any role that can author this markup | |
| 523 | + // persist CSS (@import, attribute-selector data exfiltration, UI redress) against | |
| 524 | + // every viewer. Embed/media HTML never needs a <style> element. | |
| 525 | + | |
| 526 | + // iframe. Note there is deliberately no 'srcdoc' here: a srcdoc iframe without a | |
| 527 | + // sandbox attribute is same-origin with the portal, so allowing it would let any | |
| 528 | + // role that can author embed markup run script against every viewer. Real embed | |
| 529 | + // providers only ever use src. | |
| 346 | 530 | $tags['iframe'] = [ |
| 347 | 531 | 'width' => [], |
| 348 | 532 | 'height' => [], |
| 349 | 533 | 'src' => [], |
| 350 | - 'srcdoc' => [], | |
| 351 | 534 | 'title' => [], |
| 352 | 535 | 'frameborder' => [], |
| 353 | 536 | 'allow' => [], |
| 354 | 537 | 'class' => [], |
| @@ -354,9 +537,8 @@ | ||
| 354 | 537 | 'class' => [], |
| 355 | 538 | 'id' => [], |
| 356 | 539 | 'allowfullscreen' => [], |
| 357 | 540 | 'referrerpolicy' => [], |
| 358 | - 'style' => [], | |
| 359 | 541 | ]; |
| 360 | 542 | |
| 361 | 543 | $tags = apply_filters('fluent_community/allowed_html_tags', $tags); |
| 362 | 544 | |
| @@ -371,9 +553,8 @@ | ||
| 371 | 553 | $yesNoFields = ['com_my_post_mail', 'reply_my_com_mail', 'mention_mail', 'digest_email_status', 'disable_powered_by']; |
| 372 | 554 | $textFields = ['send_from_name', 'reply_to_name']; |
| 373 | 555 | $emailFields = ['send_from_email', 'reply_to_email']; |
| 374 | 556 | |
| 375 | - | |
| 376 | 557 | foreach ($yesNoFields as $field) { |
| 377 | 558 | if (isset($settings[$field])) { |
| 378 | 559 | $settings[$field] = $settings[$field] === 'yes' ? 'yes' : 'no'; |
| 379 | 560 | } |
| @@ -419,13 +600,23 @@ | ||
| 419 | 600 | $settings['daily_digest_time'] = $time; |
| 420 | 601 | $settings['email_footer'] = wp_kses_post(self::unslashMarkdown(Arr::get($settings, 'email_footer'))); |
| 421 | 602 | $settings['email_footer_rendered'] = FeedsHelper::mdToHtml($settings['email_footer']); |
| 422 | 603 | |
| 604 | + if (!empty($settings['logo'])) { | |
| 605 | + $settings['logo'] = sanitize_url($settings['logo']); | |
| 606 | + } | |
| 607 | + | |
| 423 | 608 | return $settings; |
| 424 | 609 | } |
| 425 | 610 | |
| 426 | 611 | public static function sanitizeUserName($username) |
| 427 | 612 | { |
| 613 | + $username = strtolower($username); | |
| 614 | + // check of @ symbol | |
| 615 | + if (strpos($username, '@') !== false) { | |
| 616 | + $username = explode('@', $username)[0]; | |
| 617 | + } | |
| 618 | + | |
| 428 | 619 | $username = sanitize_user($username); |
| 429 | 620 | $username = preg_replace('/[^a-zA-Z0-9_]/', '', $username); |
| 430 | 621 | return $username; |
| 431 | 622 | } |
| @@ -431,7 +622,92 @@ | ||
| 431 | 622 | } |
| 432 | 623 | |
| 433 | 624 | public static function unslashMarkdown($markdown) |
| 434 | 625 | { |
| 435 | - return str_replace(["\\\n", '\@', '\\_', '\\&', '\\*', '\\~'], ["\n", '@', '_', '&', '*', '~'], $markdown); | |
| 626 | + $replaceMaps = [ | |
| 627 | + "\\\n" => PHP_EOL, | |
| 628 | + '\@' => '@', | |
| 629 | + '\\_' => '_', | |
| 630 | + '\\&' => '&', | |
| 631 | + '\\*' => '*', | |
| 632 | + '\\~' => '~', | |
| 633 | + '\\:' => ':', | |
| 634 | + '\\.' => '.' | |
| 635 | + ]; | |
| 636 | + | |
| 637 | + return str_replace(array_keys($replaceMaps), array_values($replaceMaps), $markdown); | |
| 638 | + } | |
| 639 | + | |
| 640 | + public static function santizeSpaceSettings($settings = [], $privacy = 'public') | |
| 641 | + { | |
| 642 | + $yesNotFields = [ | |
| 643 | + 'restricted_post_only', | |
| 644 | + 'verified_post_only', | |
| 645 | + 'can_request_join', | |
| 646 | + 'show_paywalls', | |
| 647 | + 'show_sidebar', | |
| 648 | + 'hide_members_count', | |
| 649 | + 'document_library', | |
| 650 | + 'media_gallery', | |
| 651 | + 'disable_post_sort_by', | |
| 652 | + 'disable_layout_style' | |
| 653 | + ]; | |
| 654 | + | |
| 655 | + $settings = Arr::only($settings, array_keys((new Space())->defaultSettings())); | |
| 656 | + | |
| 657 | + foreach ($yesNotFields as $field) { | |
| 658 | + $settings[$field] = Arr::get($settings, $field) === 'yes' ? 'yes' : 'no'; | |
| 659 | + } | |
| 660 | + | |
| 661 | + $settings['shape_svg'] = self::sanitizeSvg(Arr::get($settings, 'shape_svg', '')); | |
| 662 | + if (empty($settings['shape_svg'])) { | |
| 663 | + $settings['emoji'] = self::sanitizeEmoji(Arr::get($settings, 'emoji', '')); | |
| 664 | + } else { | |
| 665 | + $settings['emoji'] = ''; | |
| 666 | + } | |
| 667 | + | |
| 668 | + $lockScreenType = Arr::get($settings, 'custom_lock_screen'); | |
| 669 | + if (!in_array($lockScreenType, ['yes', 'no', 'redirect']) || $privacy !== 'private') { | |
| 670 | + $lockScreenType = 'no'; | |
| 671 | + } | |
| 672 | + $settings['custom_lock_screen'] = $lockScreenType; | |
| 673 | + | |
| 674 | + | |
| 675 | + if ($lockScreenType === 'redirect') { | |
| 676 | + $redirectUrl = Arr::get($settings, 'onboard_redirect_url'); | |
| 677 | + if (!$redirectUrl || !filter_var($redirectUrl, FILTER_VALIDATE_URL)) { | |
| 678 | + return new \WP_Error('invalid_redirect_url', __('Invalid redirect URL.', 'fluent-community')); | |
| 679 | + } | |
| 680 | + $settings['onboard_redirect_url'] = sanitize_url($redirectUrl); | |
| 681 | + } | |
| 682 | + | |
| 683 | + $validOrderOptions = array_keys(Helper::getPostOrderOptions()); | |
| 684 | + $defaultOrder = Arr::get($settings, 'default_post_sort_by', ''); | |
| 685 | + $settings['default_post_sort_by'] = in_array($defaultOrder, $validOrderOptions) ? $defaultOrder : ''; | |
| 686 | + | |
| 687 | + $validCommentOrderOptions = array_keys(Helper::getCommentOrderOptions()); | |
| 688 | + $defaultCommentOrder = Arr::get($settings, 'default_comment_sort_by', ''); | |
| 689 | + $settings['default_comment_sort_by'] = in_array($defaultCommentOrder, $validCommentOrderOptions) ? $defaultCommentOrder : ''; | |
| 690 | + | |
| 691 | + $accessOptions = ['members_only', 'logged_in', 'everybody']; | |
| 692 | + $mediaAccess = Arr::get($settings, 'media_access'); | |
| 693 | + $settings['media_access'] = in_array($mediaAccess, $accessOptions, true) ? $mediaAccess : 'members_only'; | |
| 694 | + | |
| 695 | + $documentAccess = Arr::get($settings, 'document_access'); | |
| 696 | + $settings['document_access'] = in_array($documentAccess, $accessOptions, true) ? $documentAccess : 'members_only'; | |
| 697 | + | |
| 698 | + $documentUploadOptions = ['admin_only', 'members_only']; | |
| 699 | + $documentUpload = Arr::get($settings, 'document_upload'); | |
| 700 | + $settings['document_upload'] = in_array($documentUpload, $documentUploadOptions, true) ? $documentUpload : 'admin_only'; | |
| 701 | + | |
| 702 | + return $settings; | |
| 703 | + } | |
| 704 | + | |
| 705 | + public static function santizeEditorBody($body) | |
| 706 | + { | |
| 707 | + if (current_user_can('unfiltered_html')) { | |
| 708 | + return $body; | |
| 709 | + } | |
| 710 | + | |
| 711 | + return wp_kses_post($body); | |
| 436 | 712 | } |
| 437 | 713 | } |