Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Improve error checking for OIDC/SAML mapping providers #8774

Merged
merged 11 commits into from
Nov 19, 2020
1 change: 1 addition & 0 deletions changelog.d/8774.misc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Expand tests for OpenID Connect.
2 changes: 1 addition & 1 deletion docs/sso_mapping_providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ SSO mapping providers are currently supported for OpenID and SAML SSO
configurations. Please see the details below for how to implement your own.

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
as Synapse). The Synapse config is then modified to point to the mapping provider
(and optionally provide additional configuration for it).
Expand Down
25 changes: 20 additions & 5 deletions synapse/handlers/oidc_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@
from synapse.handlers.sso import MappingException
from synapse.http.site import SynapseRequest
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

if TYPE_CHECKING:
Expand Down Expand Up @@ -885,10 +890,12 @@ async def _map_userinfo_to_user(
"Retrieved user attributes from user mapping provider: %r", attributes
)

if not attributes["localpart"]:
raise MappingException("localpart is empty")

localpart = map_username_to_mxid_localpart(attributes["localpart"])
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This got pushed into the mapping provider, which matches SAML and gives mapping providers a bit more control (e.g. the dot-replace vs. hex-encode options that SAML has).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that a change in the mapping provider API?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SAML mapping provider (implicitly) must provide a valid localpart. I think this was kind of the assumption with the OIDC one as well, but I'm not sure. I failed to update the documentation to make this explicit for both of them, but I meant to! 😄

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pushed some documentation improvements (and added something to UPGRADE.rst). I suspect not many people have their own mapping provider, but I'm not sure.

localpart = attributes["localpart"]
if not localpart:
raise MappingException(
"Error parsing OIDC response: OIDC mapping provider plugin "
"did not return a localpart value"
)

user_id = UserID(localpart, self.server_name).to_string()
users = await self.store.get_users_by_id_case_insensitive(user_id)
Expand All @@ -908,6 +915,11 @@ async def _map_userinfo_to_user(
# This mxid is taken
raise MappingException("mxid '{}' is already taken".format(user_id))
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
# not taken, register the user
registered_user_id = await self._registration_handler.register_user(
Expand Down Expand Up @@ -1076,6 +1088,9 @@ async def map_user_attributes(
) -> UserAttribute:
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]
if self._config.display_name_template is not None:
display_name = self._config.display_name_template.render(
Expand Down
6 changes: 6 additions & 0 deletions synapse/handlers/saml_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from synapse.module_api import ModuleApi
from synapse.types import (
UserID,
contains_invalid_mxid_characters,
map_username_to_mxid_localpart,
mxid_localpart_allowed_characters,
)
Expand Down Expand Up @@ -317,6 +318,11 @@ async def _map_saml_response_to_user(
"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)
registered_user_id = await self._registration_handler.register_user(
localpart=localpart,
Expand Down
6 changes: 3 additions & 3 deletions synapse/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,14 +317,14 @@ def from_string(cls: Type[DS], s: str) -> DS:
)


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

Args:
localpart (basestring): the localpart to be checked
localpart: the localpart to be checked

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)

Expand Down
64 changes: 59 additions & 5 deletions tests/handlers/test_oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -705,18 +705,72 @@ def test_map_userinfo_to_user(self):
def test_map_userinfo_to_existing_user(self):
"""Existing users can log in with OpenID Connect when allow_existing_users is True."""
store = self.hs.get_datastore()
user4 = UserID.from_string("@test_user_4:test")
user = UserID.from_string("@test_user:test")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was no reason to start a 4 here so I simplified.

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 = {
"sub": "test4",
"username": "test_user_4",
"sub": "test",
"username": "test_user",
}
token = {}
mxid = self.get_success(
self.handler._map_userinfo_to_user(
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.assertEqual(
str(e.value),
"Attempted to login as '@TEST_USER_2:test' but it matches more than one user inexactly: ['@TEST_user_2:test', '@test_USER_2:test']",
)

# 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öö")