PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.7.7
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.7.7
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 1.1.0 All 77 releases
fluent-community / app / Services / CustomSanitizer.php

CustomSanitizer.php in FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses 2.7.7, at app/Services/CustomSanitizer.php

555 lines 21.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCommunity\App\Services;
4
5 use FluentCommunity\App\Functions\Utility;
6 use FluentCommunity\App\Models\Space;
7 use FluentCommunity\Framework\Support\Arr;
8
9 class CustomSanitizer
10 {
11 public static function sanitizeMenuLink($item)
12 {
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'];
14 $item = array_filter(Arr::only($item, $validKeys));
15
16 $yesNoItems = ['enabled', 'is_custom', 'is_system', 'is_locked', 'is_unavailable'];
17 foreach ($yesNoItems as $key) {
18 if (isset($item[$key])) {
19 $item[$key] = $item[$key] === 'yes' ? 'yes' : 'no';
20 }
21 }
22
23 $textTypes = ['title', 'new_tab', 'link_classes', 'slug', 'privacy'];
24 foreach ($textTypes as $key) {
25 if (isset($item[$key])) {
26 $item[$key] = sanitize_text_field($item[$key]);
27 }
28 }
29
30 $item['permalink'] = sanitize_url(Arr::get($item, 'permalink', ''));
31
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']);
51 }
52 }
53
54 if (Arr::get($item, 'privacy') == 'members_only') {
55 $item['membership_ids'] = array_map('sanitize_text_field', (array)Arr::get($item, 'membership_ids', []));
56 }
57
58 return $item;
59 }
60
61 public static function sanitizeSvg($svg_content)
62 {
63 if (!$svg_content) {
64 return '';
65 }
66
67 if (current_user_can('unfiltered_html')) {
68 return $svg_content;
69 }
70
71 // Remove any comments
72 $svg_content = preg_replace('/<!--(.|\s)*?-->/', '', $svg_content);
73
74 // Remove XML or DOCTYPE declarations
75 $svg_content = preg_replace('/<\?xml(.|\s)*?\?>/', '', $svg_content);
76 $svg_content = preg_replace('/<!DOCTYPE(.|\s)*?>/i', '', $svg_content);
77
78 // Remove embedded scripts, iframes, or event handlers
79 $svg_content = preg_replace('/<script\b[^>]*>(.*?)<\/script>/is', '', $svg_content);
80 $svg_content = preg_replace('/<iframe\b[^>]*>(.*?)<\/iframe>/is', '', $svg_content);
81 $svg_content = preg_replace('/on\w+="[^"]*"/i', '', $svg_content);
82
83 $allowed_tags = [
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 '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, '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 'circle' => ['cx' => true, 'cy' => true, 'r' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'transform' => true],
89 'ellipse' => ['cx' => true, 'cy' => true, 'rx' => true, 'ry' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'transform' => true],
90 'line' => ['x1' => true, 'x2' => true, 'y1' => true, 'y2' => true, 'stroke' => true, 'stroke-width' => true, 'transform' => true],
91 'polyline' => ['points' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'transform' => true],
92 'polygon' => ['points' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'transform' => true],
93 'text' => ['x' => true, 'y' => true, 'font-size' => true, 'font-family' => true, 'text-anchor' => true, 'fill' => true, 'transform' => true],
94 'tspan' => ['x' => true, 'y' => true, 'font-size' => true, 'font-family' => true, 'text-anchor' => true, 'fill' => true],
95 'defs' => [],
96 'clipPath' => ['id' => true, 'clipPathUnits' => true],
97 'stop' => ['offset' => true, 'stop-color' => true, 'stop-opacity' => true],
98 'linearGradient' => ['id' => true, 'x1' => true, 'y1' => true, 'x2' => true, 'y2' => true, 'gradientUnits' => true, 'gradientTransform' => true],
99 'radialGradient' => ['id' => true, 'cx' => true, 'cy' => true, 'r' => true, 'fx' => true, 'fy' => true, 'gradientUnits' => true, 'gradientTransform' => true],
100 'mask' => ['id' => true, 'maskUnits' => true, 'maskContentUnits' => true, 'x' => true, 'y' => true, 'width' => true, 'height' => true],
101 'use' => ['x' => true, 'y' => true, 'width' => true, 'height' => true],
102 'title' => [],
103 'desc' => [],
104 ];
105
106 // Load the SVG string into a DOMDocument and discard errors for malformed XML
107 $dom = new \DOMDocument();
108 libxml_use_internal_errors(true);
109 $dom->loadXML($svg_content);
110 libxml_clear_errors();
111
112 if ($dom->documentElement) {
113 // Sanitize by removing unwanted tags and attributes
114 self::sanitizeNode($dom->documentElement, $allowed_tags);
115 }
116
117 return $dom->saveXML($dom->documentElement);
118 }
119
120 private static function sanitizeNode(\DOMNode $node, array $allowed_tags)
121 {
122 if ($node->nodeType === XML_ELEMENT_NODE) {
123 /** @var \DOMElement $node */
124 if (!isset($allowed_tags[$node->nodeName])) {
125 $node->parentNode->removeChild($node);
126 return;
127 }
128
129 // Check attributes
130 $attributes = $node->attributes;
131 $length = $attributes->length;
132 for ($i = $length - 1; $i >= 0; $i--) {
133 $attr = $attributes->item($i);
134 $attr_name = $attr->nodeName;
135 if (!isset($allowed_tags[$node->nodeName][$attr_name])) {
136 $node->removeAttribute($attr_name);
137 } else {
138 // Sanitize attribute values
139 // FILTER_SANITIZE_STRING is deprecated in PHP 8.1
140 $sanitized_value = htmlspecialchars($attr->nodeValue, ENT_QUOTES, 'UTF-8');
141 $node->setAttribute($attr_name, $sanitized_value);
142 }
143 }
144 }
145
146 // Recursively sanitize child nodes
147 for ($i = $node->childNodes->length - 1; $i >= 0; $i--) {
148 self::sanitizeNode($node->childNodes->item($i), $allowed_tags);
149 }
150 }
151
152 public static function sanitizeEmoji($emoji, $single = true)
153 {
154 $emoji = (string)$emoji;
155 $emoji = trim($emoji);
156
157 if (!$emoji) {
158 return '';
159 }
160
161 if ($single && function_exists('\mb_substr')) {
162 $emoji = \mb_substr($emoji, 0, 4, 'UTF-8');
163 }
164
165 $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);
166
167 if ($isEmoji) {
168 return $emoji;
169 }
170 return '';
171 }
172
173 public static function sanitizeWelcomeBannerSettings($settings, $views = ['login', 'logout'])
174 {
175 $views = array_intersect($views, ['login', 'logout', 'enrolled', 'not_enrolled']);
176
177 $rules = [
178 'title' => 'sanitize_text_field',
179 'description' => 'wp_kses_post',
180 'mediaType' => 'sanitize_text_field',
181 'allowClose' => 'sanitize_text_field',
182 'enabled' => 'sanitize_text_field',
183 ];
184
185 $sanitizedSettings = [];
186 foreach ($views as $type) {
187 $typeSettings = Arr::get($settings, $type, []);
188 if (empty($typeSettings)) {
189 continue;
190 }
191
192 $bannerVideo = Arr::get($typeSettings, 'bannerVideo', []);
193 $bannerImage = Arr::get($typeSettings, 'bannerImage', '');
194 $ctaButtons = Arr::get($typeSettings, 'ctaButtons', []);
195
196 $sanitizedSettings[$type]['bannerVideo'] = self::sanitizeBannerVideo($bannerVideo);
197 $sanitizedSettings[$type]['bannerImage'] = self::sanitizeBannerImage($bannerImage);
198 $sanitizedSettings[$type]['ctaButtons'] = self::sanitizeCtaButtons($ctaButtons);
199
200 $description = Arr::get($typeSettings, 'description');
201 if (!empty($description)) {
202 $description = wp_kses_post(self::unslashMarkdown(wp_unslash($description)));
203 }
204 $sanitizedSettings[$type]['description'] = $description;
205
206 foreach ($typeSettings as $key => $value) {
207 if (isset($rules[$key]) && !in_array($key, ['bannerVideo', 'bannerImage', 'ctaButtons', 'description'])) {
208 $sanitizedSettings[$type][$key] = call_user_func($rules[$key], $value);
209 }
210 }
211 }
212
213 return $sanitizedSettings;
214 }
215
216 private static function sanitizeBannerVideo($video)
217 {
218 if (empty($video)) {
219 return [];
220 }
221
222 return array_filter([
223 'type' => sanitize_text_field(Arr::get($video, 'type', '')),
224 'url' => sanitize_url(Arr::get($video, 'url', '')),
225 'content_type' => sanitize_text_field(Arr::get($video, 'content_type', '')),
226 'provider' => sanitize_text_field(Arr::get($video, 'provider', '')),
227 'title' => sanitize_text_field(Arr::get($video, 'title', '')),
228 'author_name' => sanitize_text_field(Arr::get($video, 'author_name', '')),
229 'html' => self::sanitizeRichText(Arr::get($video, 'html', '')),
230 'image' => sanitize_url(Arr::get($video, 'image', '')),
231 ]);
232 }
233
234 private static function sanitizeBannerImage($imageUrl)
235 {
236 if (empty($imageUrl)) {
237 return '';
238 }
239
240 $media = Helper::getMediaFromUrl($imageUrl);
241 if ($media) {
242 $media->update([
243 'is_active' => true,
244 'user_id' => get_current_user_id(),
245 'object_source' => 'general'
246 ]);
247 return $media->public_url;
248 }
249
250 return sanitize_url($imageUrl);
251 }
252
253 private static function sanitizeCtaButtons($ctaButtons)
254 {
255 if (empty($ctaButtons)) {
256 return [];
257 }
258
259 $sanitizerMap = [
260 'label' => 'sanitize_text_field',
261 'link' => function ($url) {
262 return esc_url_raw($url, ['http', 'https', 'mailto']);
263 },
264 'type' => 'sanitize_text_field',
265 'newTab' => 'sanitize_text_field'
266 ];
267
268 foreach ($ctaButtons as $btnKey => $btnValue) {
269 foreach ($btnValue as $key => $value) {
270 if (isset($sanitizerMap[$key])) {
271 $ctaButtons[$btnKey][$key] = call_user_func($sanitizerMap[$key], $value);
272 }
273 }
274 }
275
276 return $ctaButtons;
277 }
278
279 public static function santizeLinkItem($item)
280 {
281 $validKeys = ['title', 'enabled', 'new_tab', 'emoji', 'icon_image', 'shape_svg', 'title', 'permalink', 'slug', 'privacy', 'membership_ids'];
282 $item = array_filter(Arr::only($item, $validKeys));
283
284 $yesNoItems = ['enabled', 'new_tab', 'is_locked', 'is_unavailable'];
285 foreach ($yesNoItems as $key) {
286 if (isset($item[$key])) {
287 $item[$key] = $item[$key] === 'yes' ? 'yes' : 'no';
288 }
289 }
290
291 $item['emoji'] = self::sanitizeEmoji(Arr::get($item, 'emoji'));
292
293 if (empty($item['slug'])) {
294 $item['slug'] = sanitize_title(Arr::get($item, 'title', ''));
295 } else {
296 $item['slug'] = sanitize_title($item['slug']);
297 }
298
299 $textTypes = ['title'];
300 foreach ($textTypes as $key) {
301 if (isset($item[$key])) {
302 $item[$key] = sanitize_text_field($item[$key]);
303 }
304 }
305 $item['permalink'] = sanitize_url(Arr::get($item, 'permalink', ''));
306
307
308 if (!empty($item['icon_image'])) {
309 $media = Helper::getMediaFromUrl($item['icon_image']);
310 if ($media) {
311 $media->update([
312 'is_active' => true,
313 'user_id' => get_current_user_id(),
314 'object_source' => 'general'
315 ]);
316 $item['icon_image'] = $media->public_url;
317 } else {
318 $item['icon_image'] = sanitize_text_field($item['icon_image']);
319 }
320 }
321
322 if (!empty($item['icon_svg'])) {
323 $item['icon_svg'] = self::sanitizeSvg($item['icon_svg']);
324 }
325
326 if (!empty($item['shape_svg'])) {
327 $item['shape_svg'] = self::sanitizeSvg($item['shape_svg']);
328 }
329
330 if (Arr::get($item, 'privacy') == 'members_only') {
331 $item['membership_ids'] = array_map('sanitize_text_field', (array)Arr::get($item, 'membership_ids', []));
332 }
333
334 return array_filter($item);
335 }
336
337 public static function sanitizeRichText($content, $print = false)
338 {
339 if ($print) {
340 echo self::sanitizeHtml($content); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
341 }
342
343 return self::sanitizeHtml($content);
344 }
345
346 public static function sanitizeHtml($html)
347 {
348 if (current_user_can('unfiltered_html')) {
349 return $html;
350 }
351
352 if (!$html) {
353 return $html;
354 }
355
356 // Return $html if it's just a plain text
357 if (!preg_match('/<[^>]*>/', $html)) {
358 return $html;
359 }
360
361 $tags = wp_kses_allowed_html('post');
362
363 // No <style> element: kses filters style="" attributes but never the text content
364 // of a <style> block, so allowing it would let any role that can author this markup
365 // persist CSS (@import, attribute-selector data exfiltration, UI redress) against
366 // every viewer. Embed/media HTML never needs a <style> element.
367
368 // iframe. Note there is deliberately no 'srcdoc' here: a srcdoc iframe without a
369 // sandbox attribute is same-origin with the portal, so allowing it would let any
370 // role that can author embed markup run script against every viewer. Real embed
371 // providers only ever use src.
372 $tags['iframe'] = [
373 'width' => [],
374 'height' => [],
375 'src' => [],
376 'title' => [],
377 'frameborder' => [],
378 'allow' => [],
379 'class' => [],
380 'id' => [],
381 'allowfullscreen' => [],
382 'referrerpolicy' => [],
383 ];
384
385 $tags = apply_filters('fluent_community/allowed_html_tags', $tags);
386
387 return wp_kses($html, $tags);
388 }
389
390 public static function santizeEmailSettings($settings)
391 {
392 $prevSettings = Utility::getEmailNotificationSettings();
393 $settings = Arr::only($settings, array_keys($prevSettings));
394
395 $yesNoFields = ['com_my_post_mail', 'reply_my_com_mail', 'mention_mail', 'digest_email_status', 'disable_powered_by'];
396 $textFields = ['send_from_name', 'reply_to_name'];
397 $emailFields = ['send_from_email', 'reply_to_email'];
398
399 foreach ($yesNoFields as $field) {
400 if (isset($settings[$field])) {
401 $settings[$field] = $settings[$field] === 'yes' ? 'yes' : 'no';
402 }
403 }
404
405 foreach ($textFields as $field) {
406 if (isset($settings[$field])) {
407 $settings[$field] = sanitize_text_field($settings[$field]);
408 }
409 }
410
411 foreach ($emailFields as $field) {
412 if (isset($settings[$field])) {
413 $settings[$field] = sanitize_email($settings[$field]);
414 }
415 }
416
417 $time = Arr::get($settings, 'daily_digest_time');
418
419 if ($time) {
420 $time = sanitize_text_field($time);
421
422 // check time is valid or not
423 if (!preg_match('/^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$/', $time)) {
424 $time = '09:00';
425 }
426 } else {
427 $time = '09:00';
428 }
429
430 $emailDay = Arr::get($settings, 'digest_mail_day');
431
432 if ($emailDay) {
433 $emailDay = sanitize_text_field($emailDay);
434 if (!in_array($emailDay, ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'])) {
435 $emailDay = 'tue';
436 }
437 } else {
438 $emailDay = 'tue';
439 }
440
441 $settings['digest_mail_day'] = $emailDay;
442 $settings['daily_digest_time'] = $time;
443 $settings['email_footer'] = wp_kses_post(self::unslashMarkdown(Arr::get($settings, 'email_footer')));
444 $settings['email_footer_rendered'] = FeedsHelper::mdToHtml($settings['email_footer']);
445
446 if (!empty($settings['logo'])) {
447 $settings['logo'] = sanitize_url($settings['logo']);
448 }
449
450 return $settings;
451 }
452
453 public static function sanitizeUserName($username)
454 {
455 $username = strtolower($username);
456 // check of @ symbol
457 if (strpos($username, '@') !== false) {
458 $username = explode('@', $username)[0];
459 }
460
461 $username = sanitize_user($username);
462 $username = preg_replace('/[^a-zA-Z0-9_]/', '', $username);
463 return $username;
464 }
465
466 public static function unslashMarkdown($markdown)
467 {
468 $replaceMaps = [
469 "\\\n" => PHP_EOL,
470 '\@' => '@',
471 '\\_' => '_',
472 '\\&' => '&',
473 '\\*' => '*',
474 '\\~' => '~',
475 '\\:' => ':',
476 '\\.' => '.'
477 ];
478
479 return str_replace(array_keys($replaceMaps), array_values($replaceMaps), $markdown);
480 }
481
482 public static function santizeSpaceSettings($settings = [], $privacy = 'public')
483 {
484 $yesNotFields = [
485 'restricted_post_only',
486 'can_request_join',
487 'show_paywalls',
488 'show_sidebar',
489 'hide_members_count',
490 'document_library',
491 'media_gallery',
492 'disable_post_sort_by',
493 'disable_layout_style'
494 ];
495
496 $settings = Arr::only($settings, array_keys((new Space())->defaultSettings()));
497
498 foreach ($yesNotFields as $field) {
499 $settings[$field] = Arr::get($settings, $field) === 'yes' ? 'yes' : 'no';
500 }
501
502 $settings['shape_svg'] = self::sanitizeSvg(Arr::get($settings, 'shape_svg', ''));
503 if (empty($settings['shape_svg'])) {
504 $settings['emoji'] = self::sanitizeEmoji(Arr::get($settings, 'emoji', ''));
505 } else {
506 $settings['emoji'] = '';
507 }
508
509 $lockScreenType = Arr::get($settings, 'custom_lock_screen');
510 if (!in_array($lockScreenType, ['yes', 'no', 'redirect']) || $privacy !== 'private') {
511 $lockScreenType = 'no';
512 }
513 $settings['custom_lock_screen'] = $lockScreenType;
514
515
516 if ($lockScreenType === 'redirect') {
517 $redirectUrl = Arr::get($settings, 'onboard_redirect_url');
518 if (!$redirectUrl || !filter_var($redirectUrl, FILTER_VALIDATE_URL)) {
519 return new \WP_Error('invalid_redirect_url', __('Invalid redirect URL.', 'fluent-community'));
520 }
521 $settings['onboard_redirect_url'] = sanitize_url($redirectUrl);
522 }
523
524 $validOrderOptions = array_keys(Helper::getPostOrderOptions());
525 $defaultOrder = Arr::get($settings, 'default_post_sort_by', '');
526 $settings['default_post_sort_by'] = in_array($defaultOrder, $validOrderOptions) ? $defaultOrder : '';
527
528 $validCommentOrderOptions = array_keys(Helper::getCommentOrderOptions());
529 $defaultCommentOrder = Arr::get($settings, 'default_comment_sort_by', '');
530 $settings['default_comment_sort_by'] = in_array($defaultCommentOrder, $validCommentOrderOptions) ? $defaultCommentOrder : '';
531
532 $accessOptions = ['members_only', 'logged_in', 'everybody'];
533 $mediaAccess = Arr::get($settings, 'media_access');
534 $settings['media_access'] = in_array($mediaAccess, $accessOptions, true) ? $mediaAccess : 'members_only';
535
536 $documentAccess = Arr::get($settings, 'document_access');
537 $settings['document_access'] = in_array($documentAccess, $accessOptions, true) ? $documentAccess : 'members_only';
538
539 $documentUploadOptions = ['admin_only', 'members_only'];
540 $documentUpload = Arr::get($settings, 'document_upload');
541 $settings['document_upload'] = in_array($documentUpload, $documentUploadOptions, true) ? $documentUpload : 'admin_only';
542
543 return $settings;
544 }
545
546 public static function santizeEditorBody($body)
547 {
548 if (current_user_can('unfiltered_html')) {
549 return $body;
550 }
551
552 return wp_kses_post($body);
553 }
554 }
555