Skip to content

Commit

Permalink
Merge pull request #1331 from carlfeberhard/CORS
Browse files Browse the repository at this point in the history
Core, CORS: allow (non-preflighted) CORS requests by echoing back the…
  • Loading branch information
jmchilton committed Jan 15, 2016
2 parents 84105b5 + 1a5bc43 commit 274af50
Show file tree
Hide file tree
Showing 6 changed files with 235 additions and 5 deletions.
14 changes: 11 additions & 3 deletions config/galaxy.ini.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions lib/galaxy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ def __init__( self, **kwargs ):
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 )
Expand Down Expand Up @@ -672,6 +673,24 @@ def guess_galaxy_port(self):
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='' ):
"""
Expand Down
48 changes: 48 additions & 0 deletions lib/galaxy/web/framework/webapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import socket
import string
import time
import urlparse
from Cookie import CookieError

from Cheetah.Template import Template
Expand Down Expand Up @@ -184,6 +185,9 @@ def __init__( self, environ, app, webapp, session_cookie=None):
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.
Expand Down Expand Up @@ -255,6 +259,50 @@ def setup_i18n( self ):
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:
Expand Down
38 changes: 36 additions & 2 deletions test/unit/unittest_utils/galaxy_mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
Expand All @@ -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 ):
Expand All @@ -75,6 +106,9 @@ def __init__( self, app=None, user=None, history=None, **kwargs ):
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
Expand Down
Empty file.
121 changes: 121 additions & 0 deletions test/unit/web/framework/test_webapp.py
Original file line number Diff line number Diff line change
@@ -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()

0 comments on commit 274af50

Please sign in to comment.