8000 Lambda: improve function name and qualifier validation · localstack/localstack@4b4362b · GitHub
[go: up one dir, main page]

Skip to content
< 8000 header class="HeaderMktg header-logged-out js-details-container js-header Details f4 py-3" role="banner" data-is-top="true" data-color-mode=light data-light-theme=light data-dark-theme=dark>

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

8000
Appearance settings

Commit 4b4362b

Browse files
committed
Lambda: improve function name and qualifier validation
1 parent 1f1cd5f commit 4b4362b

File tree

5 files changed

+1549
-63
lines changed

5 files changed

+1549
-63
lines changed

localstack-core/localstack/services/lambda_/api_utils.py

Lines changed: 144 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import string
99
from typing import TYPE_CHECKING, Any, Optional, Tuple
1010

11-
from localstack.aws.api import RequestContext
11+
from localstack.aws.api import CommonServiceException, RequestContext
1212
from localstack.aws.api import lambda_ as api_spec
1313
from localstack.aws.api.lambda_ import (
1414
AliasConfiguration,
@@ -27,6 +27,7 @@
2727
TracingConfig,
2828
VpcConfigResponse,
2929
)
30+
from localstack.services.lambda_.invocation import AccessDeniedException
3031
from localstack.services.lambda_.runtimes import ALL_RUNTIMES, VALID_LAYER_RUNTIMES, VALID_RUNTIMES
3132
from localstack.utils.aws.arns import ARN_PARTITION_REGEX, get_partition
3233
from localstack.utils.collections import merge_recursive
@@ -48,7 +49,7 @@
4849
rf"{ARN_PARTITION_REGEX}:lambda:(?P<region_name>[^:]+):(?P<account_id>\d{{12}}):function:(?P<function_name>[^:]+)(:(?P<qualifier>.*))?$"
4950
)
5051

51-
# Pattern for a full (both with and without qualifier) lambda function ARN
52+
# Pattern for a full (both with and without qualifier) lambda layer ARN
5253
LAYER_VERSION_ARN_PATTERN = re.compile(
5354
rf"{ARN_PARTITION_REGEX}:lambda:(?P<region_name>[^:]+):(?P<account_id>\d{{12}}):layer:(?P<layer_name>[^:]+)(:(?P<layer_version>\d+))?$"
5455
)
@@ -102,6 +103,65 @@
102103
# An unordered list of all Lambda CPU architectures supported by LocalStack.
103104
ARCHITECTURES = [Architecture.arm64, Architecture.x86_64]
104105

106+
# ARN pattern returned in validation exception messages.
107+
# Some excpetions from AWS return a '\.' in the function name regex
108+
# pattern therefore we can sub this value in when appropriate.
109+
ARN_NAME_PATTERN_VALIDATION_TEMPLATE = "(arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{{2}}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{{1}}:)?(\\d{{12}}:)?(function:)?([a-zA-Z0-9-_{0}]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?"
110+
111+
112+
def validate_function_name(function_name_or_arn: str, operation_type: str):
113+
function_name, *_ = function_locators_from_arn(function_name_or_arn)
114+
arn_name_pattern = ARN_NAME_PATTERN_VALIDATION_TEMPLATE.format("")
115+
max_length = 170
116+
117+
match operation_type:
118+
case "GetFunction" | "Invoke":
119+
arn_name_pattern = ARN_NAME_PATTERN_VALIDATION_TEMPLATE.format(r"\.")
120+
case "CreateFunction" if function_name == function_name_or_arn: # only a function name
121+
max_length = 64
122+
case "CreateFunction" | "DeleteFunction":
123+
max_length = 140
124+
125+
validations = []
126+
if len(function_name_or_arn) > max_length:
127< 67E6 span class="diff-text-marker">+
constraint = f"Member must have length less than or equal to {max_length}"
128+
validation_msg = f"Value '{function_name_or_arn}' at 'functionName' failed to satisfy constraint: {constraint}"
129+
validations.append(validation_msg)
130+
131+
if not AWS_FUNCTION_NAME_REGEX.match(function_name_or_arn) or not function_name:
132+
constraint = f"Member must satisfy regular expression pattern: {arn_name_pattern}"
133+
validation_msg = f"Value '{function_name_or_arn}' at 'functionName' failed to satisfy constraint: {constraint}"
134+
validations.append(validation_msg)
135+
136+
return validations
137+
138+
139+
def validate_qualifier(qualifier: str):
140+
validations = []
141+
142+
if len(qualifier) > 128:
143+
constraint = "Member must have length less than or equal to 128"
144+
validation_msg = (
145+
f"Value '{qualifier}' at 'qualifier' failed to satisfy constraint: {constraint}"
146+
)
147+
validations.append(validation_msg)
148+
149+
if not QUALIFIER_REGEX.match(qualifier):
150+
constraint = "Member must satisfy regular expression pattern: (|[a-zA-Z0-9$_-]+)"
151+
validation_msg = (
152+
f"Value '{qualifier}' at 'qualifier' failed to satisfy constraint: {constraint}"
153+
)
154+
validations.append(validation_msg)
155+
156+
return validations
157+
158+
159+
def construct_validation_exception_message(validation_errors):
160+
if validation_errors:
161+
return f"{len(validation_errors)} validation error{'s' if len(validation_errors) > 1 else ''} detected: {'; '.join(validation_errors)}"
162+
163+
return None
164+
105165

106166
def map_function_url_config(model: "FunctionUrlConfig") -> api_spec.FunctionUrlConfig:
107167
return api_spec.FunctionUrlConfig(
@@ -185,14 +245,22 @@ def get_function_name(function_arn_or_name: str, context: RequestContext) -> str
185245
return name
186246

187247

188-
def function_locators_from_arn(arn: str) -> tuple[str, str | None, str | None, str | None]:
248+
def function_locators_from_arn(arn: str) -> tuple[str | None, str | None, str | None, str | None]:
189249
"""
190250
Takes a full or partial arn, or a name
191251
192252
:param arn: Given arn (or name)
193253
:return: tuple with (name, qualifier, account, region). Qualifier and region are none if missing
194254
"""
195-
return FUNCTION_NAME_REGEX.match(arn).group("name", "qualifier", "account", "region")
255+
256+
if matched := FUNCTION_NAME_REGEX.match(arn):
257+
name = matched.group("name")
258+
qualifier = matched.group("qualifier")
259+
account = matched.group("account")
260+
region = matched.group("region")
261+
return (name, qualifier, account, region)
262+
263+
return None, None, None, None
196264

197265

198266
def get_account_and_region(function_arn_or_name: str, context: RequestContext) -> Tuple[str, str]:
@@ -210,25 +278,57 @@ def get_name_and_qualifier(
210278
function_arn_or_name: str, qualifier: str | None, context: RequestContext
211279
) -> tuple[str, str | None]:
212280
"""
213-
Takes a full or partial arn, or a name and a qualifier
214-
Will raise exception if a qualified arn is provided and the qualifier does not match (but is given)
281+
Takes a full or partial arn, or a name and a qualifier.
215282
216283
:param function_arn_or_name: Given arn (or name)
217284
:param qualifier: A qualifier for the function (or None)
218285
:param context: Request context
219286
:return: tuple with (name, qualifier). Qualifier is none if missing
287+
:raises: `ResourceNotFoundException` when the context's region differs from the ARN's region
288+
:raises: `AccessDeniedException` when the context's account ID differs from the ARN's account ID
289+
:raises: `ValidationExcpetion` when a function ARN/name or qualifier fails validation checks
290+
:raises: `InvalidParameterValueException` when a qualified arn is provided and the qualifier does not match (but is given)
220291
"""
221-
function_name, arn_qualifier, _, arn_region = function_locators_from_arn(function_arn_or_name)
292+
function_name, arn_qualifier, account, region = function_locators_from_arn(function_arn_or_name)
293+
operation_type = context.operation.name
294+
295+
if operation_type not in _supported_resource_based_operations:
296+
if account and account != context.account_id:
297+
raise AccessDeniedException(None)
298+
299+
# TODO: should this only run if operation type is unsupported?
300+
if region and region != context.region:
301+
raise ResourceNotFoundException(
302+
f"Functions from '{region}' are not reachable in this region ('{context.region}')",
303+
Type="User",
304+
)
305+
306+
validation_errors = []
307+
if function_arn_or_name:
308+
validation_errors.extend(validate_function_name(function_arn_or_name, operation_type))
309+
310+
if qualifier:
311+
validation_errors.extend(validate_qualifier(qualifier))
312+
313+
is_only_function_name = function_arn_or_name == function_name
314+
if validation_errors:
315+
message = construct_validation_exception_message(validation_errors)
316+
# Edge-case where the error type is not ValidationException
317+
if (
318+
operation_type == "CreateFunction"
319+
and is_only_function_name
320+
and arn_qualifier is None
321+
and region is None
322+
): # just name OR partial
323+
raise InvalidParameterValueException(message=message, Type="User")
324+
raise CommonServiceException(message=message, code="ValidationException")
325+
222326
if qualifier and arn_qualifier and arn_qualifier != qualifier:
223327
raise InvalidParameterValueException(
224328
"The derived qualifier from the function name does not match the specified qualifier.",
225329
Type="User",
226330
)
227-
if arn_region and arn_region != context.region:
228-
raise ResourceNotFoundException(
229-
f"Functions from '{arn_region}' are not reachable in this region ('{context.region}')",
230-
Type="User",
231-
)
331+
232332
qualifier = qualifier or arn_qualifier
233333
return function_name, qualifier
234334

@@ -627,5 +727,35 @@ def is_layer_arn(layer_name: str) -> bool:
627727
return LAYER_VERSION_ARN_PATTERN.match(layer_name) is not None
628728

629729

630-
def validate_function_name(function_name):
631-
return AWS_FUNCTION_NAME_REGEX.match(function_name)
730+
# See Lambda API actions that support resource-based IAM policies
731+
# https://docs.aws.amazon.com/lambda/latest/dg/access-control-resource-based.html#permissions-resource-api
732+
_supported_resource_based_operations = {
733+
"CreateAlias",
734+
"DeleteAlias",
735+
"DeleteFunction",
736+
"DeleteFunctionConcurrency",
737+
"DeleteFunctionEventInvokeConfig",
738+
"DeleteProvisionedConcurrencyConfig",
739+
"GetAlias",
740+
"GetFunction",
741+
"GetFunctionConcurrency",
742+
"GetFunctionConfiguration",
743+
"GetFunctionEventInvokeConfig",
744+
"GetPolicy",
745+
"GetProvisionedConcurrencyConfig",
746+
"Invoke",
747+
"ListAliases",
748+
"ListFunctionEventInvokeConfigs",
749+
"ListProvisionedConcurrencyConfigs",
750+
"ListTags",
751+
"ListVersionsByFunction",
752+
"PublishVersion",
753+
"PutFunctionConcurrency",
754+
"PutFunctionEventInvokeConfig",
755+
"PutProvisionedConcurrencyConfig",
756+
"TagResource",
757+
"UntagResource",
758+
"UpdateAlias",
759+
"UpdateFunctionCode",
760+
"UpdateFunctionEventInvokeConfig",
761+
}

localstack-core/localstack/services/lambda_/provider.py

Lines changed: 16 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -356,10 +356,9 @@ def _get_function(function_name: str, account_id: str, region: str) -> Function:
356356

357357
@staticmethod
358358
def _validate_qualifier_expression(qualifier: str) -> None:
359-
if not api_uti F438 ls.is_qualifier_expression(qualifier):
359+
if error_messages := api_utils.validate_qualifier(qualifier):
360360
raise ValidationException(
361-
f"1 validation error detected: Value '{qualifier}' at 'qualifier' failed to satisfy constraint: "
362-
f"Member must satisfy regular expression pattern: (|[a-zA-Z0-9$_-]+)"
361+
message=api_utils.construct_validation_exception_message(error_messages)
363362
)
364363

365364
@staticmethod
@@ -769,43 +768,13 @@ def create_function(
769768
runtime = request.get("Runtime")
770769
self._validate_runtime(package_type, runtime)
771770

772-
request_function_name = request["FunctionName"]
773-
# Validate FunctionName:
774-
# a) Function name: just function name (max 64 chars)
775-
# b) Function ARN: unqualified arn (min 1, max 64 chars)
776-
# c) Partial ARN: ACCOUNT_ID:function:FUNCTION_NAME
777-
function_name, qualifier, account, region = function_locators_from_arn(
778-
request_function_name
771+
request_function_name = request.get("FunctionName")
772+
773+
function_name, *_ = api_utils.get_name_and_qualifier(
774+
function_arn_or_name=request_function_name,
775+
qualifier=None,
776+
context=context,
779777
)
780-
if (
781-
function_name and qualifier is None and account is None and region is None
782-
): # just function name
783-
pass
784-
elif function_name and account and qualifier is None and region is None: # partial arn
785-
if account != context_account_id:
786-
raise AccessDeniedException(None)
787-
elif function_name and account and region and qualifier is None: # unqualified arn
788-
if len(request_function_name) > 140:
789-
raise ValidationException(
790-
f"1 validation error detected: Value '{request_function_name}' at 'functionName' failed to satisfy constraint: Member must have length less than or equal to 140"
791-
)
792-
if region != context.region:
793-
raise ResourceNotFoundException(
794-
f"Functions from '{region}' are not reachable in this region ('{context.region}')",
795-
Type="User",
796-
)
797-
if account != context_account_id:
798-
raise AccessDeniedException(None)
799-
else:
800-
pattern = r"(arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{2}((-gov)|(-iso(b?)))?-[a-z]+-\d{1}:)?(\d{12}:)?(function:)?([a-zA-Z0-9-_]+)(:(\$LATEST|[a-zA-Z0-9-_]+))?"
801-
raise ValidationException(
802-
f"1 validation error detected: Value '{request_function_name}' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: {pattern}"
803-
)
804-
if len(function_name) > 64:
805-
raise InvalidParameterValueException(
806-
f"1 validation error detected: Value '{function_name}' at 'functionName' failed to satisfy constraint: Member must have length less than or equal to 64",
807-
Type="User",
808-
)
809778

810779
if runtime in DEPRECATED_RUNTIMES:
811780
LOG.warning(
@@ -1290,6 +1259,13 @@ def delete_function(
12901259
function_name, qualifier = api_utils.get_name_and_qualifier(
12911260
function_name, qualifier, context
12921261
)
1262+
1263+
if qualifier and qualifier.startswith("-"):
1264+
raise InvalidParameterValueException(
1265+
"Deletion of aliases is not currently supported.",
1266+
Type="User",
1267+
)
1268+
12931269
store = lambda_stores[account_id][region]
12941270
if qualifier == "$LATEST":
12951271
raise InvalidParameterValueException(
@@ -1371,6 +1347,7 @@ def get_function(
13711347
function_name, qualifier = api_utils.get_name_and_qualifier(
13721348
function_name, qualifier, context
13731349
)
1350+
13741351
fn = lambda_stores[account_id][region].functions.get(function_name)
13751352
if fn is None:
13761353
if qualifier is None:
@@ -1456,11 +1433,6 @@ def invoke(
14561433
**kwargs,
14571434
) -> InvocationResponse:
14581435
account_id, region = api_utils.get_account_and_region(function_name, context)
1459-
if not api_utils.validate_function_name(function_name):
1460-
raise ValidationException(
1461-
f"1 validation error detected: Value '{function_name}' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: (arn:(aws[a-zA-Z-]*)?:lambda:)?([a-z]{{2}}((-gov)|(-iso([a-z]?)))?-[a-z]+-\\d{{1}}:)?(\\d{{12}}:)?(function:)?([a-zA-Z0-9-_\\.]+)(:(\\$LATEST|[a-zA-Z0-9-_]+))?"
1462-
)
1463-
14641436
function_name, qualifier = api_utils.get_name_and_qualifier(
14651437
function_name, qualifier, context
14661438
)

0 commit comments

Comments
 (0)
0