Improve error checking for OIDC/SAML mapping providers (#8774)

Checks that the localpart returned by mapping providers for SAML and
OIDC are valid before registering new users.

Extends the OIDC tests for existing users and invalid data.
This commit is contained in:
Patrick Cloke 2020-11-19 14:25:17 -05:00 committed by GitHub
parent 53a6f5ddf0
commit 79bfe966e0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 137 additions and 29 deletions

View file

@ -75,6 +75,36 @@ for example:
wget https://packages.matrix.org/debian/pool/main/m/matrix-synapse-py3/matrix-synapse-py3_1.3.0+stretch1_amd64.deb wget https://packages.matrix.org/debian/pool/main/m/matrix-synapse-py3/matrix-synapse-py3_1.3.0+stretch1_amd64.deb
dpkg -i matrix-synapse-py3_1.3.0+stretch1_amd64.deb dpkg -i matrix-synapse-py3_1.3.0+stretch1_amd64.deb
Upgrading to v1.24.0
====================
Custom OpenID Connect mapping provider breaking change
------------------------------------------------------
This release allows the OpenID Connect mapping provider to perform normalisation
of the localpart of the Matrix ID. This allows for the mapping provider to
specify different algorithms, instead of the [default way](https://matrix.org/docs/spec/appendices#mapping-from-other-character-sets).
If your Synapse configuration uses a custom mapping provider
(`oidc_config.user_mapping_provider.module` is specified and not equal to
`synapse.handlers.oidc_handler.JinjaOidcMappingProvider`) then you *must* ensure
that `map_user_attributes` of the mapping provider performs some normalisation
of the `localpart` returned. To match previous behaviour you can use the
`map_username_to_mxid_localpart` function provided by Synapse. An example is
shown below:
.. code-block:: python
from synapse.types import map_username_to_mxid_localpart
class MyMappingProvider:
def map_user_attributes(self, userinfo, token):
# ... your custom logic ...
sso_user_id = ...
localpart = map_username_to_mxid_localpart(sso_user_id)
return {"localpart": localpart}
Upgrading to v1.23.0 Upgrading to v1.23.0
==================== ====================

1
changelog.d/8774.misc Normal file
View file

@ -0,0 +1 @@
Add additional error checking for OpenID Connect and SAML mapping providers.

View file

@ -15,8 +15,15 @@ where SAML mapping providers come into play.
SSO mapping providers are currently supported for OpenID and SAML SSO SSO mapping providers are currently supported for OpenID and SAML SSO
configurations. Please see the details below for how to implement your own. configurations. Please see the details below for how to implement your own.
It is the responsibility of the mapping provider to normalise the SSO attributes
and map them to a valid Matrix ID. The
[specification for Matrix IDs](https://matrix.org/docs/spec/appendices#user-identifiers)
has some information about what is considered valid. Alternately an easy way to
ensure it is valid is to use a Synapse utility function:
`synapse.types.map_username_to_mxid_localpart`.
External mapping providers are provided to Synapse in the form of an external External mapping providers are provided to Synapse in the form of an external
Python module. You can retrieve this module from [PyPi](https://pypi.org) or elsewhere, Python module. You can retrieve this module from [PyPI](https://pypi.org) or elsewhere,
but it must be importable via Synapse (e.g. it must be in the same virtualenv but it must be importable via Synapse (e.g. it must be in the same virtualenv
as Synapse). The Synapse config is then modified to point to the mapping provider as Synapse). The Synapse config is then modified to point to the mapping provider
(and optionally provide additional configuration for it). (and optionally provide additional configuration for it).

View file

@ -38,7 +38,12 @@ from synapse.handlers._base import BaseHandler
from synapse.handlers.sso import MappingException from synapse.handlers.sso import MappingException
from synapse.http.site import SynapseRequest from synapse.http.site import SynapseRequest
from synapse.logging.context import make_deferred_yieldable from synapse.logging.context import make_deferred_yieldable
from synapse.types import JsonDict, UserID, map_username_to_mxid_localpart from synapse.types import (
JsonDict,
UserID,
contains_invalid_mxid_characters,
map_username_to_mxid_localpart,
)
from synapse.util import json_decoder from synapse.util import json_decoder
if TYPE_CHECKING: if TYPE_CHECKING:
@ -885,10 +890,12 @@ class OidcHandler(BaseHandler):
"Retrieved user attributes from user mapping provider: %r", attributes "Retrieved user attributes from user mapping provider: %r", attributes
) )
if not attributes["localpart"]: localpart = attributes["localpart"]
raise MappingException("localpart is empty") if not localpart:
raise MappingException(
localpart = map_username_to_mxid_localpart(attributes["localpart"]) "Error parsing OIDC response: OIDC mapping provider plugin "
"did not return a localpart value"
)
user_id = UserID(localpart, self.server_name).to_string() user_id = UserID(localpart, self.server_name).to_string()
users = await self.store.get_users_by_id_case_insensitive(user_id) users = await self.store.get_users_by_id_case_insensitive(user_id)
@ -908,6 +915,11 @@ class OidcHandler(BaseHandler):
# This mxid is taken # This mxid is taken
raise MappingException("mxid '{}' is already taken".format(user_id)) raise MappingException("mxid '{}' is already taken".format(user_id))
else: else:
# Since the localpart is provided via a potentially untrusted module,
# ensure the MXID is valid before registering.
if contains_invalid_mxid_characters(localpart):
raise MappingException("localpart is invalid: %s" % (localpart,))
# It's the first time this user is logging in and the mapped mxid was # It's the first time this user is logging in and the mapped mxid was
# not taken, register the user # not taken, register the user
registered_user_id = await self._registration_handler.register_user( registered_user_id = await self._registration_handler.register_user(
@ -1076,6 +1088,9 @@ class JinjaOidcMappingProvider(OidcMappingProvider[JinjaOidcMappingConfig]):
) -> UserAttribute: ) -> UserAttribute:
localpart = self._config.localpart_template.render(user=userinfo).strip() localpart = self._config.localpart_template.render(user=userinfo).strip()
# Ensure only valid characters are included in the MXID.
localpart = map_username_to_mxid_localpart(localpart)
display_name = None # type: Optional[str] display_name = None # type: Optional[str]
if self._config.display_name_template is not None: if self._config.display_name_template is not None:
display_name = self._config.display_name_template.render( display_name = self._config.display_name_template.render(

View file

@ -31,6 +31,7 @@ from synapse.http.site import SynapseRequest
from synapse.module_api import ModuleApi from synapse.module_api import ModuleApi
from synapse.types import ( from synapse.types import (
UserID, UserID,
contains_invalid_mxid_characters,
map_username_to_mxid_localpart, map_username_to_mxid_localpart,
mxid_localpart_allowed_characters, mxid_localpart_allowed_characters,
) )
@ -318,6 +319,11 @@ class SamlHandler(BaseHandler):
"Unable to generate a Matrix ID from the SAML response" "Unable to generate a Matrix ID from the SAML response"
) )
# Since the localpart is provided via a potentially untrusted module,
# ensure the MXID is valid before registering.
if contains_invalid_mxid_characters(localpart):
raise MappingException("localpart is invalid: %s" % (localpart,))
logger.info("Mapped SAML user to local part %s", localpart) logger.info("Mapped SAML user to local part %s", localpart)
registered_user_id = await self._registration_handler.register_user( registered_user_id = await self._registration_handler.register_user(
localpart=localpart, localpart=localpart,

View file

@ -317,14 +317,14 @@ mxid_localpart_allowed_characters = set(
) )
def contains_invalid_mxid_characters(localpart): def contains_invalid_mxid_characters(localpart: str) -> bool:
"""Check for characters not allowed in an mxid or groupid localpart """Check for characters not allowed in an mxid or groupid localpart
Args: Args:
localpart (basestring): the localpart to be checked localpart: the localpart to be checked
Returns: Returns:
bool: True if there are any naughty characters True if there are any naughty characters
""" """
return any(c not in mxid_localpart_allowed_characters for c in localpart) return any(c not in mxid_localpart_allowed_characters for c in localpart)

View file

@ -12,7 +12,6 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import json import json
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
@ -24,12 +23,8 @@ import pymacaroons
from twisted.python.failure import Failure from twisted.python.failure import Failure
from twisted.web._newclient import ResponseDone from twisted.web._newclient import ResponseDone
from synapse.handlers.oidc_handler import ( from synapse.handlers.oidc_handler import OidcError, OidcHandler, OidcMappingProvider
MappingException, from synapse.handlers.sso import MappingException
OidcError,
OidcHandler,
OidcMappingProvider,
)
from synapse.types import UserID from synapse.types import UserID
from tests.unittest import HomeserverTestCase, override_config from tests.unittest import HomeserverTestCase, override_config
@ -132,14 +127,13 @@ class OidcHandlerTestCase(HomeserverTestCase):
config = self.default_config() config = self.default_config()
config["public_baseurl"] = BASE_URL config["public_baseurl"] = BASE_URL
oidc_config = {} oidc_config = {
oidc_config["enabled"] = True "enabled": True,
oidc_config["client_id"] = CLIENT_ID "client_id": CLIENT_ID,
oidc_config["client_secret"] = CLIENT_SECRET "client_secret": CLIENT_SECRET,
oidc_config["issuer"] = ISSUER "issuer": ISSUER,
oidc_config["scopes"] = SCOPES "scopes": SCOPES,
oidc_config["user_mapping_provider"] = { "user_mapping_provider": {"module": __name__ + ".TestMappingProvider"},
"module": __name__ + ".TestMappingProvider",
} }
# Update this config with what's in the default config so that # Update this config with what's in the default config so that
@ -705,13 +699,13 @@ class OidcHandlerTestCase(HomeserverTestCase):
def test_map_userinfo_to_existing_user(self): def test_map_userinfo_to_existing_user(self):
"""Existing users can log in with OpenID Connect when allow_existing_users is True.""" """Existing users can log in with OpenID Connect when allow_existing_users is True."""
store = self.hs.get_datastore() store = self.hs.get_datastore()
user4 = UserID.from_string("@test_user_4:test") user = UserID.from_string("@test_user:test")
self.get_success( self.get_success(
store.register_user(user_id=user4.to_string(), password_hash=None) store.register_user(user_id=user.to_string(), password_hash=None)
) )
userinfo = { userinfo = {
"sub": "test4", "sub": "test",
"username": "test_user_4", "username": "test_user",
} }
token = {} token = {}
mxid = self.get_success( mxid = self.get_success(
@ -719,4 +713,59 @@ class OidcHandlerTestCase(HomeserverTestCase):
userinfo, token, "user-agent", "10.10.10.10" userinfo, token, "user-agent", "10.10.10.10"
) )
) )
self.assertEqual(mxid, "@test_user_4:test") self.assertEqual(mxid, "@test_user:test")
# Register some non-exact matching cases.
user2 = UserID.from_string("@TEST_user_2:test")
self.get_success(
store.register_user(user_id=user2.to_string(), password_hash=None)
)
user2_caps = UserID.from_string("@test_USER_2:test")
self.get_success(
store.register_user(user_id=user2_caps.to_string(), password_hash=None)
)
# Attempting to login without matching a name exactly is an error.
userinfo = {
"sub": "test2",
"username": "TEST_USER_2",
}
e = self.get_failure(
self.handler._map_userinfo_to_user(
userinfo, token, "user-agent", "10.10.10.10"
),
MappingException,
)
self.assertTrue(
str(e.value).startswith(
"Attempted to login as '@TEST_USER_2:test' but it matches more than one user inexactly:"
)
)
# Logging in when matching a name exactly should work.
user2 = UserID.from_string("@TEST_USER_2:test")
self.get_success(
store.register_user(user_id=user2.to_string(), password_hash=None)
)
mxid = self.get_success(
self.handler._map_userinfo_to_user(
userinfo, token, "user-agent", "10.10.10.10"
)
)
self.assertEqual(mxid, "@TEST_USER_2:test")
def test_map_userinfo_to_invalid_localpart(self):
"""If the mapping provider generates an invalid localpart it should be rejected."""
userinfo = {
"sub": "test2",
"username": "föö",
}
token = {}
e = self.get_failure(
self.handler._map_userinfo_to_user(
userinfo, token, "user-agent", "10.10.10.10"
),
MappingException,
)
self.assertEqual(str(e.value), "localpart is invalid: föö")