Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Core, CORS: allow (non-preflighted) CORS requests by echoing back the… #1331

Merged
merged 8 commits into from
Jan 15, 2016
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 @@ -653,6 +653,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 @@ -869,8 +877,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 @@ -258,6 +258,7 @@ def __init__( self, **kwargs ):
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 )
Expand Down Expand Up @@ -667,6 +668,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()