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 +209 -20 2.4.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 }
@@ -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