From 919a6c102cd36c9278529cc0c3f212bf511a8471 Mon Sep 17 00:00:00 2001
From: Thijs Kinkhorst <thijs@kinkhorst.com>
Date: Fri, 27 Aug 2021 13:03:17 +0000
Subject: [PATCH] Fix filterscopes using hostname when explicit scopes set.

FilterScopes documented behaviour is that hostname is only used as
a fallback when there are no explicit scopes (makes sense). However
the code does not do that but always includes the hostname to match.

This refactors the code so that hostname is only evaluated when
no explicit scopes are there. Also it takes the whole fetching and
parsing of the SSOlocation outside of the inner loop whcih is
inefficient. This is now only done once, and only if needed (when
no explicit scopes present).

To confirm the changes testcases have been added for this and also
for some other cases to reach 100% coverage for the filter.
---
 .../saml/lib/Auth/Process/FilterScopes.php    | 68 ++++++-------
 .../lib/Auth/Process/FilterScopesTest.php     | 97 +++++++++++++++++--
 2 files changed, 118 insertions(+), 47 deletions(-)

diff --git a/modules/saml/lib/Auth/Process/FilterScopes.php b/modules/saml/lib/Auth/Process/FilterScopes.php
index 9748a9c61..f6c0a8f92 100644
--- a/modules/saml/lib/Auth/Process/FilterScopes.php
+++ b/modules/saml/lib/Auth/Process/FilterScopes.php
@@ -25,7 +25,6 @@ class FilterScopes extends ProcessingFilter
         'eduPersonPrincipalName'
     ];
 
-
     /**
      * Constructor for the processing filter.
      *
@@ -41,7 +40,6 @@ class FilterScopes extends ProcessingFilter
         }
     }
 
-
     /**
      * This method applies the filter, removing any values
      *
@@ -50,53 +48,45 @@ class FilterScopes extends ProcessingFilter
     public function process(array &$request): void
     {
         $src = $request['Source'];
-        if (!count($this->scopedAttributes)) {
-            // paranoia, should never happen
-            Logger::warning('No scoped attributes configured.');
-            return;
-        }
+
         $validScopes = [];
+        $host = '';
         if (array_key_exists('scope', $src) && is_array($src['scope']) && !empty($src['scope'])) {
             $validScopes = $src['scope'];
+        } else {
+            $ep = Utils\Config\Metadata::getDefaultEndpoint($request['Source']['SingleSignOnService']);
+            $host = parse_url($ep['Location'], PHP_URL_HOST) ?? '';
         }
 
-        $ep = Utils\Config\Metadata::getDefaultEndpoint($request['Source']['SingleSignOnService']);
-        if ($ep !== null) {
-            foreach ($this->scopedAttributes as $attribute) {
-                if (!isset($request['Attributes'][$attribute])) {
-                    continue;
-                }
+        foreach ($this->scopedAttributes as $attribute) {
+            if (!isset($request['Attributes'][$attribute])) {
+                continue;
+            }
 
-                $values = $request['Attributes'][$attribute];
-                $newValues = [];
-                foreach ($values as $value) {
-                    $loc = $ep['Location'];
-                    $host = parse_url($loc, PHP_URL_HOST);
-                    if ($host === null) {
-                        $host = '';
-                    }
-                    $value_a = explode('@', $value, 2);
-                    if (count($value_a) < 2) {
-                        $newValues[] = $value;
-                        continue; // there's no scope
-                    }
-                    $scope = $value_a[1];
-                    if (in_array($scope, $validScopes, true)) {
-                        $newValues[] = $value;
-                    } elseif (strpos($host, $scope) === strlen($host) - strlen($scope)) {
-                        $newValues[] = $value;
-                    } else {
-                        Logger::warning("Removing value '$value' for attribute '$attribute'. Undeclared scope.");
-                    }
+            $values = $request['Attributes'][$attribute];
+            $newValues = [];
+            foreach ($values as $value) {
+                $value_a = explode('@', $value, 2);
+                if (count($value_a) < 2) {
+                    $newValues[] = $value;
+                    continue; // there's no scope
                 }
-
-                if (empty($newValues)) {
-                    Logger::warning("No suitable values for attribute '$attribute', removing it.");
-                    unset($request['Attributes'][$attribute]); // remove empty attributes
+                $scope = $value_a[1];
+                if (in_array($scope, $validScopes, true)) {
+                    $newValues[] = $value;
+                } elseif (strpos($host, $scope) === strlen($host) - strlen($scope)) {
+                    $newValues[] = $value;
                 } else {
-                    $request['Attributes'][$attribute] = $newValues;
+                    Logger::warning("Removing value '$value' for attribute '$attribute'. Undeclared scope.");
                 }
             }
+
+            if (empty($newValues)) {
+                Logger::warning("No suitable values for attribute '$attribute', removing it.");
+                unset($request['Attributes'][$attribute]); // remove empty attributes
+            } else {
+                $request['Attributes'][$attribute] = $newValues;
+            }
         }
     }
 }
diff --git a/tests/modules/saml/lib/Auth/Process/FilterScopesTest.php b/tests/modules/saml/lib/Auth/Process/FilterScopesTest.php
index 724eebe57..2730453c3 100644
--- a/tests/modules/saml/lib/Auth/Process/FilterScopesTest.php
+++ b/tests/modules/saml/lib/Auth/Process/FilterScopesTest.php
@@ -68,19 +68,12 @@ class FilterScopesTest extends TestCase
         $result = $this->processFilter($config, $request);
         $this->assertEquals($request['Attributes'], $result['Attributes']);
 
-        // test implicit scope
-        $request['Attributes'] = [
-            'eduPersonPrincipalName' => ['jdoe@example.org'],
-        ];
-        $result = $this->processFilter($config, $request);
-        $this->assertEquals($request['Attributes'], $result['Attributes']);
-
         // test alternative attributes
         $config['attributes'] = [
             'mail',
         ];
         $request['Attributes'] = [
-            'mail' => ['john.doe@example.org'],
+            'mail' => ['john.doe@example.com'],
         ];
         $result = $this->processFilter($config, $request);
         $this->assertEquals($request['Attributes'], $result['Attributes']);
@@ -91,6 +84,35 @@ class FilterScopesTest extends TestCase
         $this->assertEquals($request['Attributes'], $result['Attributes']);
     }
 
+    /**
+     * Test implict scope matching on IdP hostname
+     */
+    public function testImplicitScopes(): void
+    {
+        $config = [];
+        $request = [
+            'Source'     => [
+                'SingleSignOnService' => [
+                    [
+                        'Binding'  => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
+                        'Location' => 'https://example.org/saml2/idp/SSOService.php',
+                    ],
+                ],
+            ],
+            'Attributes' => [
+                'eduPersonPrincipalName' => ['jdoe@example.org'],
+            ],
+        ];
+
+        $result = $this->processFilter($config, $request);
+        $this->assertEquals($request['Attributes'], $result['Attributes']);
+
+        $request['Attributes'] = [
+            'eduPersonPrincipalName' => ['jdoe@example.com'],
+        ];
+        $result = $this->processFilter($config, $request);
+        $this->assertEquals([], $result['Attributes']);
+    }
 
     /**
      * Test invalid scopes.
@@ -137,5 +159,64 @@ class FilterScopesTest extends TestCase
         ];
         $result = $this->processFilter($config, $request);
         $this->assertEquals($request['Attributes'], $result['Attributes']);
+
+    }
+
+    /**
+     * Test that implicit matching is not done when explicit scopes present
+     */
+    public function testNoImplicitMatchingWhenExplicitScopes(): void
+    {
+        // test declared scopes
+        $config = [];
+        $request = [
+            'Source'     => [
+                'SingleSignOnService' => [
+                    [
+                        'Binding'  => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
+                        'Location' => 'https://example.org/saml2/idp/SSOService.php',
+                    ],
+                ],
+                'scope' => [
+                    'example.com',
+                    'example.net',
+                ],
+            ],
+            'Attributes' => [
+                'eduPersonPrincipalName' => ['jdoe@example.org'],
+            ],
+        ];
+        $result = $this->processFilter($config, $request);
+        $this->assertEquals([], $result['Attributes']);
+    }
+
+    /**
+     * Test that the scope is considered to be the part after the first @ sign
+     */
+    public function testAttributeValueMultipleAt(): void
+    {
+        $config = [];
+        $request = [
+            'Source'     => [
+                'SingleSignOnService' => [
+                    [
+                        'Binding'  => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
+                        'Location' => 'https://example.org/saml2/idp/SSOService.php',
+                    ],
+                ],
+                'scope' => [
+                    'example.com',
+                ],
+            ],
+            'Attributes' => [
+                'eduPersonPrincipalName' => ['jdoe@gmail.com@example.com'],
+            ],
+        ];
+        $result = $this->processFilter($config, $request);
+        $this->assertEquals([], $result['Attributes']);
+
+        $request['Source']['scope'] = ['gmail.com@example.com'];
+        $result = $this->processFilter($config, $request);
+        $this->assertEquals($request['Attributes'], $result['Attributes']);
     }
 }
-- 
GitLab