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

Commit ff91a45

Browse files
committed
Add a config option for validating 'next_link' parameters against a domain whitelist (#8275)
This is a config option ported over from DINUM's Sydent: matrix-org/sydent#285 They've switched to validating 3PIDs via Synapse rather than Sydent, and would like to retain this functionality. This original purpose for this change is phishing prevention. This solution could also potentially be replaced by a similar one to #8004, but across all `*/submit_token` endpoint. This option may still be useful to enterprise even with that safeguard in place though, if they want to be absolutely sure that their employees don't follow links to other domains.
1 parent fedb89a commit ff91a45

File tree

5 files changed

+218
-18
lines changed

5 files changed

+218
-18
lines changed

changelog.d/8275.feature

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add a config option to specify a whitelist of domains that a user can be redirected to after validating their email or phone number.

docs/sample_config.yaml

+18
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,24 @@ retention:
479479
#
480480
#request_token_inhibit_3pid_errors: true
481481

482+
# A list of domains that the domain portion of 'next_link' parameters
483+
# must match.
484+
#
485+
# This parameter is optionally provided by clients while requesting
486+
# validation of an email or phone number, and maps to a link that
487+
# users will be automatically redirected to after validation
488+
# succeeds. Clients can make use this parameter to aid the validation
489+
# process.
490+
#
491+
# The whitelist is applied whether the homeserver or an
492+
# identity server is handling validation.
493+
#
494+
# The default value is no whitelist functionality; all domains are
495+
# allowed. Setting this value to an empty list will instead disallow
496+
# all domains.
497+
#
498+
#next_link_domain_whitelist: ["matrix.org"]
499+
482500

483501
## TLS ##
484502

synapse/config/server.py

+47-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import os.path
2020
import re
2121
from textwrap import indent
22-
from typing import Any, Dict, Iterable, List, Optional
22+
from typing import Any, Dict, Iterable, List, Optional, Set
2323

2424
import attr
2525
import yaml
@@ -533,6 +533,34 @@ class LimitRemoteRoomsConfig(object):
533533
"request_token_inhibit_3pid_errors", False,
534534
)
535535

536+
# List of users trialing the new experimental default push rules. This setting is
537+
# not included in the sample configuration file on purpose as it's a temporary
538+
# hack, so that some users can trial the new defaults without impacting every
539+
# user on the homeserver.
540+
users_new_default_push_rules = (
541+
config.get("users_new_default_push_rules") or []
542+
) # type: list
543+
if not isinstance(users_new_default_push_rules, list):
544+
raise ConfigError("'users_new_default_push_rules' must be a list")
545+
546+
# Turn the list into a set to improve lookup speed.
547+
self.users_new_default_push_rules = set(
548+
users_new_default_push_rules
549+
) # type: set
550+
551+
# Whitelist of domain names that given next_link parameters must have
552+
next_link_domain_whitelist = config.get(
553+
"next_link_domain_whitelist"
554+
) # type: Optional[List[str]]
555+
556+
self.next_link_domain_whitelist = None # type: Optional[Set[str]]
557+
if next_link_domain_whitelist is not None:
558+
if not isinstance(next_link_domain_whitelist, list):
559+
raise ConfigError("'next_link_domain_whitelist' must be a list")
560+
561+
# Turn the list into a set to improve lookup speed.
562+
self.next_link_domain_whitelist = set(next_link_domain_whitelist)
563+
536564
def has_tls_listener(self) -> bool:
537565
return any(listener.tls for listener in self.listeners)
538566

@@ -1063,6 +1091,24 @@ def generate_config_section(
10631091
# act as if no error happened and return a fake session ID ('sid') to clients.
10641092
#
10651093
#request_token_inhibit_3pid_errors: true
1094+
1095+
# A list of domains that the domain portion of 'next_link' parameters
1096+
# must match.
1097+
#
1098+
# This parameter is optionally provided by clients while requesting
1099+
# validation of an email or phone number, and maps to a link that
1100+
# users will be automatically redirected to after validation
1101+
# succeeds. Clients can make use this parameter to aid the validation
1102+
# process.
1103+
#
1104+
# The whitelist is applied whether the homeserver or an
1105+
# identity server is handling validation.
1106+
#
1107+
# The default value is no whitelist functionality; all domains are
1108+
# allowed. Setting this value to an empty list will instead disallow
1109+
# all domains.
1110+
#
1111+
#next_link_domain_whitelist: ["matrix.org"]
10661112
"""
10671113
% locals()
10681114
)

synapse/rest/client/v2_alpha/account.py

+56-10
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@
1717
import logging
1818
import re
1919
from http import HTTPStatus
20+
from typing import TYPE_CHECKING
21+
from urllib.parse import urlparse
22+
23+
if TYPE_CHECKING:
24+
from synapse.app.homeserver import HomeServer
2025

2126
from twisted.internet import defer
2227

@@ -106,6 +111,9 @@ async def on_POST(self, request):
106111
Codes.THREEPID_DENIED,
107112
)
108113

114+
# Raise if the provided next_link value isn't valid
115+
assert_valid_next_link(self.hs, next_link)
116+
109117
# The email will be sent to the stored address.
110118
# This avoids a potential account hijack by requesting a password reset to
111119
# an email address which is controlled by the attacker but which, after
@@ -454,6 +462,9 @@ async def on_POST(self, request):
454462
Codes.THREEPID_DENIED,
455463
)
456464

465+
# Raise if the provided next_link value isn't valid
466+
assert_valid_next_link(self.hs, next_link)
467+
457468
existing_user_id = await self.store.get_user_id_by_threepid("email", email)
458469

459470
if existing_user_id is not None:
@@ -522,7 +533,8 @@ async def on_POST(self, request):
522533
Codes.THREEPID_DENIED,
523534
)
524535

525-
assert_valid_client_secret(body["client_secret"])
536+
# Raise if the provided next_link value isn't valid
537+
assert_valid_next_link(self.hs, next_link)
526538

527539
existing_user_id = await self.store.get_user_id_by_threepid("msisdn", msisdn)
528540

@@ -608,15 +620,10 @@ async def on_GET(self, request):
608620

609621
# Perform a 302 redirect if next_link is set
610622
if next_link:
611-
if next_link.startswith("file:///"):
612-
logger.warning(
613-
"Not redirecting to next_link as it is a local file: address"
614-
)
615-
else:
616-
request.setResponseCode(302)
617-
request.setHeader("Location", next_link)
618-
finish_request(request)
619-
return None
623+
request.setResponseCode(302)
624+
request.setHeader("Location", next_link)
625+
finish_request(request)
626+
return None
620627

621628
# Otherwise show the success template
622629
html = self.config.email_add_threepid_template_success_html_content
@@ -1029,6 +1036,45 @@ def on_POST(self, request):
10291036
defer.returnValue((200, ret))
10301037

10311038

1039+
def assert_valid_next_link(hs: "HomeServer", next_link: str):
1040+
"""
1041+
Raises a SynapseError if a given next_link value is invalid
1042+
1043+
next_link is valid if the scheme is http(s) and the next_link.domain_whitelist config
1044+
option is either empty or contains a domain that matches the one in the given next_link
1045+
1046+
Args:
1047+
hs: The homeserver object
1048+
next_link: The next_link value given by the client
1049+
1050+
Raises:
1051+
SynapseError: If the next_link is invalid
1052+
"""
1053+
valid = True
1054+
1055+
# Parse the contents of the URL
1056+
next_link_parsed = urlparse(next_link)
1057+
1058+
# Scheme must not point to the local drive
1059+
if next_link_parsed.scheme == "file":
1060+
valid = False
1061+
1062+
# If the domain whitelist is set, the domain must be in it
1063+
if (
1064+
valid
1065+
and hs.config.next_link_domain_whitelist is not None
1066+
and next_link_parsed.hostname not in hs.config.next_link_domain_whitelist
1067+
):
1068+
valid = False
1069+
1070+
if not valid:
1071+
raise SynapseError(
1072+
400,
1073+
"'next_link' domain not included in whitelist, or not http(s)",
1074+
errcode=Codes.INVALID_PARAM,
1075+
)
1076+
1077+
10321078
class WhoamiRestServlet(RestServlet):
10331079
PATTERNS = client_patterns("/account/whoami$")
10341080

tests/rest/client/v2_alpha/test_account.py

+96-7
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@
1414
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1515
# See the License for the specific language governing permissions and
1616
# limitations under the License.
17-
1817
import json
1918
import os
2019
import re
2120
from email.parser import Parser
21+
from typing import Optional
2222

2323
import pkg_resources
2424

@@ -29,6 +29,7 @@
2929
from synapse.rest.client.v2_alpha import account, register
3030

3131
from tests import unittest
32+
from tests.unittest import override_config
3233

3334

3435
class PasswordResetTestCase(unittest.HomeserverTestCase):
@@ -668,16 +669,104 @@ def test_no_valid_token(self):
668669
self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])
669670
self.assertFalse(channel.json_body["threepids"])
670671

671-
def _request_token(self, email, client_secret):
672+
@override_config({"next_link_domain_whitelist": None})
673+
def test_next_link(self):
674+
"""Tests a valid next_link parameter value with no whitelist (good case)"""
675+
self._request_token(
676+
677+
"some_secret",
678+
next_link="https://example.com/a/good/site",
679+
expect_code=200,
680+
)
681+
682+
@override_config({"next_link_domain_whitelist": None})
683+
def test_next_link_exotic_protocol(self):
684+
"""Tests using a esoteric protocol as a next_link parameter value.
685+
Someone may be hosting a client on IPFS etc.
686+
"""
687+
self._request_token(
688+
689+
"some_secret",
690+
next_link="some-protocol://abcdefghijklmopqrstuvwxyz",
691+
expect_code=200,
692+
)
693+
694+
@override_config({"next_link_domain_whitelist": None})
695+
def test_next_link_file_uri(self):
696+
"""Tests next_link parameters cannot be file URI"""
697+
# Attempt to use a next_link value that points to the local disk
698+
self._request_token(
699+
700+
"some_secret",
701+
next_link="file:///host/path",
702+
expect_code=400,
703+
)
704+
705+
@override_config({"next_link_domain_whitelist": ["example.com", "example.org"]})
706+
def test_next_link_domain_whitelist(self):
707+
"""Tests next_link parameters must fit the whitelist if provided"""
708+
self._request_token(
709+
710+
"some_secret",
711+
next_link="https://example.com/some/good/page",
712+
expect_code=200,
713+
)
714+
715+
self._request_token(
716+
717+
"some_secret",
718+
next_link="https://example.org/some/also/good/page",
719+
expect_code=200,
720+
)
721+
722+
self._request_token(
723+
724+
"some_secret",
725+
next_link="https://bad.example.org/some/bad/page",
726+
expect_code=400,
727+
)
728+
729+
@override_config({"next_link_domain_whitelist": []})
730+
def test_empty_next_link_domain_whitelist(self):
731+
"""Tests an empty next_lint_domain_whitelist value, meaning next_link is essentially
732+
disallowed
733+
"""
734+
self._request_token(
735+
736+
"some_secret",
737+
next_link="https://example.com/a/page",
738+
expect_code=400,
739+
)
740+
741+
def _request_token(
742+
self,
743+
email: str,
744+
client_secret: str,
745+
next_link: Optional[str] = None,
746+
expect_code: int = 200,
747+
) -> str:
748+
"""Request a validation token to add an email address to a user's account
749+
750+
Args:
751+
email: The email address to validate
752+
client_secret: A secret string
753+
next_link: A link to redirect the user to after validation
754+
expect_code: Expected return code of the call
755+
756+
Returns:
757+
The ID of the new threepid validation session
758+
"""
759+
body = {"client_secret": client_secret, "email": email, "send_attempt": 1}
760+
if next_link:
761+
body["next_link"] = next_link
762+
672763
request, channel = self.make_request(
673-
"POST",
674-
b"account/3pid/email/requestToken",
675-
{"client_secret": client_secret, "email": email, "send_attempt": 1},
764+
"POST", b"account/3pid/email/requestToken", body,
676765
)
677766
self.render(request)
678-
self.assertEquals(200, channel.code, channel.result)
767+
self.assertEquals(expect_code, channel.code, channel.result)
679768

680-
return channel.json_body["sid"]
769+
return channel.json_body.get("sid")
681770

682771
def _request_token_invalid_email(
683772
self, email, expected_errcode, expected_error, client_secret="foobar",

0 commit comments

Comments
 (0)