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

Ability to blacklist ip ranges for federation traffic #5043

Merged
merged 30 commits into from
May 13, 2019
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
1b8532b
tests fail
anoadragon453 Apr 10, 2019
4501489
tests pass
anoadragon453 Apr 10, 2019
0200c86
lint
anoadragon453 Apr 10, 2019
9f1f03f
lint and changelog
anoadragon453 Apr 10, 2019
6631485
actually add changelog
anoadragon453 Apr 10, 2019
25c99dc
sample config
anoadragon453 Apr 10, 2019
9795344
Don't raise an exception if coming from federation
anoadragon453 Apr 10, 2019
1b3989b
lint
anoadragon453 Apr 10, 2019
0e2f8ca
Add some notes
anoadragon453 Apr 10, 2019
6479cd5
Use an empty list as default
anoadragon453 Apr 30, 2019
3f4f931
Merge branch 'develop' into anoa/blacklist_ip_ranges
anoadragon453 Apr 30, 2019
968ddca
Testing
anoadragon453 May 2, 2019
e1feb45
We can't throw exceptions in an IResolutionReceiver
richvdh May 2, 2019
152d7a8
Remove different behaviour for fed vs. nonfed
anoadragon453 May 2, 2019
6592691
Import at the top
anoadragon453 May 2, 2019
517794e
isort locally didn't have a problem >:(
anoadragon453 May 3, 2019
15d1802
lint
anoadragon453 May 3, 2019
131b9c0
yield deferred
anoadragon453 May 3, 2019
13f430c
Same behavior for no result and result blacklisted
anoadragon453 May 3, 2019
e2bc9af
lint
anoadragon453 May 3, 2019
ec67848
Remove yield
anoadragon453 May 3, 2019
43ffe47
Enable federation blacklisting by default
anoadragon453 May 8, 2019
aee810a
Fix tests and various small review issues
anoadragon453 May 8, 2019
a30a778
Update tests
anoadragon453 May 8, 2019
ede582f
lint
anoadragon453 May 8, 2019
4ba420f
always blacklist 0.0.0.0, ::
anoadragon453 May 10, 2019
358777d
lower pump value
anoadragon453 May 10, 2019
7f15dd7
lint
anoadragon453 May 10, 2019
6b29f7e
regen config
anoadragon453 May 10, 2019
e0715d0
Merge branch 'develop' into anoa/blacklist_ip_ranges
anoadragon453 May 10, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/5043.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add ability to blacklist IP ranges for the federation client.
14 changes: 14 additions & 0 deletions docs/sample_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,20 @@ pid_file: DATADIR/homeserver.pid
# - nyc.example.com
# - syd.example.com

# Prevent federation requests from being sent to the following
# blacklist IP address CIDR ranges.
#
#federation_ip_range_blacklist:
# - '127.0.0.0/8'
# - '10.0.0.0/8'
# - '172.16.0.0/12'
# - '192.168.0.0/16'
# - '100.64.0.0/10'
# - '169.254.0.0/16'
# - '::1/128'
# - 'fe80::/64'
# - 'fc00::/7'

# List of ports that Synapse should listen on, their purpose and their
# configuration.
#
Expand Down
38 changes: 38 additions & 0 deletions synapse/config/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,30 @@ def read_config(self, config):
for domain in federation_domain_whitelist:
self.federation_domain_whitelist[domain] = True

self.federation_ip_range_blacklist = config.get(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we ought to follow the example of #5134 and include 0.0.0.0 and ::, whether they were explicitly listed or not.

"federation_ip_range_blacklist", None,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we make the default [] rather than None we can save ourselves a bunch of special-casing?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose so since this is a blacklist and an empty one is the same as not having one at all.

)
if self.federation_ip_range_blacklist is not None:
# Import IPSet
try:
from netaddr import IPSet
except ImportError:
raise ConfigError(
"Missing netaddr library. This is required to use "
"federation_ip_range_blacklist"
)

# Attempt to create an IPSet from the given ranges
try:
self.federation_ip_range_blacklist = IPSet(
self.federation_ip_range_blacklist
)
except Exception as e:
raise ConfigError(
"Invalid range(s) provided in "
"federation_ip_range_blacklist: %s" % e
)

if self.public_baseurl is not None:
if self.public_baseurl[-1] != '/':
self.public_baseurl += '/'
Expand Down Expand Up @@ -351,6 +375,20 @@ def default_config(self, server_name, data_dir_path, **kwargs):
# - nyc.example.com
# - syd.example.com

# Prevent federation requests from being sent to the following
# blacklist IP address CIDR ranges.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please could you explain what the default is (even if it's that nothing is blacklisted)?

#
#federation_ip_range_blacklist:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you think we should uncomment this so that we blacklist the RFC1918 addresses by default for new deployments?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seeing as it would be a security benefit for the vast majority of use cases, I would argue yes.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so would I. Please can you make it so?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done so, but requires having sytest override it so that tests don't fail.

# - '127.0.0.0/8'
# - '10.0.0.0/8'
# - '172.16.0.0/12'
# - '192.168.0.0/16'
# - '100.64.0.0/10'
# - '169.254.0.0/16'
# - '::1/128'
# - 'fe80::/64'
# - 'fc00::/7'

# List of ports that Synapse should listen on, their purpose and their
# configuration.
#
Expand Down
11 changes: 9 additions & 2 deletions synapse/http/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,16 +76,18 @@ class IPBlacklistingResolver(object):
addresses, preventing DNS rebinding attacks on URL preview.
"""

def __init__(self, reactor, ip_whitelist, ip_blacklist):
def __init__(self, reactor, ip_whitelist, ip_blacklist, federation=False):
"""
Args:
reactor (twisted.internet.reactor)
ip_whitelist (netaddr.IPSet)
ip_blacklist (netaddr.IPSet)
federation (bool): this resolver is for federation traffic
"""
self._reactor = reactor
self._ip_whitelist = ip_whitelist
self._ip_blacklist = ip_blacklist
self._from_federation = federation

def resolveHostName(self, recv, hostname, portNumber=0):

Expand All @@ -109,7 +111,12 @@ def addressResolved(address):
logger.info(
"Dropped %s from DNS resolution to %s" % (ip_address, hostname)
)
raise SynapseError(403, "IP address blocked by IP blacklist entry")
anoadragon453 marked this conversation as resolved.
Show resolved Hide resolved
# Only raise a 403 if this request originated from a
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need to do this?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm struggling to remember now. I remember I worked with @erikjohnston on this.

Something will passing tests perhaps?

Copy link
Member Author

@anoadragon453 anoadragon453 May 2, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

T'was actually due to special-casing for unit tests. We've now removed this, as the existing implementation didn't make sense anyways.

# client-server call
if not self._from_federation:
raise SynapseError(403,
"IP address blocked by IP blacklist entry")
return

addresses.append(address)

Expand Down
61 changes: 48 additions & 13 deletions synapse/http/matrixfederationclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@
from canonicaljson import encode_canonical_json
from prometheus_client import Counter
from signedjson.sign import sign_json
from zope.interface import implementer

from twisted.internet import defer, protocol
from twisted.internet.error import DNSLookupError
from twisted.internet.interfaces import IReactorPluggableNameResolver
from twisted.internet.task import _EPSILON, Cooperator
from twisted.web._newclient import ResponseDone
from twisted.web.http_headers import Headers
Expand All @@ -44,6 +46,7 @@
SynapseError,
)
from synapse.http import QuieterFileBodyProducer
from synapse.http.client import BlacklistingAgentWrapper, IPBlacklistingResolver
from synapse.http.federation.matrix_federation_agent import MatrixFederationAgent
from synapse.util.async_helpers import timeout_deferred
from synapse.util.logcontext import make_deferred_yieldable
Expand Down Expand Up @@ -172,19 +175,51 @@ def __init__(self, hs, tls_client_options_factory):
self.hs = hs
self.signing_key = hs.config.signing_key[0]
self.server_name = hs.hostname
reactor = hs.get_reactor()

self.agent = MatrixFederationAgent(
hs.get_reactor(),
tls_client_options_factory,
)
if hs.config.federation_ip_range_blacklist is not None:
real_reactor = hs.get_reactor()
# If we have an IP blacklist, we need to use a DNS resolver which
# filters out blacklisted IP addresses, to prevent DNS rebinding.
nameResolver = IPBlacklistingResolver(
real_reactor, None, hs.config.federation_ip_range_blacklist,
federation=True,
)

@implementer(IReactorPluggableNameResolver)
class Reactor(object):
def __getattr__(_self, attr):
if attr == "nameResolver":
return nameResolver
else:
return getattr(real_reactor, attr)

self.reactor = Reactor()

self.agent = MatrixFederationAgent(
self.reactor,
tls_client_options_factory,
)

# Prevent direct connections to blacklisted IP addresses
self.agent = BlacklistingAgentWrapper(
self.agent, self.reactor,
ip_blacklist=hs.config.federation_ip_range_blacklist,
)
else:
self.reactor = hs.get_reactor()

self.agent = MatrixFederationAgent(
self.reactor,
tls_client_options_factory,
)

self.clock = hs.get_clock()
self._store = hs.get_datastore()
self.version_string_bytes = hs.version_string.encode('ascii')
self.default_timeout = 60

def schedule(x):
reactor.callLater(_EPSILON, x)
self.reactor.callLater(_EPSILON, x)

self._cooperator = Cooperator(scheduler=schedule)

Expand Down Expand Up @@ -370,7 +405,7 @@ def _send_request(
request_deferred = timeout_deferred(
request_deferred,
timeout=_sec_timeout,
reactor=self.hs.get_reactor(),
reactor=self.reactor,
)

response = yield request_deferred
Expand All @@ -397,7 +432,7 @@ def _send_request(
d = timeout_deferred(
d,
timeout=_sec_timeout,
reactor=self.hs.get_reactor(),
reactor=self.reactor,
)

try:
Expand Down Expand Up @@ -586,7 +621,7 @@ def put_json(self, destination, path, args={}, data={},
)

body = yield _handle_json_response(
self.hs.get_reactor(), self.default_timeout, request, response,
self.reactor, self.default_timeout, request, response,
)

defer.returnValue(body)
Expand Down Expand Up @@ -645,7 +680,7 @@ def post_json(self, destination, path, data={}, long_retries=False,
_sec_timeout = self.default_timeout

body = yield _handle_json_response(
self.hs.get_reactor(), _sec_timeout, request, response,
self.reactor, _sec_timeout, request, response,
)
defer.returnValue(body)

Expand Down Expand Up @@ -704,7 +739,7 @@ def get_json(self, destination, path, args=None, retry_on_dns_fail=True,
)

body = yield _handle_json_response(
self.hs.get_reactor(), self.default_timeout, request, response,
self.reactor, self.default_timeout, request, response,
)

defer.returnValue(body)
Expand Down Expand Up @@ -753,7 +788,7 @@ def delete_json(self, destination, path, long_retries=False,
)

body = yield _handle_json_response(
self.hs.get_reactor(), self.default_timeout, request, response,
self.reactor, self.default_timeout, request, response,
)
defer.returnValue(body)

Expand Down Expand Up @@ -801,7 +836,7 @@ def get_file(self, destination, path, output_stream, args={},

try:
d = _readBodyToFile(response, output_stream, max_size)
d.addTimeout(self.default_timeout, self.hs.get_reactor())
d.addTimeout(self.default_timeout, self.reactor)
length = yield make_deferred_yieldable(d)
except Exception as e:
logger.warn(
Expand Down
106 changes: 105 additions & 1 deletion tests/http/test_fedclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def do_request():
# Nothing happened yet
self.assertNoResult(test_d)

# Make sure treq is trying to connect
# Make sure the req is trying to connect
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

treq is correct

clients = self.reactor.tcpClients
self.assertEqual(len(clients), 1)
(host, port, factory, _timeout, _bindAddress) = clients[0]
Expand Down Expand Up @@ -211,6 +211,110 @@ def test_client_connect_no_response(self):
self.assertIsInstance(f.value, RequestSendFailed)
self.assertIsInstance(f.value.inner_exception, ResponseNeverReceived)

def test_client_ip_range_blacklist(self):
"""Ensure that Synapse does not try to connect to blacklisted IPs"""
# Set up the ip_range blacklist
from netaddr import IPSet
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import this at the top?


self.hs.config.federation_ip_range_blacklist = IPSet([
"127.0.0.0/8",
"fe80::/64",
])
self.reactor.lookups["internal"] = "127.0.0.1"
self.reactor.lookups["internalv6"] = "fe80:0:0:0:0:8a2e:370:7337"
self.reactor.lookups["fine"] = "10.20.30.40"
cl = MatrixFederationHttpClient(self.hs, None)

# Try making a GET request to a blacklisted IPv4 address
# ------------------------------------------------------
@defer.inlineCallbacks
def do_request():
with LoggingContext("one") as context:
fetch_d = cl.get_json("internal:8008", "foo/bar")

# Nothing happened yet
self.assertNoResult(fetch_d)

# should have reset logcontext to the sentinel
check_logcontext(LoggingContext.sentinel)

try:
fetch_res = yield fetch_d
defer.returnValue(fetch_res)
finally:
check_logcontext(context)

# Make the request
d = do_request()
self.pump()

# Nothing has happened yet
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please remember to test that these deferreds eventually get resolved one way or the other, otherwise we'll end up with requests blocking forever.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still not 100% sure on how to do this. yield'ing results in the function timing out (though maybe that's what we want to test?).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yield'ing results in the function timing out

yes. The thing to note here is that the test function itself is running synchronously: it starts at the beginning, and doesn't return until it is finished. Note that is different to a Deferred-returning function, which only does part of its work synchronously, and leaves the rest to be done later (at which point, the Deferred will resolve).

Now, the way that we test the asynchronous code from a synchronous function is something like:

  1. run a function that returns a deferred
  2. satisfy the condition that the function is waiting for (eg: pretend that some time has passed; connect a TCP socket; spoof some results from the database).
  • Once we satisfy the awaited condition, the second half of the function should complete, and hence resolve its deferred.
  1. Check that the deferred has now resolved with the expected result.

Often, all that is needed is to "pretend some time has passed". We do this by using a fake Reactor where we can manipulate its idea of the current time. "Pretending time has passed" is then called "advancing the reactor", and is what self.pump does.

[It may be helpful to note that this means that there are actually two reactors in play, since the test runner (trial) is a Twisted application which runs on a regular reactor: so we end up with the main reactor which is running trial and the test reactor which is running the code under test. But in practice, as long as your test functions are synchronous, you can forget about the trial reactor.]

So, long story short: how do we test that the deferreds eventually get resolved? well, somewhere in the test we ought to be able to call self.successResultOf(d) or self.failureResultOf(d, cls) [iirc]. If that assertion fails, it means that the deferred isn't completing, and that is a bad thing!

self.assertNoResult(d)

# Check that it was unable to resolve the address
clients = self.reactor.tcpClients
self.assertEqual(len(clients), 0)

# Try making a POST request to a blacklisted IPv6 address
# -------------------------------------------------------
@defer.inlineCallbacks
def do_request():
with LoggingContext("one") as context:
fetch_d = cl.post_json("internalv6:8008", "foo/bar")

# Nothing happened yet
self.assertNoResult(fetch_d)

# should have reset logcontext to the sentinel
check_logcontext(LoggingContext.sentinel)

try:
fetch_res = yield fetch_d
defer.returnValue(fetch_res)
finally:
check_logcontext(context)

# Make the request
d = do_request()
self.pump()

# Nothing has happened yet
self.assertNoResult(d)

# Check that it was unable to resolve the address
clients = self.reactor.tcpClients
self.assertEqual(len(clients), 0)

# Try making a GET request to a non-blacklisted IPv4 address
# ----------------------------------------------------------
@defer.inlineCallbacks
def do_request():
with LoggingContext("one") as context:
fetch_d = cl.post_json("fine:8008", "foo/bar")

# Nothing happened yet
self.assertNoResult(fetch_d)

# should have reset logcontext to the sentinel
check_logcontext(LoggingContext.sentinel)

try:
fetch_res = yield fetch_d
defer.returnValue(fetch_res)
finally:
check_logcontext(context)

# Make the request
d = do_request()
self.pump()

# Nothing has happened yet
self.assertNoResult(d)

# Check that it was able to resolve the address
clients = self.reactor.tcpClients
self.assertEqual(len(clients), 1)

def test_client_gets_headers(self):
"""
Once the client gets the headers, _request returns successfully.
Expand Down