| @@ -26,9 +26,9 @@ | ||
| 26 | 26 | $item[$key] = sanitize_text_field($item[$key]); |
| 27 | 27 | } |
| 28 | 28 | } |
| 29 | 29 | |
| 30 | - $item['permalink'] = sanitize_url($item['permalink']); | |
| 30 | + $item['permalink'] = sanitize_url(Arr::get($item, 'permalink', '')); | |
| 31 | 31 | |
| 32 | 32 | if (!empty($item['shape_svg'])) { |
| 33 | 33 | $item['shape_svg'] = self::sanitizeSvg($item['shape_svg']); |
| 34 | 34 | } |
| @@ -102,19 +102,33 @@ | ||
| 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 | |
| 112 | - if ($dom->documentElement) { | |
| 113 | - // Sanitize by removing unwanted tags and attributes | |
| 114 | - self::sanitizeNode($dom->documentElement, $allowed_tags); | |
| 124 | + if (!$loaded || !$dom->documentElement) { | |
| 125 | + return ''; | |
| 115 | 126 | } |
| 116 | 127 | |
| 128 | + // Sanitize by removing unwanted tags and attributes | |
| 129 | + self::sanitizeNode($dom->documentElement, $allowed_tags); | |
| 130 | + | |
| 117 | 131 | return $dom->saveXML($dom->documentElement); |
| 118 | 132 | } |
| 119 | 133 | |
| 120 | 134 | private static function sanitizeNode(\DOMNode $node, array $allowed_tags) |
| @@ -119,8 +133,9 @@ | ||
| 119 | 133 | |
| 120 | 134 | private static function sanitizeNode(\DOMNode $node, array $allowed_tags) |
| 121 | 135 | { |
| 122 | 136 | if ($node->nodeType === XML_ELEMENT_NODE) { |
| 137 | + /** @var \DOMElement $node */ | |
| 123 | 138 | if (!isset($allowed_tags[$node->nodeName])) { |
| 124 | 139 | $node->parentNode->removeChild($node); |
| 125 | 140 | return; |
| 126 | 141 | } |
| @@ -168,10 +183,12 @@ | ||
| 168 | 183 | } |
| 169 | 184 | return ''; |
| 170 | 185 | } |
| 171 | 186 | |
| 172 | - public static function sanitizeWelcomeBannerSettings($settings) | |
| 187 | + public static function sanitizeWelcomeBannerSettings($settings, $views = ['login', 'logout']) | |
| 173 | 188 | { |
| 189 | + $views = array_intersect($views, ['login', 'logout', 'enrolled', 'not_enrolled']); | |
| 190 | + | |
| 174 | 191 | $rules = [ |
| 175 | 192 | 'title' => 'sanitize_text_field', |
| 176 | 193 | 'description' => 'wp_kses_post', |
| 177 | 194 | 'mediaType' => 'sanitize_text_field', |
| @@ -179,9 +196,9 @@ | ||
| 179 | 196 | 'enabled' => 'sanitize_text_field', |
| 180 | 197 | ]; |
| 181 | 198 | |
| 182 | 199 | $sanitizedSettings = []; |
| 183 | - foreach (['login', 'logout'] as $type) { | |
| 200 | + foreach ($views as $type) { | |
| 184 | 201 | $typeSettings = Arr::get($settings, $type, []); |
| 185 | 202 | if (empty($typeSettings)) { |
| 186 | 203 | continue; |
| 187 | 204 | } |
| @@ -192,12 +209,17 @@ | ||
| 192 | 209 | |
| 193 | 210 | $sanitizedSettings[$type]['bannerVideo'] = self::sanitizeBannerVideo($bannerVideo); |
| 194 | 211 | $sanitizedSettings[$type]['bannerImage'] = self::sanitizeBannerImage($bannerImage); |
| 195 | 212 | $sanitizedSettings[$type]['ctaButtons'] = self::sanitizeCtaButtons($ctaButtons); |
| 196 | - $sanitizedSettings[$type]['description'] = Arr::get($typeSettings, 'description'); | |
| 197 | 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 | + | |
| 198 | 220 | foreach ($typeSettings as $key => $value) { |
| 199 | - if (isset($rules[$key]) && !in_array($key, ['bannerVideo', 'bannerImage', 'ctaButtons'])) { | |
| 221 | + if (isset($rules[$key]) && !in_array($key, ['bannerVideo', 'bannerImage', 'ctaButtons', 'description'])) { | |
| 200 | 222 | $sanitizedSettings[$type][$key] = call_user_func($rules[$key], $value); |
| 201 | 223 | } |
| 202 | 224 | } |
| 203 | 225 | } |
| @@ -214,12 +236,13 @@ | ||
| 214 | 236 | return array_filter([ |
| 215 | 237 | 'type' => sanitize_text_field(Arr::get($video, 'type', '')), |
| 216 | 238 | 'url' => sanitize_url(Arr::get($video, 'url', '')), |
| 217 | 239 | 'content_type' => sanitize_text_field(Arr::get($video, 'content_type', '')), |
| 218 | - 'provider' => sanitize_url(Arr::get($video, 'provider', '')), | |
| 240 | + 'provider' => sanitize_text_field(Arr::get($video, 'provider', '')), | |
| 219 | 241 | 'title' => sanitize_text_field(Arr::get($video, 'title', '')), |
| 220 | 242 | 'author_name' => sanitize_text_field(Arr::get($video, 'author_name', '')), |
| 221 | 243 | 'html' => self::sanitizeRichText(Arr::get($video, 'html', '')), |
| 244 | + 'image' => sanitize_url(Arr::get($video, 'image', '')), | |
| 222 | 245 | ]); |
| 223 | 246 | } |
| 224 | 247 | |
| 225 | 248 | private static function sanitizeBannerImage($imageUrl) |
| @@ -248,9 +271,11 @@ | ||
| 248 | 271 | } |
| 249 | 272 | |
| 250 | 273 | $sanitizerMap = [ |
| 251 | 274 | 'label' => 'sanitize_text_field', |
| 252 | - 'link' => 'sanitize_url', | |
| 275 | + 'link' => function ($url) { | |
| 276 | + return esc_url_raw($url, ['http', 'https', 'mailto']); | |
| 277 | + }, | |
| 253 | 278 | 'type' => 'sanitize_text_field', |
| 254 | 279 | 'newTab' => 'sanitize_text_field' |
| 255 | 280 | ]; |
| 256 | 281 | |
| @@ -279,9 +304,9 @@ | ||
| 279 | 304 | |
| 280 | 305 | $item['emoji'] = self::sanitizeEmoji(Arr::get($item, 'emoji')); |
| 281 | 306 | |
| 282 | 307 | if (empty($item['slug'])) { |
| 283 | - $item['slug'] = sanitize_title($item['title']); | |
| 308 | + $item['slug'] = sanitize_title(Arr::get($item, 'title', '')); | |
| 284 | 309 | } else { |
| 285 | 310 | $item['slug'] = sanitize_title($item['slug']); |
| 286 | 311 | } |
| 287 | 312 | |
| @@ -290,9 +315,9 @@ | ||
| 290 | 315 | if (isset($item[$key])) { |
| 291 | 316 | $item[$key] = sanitize_text_field($item[$key]); |
| 292 | 317 | } |
| 293 | 318 | } |
| 294 | - $item['permalink'] = sanitize_url($item['permalink']); | |
| 319 | + $item['permalink'] = sanitize_url(Arr::get($item, 'permalink', '')); | |
| 295 | 320 | |
| 296 | 321 | |
| 297 | 322 | if (!empty($item['icon_image'])) { |
| 298 | 323 | $media = Helper::getMediaFromUrl($item['icon_image']); |
| @@ -310,9 +335,13 @@ | ||
| 310 | 335 | |
| 311 | 336 | if (!empty($item['icon_svg'])) { |
| 312 | 337 | $item['icon_svg'] = self::sanitizeSvg($item['icon_svg']); |
| 313 | 338 | } |
| 314 | - | |
| 339 | + | |
| 340 | + if (!empty($item['shape_svg'])) { | |
| 341 | + $item['shape_svg'] = self::sanitizeSvg($item['shape_svg']); | |
| 342 | + } | |
| 343 | + | |
| 315 | 344 | if (Arr::get($item, 'privacy') == 'members_only') { |
| 316 | 345 | $item['membership_ids'] = array_map('sanitize_text_field', (array)Arr::get($item, 'membership_ids', [])); |
| 317 | 346 | } |
| 318 | 347 | |
| @@ -318,8 +347,152 @@ | ||
| 318 | 347 | |
| 319 | 348 | return array_filter($item); |
| 320 | 349 | } |
| 321 | 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 | + | |
| 322 | 495 | public static function sanitizeRichText($content, $print = false) |
| 323 | 496 | { |
| 324 | 497 | if ($print) { |
| 325 | 498 | echo self::sanitizeHtml($content); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped |
| @@ -343,18 +516,22 @@ | ||
| 343 | 516 | return $html; |
| 344 | 517 | } |
| 345 | 518 | |
| 346 | 519 | $tags = wp_kses_allowed_html('post'); |
| 347 | - $tags['style'] = [ | |
| 348 | - 'types' => [], | |
| 349 | - ]; | |
| 350 | 520 | |
| 351 | - // 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. | |
| 352 | 530 | $tags['iframe'] = [ |
| 353 | 531 | 'width' => [], |
| 354 | 532 | 'height' => [], |
| 355 | 533 | 'src' => [], |
| 356 | - 'srcdoc' => [], | |
| 357 | 534 | 'title' => [], |
| 358 | 535 | 'frameborder' => [], |
| 359 | 536 | 'allow' => [], |
| 360 | 537 | 'class' => [], |
| @@ -360,9 +537,8 @@ | ||
| 360 | 537 | 'class' => [], |
| 361 | 538 | 'id' => [], |
| 362 | 539 | 'allowfullscreen' => [], |
| 363 | 540 | 'referrerpolicy' => [], |
| 364 | - 'style' => [], | |
| 365 | 541 | ]; |
| 366 | 542 | |
| 367 | 543 | $tags = apply_filters('fluent_community/allowed_html_tags', $tags); |
| 368 | 544 | |
| @@ -464,13 +640,15 @@ | ||
| 464 | 640 | public static function santizeSpaceSettings($settings = [], $privacy = 'public') |
| 465 | 641 | { |
| 466 | 642 | $yesNotFields = [ |
| 467 | 643 | 'restricted_post_only', |
| 644 | + 'verified_post_only', | |
| 468 | 645 | 'can_request_join', |
| 469 | 646 | 'show_paywalls', |
| 470 | 647 | 'show_sidebar', |
| 471 | 648 | 'hide_members_count', |
| 472 | 649 | 'document_library', |
| 650 | + 'media_gallery', | |
| 473 | 651 | 'disable_post_sort_by', |
| 474 | 652 | 'disable_layout_style' |
| 475 | 653 | ]; |
| 476 | 654 | |
| @@ -508,8 +686,19 @@ | ||
| 508 | 686 | |
| 509 | 687 | $validCommentOrderOptions = array_keys(Helper::getCommentOrderOptions()); |
| 510 | 688 | $defaultCommentOrder = Arr::get($settings, 'default_comment_sort_by', ''); |
| 511 | 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'; | |
| 512 | 701 | |
| 513 | 702 | return $settings; |
| 514 | 703 | } |
| 515 | 704 | |