Merge "Websocket Proxy should verify Origin header" into stable/icehouse
This commit is contained in:
commit
ecac4bf998
|
@ -20,13 +20,22 @@ Leverages websockify.py by Joel Martin
|
||||||
|
|
||||||
import Cookie
|
import Cookie
|
||||||
import socket
|
import socket
|
||||||
|
import urlparse
|
||||||
|
|
||||||
import websockify
|
import websockify
|
||||||
|
|
||||||
from nova.consoleauth import rpcapi as consoleauth_rpcapi
|
from nova.consoleauth import rpcapi as consoleauth_rpcapi
|
||||||
from nova import context
|
from nova import context
|
||||||
|
from nova import exception
|
||||||
from nova.openstack.common.gettextutils import _
|
from nova.openstack.common.gettextutils import _
|
||||||
from nova.openstack.common import log as logging
|
from nova.openstack.common import log as logging
|
||||||
|
from oslo.config import cfg
|
||||||
|
|
||||||
|
CONF = cfg.CONF
|
||||||
|
CONF.import_opt('novncproxy_base_url', 'nova.vnc')
|
||||||
|
CONF.import_opt('html5proxy_base_url', 'nova.spice', group='spice')
|
||||||
|
CONF.import_opt('vnc_enabled', 'nova.vnc')
|
||||||
|
CONF.import_opt('enabled', 'nova.spice', group='spice')
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -37,6 +46,20 @@ class NovaWebSocketProxy(websockify.WebSocketProxy):
|
||||||
target_cfg=None,
|
target_cfg=None,
|
||||||
ssl_target=None, *args, **kwargs)
|
ssl_target=None, *args, **kwargs)
|
||||||
|
|
||||||
|
def verify_origin_proto(self, console_type, origin_proto):
|
||||||
|
if console_type == 'novnc':
|
||||||
|
expected_proto = \
|
||||||
|
urlparse.urlparse(CONF.novncproxy_base_url).scheme
|
||||||
|
elif console_type == 'spice-html5':
|
||||||
|
expected_proto = \
|
||||||
|
urlparse.urlparse(CONF.spice.html5proxy_base_url).scheme
|
||||||
|
else:
|
||||||
|
detail = _("Invalid Console Type for WebSocketProxy: '%s'") % \
|
||||||
|
console_type
|
||||||
|
LOG.audit(detail)
|
||||||
|
raise exception.ValidationError(detail=detail)
|
||||||
|
return origin_proto == expected_proto
|
||||||
|
|
||||||
def new_client(self):
|
def new_client(self):
|
||||||
"""Called after a new WebSocket connection has been established."""
|
"""Called after a new WebSocket connection has been established."""
|
||||||
# Reopen the eventlet hub to make sure we don't share an epoll
|
# Reopen the eventlet hub to make sure we don't share an epoll
|
||||||
|
@ -55,6 +78,28 @@ class NovaWebSocketProxy(websockify.WebSocketProxy):
|
||||||
LOG.audit("Invalid Token: %s", token)
|
LOG.audit("Invalid Token: %s", token)
|
||||||
raise Exception(_("Invalid Token"))
|
raise Exception(_("Invalid Token"))
|
||||||
|
|
||||||
|
# Verify Origin
|
||||||
|
expected_origin_hostname = self.headers.getheader('Host')
|
||||||
|
if ':' in expected_origin_hostname:
|
||||||
|
e = expected_origin_hostname
|
||||||
|
expected_origin_hostname = e.split(':')[0]
|
||||||
|
origin_url = self.headers.getheader('Origin')
|
||||||
|
# missing origin header indicates non-browser client which is OK
|
||||||
|
if origin_url is not None:
|
||||||
|
origin = urlparse.urlparse(origin_url)
|
||||||
|
origin_hostname = origin.hostname
|
||||||
|
origin_scheme = origin.scheme
|
||||||
|
if origin_hostname == '' or origin_scheme == '':
|
||||||
|
detail = _("Origin header not valid.")
|
||||||
|
raise exception.ValidationError(detail=detail)
|
||||||
|
if expected_origin_hostname != origin_hostname:
|
||||||
|
detail = _("Origin header does not match this host.")
|
||||||
|
raise exception.ValidationError(detail=detail)
|
||||||
|
if not self.verify_origin_proto(connect_info['console_type'],
|
||||||
|
origin.scheme):
|
||||||
|
detail = _("Origin header protocol does not match this host.")
|
||||||
|
raise exception.ValidationError(detail=detail)
|
||||||
|
|
||||||
host = connect_info['host']
|
host = connect_info['host']
|
||||||
port = int(connect_info['port'])
|
port = int(connect_info['port'])
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,216 @@
|
||||||
|
# All Rights Reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||||
|
# not use this file except in compliance with the License. You may obtain
|
||||||
|
# a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||||
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
|
# License for the specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
|
||||||
|
"""Tests for nova websocketproxy."""
|
||||||
|
|
||||||
|
|
||||||
|
import mock
|
||||||
|
|
||||||
|
from nova.console import websocketproxy
|
||||||
|
from nova import exception
|
||||||
|
from nova import test
|
||||||
|
from oslo.config import cfg
|
||||||
|
|
||||||
|
CONF = cfg.CONF
|
||||||
|
|
||||||
|
|
||||||
|
class NovaProxyRequestHandlerBaseTestCase(test.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super(NovaProxyRequestHandlerBaseTestCase, self).setUp()
|
||||||
|
|
||||||
|
self.wh = websocketproxy.NovaWebSocketProxy()
|
||||||
|
self.wh.socket = mock.MagicMock()
|
||||||
|
self.wh.msg = mock.MagicMock()
|
||||||
|
self.wh.do_proxy = mock.MagicMock()
|
||||||
|
self.wh.headers = mock.MagicMock()
|
||||||
|
CONF.set_override('novncproxy_base_url',
|
||||||
|
'https://example.net:6080/vnc_auto.html')
|
||||||
|
CONF.set_override('html5proxy_base_url',
|
||||||
|
'https://example.net:6080/vnc_auto.html',
|
||||||
|
'spice')
|
||||||
|
|
||||||
|
@mock.patch('nova.consoleauth.rpcapi.ConsoleAuthAPI.check_token')
|
||||||
|
def test_new_client(self, check_token):
|
||||||
|
def _fake_getheader(header):
|
||||||
|
if header == 'cookie':
|
||||||
|
return 'token="123-456-789"'
|
||||||
|
elif header == 'Origin':
|
||||||
|
return 'https://example.net:6080'
|
||||||
|
elif header == 'Host':
|
||||||
|
return 'example.net:6080'
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
check_token.return_value = {
|
||||||
|
'host': 'node1',
|
||||||
|
'port': '10000',
|
||||||
|
'console_type': 'novnc'
|
||||||
|
}
|
||||||
|
self.wh.socket.return_value = '<socket>'
|
||||||
|
self.wh.path = "http://127.0.0.1/?token=123-456-789"
|
||||||
|
self.wh.headers.getheader = _fake_getheader
|
||||||
|
|
||||||
|
self.wh.new_client()
|
||||||
|
|
||||||
|
check_token.assert_called_with(mock.ANY, token="123-456-789")
|
||||||
|
self.wh.socket.assert_called_with('node1', 10000, connect=True)
|
||||||
|
self.wh.do_proxy.assert_called_with('<socket>')
|
||||||
|
|
||||||
|
@mock.patch('nova.consoleauth.rpcapi.ConsoleAuthAPI.check_token')
|
||||||
|
def test_new_client_raises_with_invalid_origin(self, check_token):
|
||||||
|
def _fake_getheader(header):
|
||||||
|
if header == 'cookie':
|
||||||
|
return 'token="123-456-789"'
|
||||||
|
elif header == 'Origin':
|
||||||
|
return 'https://bad-origin-example.net:6080'
|
||||||
|
elif header == 'Host':
|
||||||
|
return 'example.net:6080'
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
check_token.return_value = {
|
||||||
|
'host': 'node1',
|
||||||
|
'port': '10000',
|
||||||
|
'console_type': 'novnc'
|
||||||
|
}
|
||||||
|
self.wh.socket.return_value = '<socket>'
|
||||||
|
self.wh.path = "http://127.0.0.1/?token=123-456-789"
|
||||||
|
self.wh.headers.getheader = _fake_getheader
|
||||||
|
|
||||||
|
self.assertRaises(exception.ValidationError,
|
||||||
|
self.wh.new_client)
|
||||||
|
|
||||||
|
@mock.patch('nova.consoleauth.rpcapi.ConsoleAuthAPI.check_token')
|
||||||
|
def test_new_client_raises_with_blank_origin(self, check_token):
|
||||||
|
def _fake_getheader(header):
|
||||||
|
if header == 'cookie':
|
||||||
|
return 'token="123-456-789"'
|
||||||
|
elif header == 'Origin':
|
||||||
|
return ''
|
||||||
|
elif header == 'Host':
|
||||||
|
return 'example.net:6080'
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
check_token.return_value = {
|
||||||
|
'host': 'node1',
|
||||||
|
'port': '10000',
|
||||||
|
'console_type': 'novnc'
|
||||||
|
}
|
||||||
|
self.wh.socket.return_value = '<socket>'
|
||||||
|
self.wh.path = "http://127.0.0.1/?token=123-456-789"
|
||||||
|
self.wh.headers.getheader = _fake_getheader
|
||||||
|
|
||||||
|
self.assertRaises(exception.ValidationError,
|
||||||
|
self.wh.new_client)
|
||||||
|
|
||||||
|
@mock.patch('nova.consoleauth.rpcapi.ConsoleAuthAPI.check_token')
|
||||||
|
def test_new_client_with_no_origin(self, check_token):
|
||||||
|
def _fake_getheader(header):
|
||||||
|
if header == 'cookie':
|
||||||
|
return 'token="123-456-789"'
|
||||||
|
elif header == 'Origin':
|
||||||
|
return None
|
||||||
|
elif header == 'Host':
|
||||||
|
return 'example.net:6080'
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
check_token.return_value = {
|
||||||
|
'host': 'node1',
|
||||||
|
'port': '10000',
|
||||||
|
'console_type': 'novnc'
|
||||||
|
}
|
||||||
|
self.wh.socket.return_value = '<socket>'
|
||||||
|
self.wh.path = "http://127.0.0.1/?token=123-456-789"
|
||||||
|
self.wh.headers.getheader = _fake_getheader
|
||||||
|
|
||||||
|
self.wh.new_client()
|
||||||
|
|
||||||
|
check_token.assert_called_with(mock.ANY, token="123-456-789")
|
||||||
|
self.wh.socket.assert_called_with('node1', 10000, connect=True)
|
||||||
|
self.wh.do_proxy.assert_called_with('<socket>')
|
||||||
|
|
||||||
|
@mock.patch('nova.consoleauth.rpcapi.ConsoleAuthAPI.check_token')
|
||||||
|
def test_new_client_raises_with_wrong_proto_vnc(self, check_token):
|
||||||
|
def _fake_getheader(header):
|
||||||
|
if header == 'cookie':
|
||||||
|
return 'token="123-456-789"'
|
||||||
|
elif header == 'Origin':
|
||||||
|
return 'http://example.net:6080'
|
||||||
|
elif header == 'Host':
|
||||||
|
return 'example.net:6080'
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
check_token.return_value = {
|
||||||
|
'host': 'node1',
|
||||||
|
'port': '10000',
|
||||||
|
'console_type': 'novnc'
|
||||||
|
}
|
||||||
|
self.wh.socket.return_value = '<socket>'
|
||||||
|
self.wh.path = "http://127.0.0.1/?token=123-456-789"
|
||||||
|
self.wh.headers.getheader = _fake_getheader
|
||||||
|
|
||||||
|
self.assertRaises(exception.ValidationError,
|
||||||
|
self.wh.new_client)
|
||||||
|
|
||||||
|
@mock.patch('nova.consoleauth.rpcapi.ConsoleAuthAPI.check_token')
|
||||||
|
def test_raises_with_wrong_proto_spice(self, check_token):
|
||||||
|
def _fake_getheader(header):
|
||||||
|
if header == 'cookie':
|
||||||
|
return 'token="123-456-789"'
|
||||||
|
elif header == 'Origin':
|
||||||
|
return 'http://example.net:6080'
|
||||||
|
elif header == 'Host':
|
||||||
|
return 'example.net:6080'
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
check_token.return_value = {
|
||||||
|
'host': 'node1',
|
||||||
|
'port': '10000',
|
||||||
|
'console_type': 'spice-html5'
|
||||||
|
}
|
||||||
|
self.wh.socket.return_value = '<socket>'
|
||||||
|
self.wh.path = "http://127.0.0.1/?token=123-456-789"
|
||||||
|
self.wh.headers.getheader = _fake_getheader
|
||||||
|
|
||||||
|
self.assertRaises(exception.ValidationError,
|
||||||
|
self.wh.new_client)
|
||||||
|
|
||||||
|
@mock.patch('nova.consoleauth.rpcapi.ConsoleAuthAPI.check_token')
|
||||||
|
def test_raises_with_bad_console_type(self, check_token):
|
||||||
|
def _fake_getheader(header):
|
||||||
|
if header == 'cookie':
|
||||||
|
return 'token="123-456-789"'
|
||||||
|
elif header == 'Origin':
|
||||||
|
return 'https://example.net:6080'
|
||||||
|
elif header == 'Host':
|
||||||
|
return 'example.net:6080'
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
check_token.return_value = {
|
||||||
|
'host': 'node1',
|
||||||
|
'port': '10000',
|
||||||
|
'console_type': 'bad-console-type'
|
||||||
|
}
|
||||||
|
self.wh.socket.return_value = '<socket>'
|
||||||
|
self.wh.path = "http://127.0.0.1/?token=123-456-789"
|
||||||
|
self.wh.headers.getheader = _fake_getheader
|
||||||
|
|
||||||
|
self.assertRaises(exception.ValidationError,
|
||||||
|
self.wh.new_client)
|
Loading…
Reference in New Issue