From 36ca1eff23c973c9cc2200561ae35939c2fe7839 Mon Sep 17 00:00:00 2001 From: Dave McCowan Date: Mon, 2 Mar 2015 15:00:22 -0500 Subject: [PATCH] Websocket Proxy should verify Origin header If the Origin HTTP header passed in the WebSocket handshake does not match the host, this could indicate an attempt at a cross-site attack. This commit adds a check to verify the origin matches the host. Closes-Bug: 1409142 --- nova/console/websocketproxy.py | 35 ++++++ nova/tests/unit/console/test_websocketproxy.py | 158 +++++++++++++++++++++++-- 2 files changed, 185 insertions(+), 8 deletions(-) diff --git a/nova/console/websocketproxy.py b/nova/console/websocketproxy.py index 01e6bb8..914ee14 100644 --- a/nova/console/websocketproxy.py +++ b/nova/console/websocketproxy.py @@ -30,9 +30,14 @@ from nova.consoleauth import rpcapi as consoleauth_rpcapi from nova import context from nova import exception from nova.i18n import _ +from oslo_config import cfg LOG = logging.getLogger(__name__) +CONF = cfg.CONF +CONF.import_opt('novncproxy_base_url', 'nova.vnc') +CONF.import_opt('html5proxy_base_url', 'nova.spice', group='spice') + class NovaProxyRequestHandlerBase(object): def address_string(self): @@ -41,6 +46,19 @@ class NovaProxyRequestHandlerBase(object): # deployments due to DNS configuration and break VNC access completely return str(self.client_address[0]) + def verify_origin_proto(self, console_type, origin_proto): + if console_type == 'vnc': + 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 + raise exception.ValidationError(detail=detail) + return origin_proto == expected_proto + def new_websocket_client(self): """Called after a new WebSocket connection has been established.""" # Reopen the eventlet hub to make sure we don't share an epoll @@ -79,6 +97,23 @@ class NovaProxyRequestHandlerBase(object): if not connect_info: raise exception.InvalidToken(token=token) + # Verify Origin + expected_origin_netloc = self.headers.getheader('Host') + origin_url = self.headers.getheader('Origin') + origin = urlparse.urlparse(origin_url) + origin_netloc = origin.netloc + origin_scheme = origin.scheme + if origin_netloc == '' or origin_scheme == '': + detail = _("Origin header not valid.") + raise exception.ValidationError(detail=detail) + if expected_origin_netloc != origin_netloc: + 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) + self.msg(_('connect info: %s'), str(connect_info)) host = connect_info['host'] port = int(connect_info['port']) diff --git a/nova/tests/unit/console/test_websocketproxy.py b/nova/tests/unit/console/test_websocketproxy.py index c40a77c..a98627c 100644 --- a/nova/tests/unit/console/test_websocketproxy.py +++ b/nova/tests/unit/console/test_websocketproxy.py @@ -20,6 +20,9 @@ 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): @@ -32,15 +35,72 @@ class NovaProxyRequestHandlerBaseTestCase(test.TestCase): 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') + + def _fake_getheader(self, 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 + + def _fake_getheader_bad_token(self, header): + if header == 'cookie': + return 'token="XXX"' + elif header == 'Origin': + return 'https://example.net:6080' + elif header == 'Host': + return 'example.net:6080' + else: + return + + def _fake_getheader_bad_origin(self, 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 + + def _fake_getheader_blank_origin(self, header): + if header == 'cookie': + return 'token="123-456-789"' + elif header == 'Origin': + return '' + elif header == 'Host': + return 'example.net:6080' + else: + return + + def _fake_getheader_http(self, 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 @mock.patch('nova.consoleauth.rpcapi.ConsoleAuthAPI.check_token') def test_new_websocket_client(self, check_token): check_token.return_value = { 'host': 'node1', - 'port': '10000' + 'port': '10000', + 'console_type': 'vnc' } self.wh.socket.return_value = '' self.wh.path = "http://127.0.0.1/?token=123-456-789" + self.wh.headers.getheader = self._fake_getheader self.wh.new_websocket_client() @@ -53,6 +113,7 @@ class NovaProxyRequestHandlerBaseTestCase(test.TestCase): check_token.return_value = False self.wh.path = "http://127.0.0.1/?token=XXX" + self.wh.headers.getheader = self._fake_getheader_bad_token self.assertRaises(exception.InvalidToken, self.wh.new_websocket_client) @@ -62,11 +123,12 @@ class NovaProxyRequestHandlerBaseTestCase(test.TestCase): def test_new_websocket_client_novnc(self, check_token): check_token.return_value = { 'host': 'node1', - 'port': '10000' + 'port': '10000', + 'console_type': 'vnc' } self.wh.socket.return_value = '' self.wh.path = "http://127.0.0.1/" - self.wh.headers.getheader.return_value = "token=123-456-789" + self.wh.headers.getheader = self._fake_getheader self.wh.new_websocket_client() @@ -79,7 +141,7 @@ class NovaProxyRequestHandlerBaseTestCase(test.TestCase): check_token.return_value = False self.wh.path = "http://127.0.0.1/" - self.wh.headers.getheader.return_value = "token=XXX" + self.wh.headers.getheader = self._fake_getheader_bad_token self.assertRaises(exception.InvalidToken, self.wh.new_websocket_client) @@ -90,7 +152,8 @@ class NovaProxyRequestHandlerBaseTestCase(test.TestCase): check_token.return_value = { 'host': 'node1', 'port': '10000', - 'internal_access_path': 'vmid' + 'internal_access_path': 'vmid', + 'console_type': 'vnc' } tsock = mock.MagicMock() @@ -98,6 +161,7 @@ class NovaProxyRequestHandlerBaseTestCase(test.TestCase): self.wh.socket.return_value = tsock self.wh.path = "http://127.0.0.1/?token=123-456-789" + self.wh.headers.getheader = self._fake_getheader self.wh.new_websocket_client() @@ -110,7 +174,8 @@ class NovaProxyRequestHandlerBaseTestCase(test.TestCase): check_token.return_value = { 'host': 'node1', 'port': '10000', - 'internal_access_path': 'xxx' + 'internal_access_path': 'xxx', + 'console_type': 'vnc' } tsock = mock.MagicMock() @@ -118,6 +183,7 @@ class NovaProxyRequestHandlerBaseTestCase(test.TestCase): self.wh.socket.return_value = tsock self.wh.path = "http://127.0.0.1/?token=123-456-789" + self.wh.headers.getheader = self._fake_getheader self.assertRaises(exception.InvalidConnectionInfo, self.wh.new_websocket_client) @@ -130,10 +196,12 @@ class NovaProxyRequestHandlerBaseTestCase(test.TestCase): version_info.return_value = (2, 7, 3) check_token.return_value = { 'host': 'node1', - 'port': '10000' + 'port': '10000', + 'console_type': 'vnc' } self.wh.socket.return_value = '' self.wh.path = "http://127.0.0.1/?token=123-456-789" + self.wh.headers.getheader = self._fake_getheader self.wh.new_websocket_client() @@ -148,10 +216,12 @@ class NovaProxyRequestHandlerBaseTestCase(test.TestCase): version_info.return_value = (2, 7, 3) check_token.return_value = { 'host': 'node1', - 'port': '10000' + 'port': '10000', + 'console_type': 'vnc' } self.wh.socket.return_value = '' self.wh.path = "ws://127.0.0.1/?token=123-456-789" + self.wh.headers.getheader = self._fake_getheader self.assertRaises(exception.NovaException, self.wh.new_websocket_client) @@ -172,3 +242,75 @@ class NovaProxyRequestHandlerBaseTestCase(test.TestCase): self.assertFalse(getfqdn.called) # no reverse dns look up self.assertEqual(handler.address_string(), '8.8.8.8') # plain address + + @mock.patch('nova.consoleauth.rpcapi.ConsoleAuthAPI.check_token') + def test_new_websocket_client_novnc_bad_origin_header(self, check_token): + check_token.return_value = { + 'host': 'node1', + 'port': '10000', + 'console_type': 'vnc' + } + + self.wh.path = "http://127.0.0.1/" + self.wh.headers.getheader = self._fake_getheader_bad_origin + + self.assertRaises(exception.ValidationError, + self.wh.new_websocket_client) + + @mock.patch('nova.consoleauth.rpcapi.ConsoleAuthAPI.check_token') + def test_new_websocket_client_novnc_blank_origin_header(self, check_token): + check_token.return_value = { + 'host': 'node1', + 'port': '10000', + 'console_type': 'vnc' + } + + self.wh.path = "http://127.0.0.1/" + self.wh.headers.getheader = self._fake_getheader_blank_origin + + self.assertRaises(exception.ValidationError, + self.wh.new_websocket_client) + + @mock.patch('nova.consoleauth.rpcapi.ConsoleAuthAPI.check_token') + def test_new_websocket_client_novnc_bad_origin_proto_vnc(self, + check_token): + check_token.return_value = { + 'host': 'node1', + 'port': '10000', + 'console_type': 'vnc' + } + + self.wh.path = "http://127.0.0.1/" + self.wh.headers.getheader = self._fake_getheader_http + + self.assertRaises(exception.ValidationError, + self.wh.new_websocket_client) + + @mock.patch('nova.consoleauth.rpcapi.ConsoleAuthAPI.check_token') + def test_new_websocket_client_novnc_bad_origin_proto_spice(self, + check_token): + check_token.return_value = { + 'host': 'node1', + 'port': '10000', + 'console_type': 'spice-html5' + } + + self.wh.path = "http://127.0.0.1/" + self.wh.headers.getheader = self._fake_getheader_http + + self.assertRaises(exception.ValidationError, + self.wh.new_websocket_client) + + @mock.patch('nova.consoleauth.rpcapi.ConsoleAuthAPI.check_token') + def test_new_websocket_client_novnc_bad_console_type(self, check_token): + check_token.return_value = { + 'host': 'node1', + 'port': '10000', + 'console_type': 'bad-console-type' + } + + self.wh.path = "http://127.0.0.1/" + self.wh.headers.getheader = self._fake_getheader + + self.assertRaises(exception.ValidationError, + self.wh.new_websocket_client) -- 1.9.3 (Apple Git-50)