Issues (2160)

main/inc/lib/security.lib.php (2 issues)

1
<?php
2
3
/* For licensing terms, see /license.txt */
4
5
use Chamilo\CoreBundle\Component\HTMLPurifier\Filter\AllowIframes;
6
use Chamilo\CoreBundle\Component\HTMLPurifier\Filter\RemoveOnAttributes;
7
use ChamiloSession as Session;
8
9
/**
10
 * This is the security library for Chamilo.
11
 *
12
 * This library is based on recommendations found in the PHP5 Certification
13
 * Guide published at PHP|Architect, and other recommendations found on
14
 * http://www.phpsec.org/
15
 * The principles here are that all data is tainted (most scripts of Chamilo are
16
 * open to the public or at least to a certain public that could be malicious
17
 * under specific circumstances). We use the white list approach, whereas we
18
 * consider that data can only be used in the database or in a file if it has
19
 * been filtered.
20
 *
21
 * For session fixation, use ...
22
 * For session hijacking, use get_ua() and check_ua()
23
 * For Cross-Site Request Forgeries, use get_token() and check_token()
24
 * For basic filtering, use filter()
25
 * For files inclusions (using dynamic paths) use check_rel_path() and check_abs_path()
26
 *
27
 * @author Yannick Warnier <[email protected]>
28
 */
29
30
/**
31
 * Security class.
32
 *
33
 * Include/require it in your code and call Security::function()
34
 * to use its functionalities.
35
 *
36
 * This class can also be used as a container for filtered data, by creating
37
 * a new Security object and using $secure->filter($new_var,[more options])
38
 * and then using $secure->clean['var'] as a filtered equivalent, although
39
 * this is *not* mandatory at all.
40
 */
41
class Security
42
{
43
    public const CHAR_UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
44
    public const CHAR_LOWER = 'abcdefghijklmnopqrstuvwxyz';
45
    public const CHAR_DIGITS = '0123456789';
46
    public const CHAR_SYMBOLS = '!"#$%&\'()*+,-./:;<=>?@[\]^_`{|}~';
47
48
    public static $clean = [];
49
50
    /**
51
     * Checks if the absolute path (directory) given is really under the
52
     * checker path (directory).
53
     *
54
     * @param string $abs_path     Absolute path to be checked (with trailing slash)
55
     * @param string $checker_path Checker path under which the path
56
     *                             should be (absolute path, with trailing slash, get it from api_get_path(SYS_COURSE_PATH))
57
     *
58
     * @return bool True if the path is under the checker, false otherwise
59
     */
60
    public static function check_abs_path(string $abs_path, string $checker_path): bool
61
    {
62
        // The checker path must be set.
63
        if (empty($checker_path)) {
64
            return false;
65
        }
66
67
        // Clean $abs_path.
68
        $true_path = self::cleanPath($abs_path);
69
        $checker_path = str_replace("\\", '/', realpath($checker_path));
70
71
        if (empty($checker_path)) {
72
            return false;
73
        }
74
75
        $found = strpos($true_path.'/', $checker_path);
76
77
        if ($found === 0) {
78
            return true;
79
        } else {
80
            // Code specific to Windows and case-insensitive behaviour
81
            if (api_is_windows_os()) {
82
                $found = stripos($true_path.'/', $checker_path);
83
                if ($found === 0) {
84
                    return true;
85
                }
86
            }
87
        }
88
89
        return false;
90
    }
91
92
    public static function cleanPath(string $absPath): string
93
    {
94
        $absPath = str_replace(['//', '../'], ['/', ''], $absPath);
95
96
        return str_replace("\\", '/', realpath($absPath));
97
    }
98
99
    /**
100
     * Checks if the relative path (directory) given is really under the
101
     * checker path (directory).
102
     *
103
     * @param string $rel_path     Relative path to be checked (relative to the current directory) (with trailing slash)
104
     * @param string $checker_path Checker path under which the path
105
     *                             should be (absolute path, with trailing slash, get it from api_get_path(SYS_COURSE_PATH))
106
     *
107
     * @return bool True if the path is under the checker, false otherwise
108
     */
109
    public static function check_rel_path(string $rel_path, string $checker_path): bool
110
    {
111
        // The checker path must be set.
112
        if (empty($checker_path)) {
113
            return false;
114
        }
115
        $current_path = getcwd(); // No trailing slash.
116
        if (substr($rel_path, -1, 1) != '/') {
117
            $rel_path = '/'.$rel_path;
118
        }
119
        $abs_path = $current_path.$rel_path;
120
        $true_path = str_replace("\\", '/', realpath($abs_path));
121
        $found = strpos($true_path.'/', $checker_path);
122
        if ($found === 0) {
123
            return true;
124
        }
125
126
        return false;
127
    }
128
129
    /**
130
     * Filters dangerous filenames (*.php[.]?* and .htaccess) and returns it in
131
     * a non-executable form (for PHP and htaccess, this is still vulnerable to
132
     * other languages' files extensions).
133
     *
134
     * @param string $filename Unfiltered filename
135
     */
136
    public static function filter_filename(string $filename): string
137
    {
138
        return disable_dangerous_file($filename);
139
    }
140
141
    public static function getTokenFromSession(string $prefix = '')
142
    {
143
        $secTokenVariable = self::generateSecTokenVariable($prefix);
144
145
        return Session::read($secTokenVariable);
146
    }
147
148
    /**
149
     * This function checks that the token generated in get_token() has been kept (prevents
150
     * Cross-Site Request Forgeries attacks).
151
     *
152
     * @param string         $requestType The array in which to get the token ('get' or 'post')
153
     * @param ?FormValidator $form
154
     *
155
     * @return bool True if it's the right token, false otherwise
156
     */
157
    public static function check_token(string $requestType = 'post', FormValidator $form = null, string $prefix = ''): bool
158
    {
159
        $secTokenVariable = self::generateSecTokenVariable($prefix);
160
        $sessionToken = Session::read($secTokenVariable);
161
        switch ($requestType) {
162
            case 'request':
163
                if (!empty($sessionToken) && isset($_REQUEST[$secTokenVariable]) && $sessionToken === $_REQUEST[$secTokenVariable]) {
164
                    return true;
165
                }
166
167
                return false;
168
            case 'get':
169
                if (!empty($sessionToken) && isset($_GET[$secTokenVariable]) && $sessionToken === $_GET[$secTokenVariable]) {
170
                    return true;
171
                }
172
173
                return false;
174
            case 'post':
175
                if (!empty($sessionToken) && isset($_POST[$secTokenVariable]) && $sessionToken === $_POST[$secTokenVariable]) {
176
                    return true;
177
                }
178
179
                return false;
180
            case 'form':
181
                $token = $form->getSubmitValue('protect_token');
0 ignored issues
show
The method getSubmitValue() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

181
                /** @scrutinizer ignore-call */ 
182
                $token = $form->getSubmitValue('protect_token');

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
182
183
                if (!empty($sessionToken) && !empty($token) && $sessionToken === $token) {
184
                    return true;
185
                }
186
187
                return false;
188
            default:
189
                if (!empty($sessionToken) && isset($requestType) && $sessionToken === $requestType) {
190
                    return true;
191
                }
192
        }
193
194
        return false; // Just in case, don't let anything slip.
195
    }
196
197
    /**
198
     * Checks the user agent of the client as recorder by get_ua() to prevent
199
     * most session hijacking attacks.
200
     *
201
     * @return bool True if the user agent is the same, false otherwise
202
     */
203
    public static function check_ua(): bool
204
    {
205
        $security = Session::read('sec_ua');
206
        $securitySeed = Session::read('sec_ua_seed');
207
208
        if ($security === $_SERVER['HTTP_USER_AGENT'].$securitySeed) {
209
            return true;
210
        }
211
212
        return false;
213
    }
214
215
    /**
216
     * Clear the security token from the session.
217
     */
218
    public static function clear_token(string $prefix = '')
219
    {
220
        $secTokenVariable = self::generateSecTokenVariable($prefix);
221
222
        Session::erase($secTokenVariable);
223
    }
224
225
    /**
226
     * This function sets a random token to be included in a form as a hidden field
227
     * and saves it into the user's session. Returns an HTML form element
228
     * This later prevents Cross-Site Request Forgeries by checking that the user is really
229
     * the one that sent this form in knowingly (this form hasn't been generated from
230
     * another website visited by the user at the same time).
231
     * Check the token with check_token().
232
     *
233
     * @return string Hidden-type input ready to insert into a form
234
     */
235
    public static function get_HTML_token(string $prefix = ''): string
236
    {
237
        $secTokenVariable = self::generateSecTokenVariable($prefix);
238
        $token = md5(uniqid(rand(), true));
239
        $string = '<input type="hidden" name="'.$secTokenVariable.'" value="'.$token.'" />';
240
        Session::write($secTokenVariable, $token);
241
242
        return $string;
243
    }
244
245
    /**
246
     * This function sets a random token to be included in a form as a hidden field
247
     * and saves it into the user's session.
248
     * This later prevents Cross-Site Request Forgeries by checking that the user is really
249
     * the one that sent this form in knowingly (this form hasn't been generated from
250
     * another website visited by the user at the same time).
251
     * Check the token with check_token().
252
     *
253
     * @return string Token
254
     */
255
    public static function get_token($prefix = ''): string
256
    {
257
        $secTokenVariable = self::generateSecTokenVariable($prefix);
258
        $token = md5(uniqid(rand(), true));
259
        Session::write($secTokenVariable, $token);
260
261
        return $token;
262
    }
263
264
    public static function get_existing_token(string $prefix = ''): string
265
    {
266
        $secTokenVariable = self::generateSecTokenVariable($prefix);
267
        $token = Session::read($secTokenVariable);
268
        if (!empty($token)) {
269
            return $token;
270
        } else {
271
            return self::get_token($prefix);
272
        }
273
    }
274
275
    /**
276
     * Gets the user agent in the session to later check it with check_ua() to prevent
277
     * most cases of session hijacking.
278
     */
279
    public static function get_ua()
280
    {
281
        $seed = uniqid(rand(), true);
282
        Session::write('sec_ua_seed', $seed);
283
        Session::write('sec_ua', $_SERVER['HTTP_USER_AGENT'].$seed);
284
    }
285
286
    /**
287
     * This function returns a variable from the clean array. If the variable doesn't exist,
288
     * it returns null.
289
     *
290
     * @param string $varname Variable name
291
     *
292
     * @return mixed Variable or NULL on error
293
     */
294
    public static function get(string $varname)
295
    {
296
        if (isset(self::$clean[$varname])) {
297
            return self::$clean[$varname];
298
        }
299
300
        return null;
301
    }
302
303
    /**
304
     * This function tackles the XSS injections.
305
     * Filtering for XSS is very easily done by using the htmlentities() function.
306
     * This kind of filtering prevents JavaScript snippets to be understood as such.
307
     *
308
     * @param string|array $var         The variable to filter for XSS, this params can be a string or an array (example : array(x,y))
309
     * @param ?int         $user_status The user status,constant allowed (STUDENT, COURSEMANAGER, ANONYMOUS, COURSEMANAGERLOWSECURITY)
310
     *
311
     * @return mixed Filtered string or array
312
     */
313
    public static function remove_XSS($var, int $user_status = null, bool $filter_terms = false)
314
    {
315
        if ($filter_terms) {
316
            $var = self::filter_terms($var);
317
        }
318
319
        if (empty($user_status)) {
320
            if (api_is_anonymous()) {
321
                $user_status = ANONYMOUS;
322
            } else {
323
                if (api_is_allowed_to_edit()) {
324
                    $user_status = COURSEMANAGER;
325
                } else {
326
                    $user_status = STUDENT;
327
                }
328
            }
329
        }
330
331
        if ($user_status == COURSEMANAGERLOWSECURITY) {
332
            return $var; // No filtering.
333
        }
334
335
        static $purifier = [];
336
        if (!isset($purifier[$user_status])) {
337
            $cache_dir = api_get_path(SYS_ARCHIVE_PATH).'Serializer';
338
            if (!file_exists($cache_dir)) {
339
                $mode = api_get_permissions_for_new_directories();
340
                mkdir($cache_dir, $mode);
341
            }
342
            $config = HTMLPurifier_Config::createDefault();
343
            $config->set('Cache.SerializerPath', $cache_dir);
344
            $config->set('Core.Encoding', api_get_system_encoding());
345
            $config->set('HTML.Doctype', 'XHTML 1.0 Transitional');
346
            $config->set('HTML.MaxImgLength', '2560');
347
            $config->set('HTML.TidyLevel', 'light');
348
            $config->set('Core.ConvertDocumentToFragment', false);
349
            $config->set('Core.RemoveProcessingInstructions', true);
350
351
            $customFilters = [
352
                new RemoveOnAttributes(),
353
            ];
354
355
            if (api_get_setting('enable_iframe_inclusion') == 'true') {
356
                $customFilters[] = new AllowIframes();
357
            }
358
359
            if ($customFilters) {
360
                $config->set('Filter.Custom', $customFilters);
361
            }
362
363
            // Shows _target attribute in anchors
364
            $config->set('Attr.AllowedFrameTargets', ['_blank', '_top', '_self', '_parent']);
365
366
            if ($user_status == STUDENT) {
367
                global $allowed_html_student;
368
                $config->set('HTML.SafeEmbed', true);
369
                $config->set('HTML.SafeObject', true);
370
                $config->set('Filter.YouTube', true);
371
                $config->set('HTML.FlashAllowFullScreen', true);
372
                $config->set('HTML.Allowed', $allowed_html_student);
373
            } elseif ($user_status == COURSEMANAGER) {
374
                global $allowed_html_teacher;
375
                $config->set('HTML.SafeEmbed', true);
376
                $config->set('HTML.SafeObject', true);
377
                $config->set('Filter.YouTube', true);
378
                $config->set('HTML.FlashAllowFullScreen', true);
379
                $config->set('HTML.Allowed', $allowed_html_teacher);
380
            } else {
381
                global $allowed_html_anonymous;
382
                $config->set('HTML.Allowed', $allowed_html_anonymous);
383
            }
384
385
            // We need it for example for the flv player (ids of surrounding div-tags have to be preserved).
386
            $config->set('Attr.EnableID', true);
387
            $config->set('CSS.AllowImportant', true);
388
            // We need for the flv player the css definition display: none;
389
            $config->set('CSS.AllowTricky', true);
390
            $config->set('CSS.Proprietary', true);
391
392
            // Allow uri scheme.
393
            $config->set('URI.AllowedSchemes', [
394
                'http' => true,
395
                'https' => true,
396
                'mailto' => true,
397
                'ftp' => true,
398
                'nntp' => true,
399
                'news' => true,
400
                'data' => true,
401
            ]);
402
403
            // Allow <video> tag
404
            //$config->set('HTML.Doctype', 'HTML 4.01 Transitional');
405
            $config->set('HTML.SafeIframe', true);
406
407
            // Set some HTML5 properties
408
            $config->set('HTML.DefinitionID', 'html5-definitions'); // unqiue id
409
            $config->set('HTML.DefinitionRev', 1);
410
            if ($def = $config->maybeGetRawHTMLDefinition()) {
411
                // https://html.spec.whatwg.org/dev/media.html#the-video-element
412
                $def->addElement(
413
                    'video',
414
                    'Block',
415
                    'Optional: (source, Flow) | (Flow, source) | Flow',
416
                    'Common',
417
                    [
418
                        'src' => 'URI',
419
                        'type' => 'Text',
420
                        'width' => 'Length',
421
                        'height' => 'Length',
422
                        'poster' => 'URI',
423
                        'preload' => 'Enum#auto,metadata,none',
424
                        'controls' => 'Bool',
425
                    ]
426
                );
427
                // https://html.spec.whatwg.org/dev/media.html#the-audio-element
428
                $def->addElement(
429
                    'audio',
430
                    'Block',
431
                    'Optional: (source, Flow) | (Flow, source) | Flow',
432
                    'Common',
433
                    [
434
                        'autoplay' => 'Bool',
435
                        'src' => 'URI',
436
                        'loop' => 'Bool',
437
                        'preload' => 'Enum#auto,metadata,none',
438
                        'controls' => 'Bool',
439
                        'muted' => 'Bool',
440
                    ]
441
                );
442
                $def->addElement(
443
                    'source',
444
                    'Block',
445
                    'Flow',
446
                    'Common',
447
                    ['src' => 'URI', 'type' => 'Text']
448
                );
449
            }
450
451
            $purifier[$user_status] = new HTMLPurifier($config);
452
        }
453
454
        if (is_array($var)) {
455
            return $purifier[$user_status]->purifyArray($var);
456
        } else {
457
            return $purifier[$user_status]->purify($var);
458
        }
459
    }
460
461
    /**
462
     * Filter content.
463
     *
464
     * @param string $text to be filtered
465
     */
466
    public static function filter_terms(string $text): string
467
    {
468
        static $bad_terms = [];
469
470
        if (empty($bad_terms)) {
471
            $list = api_get_setting('filter_terms');
472
            if (!empty($list)) {
473
                $list = explode("\n", $list);
474
                $list = array_filter($list);
475
                if (!empty($list)) {
476
                    foreach ($list as $term) {
477
                        $term = str_replace(["\r\n", "\r", "\n", "\t"], '', $term);
478
                        $html_entities_value = api_htmlentities($term, ENT_QUOTES, api_get_system_encoding());
479
                        $bad_terms[] = $term;
480
                        if ($term != $html_entities_value) {
481
                            $bad_terms[] = $html_entities_value;
482
                        }
483
                    }
484
                }
485
                $bad_terms = array_filter($bad_terms);
486
            }
487
        }
488
489
        $replace = '***';
490
        if (!empty($bad_terms)) {
491
            // Fast way
492
            $new_text = str_ireplace($bad_terms, $replace, $text);
493
            $text = $new_text;
494
        }
495
496
        return $text;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $text could return the type array which is incompatible with the type-hinted return string. Consider adding an additional type-check to rule them out.
Loading history...
497
    }
498
499
    /**
500
     * This method provides specific protection (against XSS and other kinds of attacks)
501
     * for static images (icons) used by the system.
502
     * Image paths are supposed to be given by programmers - people who know what they do, anyway,
503
     * this method encourages a safe practice for generating icon paths, without using heavy solutions
504
     * based on HTMLPurifier for example.
505
     *
506
     * @param string $image_path the input path of the image, it could be relative or absolute URL
507
     *
508
     * @return string returns sanitized image path or an empty string when the image path is not secure
509
     *
510
     * @author Ivan Tcholakov, March 2011
511
     */
512
    public static function filter_img_path(string $image_path): string
513
    {
514
        static $allowed_extensions = ['png', 'gif', 'jpg', 'jpeg', 'svg', 'webp'];
515
        $image_path = htmlspecialchars(trim($image_path)); // No html code is allowed.
516
        // We allow static images only, query strings are forbidden.
517
        if (strpos($image_path, '?') !== false) {
518
            return '';
519
        }
520
        if (($pos = strpos($image_path, ':')) !== false) {
521
            // Protocol has been specified, let's check it.
522
            if (stripos($image_path, 'javascript:') !== false) {
523
                // Javascript everywhere in the path is not allowed.
524
                return '';
525
            }
526
            // We allow only http: and https: protocols for now.
527
            //if (!preg_match('/^https?:\/\//i', $image_path)) {
528
            //    return '';
529
            //}
530
            if (stripos($image_path, 'http://') !== 0 && stripos($image_path, 'https://') !== 0) {
531
                return '';
532
            }
533
        }
534
        // We allow file extensions for images only.
535
        //if (!preg_match('/.+\.(png|gif|jpg|jpeg)$/i', $image_path)) {
536
        //    return '';
537
        //}
538
        if (($pos = strrpos($image_path, '.')) !== false) {
539
            if (!in_array(strtolower(substr($image_path, $pos + 1)), $allowed_extensions)) {
540
                return '';
541
            }
542
        } else {
543
            return '';
544
        }
545
546
        return $image_path;
547
    }
548
549
    /**
550
     * Get password requirements
551
     * It checks config value 'password_requirements' or uses the "classic"
552
     * Chamilo password requirements.
553
     */
554
    public static function getPasswordRequirements(): array
555
    {
556
        // Default
557
        $requirements = [
558
            'min' => [
559
                'lowercase' => 0,
560
                'uppercase' => 0,
561
                'numeric' => 2,
562
                'length' => 5,
563
                'specials' => 1,
564
            ],
565
        ];
566
567
        $passwordRequirements = api_get_configuration_value('password_requirements');
568
        if (!empty($passwordRequirements)) {
569
            $requirements = $passwordRequirements;
570
        }
571
572
        return ['min' => $requirements['min']];
573
    }
574
575
    /**
576
     * Gets password requirements in the platform language using get_lang
577
     * based in platform settings. See function 'self::getPasswordRequirements'.
578
     */
579
    public static function getPasswordRequirementsToString(array $evaluatedConditions = []): string
580
    {
581
        $output = '';
582
        $setting = self::getPasswordRequirements();
583
584
        $passedIcon = Display::returnFontAwesomeIcon(
585
            'check',
586
            '',
587
            true,
588
            'text-success',
589
            get_lang('PasswordRequirementPassed')
590
        );
591
        $pendingIcon = Display::returnFontAwesomeIcon(
592
            'times',
593
            '',
594
            true,
595
            'text-danger',
596
            get_lang('PasswordRequirementPending')
597
        );
598
599
        foreach ($setting as $type => $rules) {
600
            foreach ($rules as $rule => $parameter) {
601
                if (empty($parameter)) {
602
                    continue;
603
                }
604
605
                $evaluatedCondition = $type.'_'.$rule;
606
                $icon = $passedIcon;
607
608
                if (array_key_exists($evaluatedCondition, $evaluatedConditions)
609
                    && false === $evaluatedConditions[$evaluatedCondition]
610
                ) {
611
                    $icon = $pendingIcon;
612
                }
613
614
                $output .= empty($evaluatedConditions) ? '' : $icon;
615
                $output .= sprintf(
616
                    get_lang(
617
                        'NewPasswordRequirement'.ucfirst($type).'X'.ucfirst($rule)
618
                    ),
619
                    $parameter
620
                );
621
                $output .= '<br />';
622
            }
623
        }
624
625
        return $output;
626
    }
627
628
    /**
629
     * Sanitize a string, so it can be used in the exec() command without
630
     * "jail-breaking" to execute other commands.
631
     *
632
     * @param string $param The string to filter
633
     */
634
    public static function sanitizeExecParam(string $param): string
635
    {
636
        $param = preg_replace('/[`;&|]/', '', $param);
637
638
        return escapeshellarg($param);
639
    }
640
641
    private static function generateSecTokenVariable(string $prefix = ''): string
642
    {
643
        if (empty($prefix)) {
644
            return 'sec_token';
645
        }
646
647
        return $prefix.'_sec_token';
648
    }
649
}
650