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

Commit 094896a

Browse files
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 0f545e6 commit 094896a

File tree

5 files changed

+204
-17
lines changed

5 files changed

+204
-17
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
@@ -432,6 +432,24 @@ retention:
432432
#
433433
#request_token_inhibit_3pid_errors: true
434434

435+
# A list of domains that the domain portion of 'next_link' parameters
436+
# must match.
437+
#
438+
# This parameter is optionally provided by clients while requesting
439+
# validation of an email or phone number, and maps to a link that
440+
# users will be automatically redirected to after validation
441+
# succeeds. Clients can make use this parameter to aid the validation
442+
# process.
443+
#
444+
# The whitelist is applied whether the homeserver or an
445+
# identity server is handling validation.
446+
#
447+
# The default value is no whitelist functionality; all domains are
448+
# allowed. Setting this value to an empty list will instead disallow
449+
# all domains.
450+
#
451+
#next_link_domain_whitelist: ["matrix.org"]
452+
435453

436454
## TLS ##
437455

synapse/config/server.py

+32-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
@@ -542,6 +542,19 @@ class LimitRemoteRoomsConfig:
542542
users_new_default_push_rules
543543
) # type: set
544544

545+
# Whitelist of domain names that given next_link parameters must have
546+
next_link_domain_whitelist = config.get(
547+
"next_link_domain_whitelist"
548+
) # type: Optional[List[str]]
549+
550+
self.next_link_domain_whitelist = None # type: Optional[Set[str]]
551+
if next_link_domain_whitelist is not None:
552+
if not isinstance(next_link_domain_whitelist, list):
553+
raise ConfigError("'next_link_domain_whitelist' must be a list")
554+
555+
# Turn the list into a set to improve lookup speed.
556+
self.next_link_domain_whitelist = set(next_link_domain_whitelist)
557+
545558
def has_tls_listener(self) -> bool:
546559
return any(listener.tls for listener in self.listeners)
547560

@@ -1014,6 +1027,24 @@ def generate_config_section(
10141027
# act as if no error happened and return a fake session ID ('sid') to clients.
10151028
#
10161029
#request_token_inhibit_3pid_errors: true
1030+
1031+
# A list of domains that the domain portion of 'next_link' parameters
1032+
# must match.
1033+
#
1034+
# This parameter is optionally provided by clients while requesting
1035+
# validation of an email or phone number, and maps to a link that
1036+
# users will be automatically redirected to after validation
1037+
# succeeds. Clients can make use this parameter to aid the validation
1038+
# process.
1039+
#
1040+
# The whitelist is applied whether the homeserver or an
1041+
# identity server is handling validation.
1042+
#
1043+
# The default value is no whitelist functionality; all domains are
1044+
# allowed. Setting this value to an empty list will instead disallow
1045+
# all domains.
1046+
#
1047+
#next_link_domain_whitelist: ["matrix.org"]
10171048
"""
10181049
% locals()
10191050
)

synapse/rest/client/v2_alpha/account.py

+57-9
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@
1717
import logging
1818
import random
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 synapse.api.constants import LoginType
2227
from synapse.api.errors import (
@@ -98,6 +103,9 @@ async def on_POST(self, request):
98103
Codes.THREEPID_DENIED,
99104
)
100105

106+
# Raise if the provided next_link value isn't valid
107+
assert_valid_next_link(self.hs, next_link)
108+
101109
# The email will be sent to the stored address.
102110
# This avoids a potential account hijack by requesting a password reset to
103111
# an email address which is controlled by the attacker but which, after
@@ -446,6 +454,9 @@ async def on_POST(self, request):
446454
Codes.THREEPID_DENIED,
447455
)
448456

457+
# Raise if the provided next_link value isn't valid
458+
assert_valid_next_link(self.hs, next_link)
459+
449460
existing_user_id = await self.store.get_user_id_by_threepid("email", email)
450461

451462
if existing_user_id is not None:
@@ -517,6 +528,9 @@ async def on_POST(self, request):
517528
Codes.THREEPID_DENIED,
518529
)
519530

531+
# Raise if the provided next_link value isn't valid
532+
assert_valid_next_link(self.hs, next_link)
533+
520534
existing_user_id = await self.store.get_user_id_by_threepid("msisdn", msisdn)
521535

522536
if existing_user_id is not None:
@@ -603,15 +617,10 @@ async def on_GET(self, request):
603617

604618
# Perform a 302 redirect if next_link is set
605619
if next_link:
606-
if next_link.startswith("file:///"):
607-
logger.warning(
608-
"Not redirecting to next_link as it is a local file: address"
609-
)
610-
else:
611-
request.setResponseCode(302)
612-
request.setHeader("Location", next_link)
613-
finish_request(request)
614-
return None
620+
request.setResponseCode(302)
621+
request.setHeader("Location", next_link)
622+
finish_request(request)
623+
return None
615624

616625
# Otherwise show the success template
617626
html = self.config.email_add_threepid_template_success_html_content
@@ -875,6 +884,45 @@ async def on_POST(self, request):
875884
return 200, {"id_server_unbind_result": id_server_unbind_result}
876885

877886

887+
def assert_valid_next_link(hs: "HomeServer", next_link: str):
888+
"""
889+
Raises a SynapseError if a given next_link value is invalid
890+
891+
next_link is valid if the scheme is http(s) and the next_link.domain_whitelist config
892+
option is either empty or contains a domain that matches the one in the given next_link
893+
894+
Args:
895+
hs: The homeserver object
896+
next_link: The next_link value given by the client
897+
898+
Raises:
899+
SynapseError: If the next_link is invalid
900+
"""
901+
valid = True
902+
903+
# Parse the contents of the URL
904+
next_link_parsed = urlparse(next_link)
905+
906+
# Scheme must not point to the local drive
907+
if next_link_parsed.scheme == "file":
908+
valid = False
909+
910+
# If the domain whitelist is set, the domain must be in it
911+
if (
912+
valid
913+
and hs.config.next_link_domain_whitelist is not None
914+
and next_link_parsed.hostname not in hs.config.next_link_domain_whitelist
915+
):
916+
valid = False
917+
918+
if not valid:
919+
raise SynapseError(
920+
400,
921+
"'next_link' domain not included in whitelist, or not http(s)",
922+
errcode=Codes.INVALID_PARAM,
923+
)
924+
925+
878926
class WhoamiRestServlet(RestServlet):
879927
PATTERNS = client_patterns("/account/whoami$")
880928

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+
"something@example.com",
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+
"something@example.com",
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+
"something@example.com",
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+
"something@example.com",
710+
"some_secret",
711+
next_link="https://example.com/some/good/page",
712+
expect_code=200,
713+
)
714+
715+
self._request_token(
716+
"something@example.com",
717+
"some_secret",
718+
next_link="https://example.org/some/also/good/page",
719+
expect_code=200,
720+
)
721+
722+
self._request_token(
723+
"something@example.com",
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+
"something@example.com",
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)