PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.11.0
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.11.0
2.11.0 2.10.0 2.10.01 2.9.1 2.9.0 2.8.1 2.8.0 2.7.7 2.7.5 2.7.0 2.6.01 2.6.0 2.5.0 2.4.01 trunk 1.0.90 1.0.91 1.0.92 1.0.93 1.0.94 1.0.95 1.0.96 1.0.97 1.0.98 1.0.99 All 78 releases
← All changes | app/Services/CustomSanitizer.php +193 -14 2.6.012.11.0 View file →
@@ -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 }
@@ -289,9 +304,9 @@
289 304
290 305 $item['emoji'] = self::sanitizeEmoji(Arr::get($item, 'emoji'));
291 306
292 307 if (empty($item['slug'])) {
293 - $item['slug'] = sanitize_title($item['title']);
308 + $item['slug'] = sanitize_title(Arr::get($item, 'title', ''));
294 309 } else {
295 310 $item['slug'] = sanitize_title($item['slug']);
296 311 }
297 312
@@ -300,9 +315,9 @@
300 315 if (isset($item[$key])) {
301 316 $item[$key] = sanitize_text_field($item[$key]);
302 317 }
303 318 }
304 - $item['permalink'] = sanitize_url($item['permalink']);
319 + $item['permalink'] = sanitize_url(Arr::get($item, 'permalink', ''));
305 320
306 321
307 322 if (!empty($item['icon_image'])) {
308 323 $media = Helper::getMediaFromUrl($item['icon_image']);
@@ -320,9 +335,13 @@
320 335
321 336 if (!empty($item['icon_svg'])) {
322 337 $item['icon_svg'] = self::sanitizeSvg($item['icon_svg']);
323 338 }
324 -
339 +
340 + if (!empty($item['shape_svg'])) {
341 + $item['shape_svg'] = self::sanitizeSvg($item['shape_svg']);
342 + }
343 +
325 344 if (Arr::get($item, 'privacy') == 'members_only') {
326 345 $item['membership_ids'] = array_map('sanitize_text_field', (array)Arr::get($item, 'membership_ids', []));
327 346 }
328 347
@@ -328,8 +347,152 @@
328 347
329 348 return array_filter($item);
330 349 }
331 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 +
332 495 public static function sanitizeRichText($content, $print = false)
333 496 {
334 497 if ($print) {
335 498 echo self::sanitizeHtml($content); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
@@ -353,18 +516,22 @@
353 516 return $html;
354 517 }
355 518
356 519 $tags = wp_kses_allowed_html('post');
357 - $tags['style'] = [
358 - 'types' => [],
359 - ];
360 520
361 - // 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.
362 530 $tags['iframe'] = [
363 531 'width' => [],
364 532 'height' => [],
365 533 'src' => [],
366 - 'srcdoc' => [],
367 534 'title' => [],
368 535 'frameborder' => [],
369 536 'allow' => [],
370 537 'class' => [],
@@ -370,9 +537,8 @@
370 537 'class' => [],
371 538 'id' => [],
372 539 'allowfullscreen' => [],
373 540 'referrerpolicy' => [],
374 - 'style' => [],
375 541 ];
376 542
377 543 $tags = apply_filters('fluent_community/allowed_html_tags', $tags);
378 544
@@ -474,13 +640,15 @@
474 640 public static function santizeSpaceSettings($settings = [], $privacy = 'public')
475 641 {
476 642 $yesNotFields = [
477 643 'restricted_post_only',
644 + 'verified_post_only',
478 645 'can_request_join',
479 646 'show_paywalls',
480 647 'show_sidebar',
481 648 'hide_members_count',
482 649 'document_library',
650 + 'media_gallery',
483 651 'disable_post_sort_by',
484 652 'disable_layout_style'
485 653 ];
486 654
@@ -518,8 +686,19 @@
518 686
519 687 $validCommentOrderOptions = array_keys(Helper::getCommentOrderOptions());
520 688 $defaultCommentOrder = Arr::get($settings, 'default_comment_sort_by', '');
521 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';
522 701
523 702 return $settings;
524 703 }
525 704