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 +162 -4 2.10.012.11.0 View file →
@@ -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)
@@ -331,8 +345,152 @@
331 345 $item['membership_ids'] = array_map('sanitize_text_field', (array)Arr::get($item, 'membership_ids', []));
332 346 }
333 347
334 348 return array_filter($item);
349 + }
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;
335 493 }
336 494
337 495 public static function sanitizeRichText($content, $print = false)
338 496 {