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

Commit 96358cb

Browse files
authored
Add authentication to replication endpoints. (#8853)
Authentication is done by checking a shared secret provided in the Synapse configuration file.
1 parent df4b1e9 commit 96358cb

File tree

7 files changed

+184
-15
lines changed

7 files changed

+184
-15
lines changed

changelog.d/8853.feature

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add optional HTTP authentication to replication endpoints.

docs/sample_config.yaml

+7
Original file line numberDiff line numberDiff line change
@@ -2589,6 +2589,13 @@ opentracing:
25892589
#
25902590
#run_background_tasks_on: worker1
25912591

2592+
# A shared secret used by the replication APIs to authenticate HTTP requests
2593+
# from workers.
2594+
#
2595+
# By default this is unused and traffic is not authenticated.
2596+
#
2597+
#worker_replication_secret: ""
2598+
25922599

25932600
# Configuration for Redis when using workers. This *must* be enabled when
25942601
# using workers (unless using old style direct TCP configuration).

docs/workers.md

+5-1
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,8 @@ shared configuration file.
8989
Normally, only a couple of changes are needed to make an existing configuration
9090
file suitable for use with workers. First, you need to enable an "HTTP replication
9191
listener" for the main process; and secondly, you need to enable redis-based
92-
replication. For example:
92+
replication. Optionally, a shared secret can be used to authenticate HTTP
93+
traffic between workers. For example:
9394

9495

9596
```yaml
@@ -103,6 +104,9 @@ listeners:
103104
resources:
104105
- names: [replication]
105106

107+
# Add a random shared secret to authenticate traffic.
108+
worker_replication_secret: ""
109+
106110
redis:
107111
enabled: true
108112
```

synapse/config/workers.py

+10
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ def read_config(self, config, **kwargs):
8585
# The port on the main synapse for HTTP replication endpoint
8686
self.worker_replication_http_port = config.get("worker_replication_http_port")
8787

88+
# The shared secret used for authentication when connecting to the main synapse.
89+
self.worker_replication_secret = config.get("worker_replication_secret", None)
90+
8891
self.worker_name = config.get("worker_name", self.worker_app)
8992

9093
self.worker_main_http_uri = config.get("worker_main_http_uri", None)
@@ -185,6 +188,13 @@ def generate_config_section(self, config_dir_path, server_name, **kwargs):
185188
# data). If not provided this defaults to the main process.
186189
#
187190
#run_background_tasks_on: worker1
191+
192+
# A shared secret used by the replication APIs to authenticate HTTP requests
193+
# from workers.
194+
#
195+
# By default this is unused and traffic is not authenticated.
196+
#
197+
#worker_replication_secret: ""
188198
"""
189199

190200
def read_arguments(self, args):

synapse/replication/http/_base.py

+41-6
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,25 @@ def __init__(self, hs):
106106

107107
assert self.METHOD in ("PUT", "POST", "GET")
108108

109+
self._replication_secret = None
110+
if hs.config.worker.worker_replication_secret:
111+
self._replication_secret = hs.config.worker.worker_replication_secret
112+
113+
def _check_auth(self, request) -> None:
114+
# Get the authorization header.
115+
auth_headers = request.requestHeaders.getRawHeaders(b"Authorization")
116+
117+
if len(auth_headers) > 1:
118+
raise RuntimeError("Too many Authorization headers.")
119+
parts = auth_headers[0].split(b" ")
120+
if parts[0] == b"Bearer" and len(parts) == 2:
121+
received_secret = parts[1].decode("ascii")
122+
if self._replication_secret == received_secret:
123+
# Success!
124+
return
125+
126+
raise RuntimeError("Invalid Authorization header.")
127+
109128
@abc.abstractmethod
110129
async def _serialize_payload(**kwargs):
111130
"""Static method that is called when creating a request.
@@ -150,6 +169,12 @@ def make_client(cls, hs):
150169

151170
outgoing_gauge = _pending_outgoing_requests.labels(cls.NAME)
152171

172+
replication_secret = None
173+
if hs.config.worker.worker_replication_secret:
174+
replication_secret = hs.config.worker.worker_replication_secret.encode(
175+
"ascii"
176+
)
177+
153178
@trace(opname="outgoing_replication_request")
154179
@outgoing_gauge.track_inprogress()
155180
async def send_request(instance_name="master", **kwargs):
@@ -202,6 +227,9 @@ async def send_request(instance_name="master", **kwargs):
202227
# the master, and so whether we should clean up or not.
203228
while True:
204229
headers = {} # type: Dict[bytes, List[bytes]]
230+
# Add an authorization header, if configured.
231+
if replication_secret:
232+
headers[b"Authorization"] = [b"Bearer " + replication_secret]
205233
inject_active_span_byte_dict(headers, None, check_destination=False)
206234
try:
207235
result = await request_func(uri, data, headers=headers)
@@ -236,28 +264,35 @@ def register(self, http_server):
236264
"""
237265

238266
url_args = list(self.PATH_ARGS)
239-
handler = self._handle_request
240267
method = self.METHOD
241268

242269
if self.CACHE:
243-
handler = self._cached_handler # type: ignore
244270
url_args.append("txn_id")
245271

246272
args = "/".join("(?P<%s>[^/]+)" % (arg,) for arg in url_args)
247273
pattern = re.compile("^/_synapse/replication/%s/%s$" % (self.NAME, args))
248274

249275
http_server.register_paths(
250-
method, [pattern], handler, self.__class__.__name__,
276+
method, [pattern], self._check_auth_and_handle, self.__class__.__name__,
251277
)
252278

253-
def _cached_handler(self, request, txn_id, **kwargs):
279+
def _check_auth_and_handle(self, request, **kwargs):
254280
"""Called on new incoming requests when caching is enabled. Checks
255281
if there is a cached response for the request and returns that,
256282
otherwise calls `_handle_request` and caches its response.
257283
"""
258284
# We just use the txn_id here, but we probably also want to use the
259285
# other PATH_ARGS as well.
260286

261-
assert self.CACHE
287+
# Check the authorization headers before handling the request.
288+
if self._replication_secret:
289+
self._check_auth(request)
290+
291+
if self.CACHE:
292+
txn_id = kwargs.pop("txn_id")
293+
294+
return self.response_cache.wrap(
295+
txn_id, self._handle_request, request, **kwargs
296+
)
262297

263-
return self.response_cache.wrap(txn_id, self._handle_request, request, **kwargs)
298+
return self._handle_request(request, **kwargs)

tests/replication/test_auth.py

+119
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2020 The Matrix.org Foundation C.I.C.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
import logging
16+
from typing import Tuple
17+
18+
from synapse.http.site import SynapseRequest
19+
from synapse.rest.client.v2_alpha import register
20+
21+
from tests.replication._base import BaseMultiWorkerStreamTestCase
22+
from tests.server import FakeChannel, make_request
23+
from tests.unittest import override_config
24+
25+
logger = logging.getLogger(__name__)
26+
27+
28+
class WorkerAuthenticationTestCase(BaseMultiWorkerStreamTestCase):
29+
"""Test the authentication of HTTP calls between workers."""
30+
31+
servlets = [register.register_servlets]
32+
33+
def make_homeserver(self, reactor, clock):
34+
config = self.default_config()
35+
# This isn't a real configuration option but is used to provide the main
36+
# homeserver and worker homeserver different options.
37+
main_replication_secret = config.pop("main_replication_secret", None)
38+
if main_replication_secret:
39+
config["worker_replication_secret"] = main_replication_secret
40+
return self.setup_test_homeserver(config=config)
41+
42+
def _get_worker_hs_config(self) -> dict:
43+
config = self.default_config()
44+
config["worker_app"] = "synapse.app.client_reader"
45+
config["worker_replication_host"] = "testserv"
46+
config["worker_replication_http_port"] = "8765"
47+
48+
return config
49+
50+
def _test_register(self) -> Tuple[SynapseRequest, FakeChannel]:
51+
"""Run the actual test:
52+
53+
1. Create a worker homeserver.
54+
2. Start registration by providing a user/password.
55+
3. Complete registration by providing dummy auth (this hits the main synapse).
56+
4. Return the final request.
57+
58+
"""
59+
worker_hs = self.make_worker_hs("synapse.app.client_reader")
60+
site = self._hs_to_site[worker_hs]
61+
62+
request_1, channel_1 = make_request(
63+
self.reactor,
64+
site,
65+
"POST",
66+
"register",
67+
{"username": "user", "type": "m.login.password", "password": "bar"},
68+
) # type: SynapseRequest, FakeChannel
69+
self.assertEqual(request_1.code, 401)
70+
71+
# Grab the session
72+
session = channel_1.json_body["session"]
73+
74+
# also complete the dummy auth
75+
return make_request(
76+
self.reactor,
77+
site,
78+
"POST",
79+
"register",
80+
{"auth": {"session": session, "type": "m.login.dummy"}},
81+
)
82+
83+
def test_no_auth(self):
84+
"""With no authentication the request should finish.
85+
"""
86+
request, channel = self._test_register()
87+
self.assertEqual(request.code, 200)
88+
89+
# We're given a registered user.
90+
self.assertEqual(channel.json_body["user_id"], "@user:test")
91+
92+
@override_config({"main_replication_secret": "my-secret"})
93+
def test_missing_auth(self):
94+
"""If the main process expects a secret that is not provided, an error results.
95+
"""
96+
request, channel = self._test_register()
97+
self.assertEqual(request.code, 500)
98+
99+
@override_config(
100+
{
101+
"main_replication_secret": "my-secret",
102+
"worker_replication_secret": "wrong-secret",
103+
}
104+
)
105+
def test_unauthorized(self):
106+
"""If the main process receives the wrong secret, an error results.
107+
"""
108+
request, channel = self._test_register()
109+
self.assertEqual(request.code, 500)
110+
111+
@override_config({"worker_replication_secret": "my-secret"})
112+
def test_authorized(self):
113+
"""The request should finish when the worker provides the authentication header.
114+
"""
115+
request, channel = self._test_register()
116+
self.assertEqual(request.code, 200)
117+
118+
# We're given a registered user.
119+
self.assertEqual(channel.json_body["user_id"], "@user:test")

tests/replication/test_client_reader_shard.py

+1-8
Original file line numberDiff line numberDiff line change
@@ -14,27 +14,20 @@
1414
# limitations under the License.
1515
import logging
1616

17-
from synapse.api.constants import LoginType
1817
from synapse.http.site import SynapseRequest
1918
from synapse.rest.client.v2_alpha import register
2019

2120
from tests.replication._base import BaseMultiWorkerStreamTestCase
22-
from tests.rest.client.v2_alpha.test_auth import DummyRecaptchaChecker
2321
from tests.server import FakeChannel, make_request
2422

2523
logger = logging.getLogger(__name__)
2624

2725

2826
class ClientReaderTestCase(BaseMultiWorkerStreamTestCase):
29-
"""Base class for tests of the replication streams"""
27+
"""Test using one or more client readers for registration."""
3028

3129
servlets = [register.register_servlets]
3230

33-
def prepare(self, reactor, clock, hs):
34-
self.recaptcha_checker = DummyRecaptchaChecker(hs)
35-
auth_handler = hs.get_auth_handler()
36-
auth_handler.checkers[LoginType.RECAPTCHA] = self.recaptcha_checker
37-
3831
def _get_worker_hs_config(self) -> dict:
3932
config = self.default_config()
4033
config["worker_app"] = "synapse.app.client_reader"

0 commit comments

Comments
 (0)