Release v2.2.1
This commit is contained in:
commit
9a7adc280f
1
.gitignore
vendored
1
.gitignore
vendored
@ -15,6 +15,7 @@ cover/
|
|||||||
coverage.xml
|
coverage.xml
|
||||||
dist/
|
dist/
|
||||||
docs/_build/
|
docs/_build/
|
||||||
|
docs/.doctrees/
|
||||||
mopidy.log*
|
mopidy.log*
|
||||||
nosetests.xml
|
nosetests.xml
|
||||||
tmp/
|
tmp/
|
||||||
|
|||||||
@ -5,6 +5,23 @@ Changelog
|
|||||||
This changelog is used to track all major changes to Mopidy.
|
This changelog is used to track all major changes to Mopidy.
|
||||||
|
|
||||||
|
|
||||||
|
v2.2.1 (2018-10-15)
|
||||||
|
===================
|
||||||
|
|
||||||
|
Bug fix release.
|
||||||
|
|
||||||
|
- HTTP: Stop blocking connections where the network location part of the
|
||||||
|
``Origin`` header is empty, such as WebSocket connections originating from
|
||||||
|
local files. (Fixes: :issue:`1711`, PR: :issue:`1712`)
|
||||||
|
|
||||||
|
- HTTP: Add new config value :confval:`http/csrf_protection` which enables all
|
||||||
|
CSRF protections introduced in Mopidy 2.2.0. It is enabled by default and
|
||||||
|
should only be disabled by those users who are unable to set a
|
||||||
|
``Content-Type: application/json`` request header or cannot utilise the
|
||||||
|
:confval:`http/allowed_origins` config value. (Fixes: :issue:`1713`, PR:
|
||||||
|
:issue:`1714`)
|
||||||
|
|
||||||
|
|
||||||
v2.2.0 (2018-09-30)
|
v2.2.0 (2018-09-30)
|
||||||
===================
|
===================
|
||||||
|
|
||||||
|
|||||||
@ -102,7 +102,7 @@ See :ref:`config` for general help on configuring Mopidy.
|
|||||||
.. confval:: http/allowed_origins
|
.. confval:: http/allowed_origins
|
||||||
|
|
||||||
A list of domains allowed to perform Cross-Origin Resource Sharing (CORS)
|
A list of domains allowed to perform Cross-Origin Resource Sharing (CORS)
|
||||||
requests. This applies to both JSON-RPC and Websocket requests. Values
|
requests. This applies to both JSON-RPC and WebSocket requests. Values
|
||||||
should be in the format ``hostname:port`` and separated by either a comma or
|
should be in the format ``hostname:port`` and separated by either a comma or
|
||||||
newline.
|
newline.
|
||||||
|
|
||||||
@ -110,3 +110,17 @@ See :ref:`config` for general help on configuring Mopidy.
|
|||||||
allowed and so you don't need an entry for those. However, if your requests
|
allowed and so you don't need an entry for those. However, if your requests
|
||||||
originate from a different web server, you will need to add an entry for
|
originate from a different web server, you will need to add an entry for
|
||||||
that server in this list.
|
that server in this list.
|
||||||
|
|
||||||
|
.. confval:: http/csrf_protection
|
||||||
|
|
||||||
|
Enable the HTTP server's protection against Cross-Site Request Forgery
|
||||||
|
(CSRF) from both JSON-RPC and WebSocket requests.
|
||||||
|
|
||||||
|
Disabling this will remove the requirement to set a ``Content-Type: application/json``
|
||||||
|
header for JSON-RPC POST requests. It will also disable all same-origin
|
||||||
|
checks, effectively ignoring the :confval:`http/allowed_origins` config
|
||||||
|
since requests from any origin will be allowed. Lastly, all
|
||||||
|
``Access-Control-Allow-*`` response headers will be suppressed.
|
||||||
|
|
||||||
|
This config should only be disabled if you understand the security implications
|
||||||
|
and require the HTTP server's old behaviour.
|
||||||
|
|||||||
@ -14,4 +14,4 @@ if not (2, 7) <= sys.version_info < (3,):
|
|||||||
warnings.filterwarnings('ignore', 'could not open display')
|
warnings.filterwarnings('ignore', 'could not open display')
|
||||||
|
|
||||||
|
|
||||||
__version__ = '2.2.0'
|
__version__ = '2.2.1'
|
||||||
|
|||||||
@ -26,6 +26,7 @@ class Extension(ext.Extension):
|
|||||||
schema['static_dir'] = config_lib.Path(optional=True)
|
schema['static_dir'] = config_lib.Path(optional=True)
|
||||||
schema['zeroconf'] = config_lib.String(optional=True)
|
schema['zeroconf'] = config_lib.String(optional=True)
|
||||||
schema['allowed_origins'] = config_lib.List(optional=True)
|
schema['allowed_origins'] = config_lib.List(optional=True)
|
||||||
|
schema['csrf_protection'] = config_lib.Boolean(optional=True)
|
||||||
return schema
|
return schema
|
||||||
|
|
||||||
def validate_environment(self):
|
def validate_environment(self):
|
||||||
|
|||||||
@ -5,3 +5,4 @@ port = 6680
|
|||||||
static_dir =
|
static_dir =
|
||||||
zeroconf = Mopidy HTTP server on $hostname
|
zeroconf = Mopidy HTTP server on $hostname
|
||||||
allowed_origins =
|
allowed_origins =
|
||||||
|
csrf_protection = true
|
||||||
|
|||||||
@ -20,6 +20,9 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def make_mopidy_app_factory(apps, statics):
|
def make_mopidy_app_factory(apps, statics):
|
||||||
def mopidy_app_factory(config, core):
|
def mopidy_app_factory(config, core):
|
||||||
|
if not config['http']['csrf_protection']:
|
||||||
|
logger.warn(
|
||||||
|
'HTTP Cross-Site Request Forgery protection is disabled')
|
||||||
allowed_origins = {
|
allowed_origins = {
|
||||||
x.lower() for x in config['http']['allowed_origins'] if x
|
x.lower() for x in config['http']['allowed_origins'] if x
|
||||||
}
|
}
|
||||||
@ -27,10 +30,12 @@ def make_mopidy_app_factory(apps, statics):
|
|||||||
(r'/ws/?', WebSocketHandler, {
|
(r'/ws/?', WebSocketHandler, {
|
||||||
'core': core,
|
'core': core,
|
||||||
'allowed_origins': allowed_origins,
|
'allowed_origins': allowed_origins,
|
||||||
|
'csrf_protection': config['http']['csrf_protection'],
|
||||||
}),
|
}),
|
||||||
(r'/rpc', JsonRpcHandler, {
|
(r'/rpc', JsonRpcHandler, {
|
||||||
'core': core,
|
'core': core,
|
||||||
'allowed_origins': allowed_origins,
|
'allowed_origins': allowed_origins,
|
||||||
|
'csrf_protection': config['http']['csrf_protection'],
|
||||||
}),
|
}),
|
||||||
(r'/(.+)', StaticFileHandler, {
|
(r'/(.+)', StaticFileHandler, {
|
||||||
'path': os.path.join(os.path.dirname(__file__), 'data'),
|
'path': os.path.join(os.path.dirname(__file__), 'data'),
|
||||||
@ -102,9 +107,10 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler):
|
|||||||
# One callback per client to keep time we hold up the loop short
|
# One callback per client to keep time we hold up the loop short
|
||||||
loop.add_callback(functools.partial(_send_broadcast, client, msg))
|
loop.add_callback(functools.partial(_send_broadcast, client, msg))
|
||||||
|
|
||||||
def initialize(self, core, allowed_origins):
|
def initialize(self, core, allowed_origins, csrf_protection):
|
||||||
self.jsonrpc = make_jsonrpc_wrapper(core)
|
self.jsonrpc = make_jsonrpc_wrapper(core)
|
||||||
self.allowed_origins = allowed_origins
|
self.allowed_origins = allowed_origins
|
||||||
|
self.csrf_protection = csrf_protection
|
||||||
|
|
||||||
def open(self):
|
def open(self):
|
||||||
self.set_nodelay(True)
|
self.set_nodelay(True)
|
||||||
@ -139,6 +145,8 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler):
|
|||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
def check_origin(self, origin):
|
def check_origin(self, origin):
|
||||||
|
if not self.csrf_protection:
|
||||||
|
return True
|
||||||
return check_origin(origin, self.request.headers, self.allowed_origins)
|
return check_origin(origin, self.request.headers, self.allowed_origins)
|
||||||
|
|
||||||
|
|
||||||
@ -150,28 +158,37 @@ def set_mopidy_headers(request_handler):
|
|||||||
|
|
||||||
def check_origin(origin, request_headers, allowed_origins):
|
def check_origin(origin, request_headers, allowed_origins):
|
||||||
if origin is None:
|
if origin is None:
|
||||||
logger.debug('Origin was not set')
|
logger.warn('HTTP request denied for missing Origin header')
|
||||||
return False
|
return False
|
||||||
allowed_origins.add(request_headers.get('Host'))
|
allowed_origins.add(request_headers.get('Host'))
|
||||||
parsed_origin = urllib.parse.urlparse(origin).netloc.lower()
|
parsed_origin = urllib.parse.urlparse(origin).netloc.lower()
|
||||||
return parsed_origin and parsed_origin in allowed_origins
|
# Some frameworks (e.g. Apache Cordova) use local files. Requests from
|
||||||
|
# these files don't really have a sensible Origin so the browser sets the
|
||||||
|
# header to something like 'file://' or 'null'. This results here in an
|
||||||
|
# empty parsed_origin which we choose to allow.
|
||||||
|
if parsed_origin and parsed_origin not in allowed_origins:
|
||||||
|
logger.warn('HTTP request denied for Origin "%s"', origin)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class JsonRpcHandler(tornado.web.RequestHandler):
|
class JsonRpcHandler(tornado.web.RequestHandler):
|
||||||
|
|
||||||
def initialize(self, core, allowed_origins):
|
def initialize(self, core, allowed_origins, csrf_protection):
|
||||||
self.jsonrpc = make_jsonrpc_wrapper(core)
|
self.jsonrpc = make_jsonrpc_wrapper(core)
|
||||||
self.allowed_origins = allowed_origins
|
self.allowed_origins = allowed_origins
|
||||||
|
self.csrf_protection = csrf_protection
|
||||||
|
|
||||||
def head(self):
|
def head(self):
|
||||||
self.set_extra_headers()
|
self.set_extra_headers()
|
||||||
self.finish()
|
self.finish()
|
||||||
|
|
||||||
def post(self):
|
def post(self):
|
||||||
content_type = self.request.headers.get('Content-Type', '')
|
if self.csrf_protection:
|
||||||
if content_type != 'application/json':
|
content_type = self.request.headers.get('Content-Type', '')
|
||||||
self.set_status(415, 'Content-Type must be application/json')
|
if content_type != 'application/json':
|
||||||
return
|
self.set_status(415, 'Content-Type must be application/json')
|
||||||
|
return
|
||||||
|
|
||||||
data = self.request.body
|
data = self.request.body
|
||||||
if not data:
|
if not data:
|
||||||
@ -198,14 +215,16 @@ class JsonRpcHandler(tornado.web.RequestHandler):
|
|||||||
self.set_header('Content-Type', 'application/json; utf-8')
|
self.set_header('Content-Type', 'application/json; utf-8')
|
||||||
|
|
||||||
def options(self):
|
def options(self):
|
||||||
origin = self.request.headers.get('Origin')
|
if self.csrf_protection:
|
||||||
if not check_origin(
|
origin = self.request.headers.get('Origin')
|
||||||
origin, self.request.headers, self.allowed_origins):
|
if not check_origin(origin, self.request.headers,
|
||||||
self.set_status(403, 'Access denied for origin %s' % origin)
|
self.allowed_origins):
|
||||||
return
|
self.set_status(403, 'Access denied for origin %s' % origin)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.set_header('Access-Control-Allow-Origin', '%s' % origin)
|
||||||
|
self.set_header('Access-Control-Allow-Headers', 'Content-Type')
|
||||||
|
|
||||||
self.set_header('Access-Control-Allow-Origin', '%s' % origin)
|
|
||||||
self.set_header('Access-Control-Allow-Headers', 'Content-Type')
|
|
||||||
self.set_status(204)
|
self.set_status(204)
|
||||||
self.finish()
|
self.finish()
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
from __future__ import absolute_import, unicode_literals
|
from __future__ import absolute_import, unicode_literals
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
import mock
|
import mock
|
||||||
|
|
||||||
@ -47,7 +48,8 @@ class WebSocketHandlerTest(tornado.testing.AsyncHTTPTestCase):
|
|||||||
self.core = mock.Mock()
|
self.core = mock.Mock()
|
||||||
return tornado.web.Application([
|
return tornado.web.Application([
|
||||||
(r'/ws/?', handlers.WebSocketHandler, {
|
(r'/ws/?', handlers.WebSocketHandler, {
|
||||||
'core': self.core, 'allowed_origins': []
|
'core': self.core, 'allowed_origins': [],
|
||||||
|
'csrf_protection': True
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
|
|
||||||
@ -86,3 +88,42 @@ class WebSocketHandlerTest(tornado.testing.AsyncHTTPTestCase):
|
|||||||
for client in handlers.WebSocketHandler.clients:
|
for client in handlers.WebSocketHandler.clients:
|
||||||
client.ws_connection = None
|
client.ws_connection = None
|
||||||
handlers.WebSocketHandler.broadcast('message')
|
handlers.WebSocketHandler.broadcast('message')
|
||||||
|
|
||||||
|
|
||||||
|
class CheckOriginTests(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.headers = {'Host': 'localhost:6680'}
|
||||||
|
self.allowed = set()
|
||||||
|
|
||||||
|
def test_missing_origin_blocked(self):
|
||||||
|
self.assertFalse(handlers.check_origin(
|
||||||
|
None, self.headers, self.allowed))
|
||||||
|
|
||||||
|
def test_empty_origin_allowed(self):
|
||||||
|
self.assertTrue(handlers.check_origin('', self.headers, self.allowed))
|
||||||
|
|
||||||
|
def test_chrome_file_origin_allowed(self):
|
||||||
|
self.assertTrue(handlers.check_origin(
|
||||||
|
'file://', self.headers, self.allowed))
|
||||||
|
|
||||||
|
def test_firefox_null_origin_allowed(self):
|
||||||
|
self.assertTrue(handlers.check_origin(
|
||||||
|
'null', self.headers, self.allowed))
|
||||||
|
|
||||||
|
def test_same_host_origin_allowed(self):
|
||||||
|
self.assertTrue(handlers.check_origin(
|
||||||
|
'http://localhost:6680', self.headers, self.allowed))
|
||||||
|
|
||||||
|
def test_different_host_origin_blocked(self):
|
||||||
|
self.assertFalse(handlers.check_origin(
|
||||||
|
'http://other:6680', self.headers, self.allowed))
|
||||||
|
|
||||||
|
def test_different_port_blocked(self):
|
||||||
|
self.assertFalse(handlers.check_origin(
|
||||||
|
'http://localhost:80', self.headers, self.allowed))
|
||||||
|
|
||||||
|
def test_extra_origin_allowed(self):
|
||||||
|
self.allowed.add('other:6680')
|
||||||
|
self.assertTrue(handlers.check_origin(
|
||||||
|
'http://other:6680', self.headers, self.allowed))
|
||||||
|
|||||||
@ -21,6 +21,7 @@ class HttpServerTest(tornado.testing.AsyncHTTPTestCase):
|
|||||||
'static_dir': None,
|
'static_dir': None,
|
||||||
'zeroconf': '',
|
'zeroconf': '',
|
||||||
'allowed_origins': [],
|
'allowed_origins': [],
|
||||||
|
'csrf_protection': True,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -205,6 +206,48 @@ class MopidyRPCHandlerTest(HttpServerTest):
|
|||||||
response.headers['Access-Control-Allow-Headers'], 'Content-Type')
|
response.headers['Access-Control-Allow-Headers'], 'Content-Type')
|
||||||
|
|
||||||
|
|
||||||
|
class MopidyRPCHandlerNoCSRFProtectionTest(HttpServerTest):
|
||||||
|
|
||||||
|
def get_config(self):
|
||||||
|
config = super(MopidyRPCHandlerNoCSRFProtectionTest, self).get_config()
|
||||||
|
config['http']['csrf_protection'] = False
|
||||||
|
return config
|
||||||
|
|
||||||
|
def get_cmd(self):
|
||||||
|
return tornado.escape.json_encode({
|
||||||
|
'method': 'core.get_version',
|
||||||
|
'params': [],
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'id': 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_should_ignore_incorrect_content_type(self):
|
||||||
|
response = self.fetch(
|
||||||
|
'/mopidy/rpc', method='POST', body=self.get_cmd(),
|
||||||
|
headers={'Content-Type': 'text/plain'})
|
||||||
|
|
||||||
|
self.assertEqual(response.code, 200)
|
||||||
|
|
||||||
|
def test_should_ignore_missing_content_type(self):
|
||||||
|
response = self.fetch(
|
||||||
|
'/mopidy/rpc', method='POST', body=self.get_cmd(), headers={})
|
||||||
|
|
||||||
|
self.assertEqual(response.code, 200)
|
||||||
|
|
||||||
|
def test_different_origin_returns_allowed(self):
|
||||||
|
response = self.fetch('/mopidy/rpc', method='OPTIONS', headers={
|
||||||
|
'Host': 'me:6680', 'Origin': 'http://evil:666'})
|
||||||
|
|
||||||
|
self.assertEqual(response.code, 204)
|
||||||
|
|
||||||
|
def test_should_not_return_cors_headers(self):
|
||||||
|
response = self.fetch('/mopidy/rpc', method='OPTIONS', headers={
|
||||||
|
'Host': 'me:6680', 'Origin': 'http://me:6680'})
|
||||||
|
|
||||||
|
self.assertNotIn('Access-Control-Allow-Origin', response.headers)
|
||||||
|
self.assertNotIn('Access-Control-Allow-Headers', response.headers)
|
||||||
|
|
||||||
|
|
||||||
class HttpServerWithStaticFilesTest(tornado.testing.AsyncHTTPTestCase):
|
class HttpServerWithStaticFilesTest(tornado.testing.AsyncHTTPTestCase):
|
||||||
|
|
||||||
def get_app(self):
|
def get_app(self):
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user