From c376a2d94823f3777930c4312b6f2f70a4021e4c Mon Sep 17 00:00:00 2001 From: carlfeberhard Date: Tue, 15 Dec 2015 13:43:50 -0500 Subject: [PATCH 1/5] Core, CORS: allow (non-preflighted) CORS requests by echoing back the origin header if the requesting client is allowed --- config/galaxy.ini.sample | 12 +++++++-- lib/galaxy/config.py | 18 +++++++++++++ lib/galaxy/web/framework/base.py | 45 ++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/config/galaxy.ini.sample b/config/galaxy.ini.sample index 0fde9285f67..99752b23987 100644 --- a/config/galaxy.ini.sample +++ b/config/galaxy.ini.sample @@ -626,6 +626,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,/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 @@ -841,8 +849,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 e9f7e9af4b0..17420d794a3 100644 --- a/lib/galaxy/config.py +++ b/lib/galaxy/config.py @@ -257,6 +257,7 @@ class Configuration( object ): self.sanitize_whitelist_file = resolve_path( kwargs.get( 'sanitize_whitelist_file', "config/sanitize_whitelist.txt" ), self.root ) 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 ) @@ -666,6 +667,23 @@ 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] == '/': + return re.compile( string[1:-1] ) + return string + + return [ parse( v ) for v in allowed_origin_hostnames ] + def get_database_engine_options( kwargs, model_prefix='' ): """ diff --git a/lib/galaxy/web/framework/base.py b/lib/galaxy/web/framework/base.py index 85db7e3df42..7f273a78c4b 100644 --- a/lib/galaxy/web/framework/base.py +++ b/lib/galaxy/web/framework/base.py @@ -8,6 +8,7 @@ import os.path import socket import tarfile import types +import urlparse import routes import webob @@ -153,6 +154,7 @@ class WebApplication( object ): trans = self.transaction_factory( environ ) trans.request_id = request_id rc.redirect = trans.response.send_redirect + self.set_cors_headers( trans ) # Get the controller class controller_name = map.pop( 'controller', None ) controller = controllers.get( controller_name, None ) @@ -205,6 +207,49 @@ class WebApplication( object ): trans.response.wsgi_headeritems() ) return self.make_body_iterable( trans, body ) + def set_cors_headers( self, trans ): + """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 trans.app.config.allowed_origin_hostnames: + return + # do not set any access control headers if there's no origin header on the request + origin_header = trans.request.headers.get( "Origin", None ) + if not origin_header: + return + + # singular match + def matches_allowed_origin( origin, allowed_origin ): + if isinstance( allowed_origin, basestring ): + return origin == allowed_origin + # note str() returns an empty string (a suitable default function) + return getattr( allowed_origin.match( origin ), 'group', str )() == origin + + # check for '*' or compare to list of allowed + def is_allowed_origin( origin ): + for allowed_origin in trans.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 ): + log.info( 'sending Access-Control-Allow-Origin header to: %s', origin_header ) + trans.response.headers[ 'Access-Control-Allow-Origin' ] = origin_header + # TODO: see the to do on ALLOWED_METHODS above + # trans.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 make_body_iterable( self, trans, body ): if isinstance( body, ( types.GeneratorType, list, tuple ) ): # Recursively stream the iterable From 80cce3f42827d95d9b6b9894f0af942c9bfa942d Mon Sep 17 00:00:00 2001 From: carlfeberhard Date: Tue, 5 Jan 2016 14:28:47 -0500 Subject: [PATCH 2/5] CORS: remove basestring, remove log, null-proofing, allow unicode regexp, add testing --- config/galaxy.ini.sample | 4 ++-- lib/galaxy/config.py | 5 +++-- lib/galaxy/web/framework/base.py | 10 ++++++---- test/unit/unittest_utils/galaxy_mock.py | 3 +++ 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/config/galaxy.ini.sample b/config/galaxy.ini.sample index 70dd06750be..718a5fbb758 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. @@ -657,7 +657,7 @@ nglims_config_file = tool-data/nglims.yaml # 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,/example\.*.com/ +# 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 diff --git a/lib/galaxy/config.py b/lib/galaxy/config.py index f2dcd7f6d01..b5e6efa5214 100644 --- a/lib/galaxy/config.py +++ b/lib/galaxy/config.py @@ -680,10 +680,11 @@ class Configuration( object ): def parse( string ): # a string enclosed in fwd slashes will be parsed as a regexp: e.g. // if string[0] == '/' and string[-1] == '/': - return re.compile( string[1:-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 ] + 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/base.py b/lib/galaxy/web/framework/base.py index 7f273a78c4b..5f6fcd936d3 100644 --- a/lib/galaxy/web/framework/base.py +++ b/lib/galaxy/web/framework/base.py @@ -225,13 +225,16 @@ class WebApplication( object ): # singular match def matches_allowed_origin( origin, allowed_origin ): - if isinstance( allowed_origin, basestring ): + if isinstance( allowed_origin, str ): return origin == allowed_origin - # note str() returns an empty string (a suitable default function) - return getattr( allowed_origin.match( origin ), 'group', str )() == 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 trans.app.config.allowed_origin_hostnames: if( allowed_origin == '*' or( matches_allowed_origin( origin, allowed_origin ) ) ): @@ -242,7 +245,6 @@ class WebApplication( object ): origin = urlparse.urlparse( origin_header ).hostname # check against the list of allowed strings/regexp hostnames, echo original if cleared if is_allowed_origin( origin ): - log.info( 'sending Access-Control-Allow-Origin header to: %s', origin_header ) trans.response.headers[ 'Access-Control-Allow-Origin' ] = origin_header # TODO: see the to do on ALLOWED_METHODS above # trans.response.headers[ 'Access-Control-Allow-Methods' ] = ', '.join( ALLOWED_METHODS ) diff --git a/test/unit/unittest_utils/galaxy_mock.py b/test/unit/unittest_utils/galaxy_mock.py index aee8506e8e9..33883d07bb4 100644 --- a/test/unit/unittest_utils/galaxy_mock.py +++ b/test/unit/unittest_utils/galaxy_mock.py @@ -75,6 +75,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 From 575a5a440297cb20431cb6bd7e922ab868b57a33 Mon Sep 17 00:00:00 2001 From: carlfeberhard Date: Thu, 7 Jan 2016 12:39:18 -0500 Subject: [PATCH 3/5] CORS: move check/setter to webapp.GalaxyWebTransaction from base.WebApplication, bail on lack of config key, rework tests, add test files --- lib/galaxy/web/framework/base.py | 47 ------------- lib/galaxy/web/framework/webapp.py | 49 +++++++++++++ test/unit/test_config.py | 45 ++++++++++++ test/unit/unittest_utils/galaxy_mock.py | 35 +++++++++- test/unit/web/framework/__init__.py | 0 test/unit/web/framework/test_webapp.py | 93 +++++++++++++++++++++++++ 6 files changed, 220 insertions(+), 49 deletions(-) create mode 100644 test/unit/test_config.py create mode 100644 test/unit/web/framework/__init__.py create mode 100644 test/unit/web/framework/test_webapp.py diff --git a/lib/galaxy/web/framework/base.py b/lib/galaxy/web/framework/base.py index 5f6fcd936d3..85db7e3df42 100644 --- a/lib/galaxy/web/framework/base.py +++ b/lib/galaxy/web/framework/base.py @@ -8,7 +8,6 @@ import os.path import socket import tarfile import types -import urlparse import routes import webob @@ -154,7 +153,6 @@ class WebApplication( object ): trans = self.transaction_factory( environ ) trans.request_id = request_id rc.redirect = trans.response.send_redirect - self.set_cors_headers( trans ) # Get the controller class controller_name = map.pop( 'controller', None ) controller = controllers.get( controller_name, None ) @@ -207,51 +205,6 @@ class WebApplication( object ): trans.response.wsgi_headeritems() ) return self.make_body_iterable( trans, body ) - def set_cors_headers( self, trans ): - """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 trans.app.config.allowed_origin_hostnames: - return - # do not set any access control headers if there's no origin header on the request - origin_header = trans.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 trans.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 ): - trans.response.headers[ 'Access-Control-Allow-Origin' ] = origin_header - # TODO: see the to do on ALLOWED_METHODS above - # trans.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 make_body_iterable( self, trans, body ): if isinstance( body, ( types.GeneratorType, list, tuple ) ): # Recursively stream the iterable diff --git a/lib/galaxy/web/framework/webapp.py b/lib/galaxy/web/framework/webapp.py index efb3254aa4c..411e787219f 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,51 @@ 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/test_config.py b/test/unit/test_config.py new file mode 100644 index 00000000000..bf8f001b685 --- /dev/null +++ b/test/unit/test_config.py @@ -0,0 +1,45 @@ +""" +Unit tests for ``galaxy.config`` +""" +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 +import galaxy.config + + +class Config_TestCase( test_utils.unittest.TestCase ): + + def test_default_allowed_origin_hostnames( self ): + """Shouldn't have any allowed""" + config = galaxy.config.Configuration() + self.assertTrue( isinstance( config, galaxy.config.Configuration ) ) + self.assertEqual( config.allowed_origin_hostnames, None ) + + def test_parse_allowed_origin_hostnames( self ): + """Should return a list of (possibly) mixed strings and regexps""" + config = galaxy.config.Configuration() + + # 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 ) ) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/unit/unittest_utils/galaxy_mock.py b/test/unit/unittest_utils/galaxy_mock.py index 33883d07bb4..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 ): 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..795409e2b8c --- /dev/null +++ b/test/unit/web/framework/test_webapp.py @@ -0,0 +1,93 @@ +""" +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 + +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 GalaxyWebTransaction_Headers_TestCase( test_utils.unittest.TestCase ): + + def _new_trans( self, allowed_origin_hostnames=None ): + app = galaxy_mock.MockApp() + app.config = galaxy.config.Configuration( + 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[ 'access-control-allow-origin' ], should_be ) + + def assert_cors_header_missing( self, headers ): + self.assertFalse( 'access-control-allow-origin' in headers ) + + 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() From 0d399f6919cc24fc0e3e288fa21784ea544edb29 Mon Sep 17 00:00:00 2001 From: carlfeberhard Date: Thu, 14 Jan 2016 15:32:57 -0500 Subject: [PATCH 4/5] CORS: cache and restore tempfile.tempdir when using test Configuration instances as it persists across tests and fails any tempfile related tests that follow, move test_config tests into test_webapp (cohesive subject v. ortho file struct) --- test/unit/test_config.py | 45 -------------------------- test/unit/web/framework/test_webapp.py | 34 +++++++++++++++++++ 2 files changed, 34 insertions(+), 45 deletions(-) delete mode 100644 test/unit/test_config.py diff --git a/test/unit/test_config.py b/test/unit/test_config.py deleted file mode 100644 index bf8f001b685..00000000000 --- a/test/unit/test_config.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -Unit tests for ``galaxy.config`` -""" -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 -import galaxy.config - - -class Config_TestCase( test_utils.unittest.TestCase ): - - def test_default_allowed_origin_hostnames( self ): - """Shouldn't have any allowed""" - config = galaxy.config.Configuration() - self.assertTrue( isinstance( config, galaxy.config.Configuration ) ) - self.assertEqual( config.allowed_origin_hostnames, None ) - - def test_parse_allowed_origin_hostnames( self ): - """Should return a list of (possibly) mixed strings and regexps""" - config = galaxy.config.Configuration() - - # 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 ) ) - - -if __name__ == '__main__': - unittest.main() diff --git a/test/unit/web/framework/test_webapp.py b/test/unit/web/framework/test_webapp.py index 795409e2b8c..867ff516ecf 100644 --- a/test/unit/web/framework/test_webapp.py +++ b/test/unit/web/framework/test_webapp.py @@ -12,6 +12,8 @@ test_utils = imp.load_source( 'test_utils', os.path.join( os.path.dirname( __file__), '../../unittest_utils/utility.py' ) ) import galaxy_mock +import re +import tempfile from galaxy.web.framework import webapp as Webapp import galaxy.config @@ -23,6 +25,15 @@ class StubGalaxyWebTransaction( Webapp.GalaxyWebTransaction ): class GalaxyWebTransaction_Headers_TestCase( test_utils.unittest.TestCase ): + def setUp( self ): + # galaxy uses the tempfile.tempdir interface of the tempfile module, changing this value when a Configuration + # is created. This change persists across tests (since it's scoped to the module) and breaks following tests. + # TODO: most likely this should be changed at the core level to remove tempfile.tempdir - then remove this + self._saved_tempfile_tempdir = tempfile.tempdir + + def teardown( self ): + tempfile.tempdir = self._saved_tempfile_tempdir + def _new_trans( self, allowed_origin_hostnames=None ): app = galaxy_mock.MockApp() app.config = galaxy.config.Configuration( @@ -39,6 +50,29 @@ class GalaxyWebTransaction_Headers_TestCase( test_utils.unittest.TestCase ): def assert_cors_header_missing( self, headers ): self.assertFalse( 'access-control-allow-origin' in headers ) + def test_default_allowed_origin_hostnames( self ): + """Shouldn't have any allowed""" + config = galaxy.config.Configuration() + self.assertTrue( isinstance( config, galaxy.config.Configuration ) ) + self.assertEqual( config.allowed_origin_hostnames, None ) + + def test_parse_allowed_origin_hostnames( self ): + """Should return a list of (possibly) mixed strings and regexps""" + config = galaxy.config.Configuration() + + # 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 ) From 1a5bc43a80da4917df0d225b715b9b789e70c522 Mon Sep 17 00:00:00 2001 From: carlfeberhard Date: Thu, 14 Jan 2016 16:45:28 -0500 Subject: [PATCH 5/5] CORS: try fixing tests again, this time by avoiding use of Configuration altogether --- lib/galaxy/web/framework/webapp.py | 3 +-- test/unit/web/framework/test_webapp.py | 32 +++++++++++--------------- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/lib/galaxy/web/framework/webapp.py b/lib/galaxy/web/framework/webapp.py index 411e787219f..ba942a5723c 100644 --- a/lib/galaxy/web/framework/webapp.py +++ b/lib/galaxy/web/framework/webapp.py @@ -288,8 +288,7 @@ class GalaxyWebTransaction( base.DefaultWebTransaction, 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 ) ) ): + if allowed_origin == '*' or matches_allowed_origin( origin, allowed_origin ): return True return False diff --git a/test/unit/web/framework/test_webapp.py b/test/unit/web/framework/test_webapp.py index 867ff516ecf..8bc79c79127 100644 --- a/test/unit/web/framework/test_webapp.py +++ b/test/unit/web/framework/test_webapp.py @@ -13,7 +13,6 @@ test_utils = imp.load_source( 'test_utils', import galaxy_mock import re -import tempfile from galaxy.web.framework import webapp as Webapp import galaxy.config @@ -23,20 +22,21 @@ class StubGalaxyWebTransaction( Webapp.GalaxyWebTransaction ): 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 setUp( self ): - # galaxy uses the tempfile.tempdir interface of the tempfile module, changing this value when a Configuration - # is created. This change persists across tests (since it's scoped to the module) and breaks following tests. - # TODO: most likely this should be changed at the core level to remove tempfile.tempdir - then remove this - self._saved_tempfile_tempdir = tempfile.tempdir - - def teardown( self ): - tempfile.tempdir = self._saved_tempfile_tempdir - def _new_trans( self, allowed_origin_hostnames=None ): app = galaxy_mock.MockApp() - app.config = galaxy.config.Configuration( + app.config = CORSParsingMockConfig( allowed_origin_hostnames=allowed_origin_hostnames ) webapp = galaxy_mock.MockWebapp() @@ -45,20 +45,14 @@ class GalaxyWebTransaction_Headers_TestCase( test_utils.unittest.TestCase ): return trans def assert_cors_header_equals( self, headers, should_be ): - self.assertEqual( headers[ 'access-control-allow-origin' ], 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_default_allowed_origin_hostnames( self ): - """Shouldn't have any allowed""" - config = galaxy.config.Configuration() - self.assertTrue( isinstance( config, galaxy.config.Configuration ) ) - self.assertEqual( config.allowed_origin_hostnames, None ) - def test_parse_allowed_origin_hostnames( self ): """Should return a list of (possibly) mixed strings and regexps""" - config = galaxy.config.Configuration() + config = CORSParsingMockConfig() # falsy listify value should return None self.assertEqual( config._parse_allowed_origin_hostnames({