diff --git a/chalice/local.py b/chalice/local.py index 55e92935e..740b80f66 100644 --- a/chalice/local.py +++ b/chalice/local.py @@ -20,9 +20,11 @@ from chalice.app import CORSConfig # noqa from chalice.app import ChaliceAuthorizer # noqa from chalice.app import RouteEntry # noqa +from chalice.app import Request # noqa from chalice.app import AuthResponse # noqa from chalice.app import BuiltinAuthConfig # noqa from chalice.config import Config # noqa + from chalice.compat import urlparse, parse_qs @@ -46,6 +48,57 @@ def create_local_server(app_obj, config, port): return LocalDevServer(app_obj, config, port) +class LocalArnBuilder(object): + ARN_FORMAT = ('arn:aws:execute-api:{region}:{account_id}' + ':{api_id}/{stage}/{method}/{resource_path}') + LOCAL_REGION = 'mars-west-1' + LOCAL_ACCOUNT_ID = '123456789012' + LOCAL_API_ID = 'ymy8tbxw7b' + LOCAL_STAGE = 'api' + + def build_arn(self, method, path): + # type: (str, str) -> str + # In API Gateway the method and URI are separated by a / so typically + # the uri portion omits the leading /. In the case where the entire + # url is just '/' API Gateway adds a / to the end so that the arn end + # with a '//'. + if path != '/': + path = path[1:] + return self.ARN_FORMAT.format( + region=self.LOCAL_REGION, + account_id=self.LOCAL_ACCOUNT_ID, + api_id=self.LOCAL_API_ID, + stage=self.LOCAL_STAGE, + method=method, + resource_path=path + ) + + +class ArnMatcher(object): + def __init__(self, target_arn): + # type: (str) -> None + self._arn = target_arn + + def _resource_match(self, resource): + # type: (str) -> bool + # Arn matching supports two special case characetrs that are not + # escapable. * represents a glob which translates to a non-greedy + # match of any number of characters. ? which is any single character. + # These are easy to translate to a regex using .*? and . respectivly. + escaped_resource = re.escape(resource) + resource_regex = escaped_resource.replace(r'\?', '.').replace( + r'\*', '.*?') + matcher = re.compile(resource_regex) + return matcher.match(self._arn) is not None + + def does_any_resource_match(self, resources): + # type: (List[str]) -> bool + for resource in resources: + if self._resource_match(resource): + return True + return False + + class RouteMatcher(object): def __init__(self, route_urls): # type: (List[str]) -> None @@ -149,12 +202,10 @@ def __init__(self, function_name, memory_size, self._time_source = time_source self._start_time = self._current_time_millis() self._max_runtime = runtime_millis - self._initialize_lambda_context_attributes( - function_name, memory_size) - def _initialize_lambda_context_attributes(self, function_name, - memory_size): - # type: (str, int) -> None + # Below are properties that are found on the real LambdaContext passed + # by lambda and their associated documentation. + # Name of the Lambda function that is executing. self.function_name = function_name @@ -216,10 +267,14 @@ class LocalGatewayAuthorizer(object): def __init__(self, app_object): # type: (Chalice) -> None self._app_object = app_object + self._arn_builder = LocalArnBuilder() def authorize(self, lambda_event, lambda_context): # type: (EventType, LambdaContext) -> Tuple[EventType, LambdaContext] - auth_event = self._prepare_authorizer_event(lambda_event) + method = lambda_event['requestContext']['httpMethod'] + path = lambda_event['requestContext']['resourcePath'] + arn = self._arn_builder.build_arn(method, path) + auth_event = self._prepare_authorizer_event(arn, lambda_event) route_entry = self._route_for_event(lambda_event) if not route_entry: return lambda_event, lambda_context @@ -230,16 +285,15 @@ def authorize(self, lambda_event, lambda_context): # Currently the only supported local authorizer is the # BuiltinAuthConfig type. Anything else we will err on the side of # allowing local testing by simply admiting the request. Otherwise - # there is no way for users to test their code in local mode - warnings.warn(( + # there is no way for users to test their code in local mode. + warnings.warn( '%s is not a supported in local mode. All requests made ' - 'against a route will be authorized to allow local testing.') + 'against a route will be authorized to allow local testing.' % authorizer.__class__.__name__ ) return lambda_event, lambda_context auth_result = authorizer(auth_event, lambda_context) - authed = self._check_can_invoke_view_function( - route_entry, auth_result) + authed = self._check_can_invoke_view_function(arn, auth_result) if authed: lambda_event = self._update_lambda_event(lambda_event, auth_result) else: @@ -248,37 +302,19 @@ def authorize(self, lambda_event, lambda_context): 'x-amzn-ErrorType': 'UnauthorizedException'}) return lambda_event, lambda_context - def _check_can_invoke_view_function(self, route_entry, auth_result): - # type: (RouteEntry, ResponseType) -> bool + def _check_can_invoke_view_function(self, arn, auth_result): + # type: (str, ResponseType) -> bool policy = auth_result.get('policyDocument', {}) statements = policy.get('Statement', []) - # Look for a statement that allows us to invoke the route_entry - # it will be in the form local:local/METHOD/uri the URI is always - # prefixed with / so we do not need to explicitly include it in our - # pattern. - # The method can be either an explicit method such as POST, GET, etc... - # or it can be a *. We also need to match for any prefix since the - # user can return any policy at all from the custom authorizer. - pattern_suffix = '' - if route_entry.uri_pattern == '/': - # There is a special case when the / is used since the API Gateway - # arn will have the / duplicated. On all other routes it will - # omit the leading /. - pattern_suffix = '/' - method_pattern = r'(?:\*|%s)' % route_entry.method - route_entry_pattern = r'^.*?/%s%s%s$' % (method_pattern, - route_entry.uri_pattern, - pattern_suffix) - route_entry_re = re.compile(route_entry_pattern) - # Look through all statements for one that allows us to invoke this - # particular view function. + allow_resource_statements = [] for statement in statements: if statement.get('Effect') == 'Allow' and \ statement.get('Action') == 'execute-api:Invoke': for resource in statement.get('Resource'): - if route_entry_re.match(resource): - return True - return False + allow_resource_statements.append(resource) + + arn_matcher = ArnMatcher(arn) + return arn_matcher.does_any_resource_match(allow_resource_statements) def _route_for_event(self, lambda_event): # type: (EventType) -> Optional[RouteEntry] @@ -306,14 +342,14 @@ def _update_lambda_event(self, lambda_event, auth_result): lambda_event['requestContext']['authorizer'] = auth_context return lambda_event - def _prepare_authorizer_event(self, lambda_event): - # type: (EventType) -> EventType + def _prepare_authorizer_event(self, arn, lambda_event): + # type: (str, EventType) -> EventType """Translate event for an authorizer input.""" authorizer_event = lambda_event.copy() authorizer_event['type'] = 'TOKEN' authorizer_event['authorizationToken'] = authorizer_event.get( 'headers', {}).get('authorization', '') - authorizer_event['methodArn'] = 'local' + authorizer_event['methodArn'] = arn return authorizer_event @@ -352,7 +388,14 @@ def _has_user_defined_options_method(self, lambda_event): def handle_request(self, method, path, headers, body): # type: (str, str, HeaderType, str) -> ResponseType lambda_context = self._generate_lambda_context() - lambda_event = self._generate_lambda_event(method, path, headers, body) + try: + lambda_event = self._generate_lambda_event( + method, path, headers, body) + except ValueError: + raise NotAuthorizedError( + {'x-amzn-RequestId': lambda_context.aws_request_id, + 'x-amzn-ErrorType': 'UnauthorizedException'}) + # This can either be because the user's provided an OPTIONS method # *or* this is a preflight request, which chalice automatically # responds to without invoking a user defined route. @@ -389,7 +432,6 @@ def _autogen_options_headers(self, lambda_event): # So our local version needs to add this manually to our set of allowed # headers. route_methods.append('OPTIONS') - route_methods = route_methods # The Access-Control-Allow-Methods header is not added by the # CORSConfig object it is added to the API Gateway route during diff --git a/tests/unit/test_local.py b/tests/unit/test_local.py index 77707ce94..0fdb5e699 100644 --- a/tests/unit/test_local.py +++ b/tests/unit/test_local.py @@ -11,13 +11,14 @@ from chalice import IAMAuthorizer from chalice.config import Config from chalice.local import LambdaContext +from chalice.local import LocalArnBuilder from chalice.local import LocalGateway from chalice.local import LocalGatewayAuthorizer from chalice.local import NotAuthorizedError from tests.unit.conftest import create_event -AWS_REQUEST_PATTERN = re.compile( +AWS_REQUEST_ID_PATTERN = re.compile( '^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$', re.I) @@ -35,6 +36,11 @@ def finish(self): pass +@pytest.fixture +def arn_builder(): + return LocalArnBuilder() + + @pytest.fixture def lambda_context_args(): # LambdaContext has several positional args before the ones that we @@ -132,8 +138,8 @@ def auth_with_explicit_policy(auth_request): 'Action': 'execute-api:Invoke', 'Effect': 'Allow', 'Resource': - ["arn:aws:execute-api:us-west-2:123456789011:" - "0123456abc/api/GET/explicit"] + ["arn:aws:execute-api:mars-west-1:123456789012:" + "ymy8tbxw7b/api/GET/explicit"] } ] } @@ -149,8 +155,8 @@ def auth_with_explicit_policy(auth_request): 'Action': 'execute-api:Invoke', 'Effect': 'Deny', 'Resource': - ["arn:aws:execute-api:us-west-2:123456789011:" - "012345 6abc/api/GET/explicit"] + ["arn:aws:execute-api:mars-west-1:123456789012:" + "ymy8tbxw7b/api/GET/explicit"] } ] } @@ -164,6 +170,14 @@ def demo_auth(auth_request): else: return app.AuthResponse(routes=[], principal_id='user') + @demo.authorizer() + def all_auth(auth_request): + token = auth_request.token + if token == 'allow': + return app.AuthResponse(routes=['*'], principal_id='user') + else: + return app.AuthResponse(routes=[], principal_id='user') + @demo.authorizer() def landing_page_auth(auth_request): token = auth_request.token @@ -186,6 +200,10 @@ def index_view(): def secret_view(): return {} + @demo.route('/secret/{value}', authorizer=all_auth) + def secret_view_value(value): + return {'secret': value} + @demo.route('/explicit', authorizer=auth_with_explicit_policy) def explicit(): return {} @@ -587,7 +605,7 @@ def test_can_get_remaining_time_multiple(self, lambda_context_args): def test_does_populate_aws_request_id_with_valid_uuid(self, lambda_context_args): context = LambdaContext(*lambda_context_args) - assert AWS_REQUEST_PATTERN.match(context.aws_request_id) + assert AWS_REQUEST_ID_PATTERN.match(context.aws_request_id) def test_does_set_version_to_latest(self, lambda_context_args): context = LambdaContext(*lambda_context_args) @@ -633,13 +651,25 @@ def context_view(): assert body['memory'] == 256 assert body['version'] == '$LATEST' assert body['timeout'] <= 10 - assert AWS_REQUEST_PATTERN.match(body['request_id']) + assert AWS_REQUEST_ID_PATTERN.match(body['request_id']) + + def test_can_validate_route_with_variables(self, demo_app_auth): + gateway = LocalGateway(demo_app_auth, Config()) + response = gateway.handle_request( + 'GET', '/secret/foobar', {'Authorization': 'allow'}, '') + json_body = json.loads(response['body']) + assert json_body['secret'] == 'foobar' def test_does_reject_unauthed_request(self, demo_app_auth): gateway = LocalGateway(demo_app_auth, Config()) with pytest.raises(NotAuthorizedError): gateway.handle_request('GET', '/index', {}, '') + def test_does_reject_when_route_not_found(self, demo_app_auth): + gateway = LocalGateway(demo_app_auth, Config()) + with pytest.raises(NotAuthorizedError): + gateway.handle_request('GET', '/badindex', {}, '') + class TestLocalBuiltinAuthorizers(object): def test_can_authorize_empty_path(self, lambda_context_args, @@ -744,3 +774,129 @@ def test_can_understand_explicit_deny_policy(self, demo_app_auth, context = LambdaContext(*lambda_context_args) with pytest.raises(NotAuthorizedError): authorizer.authorize(event, context) + + +class TestArnBuilder(object): + def test_can_create_basic_arn(self, arn_builder): + arn = ('arn:aws:execute-api:mars-west-1:123456789012:ymy8tbxw7b' + '/api/GET/resource') + built_arn = arn_builder.build_arn('GET', '/resource') + assert arn == built_arn + + def test_can_create_root_arn(self, arn_builder): + arn = ('arn:aws:execute-api:mars-west-1:123456789012:ymy8tbxw7b' + '/api/GET//') + built_arn = arn_builder.build_arn('GET', '/') + assert arn == built_arn + + def test_can_create_multi_part_arn(self, arn_builder): + arn = ('arn:aws:execute-api:mars-west-1:123456789012:ymy8tbxw7b' + '/api/GET/path/to/resource') + built_arn = arn_builder.build_arn('GET', '/path/to/resource') + assert arn == built_arn + + def test_can_create_glob_method_arn(self, arn_builder): + arn = ('arn:aws:execute-api:mars-west-1:123456789012:ymy8tbxw7b' + '/api/*/resource') + built_arn = arn_builder.build_arn('*', '/resource') + assert arn == built_arn + + +@pytest.mark.parametrize('arn,pattern', [ + ('mars-west-2:123456789012:ymy8tbxw7b/api/GET/foo', + 'mars-west-2:123456789012:ymy8tbxw7b/api/GET/foo' + ), + ('mars-west-1:123456789012:ymy8tbxw7b/api/GET/foobar', + 'mars-west-1:123456789012:ymy8tbxw7b/api/GET/*' + ), + ('mars-west-1:123456789012:ymy8tbxw7b/api/PUT/foobar', + 'mars-west-1:123456789012:ymy8tbxw7b/api/???/foobar' + ), + ('mars-west-1:123456789012:ymy8tbxw7b/api/GET/foobar', + 'mars-west-1:123456789012:ymy8tbxw7b/api/???/*' + ), + ('mars-west-1:123456789012:ymy8tbxw7b/api/GET/foobar', + 'mars-west-1:123456789012:*/api/GET/*' + ), + ('mars-west-2:123456789012:ymy8tbxw7b/api/GET/foobar', + '*' + ), + ('mars-west-2:123456789012:ymy8tbxw7b/api/GET/foo.bar', + 'mars-west-2:123456789012:ymy8tbxw7b/*/GET/*') +]) +def test_can_allow_route_arns(arn, pattern): + prefix = 'arn:aws:execute-api:' + full_arn = '%s%s' % (prefix, arn) + full_pattern = '%s%s' % (prefix, pattern) + matcher = local.ArnMatcher(full_arn) + does_match = matcher.does_any_resource_match([full_pattern]) + assert does_match is True + + +@pytest.mark.parametrize('arn,pattern', [ + ('mars-west-1:123456789012:ymy8tbxw7b/api/GET/foobar', + 'mars-west-1:123456789012:ymy8tbxw7b/api/PUT/*' + ), + ('mars-west-1:123456789012:ymy8tbxw7b/api/GET/foobar', + 'mars-west-1:123456789012:ymy8tbxw7b/api/??/foobar' + ), + ('mars-west-1:123456789012:ymy8tbxw7b/api/GET/foobar', + 'mars-west-2:123456789012:ymy8tbxw7b/api/???/*' + ), + ('mars-west-2:123456789012:ymy8tbxw7b/api/GET/foobar', + 'mars-west-2:123456789012:ymy8tbxw7b/*/GET/foo...') +]) +def test_can_deny_route_arns(arn, pattern): + prefix = 'arn:aws:execute-api:' + full_arn = '%s%s' % (prefix, arn) + full_pattern = '%s%s' % (prefix, pattern) + matcher = local.ArnMatcher(full_arn) + does_match = matcher.does_any_resource_match([full_pattern]) + assert does_match is False + + +@pytest.mark.parametrize('arn,patterns', [ + ('mars-west-1:123456789012:ymy8tbxw7b/api/GET/foobar', + [ + 'mars-west-1:123456789012:ymy8tbxw7b/api/PUT/*', + 'mars-west-1:123456789012:ymy8tbxw7b/api/GET/foobar' + ]), + ('mars-west-1:123456789012:ymy8tbxw7b/api/GET/foobar', + [ + 'mars-west-1:123456789012:ymy8tbxw7b/api/GET/foobar', + 'mars-west-1:123456789012:ymy8tbxw7b/api/PUT/*' + ]), + ('mars-west-1:123456789012:ymy8tbxw7b/api/GET/foobar', + [ + 'mars-west-1:123456789012:ymy8tbxw7b/api/PUT/foobar', + '*' + ]) +]) +def test_can_allow_multiple_resource_arns(arn, patterns): + prefix = 'arn:aws:execute-api:' + full_arn = '%s%s' % (prefix, arn) + full_patterns = ['%s%s' % (prefix, pattern) for pattern in patterns] + matcher = local.ArnMatcher(full_arn) + does_match = matcher.does_any_resource_match(full_patterns) + assert does_match is True + + +@pytest.mark.parametrize('arn,patterns', [ + ('mars-west-1:123456789012:ymy8tbxw7b/api/GET/foobar', + [ + 'mars-west-1:123456789012:ymy8tbxw7b/api/POST/*', + 'mars-west-1:123456789012:ymy8tbxw7b/api/PUT/foobar' + ]), + ('mars-west-1:123456789012:ymy8tbxw7b/api/GET/foobar', + [ + 'mars-west-2:123456789012:ymy8tbxw7b/api/GET/foobar', + 'mars-west-2:123456789012:ymy8tbxw7b/api/*/*' + ]) +]) +def test_can_deny_multiple_resource_arns(arn, patterns): + prefix = 'arn:aws:execute-api:' + full_arn = '%s%s' % (prefix, arn) + full_patterns = ['%s%s' % (prefix, pattern) for pattern in patterns] + matcher = local.ArnMatcher(full_arn) + does_match = matcher.does_any_resource_match(full_patterns) + assert does_match is False