Passed
Pull Request — master (#14)
by Tim
02:20
created

MetaLoader::processWhitelist()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 5
c 1
b 0
f 0
nc 3
nop 2
dl 0
loc 9
rs 10
1
<?php
2
3
namespace SimpleSAML\Module\metarefresh;
4
5
use Exception;
6
use RobRichards\XMLSecLibs\XMLSecurityDSig;
7
use SAML2\DOMDocumentFactory;
8
use SimpleSAML\Assert\Assert;
9
use SimpleSAML\Configuration;
10
use SimpleSAML\Logger;
11
use SimpleSAML\Metadata;
12
use SimpleSAML\Utils;
13
14
/**
15
 * @package SimpleSAMLphp
16
 */
17
class MetaLoader
18
{
19
    /** @var int|null */
20
    private $expire;
21
22
    /** @var array */
23
    private $metadata = [];
24
25
    /** @var object|null */
26
    private $oldMetadataSrc;
27
28
    /** @var string|null */
29
    private $stateFile = null;
30
31
    /** @var bool*/
32
    private $changed = false;
33
34
    /** @var array */
35
    private $state = [];
36
37
    /** @var array */
38
    private $types = [
39
        'saml20-idp-remote',
40
        'saml20-sp-remote',
41
        'attributeauthority-remote'
42
    ];
43
44
45
    /**
46
     * Constructor
47
     *
48
     * @param int|null $expire
49
     * @param string|null  $stateFile
50
     * @param object|null  $oldMetadataSrc
51
     */
52
    public function __construct(int $expire = null, string $stateFile = null, object $oldMetadataSrc = null)
53
    {
54
        $this->expire = $expire;
55
        $this->oldMetadataSrc = $oldMetadataSrc;
56
        $this->stateFile = $stateFile;
57
58
        // Read file containing $state from disk
59
        /** @psalm-var array|null */
60
        $state = null;
61
        if (!is_null($stateFile) && is_readable($stateFile)) {
62
            include($stateFile);
63
        }
64
65
        if (!empty($state)) {
0 ignored issues
show
introduced by
The condition empty($state) is always false.
Loading history...
66
            $this->state = $state;
67
        }
68
    }
69
70
71
    /**
72
     * Get the types of entities that will be loaded.
73
     *
74
     * @return array The entity types allowed.
75
     */
76
    public function getTypes(): array
77
    {
78
        return $this->types;
79
    }
80
81
82
    /**
83
     * Set the types of entities that will be loaded.
84
     *
85
     * @param string|array $types Either a string with the name of one single type allowed, or an array with a list of
86
     * types. Pass an empty array to reset to all types of entities.
87
     */
88
    public function setTypes($types): void
89
    {
90
        if (!is_array($types)) {
91
            $types = [$types];
92
        }
93
        $this->types = $types;
94
    }
95
96
97
    /**
98
     * This function processes a SAML metadata file.
99
     *
100
     * @param array $source
101
     */
102
    public function loadSource(array $source): void
103
    {
104
        if (preg_match('@^https?://@i', $source['src'])) {
105
            // Build new HTTP context
106
            $context = $this->createContext($source);
107
108
            // GET!
109
            try {
110
                /** @var array $response  We know this because we set the third parameter to `true` */
111
                $response = Utils\HTTP::fetch($source['src'], $context, true);
112
                list($data, $responseHeaders) = $response;
113
            } catch (Exception $e) {
114
                Logger::warning('metarefresh: ' . $e->getMessage());
115
            }
116
117
            // We have response headers, so the request succeeded
118
            if (!isset($responseHeaders)) {
119
                // No response headers, this means the request failed in some way, so re-use old data
120
                Logger::debug('No response from ' . $source['src'] . ' - attempting to re-use cached metadata');
121
                $this->addCachedMetadata($source);
122
                return;
123
            } elseif (preg_match('@^HTTP/1\.[01]\s304\s@', $responseHeaders[0])) {
124
                // 304 response
125
                Logger::debug('Received HTTP 304 (Not Modified) - attempting to re-use cached metadata');
126
                $this->addCachedMetadata($source);
127
                return;
128
            } elseif (!preg_match('@^HTTP/1\.[01]\s200\s@', $responseHeaders[0])) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $responseHeaders does not seem to be defined for all execution paths leading up to this point.
Loading history...
129
                // Other error
130
                Logger::debug('Error from ' . $source['src'] . ' - attempting to re-use cached metadata');
131
                $this->addCachedMetadata($source);
132
                return;
133
            }
134
        } else {
135
            // Local file.
136
            $data = file_get_contents($source['src']);
137
            $responseHeaders = null;
138
        }
139
140
        // Everything OK. Proceed.
141
        if (isset($source['conditionalGET']) && $source['conditionalGET']) {
142
            // Stale or no metadata, so a fresh copy
143
            Logger::debug('Downloaded fresh copy');
144
        }
145
146
        try {
147
            $entities = $this->loadXML($data, $source);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $data does not seem to be defined for all execution paths leading up to this point.
Loading history...
148
        } catch (Exception $e) {
149
            Logger::debug(
150
                'XML parser error when parsing ' . $source['src'] . ' - attempting to re-use cached metadata'
151
            );
152
            Logger::debug('XML parser returned: ' . $e->getMessage());
153
            $this->addCachedMetadata($source);
154
            return;
155
        }
156
157
        foreach ($entities as $entity) {
158
            if (!$this->processBlacklist($entity, $source)) {
159
                continue;
160
            }
161
            if (!$this->processWhitelist($entity, $source)) {
162
                continue;
163
            }
164
            if (!$this->processAttributeWhitelist($entity, $source)) {
165
                continue;
166
            }
167
168
            if (array_key_exists('certificates', $source) && ($source['certificates'] !== null)) {
169
                if (!$entity->validateSignature($source['certificates'])) {
170
                    $entityId = $entity->getEntityId();
171
                    Logger::info(
172
                        'Skipping "' . $entityId . '" - could not verify signature using certificate.' . "\n"
173
                    );
174
                    continue;
175
                }
176
            }
177
178
            $template = null;
179
            if (array_key_exists('template', $source)) {
180
                $template = $source['template'];
181
            }
182
183
            if (in_array('saml20-sp-remote', $this->types, true)) {
184
                $this->addMetadata($source['src'], $entity->getMetadata20SP(), 'saml20-sp-remote', $template);
185
            }
186
            if (in_array('saml20-idp-remote', $this->types, true)) {
187
                $this->addMetadata($source['src'], $entity->getMetadata20IdP(), 'saml20-idp-remote', $template);
188
            }
189
            if (in_array('attributeauthority-remote', $this->types, true)) {
190
                $attributeAuthorities = $entity->getAttributeAuthorities();
191
                if (!empty($attributeAuthorities)) {
192
                    $this->addMetadata(
193
                        $source['src'],
194
                        $attributeAuthorities,
195
                        'attributeauthority-remote',
196
                        $template
197
                    );
198
                }
199
            }
200
        }
201
202
        $this->saveState($source, $responseHeaders);
203
    }
204
205
206
    /**
207
     * @param \SimpleSAML\Metadata\SAMLParser $entity
208
     * @param array $source
209
     * @bool
210
     */
211
    private function processCertificates(Metadata\SAMLParser $entity, array $source): bool
0 ignored issues
show
Unused Code introduced by
The method processCertificates() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
212
    {
213
        if (array_key_exists('certificates', $source) && ($source['certificates'] !== null)) {
214
            if (!$entity->validateSignature($source['certificates'])) {
215
                $entityId = $entity->getEntityId();
216
                Logger::info(
217
                    'Skipping "' . $entityId . '" - could not verify signature using certificate.' . "\n"
218
                );
219
                return false;
220
            }
221
        }
222
        return true;
223
    }
224
225
226
    /**
227
     * @param \SimpleSAML\Metadata\SAMLParser $entity
228
     * @param array $source
229
     * @bool
230
     */
231
    private function processBlacklist(Metadata\SAMLParser $entity, array $source): bool
232
    {
233
        if (isset($source['blacklist'])) {
234
            if (!empty($source['blacklist']) && in_array($entity->getEntityId(), $source['blacklist'], true)) {
235
                Logger::info('Skipping "' . $entity->getEntityId() . '" - blacklisted.' . "\n");
236
                return false;
237
            }
238
        }
239
        return true;
240
    }
241
242
243
    /**
244
     * @param \SimpleSAML\Metadata\SAMLParser $entity
245
     * @param array $source
246
     * @bool
247
     */
248
    private function processWhitelist(Metadata\SAMLParser $entity, array $source): bool
249
    {
250
        if (isset($source['whitelist'])) {
251
            if (!empty($source['whitelist']) && !in_array($entity->getEntityId(), $source['whitelist'], true)) {
252
                Logger::info('Skipping "' . $entity->getEntityId() . '" - not in the whitelist.' . "\n");
253
                return false;
254
            }
255
        }
256
        return true;
257
    }
258
259
260
    /**
261
     * @param \SimpleSAML\Metadata\SAMLParser $entity
262
     * @param array $source
263
     * @bool
264
     */
265
    private function processAttributeWhitelist(Metadata\SAMLParser $entity, array $source): bool
266
    {
267
        /* Do we have an attribute whitelist? */
268
        if (isset($source['attributewhitelist']) && !empty($source['attributewhitelist'])) {
269
            $idpMetadata = $entity->getMetadata20IdP();
270
            if (!isset($idpMetadata)) {
271
                /* Skip non-IdPs */
272
                return false;
273
            }
274
275
            /**
276
             * Do a recursive comparison for each whitelist of the attributewhitelist with the idpMetadata for this
277
             * IdP. At least one of these whitelists should match
278
             */
279
            $match = false;
280
            foreach ($source['attributewhitelist'] as $whitelist) {
281
                if ($this->containsArray($whitelist, $idpMetadata)) {
282
                    $match = true;
283
                    break;
284
                }
285
            }
286
             if (!$match) {
287
                /* No match found -> next IdP */
288
                return false;
289
            }
290
            Logger::debug('Whitelisted entityID: ' . $entity->getEntityID());
291
        }
292
        return true;
293
    }
294
295
296
    /**
297
     * @param array|string $src
298
     * @param array|string $dst
299
     * @return bool
300
     *
301
     * Recursively checks whether array $dst contains array $src. If $src
302
     * is not an array, a literal comparison is being performed.
303
     */
304
    private function containsArray($src, $dst): bool
305
    {
306
        if (is_array($src)) {
307
            if (!is_array($dst)) {
308
                return false;
309
            }
310
            $dstKeys = array_keys($dst);
311
312
            /* Loop over all src keys */
313
            foreach ($src as $srcKey => $srcval) {
314
                if (is_int($srcKey)) {
315
                    /* key is number, check that the key appears as one
316
                     * of the destination keys: if not, then src has
317
                     * more keys than dst */
318
                    if (!array_key_exists($srcKey, $dst)) {
319
                        return false;
320
                    }
321
322
                    /* loop over dest keys, to find value: we don't know
323
                     * whether they are in the same order */
324
                    $submatch = false;
325
                    foreach ($dstKeys as $dstKey) {
326
                        if ($this->containsArray($srcval, $dst[$dstKey])) {
327
                            $submatch = true;
328
                            break;
329
                        }
330
                    }
331
                    if (!$submatch) {
332
                        return false;
333
                    }
334
                } else {
335
                    /* key is regexp: find matching keys */
336
                    /** @var array|false $matchingDstKeys */
337
                    $matchingDstKeys = preg_grep($srcKey, $dstKeys);
338
                    if (!is_array($matchingDstKeys)) {
339
                        return false;
340
                    }
341
342
                    $match = false;
343
                    foreach ($matchingDstKeys as $dstKey) {
344
                        if ($this->containsArray($srcval, $dst[$dstKey])) {
345
                            /* Found a match */
346
                            $match = true;
347
                            break;
348
                        }
349
                    }
350
                    if (!$match) {
351
                        /* none of the keys has a matching value */
352
                        return false;
353
                    }
354
                }
355
            }
356
            /* each src key/value matches */
357
            return true;
358
        } else {
359
            /* src is not an array, do a regexp match against dst */
360
            return (preg_match($src, strval($dst)) === 1);
361
        }
362
    }
363
364
    /**
365
     * Create HTTP context, with any available caches taken into account
366
     *
367
     * @param array $source
368
     * @return array
369
     */
370
    private function createContext(array $source): array
371
    {
372
        $config = Configuration::getInstance();
373
        $name = $config->getString('technicalcontact_name', null);
374
        $mail = $config->getString('technicalcontact_email', null);
375
376
        $rawheader = "User-Agent: SimpleSAMLphp metarefresh, run by $name <$mail>\r\n";
377
378
        if (isset($source['conditionalGET']) && $source['conditionalGET']) {
379
            if (array_key_exists($source['src'], $this->state)) {
380
                $sourceState = $this->state[$source['src']];
381
382
                if (isset($sourceState['last-modified'])) {
383
                    $rawheader .= 'If-Modified-Since: ' . $sourceState['last-modified'] . "\r\n";
384
                }
385
386
                if (isset($sourceState['etag'])) {
387
                    $rawheader .= 'If-None-Match: ' . $sourceState['etag'] . "\r\n";
388
                }
389
            }
390
        }
391
392
        return ['http' => ['header' => $rawheader]];
393
    }
394
395
396
    /**
397
     * @param array $source
398
     */
399
    private function addCachedMetadata(array $source): void
400
    {
401
        if (isset($this->oldMetadataSrc)) {
402
            foreach ($this->types as $type) {
403
                foreach ($this->oldMetadataSrc->getMetadataSet($type) as $entity) {
0 ignored issues
show
Bug introduced by
The method getMetadataSet() 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

403
                foreach ($this->oldMetadataSrc->/** @scrutinizer ignore-call */ getMetadataSet($type) as $entity) {

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...
404
                    if (array_key_exists('metarefresh:src', $entity)) {
405
                        if ($entity['metarefresh:src'] == $source['src']) {
406
                            $this->addMetadata($source['src'], $entity, $type);
407
                        }
408
                    }
409
                }
410
            }
411
        }
412
    }
413
414
415
    /**
416
     * Store caching state data for a source
417
     *
418
     * @param array $source
419
     * @param array|null $responseHeaders
420
     */
421
    private function saveState(array $source, ?array $responseHeaders): void
422
    {
423
        if (isset($source['conditionalGET']) && $source['conditionalGET']) {
424
            // Headers section
425
            if ($responseHeaders !== null) {
0 ignored issues
show
introduced by
The condition $responseHeaders !== null is always true.
Loading history...
426
                $candidates = ['last-modified', 'etag'];
427
428
                foreach ($candidates as $candidate) {
429
                    if (array_key_exists($candidate, $responseHeaders)) {
430
                        $this->state[$source['src']][$candidate] = $responseHeaders[$candidate];
431
                    }
432
                }
433
            }
434
435
            if (!empty($this->state[$source['src']])) {
436
                // Timestamp when this src was requested.
437
                $this->state[$source['src']]['requested_at'] = $this->getTime();
438
                $this->changed = true;
439
            }
440
        }
441
    }
442
443
444
    /**
445
     * Parse XML metadata and return entities
446
     *
447
     * @param string $data
448
     * @param array $source
449
     * @return \SimpleSAML\Metadata\SAMLParser[]
450
     * @throws \Exception
451
     */
452
    private function loadXML(string $data, array $source): array
453
    {
454
        try {
455
            $doc = DOMDocumentFactory::fromString($data);
456
        } catch (Exception $e) {
457
            throw new Exception('Failed to read XML from ' . $source['src']);
458
        }
459
        return Metadata\SAMLParser::parseDescriptorsElement($doc->documentElement);
460
    }
461
462
463
    /**
464
     * This function writes the state array back to disk
465
     *
466
     */
467
    public function writeState(): void
468
    {
469
        if ($this->changed && !is_null($this->stateFile)) {
470
            Logger::debug('Writing: ' . $this->stateFile);
471
            Utils\System::writeFile(
472
                $this->stateFile,
473
                "<?php\n/* This file was generated by the metarefresh module at " . $this->getTime() . ".\n" .
474
                " Do not update it manually as it will get overwritten. */\n" .
475
                '$state = ' . var_export($this->state, true) . ";\n?>\n",
476
                0644
477
            );
478
        }
479
    }
480
481
482
    /**
483
     * This function writes the metadata to stdout.
484
     *
485
     */
486
    public function dumpMetadataStdOut(): void
487
    {
488
        foreach ($this->metadata as $category => $elements) {
489
            echo '/* The following data should be added to metadata/' . $category . '.php. */' . "\n";
490
491
            foreach ($elements as $m) {
492
                $filename = $m['filename'];
493
                $entityID = $m['metadata']['entityid'];
494
                $time = $this->getTime();
495
                echo "\n";
496
                echo '/* The following metadata was generated from ' . $filename . ' on ' . $time . '. */' . "\n";
497
                echo '$metadata[\'' . addslashes($entityID) . '\'] = ' . var_export($m['metadata'], true) . ';' . "\n";
498
            }
499
500
            echo "\n";
501
            echo '/* End of data which should be added to metadata/' . $category . '.php. */' . "\n";
502
            echo "\n";
503
        }
504
    }
505
506
507
    /**
508
     * This function adds metadata from the specified file to the list of metadata.
509
     * This function will return without making any changes if $metadata is NULL.
510
     *
511
     * @param string $filename The filename the metadata comes from.
512
     * @param \SAML2\XML\md\AttributeAuthorityDescriptor[]|null $metadata The metadata.
513
     * @param string $type The metadata type.
514
     * @param array|null $template The template.
515
     */
516
    private function addMetadata(string $filename, ?array $metadata, string $type, array $template = null): void
517
    {
518
        if ($metadata === null) {
0 ignored issues
show
introduced by
The condition $metadata === null is always false.
Loading history...
519
            return;
520
        }
521
522
        if (isset($template)) {
523
            $metadata = array_merge($metadata, $template);
524
        }
525
526
        $metadata['metarefresh:src'] = $filename;
527
        if (!array_key_exists($type, $this->metadata)) {
528
            $this->metadata[$type] = [];
529
        }
530
531
        // If expire is defined in constructor...
532
        if (!empty($this->expire)) {
533
            // If expire is already in metadata
534
            if (array_key_exists('expire', $metadata)) {
535
                // Override metadata expire with more restrictive global config
536
                if ($this->expire < $metadata['expire']) {
537
                    $metadata['expire'] = $this->expire;
538
                }
539
540
                // If expire is not already in metadata use global config
541
            } else {
542
                $metadata['expire'] = $this->expire;
543
            }
544
        }
545
        $this->metadata[$type][] = ['filename' => $filename, 'metadata' => $metadata];
546
    }
547
548
549
    /**
550
     * This function writes the metadata to an ARP file
551
     *
552
     * @param \SimpleSAML\Configuration $config
553
     */
554
    public function writeARPfile(Configuration $config): void
555
    {
556
        $arpfile = $config->getValue('arpfile');
557
        $types = ['saml20-sp-remote'];
558
559
        $md = [];
560
        foreach ($this->metadata as $category => $elements) {
561
            if (!in_array($category, $types, true)) {
562
                continue;
563
            }
564
            $md = array_merge($md, $elements);
565
        }
566
567
        // $metadata, $attributemap, $prefix, $suffix
568
        $arp = new ARP(
569
            $md,
570
            $config->getValue('attributemap', ''),
571
            $config->getValue('prefix', ''),
572
            $config->getValue('suffix', '')
573
        );
574
575
576
        $arpxml = $arp->getXML();
577
578
        Logger::info('Writing ARP file: ' . $arpfile . "\n");
579
        file_put_contents($arpfile, $arpxml);
580
    }
581
582
583
    /**
584
     * This function writes the metadata to to separate files in the output directory.
585
     *
586
     * @param string $outputDir
587
     */
588
    public function writeMetadataFiles(string $outputDir): void
589
    {
590
        while (strlen($outputDir) > 0 && $outputDir[strlen($outputDir) - 1] === '/') {
591
            $outputDir = substr($outputDir, 0, strlen($outputDir) - 1);
592
        }
593
594
        if (!file_exists($outputDir)) {
595
            Logger::info('Creating directory: ' . $outputDir . "\n");
596
            $res = @mkdir($outputDir, 0777, true);
597
            if ($res === false) {
598
                throw new Exception('Error creating directory: ' . $outputDir);
599
            }
600
        }
601
602
        foreach ($this->types as $type) {
603
            $filename = $outputDir . '/' . $type . '.php';
604
605
            if (array_key_exists($type, $this->metadata)) {
606
                $elements = $this->metadata[$type];
607
                Logger::debug('Writing: ' . $filename);
608
609
                $content  = '<?php' . "\n" . '/* This file was generated by the metarefresh module at ';
610
                $content .= $this->getTime() . "\nDo not update it manually as it will get overwritten\n" . '*/' . "\n";
611
612
                foreach ($elements as $m) {
613
                    $entityID = $m['metadata']['entityid'];
614
                    $content .= "\n" . '$metadata[\'';
615
                    $content .= addslashes($entityID) . '\'] = ' . var_export($m['metadata'], true) . ';' . "\n";
616
                }
617
618
                $content .= "\n" . '?>';
619
620
                Utils\System::writeFile($filename, $content, 0644);
621
            } elseif (is_file($filename)) {
622
                if (unlink($filename)) {
623
                    Logger::debug('Deleting stale metadata file: ' . $filename);
624
                } else {
625
                    Logger::warning('Could not delete stale metadata file: ' . $filename);
626
                }
627
            }
628
        }
629
    }
630
631
632
    /**
633
     * Save metadata for loading with the 'serialize' metadata loader.
634
     *
635
     * @param string $outputDir  The directory we should save the metadata to.
636
     */
637
    public function writeMetadataSerialize(string $outputDir): void
638
    {
639
        $metaHandler = new Metadata\MetaDataStorageHandlerSerialize(['directory' => $outputDir]);
640
641
        // First we add all the metadata entries to the metadata handler
642
        foreach ($this->metadata as $set => $elements) {
643
            foreach ($elements as $m) {
644
                $entityId = $m['metadata']['entityid'];
645
646
                Logger::debug(
647
                    'metarefresh: Add metadata entry ' .
648
                    var_export($entityId, true) . ' in set ' . var_export($set, true) . '.'
649
                );
650
                $metaHandler->saveMetadata($entityId, $set, $m['metadata']);
651
            }
652
        }
653
654
        // Then we delete old entries which should no longer exist
655
        $ct = time();
656
        foreach ($metaHandler->getMetadataSets() as $set) {
657
            foreach ($metaHandler->getMetadataSet($set) as $entityId => $metadata) {
658
                if (!array_key_exists('expire', $metadata) || !is_int($metadata['expire'])) {
659
                    Logger::warning(
660
                        'metarefresh: Metadata entry without valid expire timestamp: ' . var_export($entityId, true) .
661
                        ' in set ' . var_export($set, true) . '.'
662
                    );
663
                    continue;
664
                }
665
666
                $expire = $metadata['expire'];
667
                if ($expire > $ct) {
668
                    continue;
669
                }
670
671
                /** @var int $stamp */
672
                $stamp = date('l jS \of F Y h:i:s A', $expire);
673
                Logger::debug('metarefresh: ' . $entityId . ' expired ' . $stamp);
674
                Logger::debug(
675
                    'metarefresh: Delete expired metadata entry ' .
676
                    var_export($entityId, true) . ' in set ' . var_export($set, true) .
677
                    '. (' . ($ct - $expire) . ' sec)'
678
                );
679
                $metaHandler->deleteMetadata($entityId, $set);
680
            }
681
        }
682
    }
683
684
685
    /**
686
     * @return string
687
     */
688
    private function getTime(): string
689
    {
690
        // The current date, as a string
691
        date_default_timezone_set('UTC');
692
        return date('Y-m-d\\TH:i:s\\Z');
693
    }
694
}
695