Skip to content

Commit 6f9fab1

Browse files
Fix open redirect in legacy SSO flow (idp) (#18909)
- Validate the `idp` parameter to only accept the ones that are known in the config file - URL-encode the `idp` parameter for safety's sake (this is the main fix) Fix matrix-org/internal-config#1651 (internal link) Regressed in #17972
1 parent 84d6425 commit 6f9fab1

File tree

5 files changed

+73
-26
lines changed

5 files changed

+73
-26
lines changed

changelog.d/18909.bugfix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix open redirect in legacy SSO flow with the `idp` query parameter.

synapse/api/urls.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"""Contains the URL paths to prefix various aspects of the server with."""
2323

2424
import hmac
25+
import urllib.parse
2526
from hashlib import sha256
2627
from typing import Optional
2728
from urllib.parse import urlencode, urljoin
@@ -96,11 +97,21 @@ def build_login_sso_redirect_uri(
9697
serialized_query_parameters = urlencode({"redirectUrl": client_redirect_url})
9798

9899
if idp_id:
100+
# Since this is a user-controlled string, make it safe to include in a URL path.
101+
url_encoded_idp_id = urllib.parse.quote(
102+
idp_id,
103+
# Since this defaults to `safe="/"`, we have to override it. We're
104+
# working with an individual URL path parameter so there shouldn't be
105+
# any slashes in it which could change the request path.
106+
safe="",
107+
encoding="utf8",
108+
)
109+
99110
resultant_url = urljoin(
100111
# We have to add a trailing slash to the base URL to ensure that the
101112
# last path segment is not stripped away when joining with another path.
102113
f"{base_url}/",
103-
f"{idp_id}?{serialized_query_parameters}",
114+
f"{url_encoded_idp_id}?{serialized_query_parameters}",
104115
)
105116
else:
106117
resultant_url = f"{base_url}?{serialized_query_parameters}"

synapse/rest/synapse/client/pick_idp.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,22 @@ async def _async_render_GET(self, request: SynapseRequest) -> None:
6363
if not idp:
6464
return await self._serve_id_picker(request, client_redirect_url)
6565

66+
# Validate the `idp` query parameter. We should only be working with known IdPs.
67+
# No need waste further effort if we don't know about it.
68+
#
69+
# Although, we primarily prevent open redirect attacks by URL encoding all of
70+
# the parameters we use in the redirect URL below, this validation also helps
71+
# prevent Synapse from crafting arbitrary URLs and being used in open redirect
72+
# attacks (defense in depth).
73+
providers = self._sso_handler.get_identity_providers()
74+
auth_provider = providers.get(idp)
75+
if not auth_provider:
76+
logger.info("Unknown idp %r", idp)
77+
self._sso_handler.render_error(
78+
request, "unknown_idp", "Unknown identity provider ID"
79+
)
80+
return
81+
6682
# Otherwise, redirect to the login SSO redirect endpoint for the given IdP
6783
# (which will in turn take us to the the IdP's redirect URI).
6884
#

tests/api/test_urls.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,29 @@ def test_tricky_redirect_uri(self) -> None:
5353
),
5454
"https://test/_matrix/client/v3/login/sso/redirect/oidc-github?redirectUrl=https%3A%2F%2Fx%3F%3Cab+c%3E%26q%22%2B%253D%252B%22%3D%22f%C3%B6%2526%3Do%22",
5555
)
56+
57+
def test_idp_id_with_slash_is_escaped(self) -> None:
58+
"""
59+
Test to make sure that we properly URL encode the IdP ID.
60+
"""
61+
self.assertEqual(
62+
self.login_sso_redirect_url_builder.build_login_sso_redirect_uri(
63+
idp_id="foo/bar",
64+
client_redirect_url="http://example.com/redirect",
65+
),
66+
"https://test/_matrix/client/v3/login/sso/redirect/foo%2Fbar?redirectUrl=http%3A%2F%2Fexample.com%2Fredirect",
67+
)
68+
69+
def test_url_as_idp_id_is_escaped(self) -> None:
70+
"""
71+
Test to make sure that we properly URL encode the IdP ID.
72+
73+
The IdP ID shouldn't be a URL.
74+
"""
75+
self.assertEqual(
76+
self.login_sso_redirect_url_builder.build_login_sso_redirect_uri(
77+
idp_id="http://should-not-be-url.com/",
78+
client_redirect_url="http://example.com/redirect",
79+
),
80+
"https://test/_matrix/client/v3/login/sso/redirect/http%3A%2F%2Fshould-not-be-url.com%2F?redirectUrl=http%3A%2F%2Fexample.com%2Fredirect",
81+
)

tests/rest/client/test_login.py

Lines changed: 18 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -939,39 +939,32 @@ def test_login_via_oidc(self) -> None:
939939
self.assertEqual(chan.code, 200, chan.result)
940940
self.assertEqual(chan.json_body["user_id"], "@user1:test")
941941

942-
def test_multi_sso_redirect_to_unknown(self) -> None:
943-
"""An unknown IdP should cause a 404"""
942+
def test_multi_sso_redirect_unknown_idp(self) -> None:
943+
"""An unknown IdP should cause a 400 bad request error"""
944944
channel = self.make_request(
945945
"GET",
946946
"/_synapse/client/pick_idp?redirectUrl=http://x&idp=xyz",
947947
)
948-
self.assertEqual(channel.code, 302, channel.result)
949-
location_headers = channel.headers.getRawHeaders("Location")
950-
assert location_headers
951-
sso_login_redirect_uri = location_headers[0]
952-
953-
# it should redirect us to the standard login SSO redirect flow
954-
self.assertEqual(
955-
sso_login_redirect_uri,
956-
self.login_sso_redirect_url_builder.build_login_sso_redirect_uri(
957-
idp_id="xyz", client_redirect_url="http://x"
958-
),
959-
)
948+
self.assertEqual(channel.code, 400, channel.result)
960949

961-
# follow the redirect
950+
def test_multi_sso_redirect_unknown_idp_as_url(self) -> None:
951+
"""
952+
An unknown IdP that looks like a URL should cause a 400 bad request error (to
953+
avoid open redirects).
954+
955+
Ideally, we'd have another test for a known IdP with a URL as the `idp_id`, but
956+
we can't configure that in our tests because the config validation on
957+
`oidc_providers` only allows a subset of characters. If we could configure
958+
`oidc_providers` with a URL as the `idp_id`, it should still be URL-encoded
959+
properly to avoid open redirections. We do have `test_url_as_idp_id_is_escaped`
960+
in the URL building tests to cover this case but is only a unit test vs
961+
something at the REST layer here that covers things end-to-end.
962+
"""
962963
channel = self.make_request(
963964
"GET",
964-
# We have to make this relative to be compatible with `make_request(...)`
965-
get_relative_uri_from_absolute_uri(sso_login_redirect_uri),
966-
# We have to set the Host header to match the `public_baseurl` to avoid
967-
# the extra redirect in the `SsoRedirectServlet` in order for the
968-
# cookies to be visible.
969-
custom_headers=[
970-
("Host", SYNAPSE_SERVER_PUBLIC_HOSTNAME),
971-
],
965+
"/_synapse/client/pick_idp?redirectUrl=something&idp=https://element.io/",
972966
)
973-
974-
self.assertEqual(channel.code, 404, channel.result)
967+
self.assertEqual(channel.code, 400, channel.result)
975968

976969
def test_client_idp_redirect_to_unknown(self) -> None:
977970
"""If the client tries to pick an unknown IdP, return a 404"""

0 commit comments

Comments
 (0)