mirror of
https://mau.dev/maunium/synapse.git
synced 2024-10-01 01:36:05 -04:00
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: https://github.com/matrix-org/sydent/pull/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 https://github.com/matrix-org/synapse/pull/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.
This commit is contained in:
parent
0f545e6b96
commit
094896a69d
1
changelog.d/8275.feature
Normal file
1
changelog.d/8275.feature
Normal file
@ -0,0 +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.
|
@ -432,6 +432,24 @@ retention:
|
|||||||
#
|
#
|
||||||
#request_token_inhibit_3pid_errors: true
|
#request_token_inhibit_3pid_errors: true
|
||||||
|
|
||||||
|
# A list of domains that the domain portion of 'next_link' parameters
|
||||||
|
# must match.
|
||||||
|
#
|
||||||
|
# This parameter is optionally provided by clients while requesting
|
||||||
|
# validation of an email or phone number, and maps to a link that
|
||||||
|
# users will be automatically redirected to after validation
|
||||||
|
# succeeds. Clients can make use this parameter to aid the validation
|
||||||
|
# process.
|
||||||
|
#
|
||||||
|
# The whitelist is applied whether the homeserver or an
|
||||||
|
# identity server is handling validation.
|
||||||
|
#
|
||||||
|
# The default value is no whitelist functionality; all domains are
|
||||||
|
# allowed. Setting this value to an empty list will instead disallow
|
||||||
|
# all domains.
|
||||||
|
#
|
||||||
|
#next_link_domain_whitelist: ["matrix.org"]
|
||||||
|
|
||||||
|
|
||||||
## TLS ##
|
## TLS ##
|
||||||
|
|
||||||
|
@ -19,7 +19,7 @@ import logging
|
|||||||
import os.path
|
import os.path
|
||||||
import re
|
import re
|
||||||
from textwrap import indent
|
from textwrap import indent
|
||||||
from typing import Any, Dict, Iterable, List, Optional
|
from typing import Any, Dict, Iterable, List, Optional, Set
|
||||||
|
|
||||||
import attr
|
import attr
|
||||||
import yaml
|
import yaml
|
||||||
@ -542,6 +542,19 @@ class ServerConfig(Config):
|
|||||||
users_new_default_push_rules
|
users_new_default_push_rules
|
||||||
) # type: set
|
) # type: set
|
||||||
|
|
||||||
|
# Whitelist of domain names that given next_link parameters must have
|
||||||
|
next_link_domain_whitelist = config.get(
|
||||||
|
"next_link_domain_whitelist"
|
||||||
|
) # type: Optional[List[str]]
|
||||||
|
|
||||||
|
self.next_link_domain_whitelist = None # type: Optional[Set[str]]
|
||||||
|
if next_link_domain_whitelist is not None:
|
||||||
|
if not isinstance(next_link_domain_whitelist, list):
|
||||||
|
raise ConfigError("'next_link_domain_whitelist' must be a list")
|
||||||
|
|
||||||
|
# Turn the list into a set to improve lookup speed.
|
||||||
|
self.next_link_domain_whitelist = set(next_link_domain_whitelist)
|
||||||
|
|
||||||
def has_tls_listener(self) -> bool:
|
def has_tls_listener(self) -> bool:
|
||||||
return any(listener.tls for listener in self.listeners)
|
return any(listener.tls for listener in self.listeners)
|
||||||
|
|
||||||
@ -1014,6 +1027,24 @@ class ServerConfig(Config):
|
|||||||
# act as if no error happened and return a fake session ID ('sid') to clients.
|
# act as if no error happened and return a fake session ID ('sid') to clients.
|
||||||
#
|
#
|
||||||
#request_token_inhibit_3pid_errors: true
|
#request_token_inhibit_3pid_errors: true
|
||||||
|
|
||||||
|
# A list of domains that the domain portion of 'next_link' parameters
|
||||||
|
# must match.
|
||||||
|
#
|
||||||
|
# This parameter is optionally provided by clients while requesting
|
||||||
|
# validation of an email or phone number, and maps to a link that
|
||||||
|
# users will be automatically redirected to after validation
|
||||||
|
# succeeds. Clients can make use this parameter to aid the validation
|
||||||
|
# process.
|
||||||
|
#
|
||||||
|
# The whitelist is applied whether the homeserver or an
|
||||||
|
# identity server is handling validation.
|
||||||
|
#
|
||||||
|
# The default value is no whitelist functionality; all domains are
|
||||||
|
# allowed. Setting this value to an empty list will instead disallow
|
||||||
|
# all domains.
|
||||||
|
#
|
||||||
|
#next_link_domain_whitelist: ["matrix.org"]
|
||||||
"""
|
"""
|
||||||
% locals()
|
% locals()
|
||||||
)
|
)
|
||||||
|
@ -17,6 +17,11 @@
|
|||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from synapse.app.homeserver import HomeServer
|
||||||
|
|
||||||
from synapse.api.constants import LoginType
|
from synapse.api.constants import LoginType
|
||||||
from synapse.api.errors import (
|
from synapse.api.errors import (
|
||||||
@ -98,6 +103,9 @@ class EmailPasswordRequestTokenRestServlet(RestServlet):
|
|||||||
Codes.THREEPID_DENIED,
|
Codes.THREEPID_DENIED,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Raise if the provided next_link value isn't valid
|
||||||
|
assert_valid_next_link(self.hs, next_link)
|
||||||
|
|
||||||
# The email will be sent to the stored address.
|
# The email will be sent to the stored address.
|
||||||
# This avoids a potential account hijack by requesting a password reset to
|
# This avoids a potential account hijack by requesting a password reset to
|
||||||
# an email address which is controlled by the attacker but which, after
|
# an email address which is controlled by the attacker but which, after
|
||||||
@ -446,6 +454,9 @@ class EmailThreepidRequestTokenRestServlet(RestServlet):
|
|||||||
Codes.THREEPID_DENIED,
|
Codes.THREEPID_DENIED,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Raise if the provided next_link value isn't valid
|
||||||
|
assert_valid_next_link(self.hs, next_link)
|
||||||
|
|
||||||
existing_user_id = await self.store.get_user_id_by_threepid("email", email)
|
existing_user_id = await self.store.get_user_id_by_threepid("email", email)
|
||||||
|
|
||||||
if existing_user_id is not None:
|
if existing_user_id is not None:
|
||||||
@ -517,6 +528,9 @@ class MsisdnThreepidRequestTokenRestServlet(RestServlet):
|
|||||||
Codes.THREEPID_DENIED,
|
Codes.THREEPID_DENIED,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Raise if the provided next_link value isn't valid
|
||||||
|
assert_valid_next_link(self.hs, next_link)
|
||||||
|
|
||||||
existing_user_id = await self.store.get_user_id_by_threepid("msisdn", msisdn)
|
existing_user_id = await self.store.get_user_id_by_threepid("msisdn", msisdn)
|
||||||
|
|
||||||
if existing_user_id is not None:
|
if existing_user_id is not None:
|
||||||
@ -603,15 +617,10 @@ class AddThreepidEmailSubmitTokenServlet(RestServlet):
|
|||||||
|
|
||||||
# Perform a 302 redirect if next_link is set
|
# Perform a 302 redirect if next_link is set
|
||||||
if next_link:
|
if next_link:
|
||||||
if next_link.startswith("file:///"):
|
request.setResponseCode(302)
|
||||||
logger.warning(
|
request.setHeader("Location", next_link)
|
||||||
"Not redirecting to next_link as it is a local file: address"
|
finish_request(request)
|
||||||
)
|
return None
|
||||||
else:
|
|
||||||
request.setResponseCode(302)
|
|
||||||
request.setHeader("Location", next_link)
|
|
||||||
finish_request(request)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Otherwise show the success template
|
# Otherwise show the success template
|
||||||
html = self.config.email_add_threepid_template_success_html_content
|
html = self.config.email_add_threepid_template_success_html_content
|
||||||
@ -875,6 +884,45 @@ class ThreepidDeleteRestServlet(RestServlet):
|
|||||||
return 200, {"id_server_unbind_result": id_server_unbind_result}
|
return 200, {"id_server_unbind_result": id_server_unbind_result}
|
||||||
|
|
||||||
|
|
||||||
|
def assert_valid_next_link(hs: "HomeServer", next_link: str):
|
||||||
|
"""
|
||||||
|
Raises a SynapseError if a given next_link value is invalid
|
||||||
|
|
||||||
|
next_link is valid if the scheme is http(s) and the next_link.domain_whitelist config
|
||||||
|
option is either empty or contains a domain that matches the one in the given next_link
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hs: The homeserver object
|
||||||
|
next_link: The next_link value given by the client
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SynapseError: If the next_link is invalid
|
||||||
|
"""
|
||||||
|
valid = True
|
||||||
|
|
||||||
|
# Parse the contents of the URL
|
||||||
|
next_link_parsed = urlparse(next_link)
|
||||||
|
|
||||||
|
# Scheme must not point to the local drive
|
||||||
|
if next_link_parsed.scheme == "file":
|
||||||
|
valid = False
|
||||||
|
|
||||||
|
# If the domain whitelist is set, the domain must be in it
|
||||||
|
if (
|
||||||
|
valid
|
||||||
|
and hs.config.next_link_domain_whitelist is not None
|
||||||
|
and next_link_parsed.hostname not in hs.config.next_link_domain_whitelist
|
||||||
|
):
|
||||||
|
valid = False
|
||||||
|
|
||||||
|
if not valid:
|
||||||
|
raise SynapseError(
|
||||||
|
400,
|
||||||
|
"'next_link' domain not included in whitelist, or not http(s)",
|
||||||
|
errcode=Codes.INVALID_PARAM,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class WhoamiRestServlet(RestServlet):
|
class WhoamiRestServlet(RestServlet):
|
||||||
PATTERNS = client_patterns("/account/whoami$")
|
PATTERNS = client_patterns("/account/whoami$")
|
||||||
|
|
||||||
|
@ -14,11 +14,11 @@
|
|||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from email.parser import Parser
|
from email.parser import Parser
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import pkg_resources
|
import pkg_resources
|
||||||
|
|
||||||
@ -29,6 +29,7 @@ from synapse.rest.client.v1 import login, room
|
|||||||
from synapse.rest.client.v2_alpha import account, register
|
from synapse.rest.client.v2_alpha import account, register
|
||||||
|
|
||||||
from tests import unittest
|
from tests import unittest
|
||||||
|
from tests.unittest import override_config
|
||||||
|
|
||||||
|
|
||||||
class PasswordResetTestCase(unittest.HomeserverTestCase):
|
class PasswordResetTestCase(unittest.HomeserverTestCase):
|
||||||
@ -668,16 +669,104 @@ class ThreepidEmailRestTestCase(unittest.HomeserverTestCase):
|
|||||||
self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])
|
self.assertEqual(200, int(channel.result["code"]), msg=channel.result["body"])
|
||||||
self.assertFalse(channel.json_body["threepids"])
|
self.assertFalse(channel.json_body["threepids"])
|
||||||
|
|
||||||
def _request_token(self, email, client_secret):
|
@override_config({"next_link_domain_whitelist": None})
|
||||||
|
def test_next_link(self):
|
||||||
|
"""Tests a valid next_link parameter value with no whitelist (good case)"""
|
||||||
|
self._request_token(
|
||||||
|
"something@example.com",
|
||||||
|
"some_secret",
|
||||||
|
next_link="https://example.com/a/good/site",
|
||||||
|
expect_code=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_config({"next_link_domain_whitelist": None})
|
||||||
|
def test_next_link_exotic_protocol(self):
|
||||||
|
"""Tests using a esoteric protocol as a next_link parameter value.
|
||||||
|
Someone may be hosting a client on IPFS etc.
|
||||||
|
"""
|
||||||
|
self._request_token(
|
||||||
|
"something@example.com",
|
||||||
|
"some_secret",
|
||||||
|
next_link="some-protocol://abcdefghijklmopqrstuvwxyz",
|
||||||
|
expect_code=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_config({"next_link_domain_whitelist": None})
|
||||||
|
def test_next_link_file_uri(self):
|
||||||
|
"""Tests next_link parameters cannot be file URI"""
|
||||||
|
# Attempt to use a next_link value that points to the local disk
|
||||||
|
self._request_token(
|
||||||
|
"something@example.com",
|
||||||
|
"some_secret",
|
||||||
|
next_link="file:///host/path",
|
||||||
|
expect_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_config({"next_link_domain_whitelist": ["example.com", "example.org"]})
|
||||||
|
def test_next_link_domain_whitelist(self):
|
||||||
|
"""Tests next_link parameters must fit the whitelist if provided"""
|
||||||
|
self._request_token(
|
||||||
|
"something@example.com",
|
||||||
|
"some_secret",
|
||||||
|
next_link="https://example.com/some/good/page",
|
||||||
|
expect_code=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._request_token(
|
||||||
|
"something@example.com",
|
||||||
|
"some_secret",
|
||||||
|
next_link="https://example.org/some/also/good/page",
|
||||||
|
expect_code=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._request_token(
|
||||||
|
"something@example.com",
|
||||||
|
"some_secret",
|
||||||
|
next_link="https://bad.example.org/some/bad/page",
|
||||||
|
expect_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_config({"next_link_domain_whitelist": []})
|
||||||
|
def test_empty_next_link_domain_whitelist(self):
|
||||||
|
"""Tests an empty next_lint_domain_whitelist value, meaning next_link is essentially
|
||||||
|
disallowed
|
||||||
|
"""
|
||||||
|
self._request_token(
|
||||||
|
"something@example.com",
|
||||||
|
"some_secret",
|
||||||
|
next_link="https://example.com/a/page",
|
||||||
|
expect_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _request_token(
|
||||||
|
self,
|
||||||
|
email: str,
|
||||||
|
client_secret: str,
|
||||||
|
next_link: Optional[str] = None,
|
||||||
|
expect_code: int = 200,
|
||||||
|
) -> str:
|
||||||
|
"""Request a validation token to add an email address to a user's account
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: The email address to validate
|
||||||
|
client_secret: A secret string
|
||||||
|
next_link: A link to redirect the user to after validation
|
||||||
|
expect_code: Expected return code of the call
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The ID of the new threepid validation session
|
||||||
|
"""
|
||||||
|
body = {"client_secret": client_secret, "email": email, "send_attempt": 1}
|
||||||
|
if next_link:
|
||||||
|
body["next_link"] = next_link
|
||||||
|
|
||||||
request, channel = self.make_request(
|
request, channel = self.make_request(
|
||||||
"POST",
|
"POST", b"account/3pid/email/requestToken", body,
|
||||||
b"account/3pid/email/requestToken",
|
|
||||||
{"client_secret": client_secret, "email": email, "send_attempt": 1},
|
|
||||||
)
|
)
|
||||||
self.render(request)
|
self.render(request)
|
||||||
self.assertEquals(200, channel.code, channel.result)
|
self.assertEquals(expect_code, channel.code, channel.result)
|
||||||
|
|
||||||
return channel.json_body["sid"]
|
return channel.json_body.get("sid")
|
||||||
|
|
||||||
def _request_token_invalid_email(
|
def _request_token_invalid_email(
|
||||||
self, email, expected_errcode, expected_error, client_secret="foobar",
|
self, email, expected_errcode, expected_error, client_secret="foobar",
|
||||||
|
Loading…
Reference in New Issue
Block a user