Completed
Push — master ( b3d43a...f93ee0 )
by
unknown
01:51 queued 50s
created

Security::get()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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

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