diff --git a/samcli/lib/providers/sam_api_provider.py b/samcli/lib/providers/sam_api_provider.py index aec497429b9..2095e618d8e 100644 --- a/samcli/lib/providers/sam_api_provider.py +++ b/samcli/lib/providers/sam_api_provider.py @@ -1,6 +1,7 @@ """Parses SAM given the template""" import logging +import re from typing import Dict, List, Optional, Tuple, Union, cast from samcli.commands.local.lib.swagger.integration_uri import LambdaUri @@ -16,6 +17,11 @@ LOG = logging.getLogger(__name__) +# API Gateway REST API resource path parts are either literals made of these characters, or a path variable +# ({name} or greedy {name+}) that spans the whole path part. Per "Amazon API Gateway important notes for REST APIs", +# path segments may only contain alphanumeric characters, underscores, hyphens, periods, commas, colons and braces. +_REST_API_PATH_PART_PATTERN = re.compile(r"[a-zA-Z0-9._,:-]+|\{[a-zA-Z0-9._,:-]+\+?\}") + class SamApiProvider(CfnBaseApiProvider): TYPES = [AWS_SERVERLESS_FUNCTION, AWS_SERVERLESS_API, AWS_SERVERLESS_HTTPAPI] @@ -503,6 +509,9 @@ def _convert_event_route( path = cast(str, event_properties.get(SamApiProvider._EVENT_PATH)) method = cast(str, event_properties.get(SamApiProvider._EVENT_METHOD)) + if event_type == SamApiProvider._EVENT_TYPE_API and isinstance(path, str): + SamApiProvider._validate_rest_api_path(path, lambda_logical_id) + # An RESTAPI (HTTPAPI) Event, can have RestApiId (ApiId) property which designates the resource that owns this # API. If omitted, the API is owned by Implicit API resource. This could either be a direct resource logical ID # or a "Ref" of the logicalID @@ -550,6 +559,30 @@ def _convert_event_route( ), ) + @staticmethod + def _validate_rest_api_path(path: str, lambda_logical_id: str) -> None: + """ + Validates the Path of an Api event against the resource path rules of API Gateway REST APIs, + so that paths which would fail on deployment are not served locally. + + :param str path: Path of the Api event + :param str lambda_logical_id: Logical Id of the AWS::Serverless::Function + :raises InvalidSamDocumentException: if the path is not a valid API Gateway REST API path + """ + path_parts = [part for part in path.split("/") if part] + for index, part in enumerate(path_parts): + if not _REST_API_PATH_PART_PATTERN.fullmatch(part): + raise InvalidSamDocumentException( + f"Invalid Path '{path}' in Api event of function '{lambda_logical_id}': " + f"Resource's path part '{part}' only allows a-zA-Z0-9._,:- or a path variable " + "with curly braces at the beginning and the end and an optional plus sign before the closing brace." + ) + if part.endswith("+}") and index != len(path_parts) - 1: + raise InvalidSamDocumentException( + f"Invalid Path '{path}' in Api event of function '{lambda_logical_id}': " + f"greedy path variable '{part}' must be the last part of the path." + ) + @staticmethod def merge_routes(collector: ApiCollector) -> List[Route]: """ diff --git a/tests/unit/commands/local/lib/test_sam_api_provider.py b/tests/unit/commands/local/lib/test_sam_api_provider.py index 9dbed45a5b7..c5788104c72 100644 --- a/tests/unit/commands/local/lib/test_sam_api_provider.py +++ b/tests/unit/commands/local/lib/test_sam_api_provider.py @@ -2256,6 +2256,47 @@ def test_add_authorizer_in_serverless_function(self, authorizer_obj, expected_au ) +class TestSamApiRestApiPathValidation(TestCase): + @parameterized.expand( + [ + ("/",), + ("/hello",), + ("/users/{id}",), + ("/{proxy+}",), + ("/files/{name}/data.json",), + ("/items/v1:batch-get",), + ("/items/a,b",), + ("/api/{version}/under_score/{proxy+}",), + ("/trailing/slash/",), + ] + ) + def test_valid_rest_api_path(self, path): + _, route = SamApiProvider._convert_event_route("", "MyFunction", {"Path": path, "Method": "get"}, Route.API) + + self.assertEqual(route.path, path) + + @parameterized.expand( + [ + ("/api/map/{z}/{x}/{y}.png", "path part '{y}.png'"), + ("/users/id-{id}", "path part 'id-{id}'"), + ("/users/{first}{last}", "path part '{first}{last}'"), + ("/hello world", "path part 'hello world'"), + ("/{}", "path part '{}'"), + ("/{proxy+}/items", "greedy path variable '{proxy\\+}' must be the last part"), + ] + ) + def test_invalid_rest_api_path_raises(self, path, expected_message): + with self.assertRaisesRegex(InvalidSamDocumentException, expected_message): + SamApiProvider._convert_event_route("", "MyFunction", {"Path": path, "Method": "get"}, Route.API) + + def test_http_api_path_is_not_validated(self): + _, route = SamApiProvider._convert_event_route( + "", "MyFunction", {"Path": "/api/map/{y}.png", "Method": "get"}, Route.HTTP + ) + + self.assertEqual(route.path, "/api/map/{y}.png") + + def make_swagger(routes, binary_media_types=None): """ Given a list of API configurations named tuples, returns a Swagger document