Issues (5)

Security Analysis    not enabled

This project does not seem to handle request data directly as such no vulnerable execution paths were found.

  File Inclusion
File Inclusion enables an attacker to inject custom files into PHP's file loading mechanism, either explicitly passed to include, or for example via PHP's auto-loading mechanism.
  Regex Injection
Regex Injection enables an attacker to execute arbitrary code in your PHP process.
  SQL Injection
SQL Injection enables an attacker to execute arbitrary SQL code on your database server gaining access to user data, or manipulating user data.
  Response Splitting
Response Splitting can be used to send arbitrary responses.
  File Manipulation
File Manipulation enables an attacker to write custom data to files. This potentially leads to injection of arbitrary code on the server.
  Object Injection
Object Injection enables an attacker to inject an object into PHP code, and can lead to arbitrary code execution, file exposure, or file manipulation attacks.
  File Exposure
File Exposure allows an attacker to gain access to local files that he should not be able to access. These files can for example include database credentials, or other configuration files.
  XML Injection
XML Injection enables an attacker to read files on your local filesystem including configuration files, or can be abused to freeze your web-server process.
  Code Injection
Code Injection enables an attacker to execute arbitrary code on the server.
  Variable Injection
Variable Injection enables an attacker to overwrite program variables with custom data, and can lead to further vulnerabilities.
  XPath Injection
XPath Injection enables an attacker to modify the parts of XML document that are read. If that XML document is for example used for authentication, this can lead to further vulnerabilities similar to SQL Injection.
  Other Vulnerability
This category comprises other attack vectors such as manipulating the PHP runtime, loading custom extensions, freezing the runtime, or similar.
  Command Injection
Command Injection enables an attacker to inject a shell command that is execute with the privileges of the web-server. This can be used to expose sensitive data, or gain access of your server.
  LDAP Injection
LDAP Injection enables an attacker to inject LDAP statements potentially granting permission to run unauthorized queries, or modify content inside the LDAP tree.
  Cross-Site Scripting
Cross-Site Scripting enables an attacker to inject code into the response of a web-request that is viewed by other users. It can for example be used to bypass access controls, or even to take over other users' accounts.
  Header Injection
Unfortunately, the security analysis is currently not available for your project. If you are a non-commercial open-source project, please contact support to gain access.

src/PowerIdPDisco.php (1 issue)

Labels
Severity
1
<?php
2
3
declare(strict_types=1);
4
5
namespace SimpleSAML\Module\discopower;
6
7
use Exception;
8
use SimpleSAML\Assert\Assert;
9
use SimpleSAML\Auth;
10
use SimpleSAML\Configuration;
11
use SimpleSAML\Logger;
12
use SimpleSAML\Module;
13
use SimpleSAML\Session;
14
use SimpleSAML\Utils;
15
use SimpleSAML\XHTML\IdPDisco;
16
use SimpleSAML\XHTML\Template;
17
18
/**
19
 * This class implements a generic IdP discovery service, for use in various IdP discovery service pages. This should
20
 * reduce code duplication.
21
 *
22
 * This module extends the basic IdP disco handler, and add features like filtering and tabs.
23
 *
24
 * @package SimpleSAMLphp
25
 */
26
class PowerIdPDisco extends IdPDisco
27
{
28
    /**
29
     * The configuration for this instance.
30
     *
31
     * @var \SimpleSAML\Configuration
32
     */
33
    private Configuration $discoconfig;
34
35
    /**
36
     * The domain to use when saving common domain cookies. This is null if support for common domain cookies is
37
     * disabled.
38
     *
39
     * @var string|null
40
     */
41
    private ?string $cdcDomain;
42
43
    /**
44
     * The lifetime of the CDC cookie, in seconds. If set to null, it will only be valid until the browser is closed.
45
     *
46
     * @var int|null
47
     */
48
    private ?int $cdcLifetime;
49
50
51
    /**
52
     * The default sort weight for entries without 'discopower.weight'.
53
     *
54
     * @var int|null
55
     */
56
    private static ?int $defaultWeight = 100;
57
58
    /**
59
     * Initializes this discovery service.
60
     *
61
     * The constructor does the parsing of the request. If this is an invalid request, it will throw an exception.
62
     *
63
     * @param array  $metadataSets Array with metadata sets we find remote entities in.
64
     * @param string $instance The name of this instance of the discovery service.
65
     */
66
    public function __construct(array $metadataSets, string $instance)
67
    {
68
        parent::__construct($metadataSets, $instance);
69
70
        $this->discoconfig = Configuration::getConfig('module_discopower.php');
71
72
        $this->cdcDomain = $this->discoconfig->getOptionalString('cdc.domain', null);
73
        if ($this->cdcDomain !== null && $this->cdcDomain[0] !== '.') {
74
            // ensure that the CDC domain starts with a dot ('.') as required by the spec
75
            $this->cdcDomain = '.' . $this->cdcDomain;
76
        }
77
78
        $this->cdcLifetime = $this->discoconfig->getOptionalInteger('cdc.lifetime', null);
79
80
        self::$defaultWeight = $this->discoconfig->getOptionalInteger('defaultweight', 100);
81
    }
82
83
84
    /**
85
     * Log a message.
86
     *
87
     * This is an helper function for logging messages. It will prefix the messages with our discovery service type.
88
     *
89
     * @param string $message The message which should be logged.
90
     */
91
    protected function log(string $message): void
92
    {
93
        Logger::info('PowerIdPDisco.' . $this->instance . ': ' . $message);
94
    }
95
96
97
    /**
98
     * Compare two entities.
99
     *
100
     * This function is used to sort the entity list. It sorts based on weights,
101
     * and where those aren't available, English name. It puts larger weights
102
     * higher, and will always put IdP's with names configured before those with
103
     * only an entityID.
104
     *
105
     * @param array $a The metadata of the first entity.
106
     * @param array $b The metadata of the second entity.
107
     *
108
     * @return int How $a compares to $b.
109
     */
110
    public static function mcmp(array $a, array $b): int
111
    {
112
        // default weights
113
        if (!isset($a['discopower.weight']) || !is_int($a['discopower.weight'])) {
114
            $a['discopower.weight'] = self::$defaultWeight;
115
        }
116
        if (!isset($b['discopower.weight']) || !is_int($b['discopower.weight'])) {
117
            $b['discopower.weight'] = self::$defaultWeight;
118
        }
119
        if ($a['discopower.weight'] > $b['discopower.weight']) {
120
            return -1; // higher weights further up
121
        } elseif ($b['discopower.weight'] > $a['discopower.weight']) {
122
            return 1; // lower weights further down
123
        } elseif (isset($a['name']['en']) && isset($b['name']['en'])) {
124
            return strcasecmp($a['name']['en'], $b['name']['en']);
125
        } elseif (isset($a['name']['en'])) {
126
            return -1; // place name before entity ID
127
        } elseif (isset($b['name']['en'])) {
128
            return 1; // Place entity ID after name
129
        } else {
130
            return strcasecmp($a['entityid'], $b['entityid']);
131
        }
132
    }
133
134
135
    /**
136
     * Structure the list of IdPs in a hierarchy based upon the tags.
137
     *
138
     * @param array $list A list of IdPs.
139
     *
140
     * @return array The list of IdPs structured accordingly.
141
     */
142
    protected function idplistStructured(array $list): array
143
    {
144
        $slist = [];
145
146
        $order = $this->discoconfig->getOptionalArray('taborder', []);
147
        foreach ($order as $oe) {
148
            $slist[$oe] = [];
149
        }
150
151
        $enableTabs = $this->discoconfig->getOptionalArray('tabs', []);
152
153
        foreach ($list as $key => $val) {
154
            $tags = ['misc'];
155
            if (array_key_exists('tags', $val)) {
156
                $tags = $val['tags'];
157
            }
158
159
160
            foreach ($tags as $tag) {
161
                if (!empty($enableTabs) && !in_array($tag, $enableTabs)) {
162
                    continue;
163
                }
164
                $slist[$tag][$key] = $val;
165
            }
166
        }
167
168
        foreach ($slist as $tab => $tbslist) {
169
            uasort($slist[$tab], [self::class, 'mcmp']);
170
            // reorder with a hook if one exists
171
            Module::callHooks('discosort', $slist[$tab]);
172
        }
173
174
        return $slist;
175
    }
176
177
178
    /**
179
     * Do the actual filtering according the rules defined.
180
     *
181
     * @param array   $filter A set of rules regarding filtering.
182
     * @param array   $entry An entry to be evaluated by the filters.
183
     * @param boolean $default What to do in case the entity does not match any rules. Defaults to true.
184
     *
185
     * @return boolean True if the entity should be kept, false if it should be discarded according to the filters.
186
     */
187
    private function processFilter(array $filter, array $entry, bool $default = true): bool
188
    {
189
        if (in_array($entry['entityid'], $filter['entities.include'])) {
190
            return true;
191
        }
192
        if (in_array($entry['entityid'], $filter['entities.exclude'])) {
193
            return false;
194
        }
195
196
        if (array_key_exists('tags', $entry)) {
197
            foreach ($filter['tags.include'] as $fe) {
198
                if (in_array($fe, $entry['tags'])) {
199
                    return true;
200
                }
201
            }
202
            foreach ($filter['tags.exclude'] as $fe) {
203
                if (in_array($fe, $entry['tags'])) {
204
                    return false;
205
                }
206
            }
207
        }
208
        return $default;
209
    }
210
211
212
    /**
213
     * Filter a list of entities according to any filters defined in the parent class, plus discopower configuration
214
     * options regarding filtering.
215
     *
216
     * @param array $list A list of entities to filter.
217
     *
218
     * @return array The list in $list after filtering entities.
219
     */
220
    protected function filterList(array $list): array
221
    {
222
        $list = parent::filterList($list);
223
224
        try {
225
            $spmd = $this->metadata->getMetaData($this->spEntityId, 'saml20-sp-remote');
226
        } catch (Exception $e) {
227
            if (
228
                $this->discoconfig->getOptionalBoolean('useunsafereturn', false)
229
                && array_key_exists('return', $_GET)
230
            ) {
231
                /*
232
                 * Get the SP metadata from the other side of the protocol bridge by retrieving the state.
233
                 * Because the disco is not explicitly passed the state ID, we can use a crude hack to
234
                 * infer it from the return parameter. This should be relatively safe because we're not
235
                 * going to trust it for anything other than finding the `discopower.filter` elements,
236
                 * and because the SP could bypass all of this anyway by specifying a known IdP in scoping.
237
                 */
238
                try {
239
                    parse_str(parse_url($_GET['return'], PHP_URL_QUERY), $returnState);
240
                    $state = Auth\State::loadState($returnState['AuthID'], 'saml:sp:sso');
241
                    if ($state && array_key_exists('SPMetadata', $state)) {
242
                        $spmd = $state['SPMetadata'];
243
                        $this->log('Updated SP metadata from ' . $this->spEntityId . ' to ' . $spmd['entityid']);
244
                    }
245
                } catch (Exception $e) {
246
                    return $list;
247
                }
248
            } else {
249
                return $list;
250
            }
251
        }
252
253
        if (!isset($spmd) || !array_key_exists('discopower.filter', $spmd)) {
254
            return $list;
255
        }
256
        $filter = $spmd['discopower.filter'];
257
258
        if (!array_key_exists('entities.include', $filter)) {
259
            $filter['entities.include'] = [];
260
        }
261
        if (!array_key_exists('entities.exclude', $filter)) {
262
            $filter['entities.exclude'] = [];
263
        }
264
        if (!array_key_exists('tags.include', $filter)) {
265
            $filter['tags.include'] = [];
266
        }
267
        if (!array_key_exists('tags.exclude', $filter)) {
268
            $filter['tags.exclude'] = [];
269
        }
270
271
        $defaultrule = true;
272
        if (
273
            array_key_exists('entities.include', $spmd['discopower.filter'])
274
            || array_key_exists('tags.include', $spmd['discopower.filter'])
275
        ) {
276
            $defaultrule = false;
277
        }
278
279
        $returnlist = [];
280
        foreach ($list as $key => $entry) {
281
            if ($this->processFilter($filter, $entry, $defaultrule)) {
282
                $returnlist[$key] = $entry;
283
            }
284
        }
285
        return $returnlist;
286
    }
287
288
289
    /**
290
     * Handles a request to this discovery service.
291
     *
292
     * The IdP disco parameters should be set before calling this function.
293
     */
294
    public function handleRequest(): void
295
    {
296
        $this->start();
297
298
        // no choice made. Show discovery service page
299
        $idpList = $this->getIdPList();
300
        $idpList = $this->idplistStructured($this->filterList($idpList));
301
        $preferredIdP = $this->getRecommendedIdP();
302
303
        $t = new Template($this->config, 'discopower:disco.twig');
304
        $translator = $t->getTranslator();
305
306
        $t->data['return'] = $this->returnURL;
307
        $t->data['returnIDParam'] = $this->returnIdParam;
308
        $t->data['entityID'] = $this->spEntityId;
309
        $t->data['defaulttab'] = $this->discoconfig->getOptionalInteger('defaulttab', 0);
310
311
        $idpList = $this->processMetadata($t, $idpList);
312
313
        $t->data['idplist'] = $idpList;
314
        $t->data['faventry'] = null;
315
        foreach ($idpList as $tab => $slist) {
316
            if (!empty($preferredIdP) && array_key_exists($preferredIdP, $slist)) {
317
                $t->data['faventry'] = $slist[$preferredIdP];
318
                break;
319
            }
320
        }
321
322
        if (isset($t->data['faventry'])) {
323
            $t->data['autofocus'] = 'favouritesubmit';
324
        }
325
326
        /* store the tab list in the session */
327
        $session = Session::getSessionFromRequest();
328
        if (array_key_exists('faventry', $t->data)) {
329
            $session->setData('discopower:tabList', 'faventry', $t->data['faventry']);
330
        }
331
        $session->setData('discopower:tabList', 'tabs', array_keys($idpList));
332
        $session->setData('discopower:tabList', 'defaulttab', $t->data['defaulttab']);
333
334
        $httpUtils = new Utils\HTTP();
335
        $t->data['score'] = $this->discoconfig->getOptionalString('score', 'quicksilver');
336
        $t->data['preferredidp'] = $preferredIdP;
337
        $t->data['urlpattern'] = htmlspecialchars($httpUtils->getSelfURLNoQuery());
338
        $t->data['rememberenabled'] = $this->config->getOptionalBoolean('idpdisco.enableremember', false);
339
        $t->data['rememberchecked'] = $this->config->getOptionalBoolean('idpdisco.rememberchecked', false);
340
        foreach (array_keys($idpList) as $tab) {
341
            Assert::regex(
342
                $tab,
343
                '/^[a-z_][a-z0-9_-]+$/i',
344
                'Tags can contain alphanumeric characters, hyphens and underscores.'
345
                . ' They must start with a A-Z or an underscore (supplied: \'%s\')',
346
            );
347
348
            $translatableTag = "{discopower:tabs:$tab}";
349
            if ($translator::translateSingularGettext($translatableTag) === $translatableTag) {
350
                $t->data['tabNames'][$tab] = $translator::noop($tab);
351
            } else {
352
                $t->data['tabNames'][$tab] = $translator::noop($translatableTag);
353
            }
354
        }
355
        $t->send();
356
    }
357
358
359
    /**
360
     * @param \SimpleSAML\XHTML\Template $t
361
     * @param array $metadata
362
     * @return array
363
     */
364
    private function processMetadata(Template $t, array $metadata): array
365
    {
366
        $basequerystring = '?' .
367
            'entityID=' . urlencode($t->data['entityID']) . '&' .
368
            'return=' . urlencode($t->data['return']) . '&' .
369
            'returnIDParam=' . urlencode($t->data['returnIDParam']) . '&idpentityid=';
370
371
        $httpUtils = new Utils\HTTP();
372
        foreach ($metadata as $tab => $idps) {
373
            foreach ($idps as $entityid => $entity) {
374
                $entity['actionUrl'] = $basequerystring . urlencode($entity['entityid']);
375
                if (array_key_exists('icon', $entity) && $entity['icon'] !== null) {
376
                    $entity['iconUrl'] = $httpUtils->resolveURL($entity['icon']);
377
                }
378
                $entity['keywords'] = implode(
379
                    ' ',
380
                    $t->getEntityPropertyTranslation('Keywords', $entity['UIInfo'] ?? []) ?? [],
0 ignored issues
show
It seems like $t->getEntityPropertyTra... ?? array()) ?? array() can also be of type string; however, parameter $pieces of implode() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

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

380
                    /** @scrutinizer ignore-type */ $t->getEntityPropertyTranslation('Keywords', $entity['UIInfo'] ?? []) ?? [],
Loading history...
381
                );
382
                $metadata[$tab][$entityid] = $entity;
383
            }
384
        }
385
        return $metadata;
386
    }
387
388
389
    /**
390
     * Get the IdP entities saved in the common domain cookie.
391
     *
392
     * @return array List of IdP entities.
393
     */
394
    private function getCDC(): array
395
    {
396
        if (!isset($_COOKIE['_saml_idp'])) {
397
            return [];
398
        }
399
400
        $ret = (string) $_COOKIE['_saml_idp'];
401
        $ret = explode(' ', $ret);
402
        foreach ($ret as &$idp) {
403
            $idp = base64_decode($idp);
404
            if ($idp === false) {
405
                // not properly base64 encoded
406
                return [];
407
            }
408
        }
409
410
        return $ret;
411
    }
412
413
414
    /**
415
     * Save the current IdP choice to a cookie.
416
     *
417
     * This function overrides the corresponding function in the parent class, to add support for common domain cookie.
418
     *
419
     * @param string $idp The entityID of the IdP.
420
     */
421
    protected function setPreviousIdP(string $idp): void
422
    {
423
        if ($this->cdcDomain === null) {
424
            parent::setPreviousIdP($idp);
425
            return;
426
        }
427
428
        $list = $this->getCDC();
429
430
        $prevIndex = array_search($idp, $list, true);
431
        if ($prevIndex !== false) {
432
            unset($list[$prevIndex]);
433
        }
434
        $list[] = $idp;
435
436
        foreach ($list as &$value) {
437
            $value = base64_encode($value);
438
        }
439
        $newCookie = implode(' ', $list);
440
441
        while (strlen($newCookie) > 4000) {
442
            // the cookie is too long. Remove the oldest elements until it is short enough
443
            $tmp = explode(' ', $newCookie, 2);
444
            if (count($tmp) === 1) {
445
                // we are left with a single entityID whose base64 representation is too long to fit in a cookie
446
                break;
447
            }
448
            $newCookie = $tmp[1];
449
        }
450
451
        $params = [
452
            'lifetime' => $this->cdcLifetime,
453
            'domain'   => $this->cdcDomain,
454
            'secure'   => true,
455
            'httponly' => false,
456
        ];
457
458
        $httpUtils = new Utils\HTTP();
459
        $httpUtils->setCookie('_saml_idp', $newCookie, $params, false);
460
    }
461
462
463
    /**
464
     * Retrieve the previous IdP the user used.
465
     *
466
     * This function overrides the corresponding function in the parent class, to add support for common domain cookie.
467
     *
468
     * @return string|null The entity id of the previous IdP the user used, or null if this is the first time.
469
     */
470
    protected function getPreviousIdP(): ?string
471
    {
472
        if ($this->cdcDomain === null) {
473
            return parent::getPreviousIdP();
474
        }
475
476
        $prevIdPs = $this->getCDC();
477
        while (count($prevIdPs) > 0) {
478
            $idp = array_pop($prevIdPs);
479
            $idp = $this->validateIdP($idp);
480
            if ($idp !== null) {
481
                return $idp;
482
            }
483
        }
484
485
        return null;
486
    }
487
}
488