From 52811771a54740cdde9dddf726054ea52b623715 Mon Sep 17 00:00:00 2001 From: Dave McCowan Date: Tue, 24 Feb 2015 21:33:58 -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 | 40 ++++++++ nova/tests/console/test_websocketproxy.py | 165 ++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 nova/tests/console/test_websocketproxy.py diff --git a/nova/console/websocketproxy.py b/nova/console/websocketproxy.py index e5bfb9e..91b8eed 100644 --- a/nova/console/websocketproxy.py +++ b/nova/console/websocketproxy.py @@ -20,13 +20,22 @@ Leverages websockify.py by Joel Martin import Cookie import socket +import urlparse import websockify from nova.consoleauth import rpcapi as consoleauth_rpcapi from nova import context +from nova import exception from nova.openstack.common.gettextutils import _ 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__) @@ -37,6 +46,20 @@ class NovaWebSocketProxy(websockify.WebSocketProxy): target_cfg=None, ssl_target=None, *args, **kwargs) + 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 + LOG.audit(detail) + raise exception.ValidationError(detail=detail) + return origin_proto == expected_proto + def new_client(self): """Called after a new WebSocket connection has been established.""" # Reopen the eventlet hub to make sure we don't share an epoll @@ -55,6 +78,23 @@ class NovaWebSocketProxy(websockify.WebSocketProxy): LOG.audit("Invalid Token: %s", token) raise Exception(_("Invalid 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) + host = connect_info['host'] port = int(connect_info['port']) diff --git a/nova/tests/console/test_websocketproxy.py b/nova/tests/console/test_websocketproxy.py new file mode 100644 index 0000000..18ea028 --- /dev/null +++ b/nova/tests/console/test_websocketproxy.py @@ -0,0 +1,165 @@ +# 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': 'vnc' + } + self.wh.socket.return_value = '' + 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('') + + @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': 'vnc' + } + self.wh.socket.return_value = '' + 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_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': 'vnc' + } + self.wh.socket.return_value = '' + 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 = '' + 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 = '' + 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) -- 1.9.3 (Apple Git-50)