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
30 changes: 30 additions & 0 deletions UPGRADE.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
====================

Expand Down
1 change: 1 addition & 0 deletions changelog.d/8774.misc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add additional error checking for OpenID Connect and SAML mapping providers.
9 changes: 8 additions & 1 deletion docs/sso_mapping_providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,15 @@ where SAML mapping providers come into play.
SSO mapping providers are currently supported for OpenID and SAML SSO
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
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
89 changes: 69 additions & 20 deletions tests/handlers/test_oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import json
from urllib.parse import parse_qs, urlparse

Expand All @@ -24,12 +23,8 @@
from twisted.python.failure import Failure
from twisted.web._newclient import ResponseDone

from synapse.handlers.oidc_handler import (
MappingException,
OidcError,
OidcHandler,
OidcMappingProvider,
)
from synapse.handlers.oidc_handler import OidcError, OidcHandler, OidcMappingProvider
from synapse.handlers.sso import MappingException
from synapse.types import UserID

from tests.unittest import HomeserverTestCase, override_config
Expand Down Expand Up @@ -132,14 +127,13 @@ def make_homeserver(self, reactor, clock):

config = self.default_config()
config["public_baseurl"] = BASE_URL
oidc_config = {}
oidc_config["enabled"] = True
oidc_config["client_id"] = CLIENT_ID
oidc_config["client_secret"] = CLIENT_SECRET
oidc_config["issuer"] = ISSUER
oidc_config["scopes"] = SCOPES
oidc_config["user_mapping_provider"] = {
"module": __name__ + ".TestMappingProvider",
oidc_config = {
"enabled": True,
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"issuer": ISSUER,
"scopes": SCOPES,
"user_mapping_provider": {"module": __name__ + ".TestMappingProvider"},
}

# Update this config with what's in the default config so that
Expand Down Expand Up @@ -705,18 +699,73 @@ 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.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öö")