From cd829c704246e727af13eb4c26c43d3f0543b87f Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Tue, 6 Mar 2018 15:46:51 +0000 Subject: [PATCH] HTTP: CSRF protection for RPC endpoint. By now enforcing the Content-Type header is set to 'application/json', we force browsers attempting a cross-domain request to first perform a CORS preflight OPTIONS request. This request always includes an Origin header which we check against our whitelist. The whitelist contains the current Host as well as anything specified in the new optional allowed_origins config value. Any non-browser tools must also now set the Context-type header. --- mopidy/http/__init__.py | 1 + mopidy/http/ext.conf | 1 + mopidy/http/handlers.py | 35 +++++++++++++++++++++++++++++++++-- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/mopidy/http/__init__.py b/mopidy/http/__init__.py index 3fa4bcd6..4f6239b9 100644 --- a/mopidy/http/__init__.py +++ b/mopidy/http/__init__.py @@ -25,6 +25,7 @@ class Extension(ext.Extension): schema['port'] = config_lib.Port() schema['static_dir'] = config_lib.Path(optional=True) schema['zeroconf'] = config_lib.String(optional=True) + schema['allowed_origins'] = config_lib.List(optional=True) return schema def validate_environment(self): diff --git a/mopidy/http/ext.conf b/mopidy/http/ext.conf index d35229bc..5750e855 100644 --- a/mopidy/http/ext.conf +++ b/mopidy/http/ext.conf @@ -4,3 +4,4 @@ hostname = 127.0.0.1 port = 6680 static_dir = zeroconf = Mopidy HTTP server on $hostname +allowed_origins = diff --git a/mopidy/http/handlers.py b/mopidy/http/handlers.py index 6250163c..2bc037a6 100644 --- a/mopidy/http/handlers.py +++ b/mopidy/http/handlers.py @@ -11,20 +11,24 @@ import tornado.websocket import mopidy from mopidy import core, models +from mopidy.compat import urllib from mopidy.internal import encoding, jsonrpc - logger = logging.getLogger(__name__) def make_mopidy_app_factory(apps, statics): def mopidy_app_factory(config, core): + origin_whitelist = { + x.lower() for x in config['http']['allowed_origins'] if x + } return [ (r'/ws/?', WebSocketHandler, { 'core': core, }), (r'/rpc', JsonRpcHandler, { 'core': core, + 'origin_whitelist': origin_whitelist, }), (r'/(.+)', StaticFileHandler, { 'path': os.path.join(os.path.dirname(__file__), 'data'), @@ -143,16 +147,31 @@ def set_mopidy_headers(request_handler): 'X-Mopidy-Version', mopidy.__version__.encode('utf-8')) +def check_origin(origin, request_headers, origin_whitelist): + if origin is None: + logger.debug('Origin was not set') + return False + origin_whitelist.add(request_headers.get('Host', None)) + parsed_origin = urllib.parse.urlparse(origin).netloc.lower() + return parsed_origin and parsed_origin in origin_whitelist + + class JsonRpcHandler(tornado.web.RequestHandler): - def initialize(self, core): + def initialize(self, core, origin_whitelist): self.jsonrpc = make_jsonrpc_wrapper(core) + self.origin_whitelist = origin_whitelist def head(self): self.set_extra_headers() self.finish() def post(self): + content_type = self.request.headers.get("Content-Type", '') + if content_type != 'application/json': + self.set_status(406, 'Content-Type must be application/json') + return + data = self.request.body if not data: return @@ -177,6 +196,18 @@ class JsonRpcHandler(tornado.web.RequestHandler): self.set_header('Accept', 'application/json') self.set_header('Content-Type', 'application/json; utf-8') + def options(self): + origin = self.request.headers.get('Origin', None) + if not check_origin(origin, self.request.headers, + self.origin_whitelist): + 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_status(204) + self.finish() + class ClientListHandler(tornado.web.RequestHandler):