diff --git a/config/galaxy.ini.sample b/config/galaxy.ini.sample index 1e98a97621e..2277f1d634e 100644 --- a/config/galaxy.ini.sample +++ b/config/galaxy.ini.sample @@ -172,7 +172,7 @@ paste.app_factory = galaxy.web.buildapp:app_factory # from the Tool Shed will fail. #tool_dependency_dir = None -# The dependency resolves config file specifies an ordering and options for how +# The dependency resolves config file specifies an ordering and options for how # Galaxy resolves tool dependencies (requirement tags in Tool XML). The default # ordering is to the use the tool shed for tools installed that way, use local # Galaxy packages, and then use conda if available. @@ -675,6 +675,14 @@ nglims_config_file = tool-data/nglims.yaml # by setting the following option to True. #serve_xss_vulnerable_mimetypes = False +# Return a Access-Control-Allow-Origin response header that matches the Origin +# header of the request if that Origin hostname matches one of the strings or +# regular expressions listed here. This is a comma separated list of hostname +# strings or regular expressions beginning and ending with /. +# E.g. mysite.com,google.com,usegalaxy.org,/^[\w\.]*example\.com/ +# See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS +#allowed_origin_hostnames = None + # Set the following to True to use IPython nbconvert to build HTML from IPython # notebooks in Galaxy histories. This process may allow users to execute # arbitrary code or serve arbitrary HTML. If enabled, IPython must be @@ -891,8 +899,8 @@ use_interactive = True # Set the following to a number of threads greater than 1 to spawn # a Python task queue for dealing with large tool submissions (either # through the tool form or as part of an individual workflow step across -# large collection). The size of a "large" tool request is controlled by -# the second parameter below and defaults to 10. This affects workflow +# large collection). The size of a "large" tool request is controlled by +# the second parameter below and defaults to 10. This affects workflow # scheduling and web processes, not job handlers. #tool_submission_burst_threads = 1 #tool_submission_burst_at = 10 diff --git a/lib/galaxy/config.py b/lib/galaxy/config.py index 216e6f4ef37..76d7e7935b4 100644 --- a/lib/galaxy/config.py +++ b/lib/galaxy/config.py @@ -263,6 +263,7 @@ class Configuration( object ): if kwargs.get('sanitize_whitelist_file', None) is not None: self.reload_sanitize_whitelist() self.serve_xss_vulnerable_mimetypes = string_as_bool( kwargs.get( 'serve_xss_vulnerable_mimetypes', False ) ) + self.allowed_origin_hostnames = self._parse_allowed_origin_hostnames( kwargs ) self.trust_ipython_notebook_conversion = string_as_bool( kwargs.get( 'trust_ipython_notebook_conversion', False ) ) self.enable_old_display_applications = string_as_bool( kwargs.get( "enable_old_display_applications", "True" ) ) self.brand = kwargs.get( 'brand', None ) @@ -672,6 +673,24 @@ class Configuration( object ): port = None return port + def _parse_allowed_origin_hostnames( self, kwargs ): + """ + Parse a CSV list of strings/regexp of hostnames that should be allowed + to use CORS and will be sent the Access-Control-Allow-Origin header. + """ + allowed_origin_hostnames = listify( kwargs.get( 'allowed_origin_hostnames', None ) ) + if not allowed_origin_hostnames: + return None + + def parse( string ): + # a string enclosed in fwd slashes will be parsed as a regexp: e.g. // + if string[0] == '/' and string[-1] == '/': + string = string[1:-1] + return re.compile( string, flags=( re.UNICODE | re.LOCALE ) ) + return string + + return [ parse( v ) for v in allowed_origin_hostnames if v ] + def get_database_engine_options( kwargs, model_prefix='' ): """ diff --git a/lib/galaxy/web/framework/webapp.py b/lib/galaxy/web/framework/webapp.py index efb3254aa4c..ba942a5723c 100644 --- a/lib/galaxy/web/framework/webapp.py +++ b/lib/galaxy/web/framework/webapp.py @@ -8,6 +8,7 @@ import random import socket import string import time +import urlparse from Cookie import CookieError from Cheetah.Template import Template @@ -184,6 +185,9 @@ class GalaxyWebTransaction( base.DefaultWebTransaction, self.galaxy_session = None self.error_message = None + # set any cross origin resource sharing headers if configured to do so + self.set_cors_headers() + if self.environ.get('is_api_request', False): # With API requests, if there's a key, use it and associate the # user with the transaction. @@ -255,6 +259,50 @@ class GalaxyWebTransaction( base.DefaultWebTransaction, t = Translations.load( dirname='locale', locales=locales, domain='ginga' ) self.template_context.update( dict( _=t.ugettext, n_=t.ugettext, N_=t.ungettext ) ) + def set_cors_headers( self ): + """Allow CORS requests if configured to do so by echoing back the request's + 'Origin' header (if any) as the response header 'Access-Control-Allow-Origin' + """ + # TODO: in order to use these, we need preflight to work, and to do that we + # need the OPTIONS method on all api calls (or everywhere we can POST/PUT) + # ALLOWED_METHODS = ( 'POST', 'PUT' ) + + # do not set any access control headers if not configured for it (common case) + if not self.app.config.get( 'allowed_origin_hostnames', None ): + return + # do not set any access control headers if there's no origin header on the request + origin_header = self.request.headers.get( "Origin", None ) + if not origin_header: + return + + # singular match + def matches_allowed_origin( origin, allowed_origin ): + if isinstance( allowed_origin, str ): + return origin == allowed_origin + match = allowed_origin.match( origin ) + return match and match.group() == origin + + # check for '*' or compare to list of allowed + def is_allowed_origin( origin ): + # localhost uses no origin header (== null) + if not origin: + return False + for allowed_origin in self.app.config.allowed_origin_hostnames: + if allowed_origin == '*' or matches_allowed_origin( origin, allowed_origin ): + return True + return False + + # boil origin header down to hostname + origin = urlparse.urlparse( origin_header ).hostname + # check against the list of allowed strings/regexp hostnames, echo original if cleared + if is_allowed_origin( origin ): + self.response.headers[ 'Access-Control-Allow-Origin' ] = origin_header + # TODO: see the to do on ALLOWED_METHODS above + # self.response.headers[ 'Access-Control-Allow-Methods' ] = ', '.join( ALLOWED_METHODS ) + + # NOTE: raising some errors (such as httpexceptions), will remove the header + # (e.g. client will get both cors error and 404 inside that) + def get_user( self ): """Return the current user if logged in or None.""" if self.galaxy_session: diff --git a/test/unit/unittest_utils/galaxy_mock.py b/test/unit/unittest_utils/galaxy_mock.py index aee8506e8e9..b31d2bf115c 100644 --- a/test/unit/unittest_utils/galaxy_mock.py +++ b/test/unit/unittest_utils/galaxy_mock.py @@ -19,11 +19,40 @@ class OpenObject( object ): pass +def buildMockEnviron( **kwargs ): + environ = { + 'CONTENT_LENGTH': '0', + 'CONTENT_TYPE': '', + 'HTTP_ACCEPT': '*/*', + 'HTTP_ACCEPT_ENCODING': 'gzip, deflate', + 'HTTP_ACCEPT_LANGUAGE': 'en-US,en;q=0.8,zh;q=0.5,ja;q=0.3', + 'HTTP_CACHE_CONTROL': 'no-cache', + 'HTTP_CONNECTION': 'keep-alive', + 'HTTP_DNT': '1', + 'HTTP_HOST': 'localhost:8000', + 'HTTP_ORIGIN': 'http://localhost:8000', + 'HTTP_PRAGMA': 'no-cache', + 'HTTP_REFERER': 'http://localhost:8000', + 'HTTP_USER_AGENT': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:43.0) Gecko/20100101 Firefox/43.0', + 'PATH_INFO': '/', + 'QUERY_STRING': '', + 'REMOTE_ADDR': '127.0.0.1', + 'REQUEST_METHOD': 'GET', + 'SCRIPT_NAME': '', + 'SERVER_NAME': '127.0.0.1', + 'SERVER_PORT': '8080', + 'SERVER_PROTOCOL': 'HTTP/1.1' + } + environ.update( **kwargs ) + return environ + + class MockApp( object ): - def __init__( self, **kwargs ): - self.config = MockAppConfig( **kwargs ) + def __init__( self, config=None, **kwargs ): + self.config = config or MockAppConfig( **kwargs ) self.security = self.config.security + self.name = kwargs.get( 'name', 'galaxy' ) self.object_store = objectstore.build_object_store_from_config( self.config ) self.model = mapping.init( "/tmp", "sqlite:///:memory:", create_tables=True, object_store=self.object_store ) self.security_agent = self.model.security_agent @@ -37,6 +66,7 @@ class MockAppConfig( Bunch ): def __init__( self, root=None, **kwargs ): Bunch.__init__( self, **kwargs ) self.security = security.SecurityHelper( id_secret='bler' ) + self.use_remote_user = kwargs.get( 'use_remote_user', False ) self.file_path = '/tmp' self.job_working_directory = '/tmp' self.new_file_path = '/tmp' @@ -60,6 +90,7 @@ class MockWebapp( object ): def __init__( self, **kwargs ): self.name = kwargs.get( 'name', 'galaxy' ) + self.security = security.SecurityHelper( id_secret='bler' ) class MockTrans( object ): @@ -75,6 +106,9 @@ class MockTrans( object ): self.security = self.app.security self.history = history + self.request = Bunch( headers={} ) + self.response = Bunch( headers={} ) + def get_user( self ): if self.galaxy_session: return self.galaxy_session.user diff --git a/test/unit/web/framework/__init__.py b/test/unit/web/framework/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/unit/web/framework/test_webapp.py b/test/unit/web/framework/test_webapp.py new file mode 100644 index 00000000000..8bc79c79127 --- /dev/null +++ b/test/unit/web/framework/test_webapp.py @@ -0,0 +1,121 @@ +""" +Unit tests for ``galaxy.web.framework.webapp`` +""" +import os +import imp +import unittest + +import logging +log = logging.getLogger( __name__ ) + +test_utils = imp.load_source( 'test_utils', + os.path.join( os.path.dirname( __file__), '../../unittest_utils/utility.py' ) ) +import galaxy_mock + +import re +from galaxy.web.framework import webapp as Webapp +import galaxy.config + + +class StubGalaxyWebTransaction( Webapp.GalaxyWebTransaction ): + def _ensure_valid_session( self, session_cookie, create=True ): + pass + + +class CORSParsingMockConfig( galaxy_mock.MockAppConfig ): + # we can't use the actual Configuration for parsing*, so steal the parser for the mock instead + # *It causes problems when it's change to tempfile.tempdir persists across tests + _parse_allowed_origin_hostnames = galaxy.config.Configuration._parse_allowed_origin_hostnames.__func__ + + def __init__( self, **kwargs ): + super( CORSParsingMockConfig, self ).__init__( **kwargs ) + self.allowed_origin_hostnames = self._parse_allowed_origin_hostnames( kwargs ) + + +class GalaxyWebTransaction_Headers_TestCase( test_utils.unittest.TestCase ): + + def _new_trans( self, allowed_origin_hostnames=None ): + app = galaxy_mock.MockApp() + app.config = CORSParsingMockConfig( + allowed_origin_hostnames=allowed_origin_hostnames + ) + webapp = galaxy_mock.MockWebapp() + environ = galaxy_mock.buildMockEnviron() + trans = StubGalaxyWebTransaction( environ, app, webapp ) + return trans + + def assert_cors_header_equals( self, headers, should_be ): + self.assertEqual( headers.get( 'access-control-allow-origin', None ), should_be ) + + def assert_cors_header_missing( self, headers ): + self.assertFalse( 'access-control-allow-origin' in headers ) + + def test_parse_allowed_origin_hostnames( self ): + """Should return a list of (possibly) mixed strings and regexps""" + config = CORSParsingMockConfig() + + # falsy listify value should return None + self.assertEqual( config._parse_allowed_origin_hostnames({ + "allowed_origin_hostnames" : "" + }), None ) + + # should parse regex if using fwd slashes, string otherwise + hostnames = config._parse_allowed_origin_hostnames({ + "allowed_origin_hostnames" : "/host\d{2}/,geocities.com,miskatonic.edu" + }) + self.assertTrue( isinstance( hostnames[0], re._pattern_type ) ) + self.assertTrue( isinstance( hostnames[1], str ) ) + self.assertTrue( isinstance( hostnames[2], str ) ) + + def test_default_set_cors_headers( self ): + """No CORS headers should be set (or even checked) by default""" + trans = self._new_trans( allowed_origin_hostnames=None ) + self.assertTrue( isinstance( trans, Webapp.GalaxyWebTransaction ) ) + + trans.request.headers[ 'Origin' ] = 'http://lisaskelprecipes.pinterest.com?id=kelpcake' + trans.set_cors_headers() + self.assert_cors_header_missing( trans.response.headers ) + + def test_set_cors_headers( self ): + """Origin should be echo'd when it matches an allowed hostname""" + # an asterisk is a special 'allow all' string + trans = self._new_trans( allowed_origin_hostnames='*,beep.com' ) + trans.request.headers[ 'Origin' ] = 'http://xxdarkhackerxx.disney.com' + trans.set_cors_headers() + self.assert_cors_header_equals( trans.response.headers, 'http://xxdarkhackerxx.disney.com' ) + + # subdomains should pass + trans = self._new_trans( allowed_origin_hostnames='something.com,/^[\w\.]*beep\.com/' ) + trans.request.headers[ 'Origin' ] = 'http://boop.beep.com' + trans.set_cors_headers() + self.assert_cors_header_equals( trans.response.headers, 'http://boop.beep.com' ) + + # ports should work + trans = self._new_trans( allowed_origin_hostnames='somethingelse.com,/^[\w\.]*beep\.com/' ) + trans.request.headers[ 'Origin' ] = 'http://boop.beep.com:8080' + trans.set_cors_headers() + self.assert_cors_header_equals( trans.response.headers, 'http://boop.beep.com:8080' ) + + # localhost should work + trans = self._new_trans( allowed_origin_hostnames='/localhost/' ) + trans.request.headers[ 'Origin' ] = 'http://localhost:8080' + trans.set_cors_headers() + self.assert_cors_header_equals( trans.response.headers, 'http://localhost:8080' ) + + # spoofing shouldn't be easy + trans.response.headers = {} + trans.request.headers[ 'Origin' ] = 'http://localhost.badstuff.tv' + trans.set_cors_headers() + self.assert_cors_header_missing( trans.response.headers ) + + # unicode should work + trans = self._new_trans( allowed_origin_hostnames='/öbb\.at/' ) + trans.request.headers[ 'Origin' ] = 'http://öbb.at' + trans.set_cors_headers() + self.assertEqual( + trans.response.headers[ 'access-control-allow-origin' ], 'http://öbb.at' + ) + + +if __name__ == '__main__': + unittest.main()