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.
This commit is contained in:
Nick Steel 2018-03-06 15:46:51 +00:00
parent 41882c6395
commit cd829c7042
3 changed files with 35 additions and 2 deletions

View File

@ -25,6 +25,7 @@ class Extension(ext.Extension):
schema['port'] = config_lib.Port() schema['port'] = config_lib.Port()
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)
return schema return schema
def validate_environment(self): def validate_environment(self):

View File

@ -4,3 +4,4 @@ hostname = 127.0.0.1
port = 6680 port = 6680
static_dir = static_dir =
zeroconf = Mopidy HTTP server on $hostname zeroconf = Mopidy HTTP server on $hostname
allowed_origins =

View File

@ -11,20 +11,24 @@ import tornado.websocket
import mopidy import mopidy
from mopidy import core, models from mopidy import core, models
from mopidy.compat import urllib
from mopidy.internal import encoding, jsonrpc from mopidy.internal import encoding, jsonrpc
logger = logging.getLogger(__name__) 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):
origin_whitelist = {
x.lower() for x in config['http']['allowed_origins'] if x
}
return [ return [
(r'/ws/?', WebSocketHandler, { (r'/ws/?', WebSocketHandler, {
'core': core, 'core': core,
}), }),
(r'/rpc', JsonRpcHandler, { (r'/rpc', JsonRpcHandler, {
'core': core, 'core': core,
'origin_whitelist': origin_whitelist,
}), }),
(r'/(.+)', StaticFileHandler, { (r'/(.+)', StaticFileHandler, {
'path': os.path.join(os.path.dirname(__file__), 'data'), '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')) '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): class JsonRpcHandler(tornado.web.RequestHandler):
def initialize(self, core): def initialize(self, core, origin_whitelist):
self.jsonrpc = make_jsonrpc_wrapper(core) self.jsonrpc = make_jsonrpc_wrapper(core)
self.origin_whitelist = origin_whitelist
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 content_type != 'application/json':
self.set_status(406, 'Content-Type must be application/json')
return
data = self.request.body data = self.request.body
if not data: if not data:
return return
@ -177,6 +196,18 @@ class JsonRpcHandler(tornado.web.RequestHandler):
self.set_header('Accept', 'application/json') self.set_header('Accept', 'application/json')
self.set_header('Content-Type', 'application/json; utf-8') 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): class ClientListHandler(tornado.web.RequestHandler):