mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-24 16:30:27 +08:00
Merge pull request #1331 from carlfeberhard/CORS
Core, CORS: allow (non-preflighted) CORS requests by echoing back the…
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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. /<some val>/
|
||||
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='' ):
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user