8000 Lambda: improve function name and qualifier validation by gregfurman · Pull Request #11366 · localstack/localstack · GitHub
[go: up one dir, main page]

Skip to content

Lambda: improve function name and qualifier validation #11366

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

Merged
merged 1 commit into from
Aug 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 144 additions & 14 deletions localstack-core/localstack/services/lambda_/api_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import string
from typing import TYPE_CHECKING, Any, Optional, Tuple

from localstack.aws.api import RequestContext
from localstack.aws.api import CommonServiceException, RequestContext
from localstack.aws.api import lambda_ as api_spec
from localstack.aws.api.lambda_ import (
AliasConfiguration,
Expand All @@ -27,6 +27,7 @@
TracingConfig,
VpcConfigResponse,
)
from localstack.services.lambda_.invocation import AccessDeniedException
from localstack.services.lambda_.runtimes import ALL_RUNTIMES, VALID_LAYER_RUNTIMES, VALID_RUNTIMES
from localstack.utils.aws.arns import ARN_PARTITION_REGEX, get_partition
from localstack.utils.collections import merge_recursive
Expand All @@ -48,7 +49,7 @@
rf"{ARN_PARTITION_REGEX}:lambda:(?P<region_name>[^:]+):(?P<account_id>\d{{12}}):function:(?P<function_name>[^:]+)(:(?P<qualifier>.*))?$"
)

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

# ARN pattern returned in validation exception messages.
# Some excpetions from AWS return a '\.' in the function name regex
# pattern therefore we can sub this value in when appropriate.
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-_]+))?"


def validate_function_name(function_name_or_arn: str, operation_type: str):
function_name, *_ = function_locators_from_arn(function_name_or_arn)
arn_name_pattern = ARN_NAME_PATTERN_VALIDATION_TEMPLATE.format("")
max_length = 170

match operation_type:
case "GetFunction" | "Invoke":
arn_name_pattern = ARN_NAME_PATTERN_VALIDATION_TEMPLATE.format(r"\.")
case "CreateFunction" if function_name == function_name_or_arn: # only a function name
max_length = 64
case "CreateFunction" | "DeleteFunction":
max_length = 140

validations = []
if len(function_name_or_arn) > max_length:
constraint = f"Member must have length less than or equal to {max_length}"
validation_msg = f"Value '{function_name_or_arn}' at 'functionName' failed to satisfy constraint: {constraint}"
validations.append(validation_msg)

if not AWS_FUNCTION_NAME_REGEX.match(function_name_or_arn) or not function_name:
constraint = f"Member must satisfy regular expression pattern: {arn_name_pattern}"
validation_msg = f"Value '{function_name_or_arn}' at 'functionName' failed to satisfy constraint: {constraint}" 8000
validations.append(validation_msg)

return validations


def validate_qualifier(qualifier: str):
validations = []

if len(qualifier) > 128:
constraint = "Member must have length less than or equal to 128"
validation_msg = (
f"Value '{qualifier}' at 'qualifier' failed to satisfy constraint: {constraint}"
)
validations.append(validation_msg)

if not QUALIFIER_REGEX.match(qualifier):
constraint = "Member must satisfy regular expression pattern: (|[a-zA-Z0-9$_-]+)"
validation_msg = (
f"Value '{qualifier}' at 'qualifier' failed to satisfy constraint: {constraint}"
)
validations.append(validation_msg)

return validations


def construct_validation_exception_message(validation_errors):
if validation_errors:
return f"{len(validation_errors)} validation error{'s' if len(validation_errors) > 1 else ''} detected: {'; '.join(validation_errors)}"

return None


def map_function_url_config(model: "FunctionUrlConfig") -> api_spec.FunctionUrlConfig:
return api_spec.FunctionUrlConfig(
Expand Down Expand Up @@ -185,14 +245,22 @@ def get_function_name(function_arn_or_name: str, context: RequestContext) -> str
return name


def function_locators_from_arn(arn: str) -> tuple[str, str | None, str | None, str | None]:
def function_locators_from_arn(arn: str) -> tuple[str | None, str | None, str | None, str | None]:
"""
Takes a full or partial arn, or a name

:param arn: Given arn (or name)
:return: tuple with (name, qualifier, account, region). Qualifier and region are none if missing
"""
return FUNCTION_NAME_REGEX.match(arn).group("name", "qualifier", "account", "region")

if matched := FUNCTION_NAME_REGEX.match(arn):
name = matched.group("name")
qualifier = matched.group("qualifier")
account = matched.group("account")
region = matched.group("region")
return (name, qualifier, account, region)

return None, None, None, None


def get_account_and_region(function_arn_or_name: str, context: RequestContext) -> Tuple[str, str]:
Expand All @@ -210,25 +278,57 @@ def get_name_and_qualifier(
function_arn_or_name: str, qualifier: str | None, context: RequestContext
) -> tuple[str, str | None]:
"""
Takes a full or partial arn, or a name and a qualifier
Will raise exception if a qualified arn is provided and the qualifier does not match (but is given)
Takes a full or partial arn, or a name and a qualifier.

:param function_arn_or_name: Given arn (or name)
:param qualifier: A qualifier for the function (or None)
:param context: Request context
:return: tuple with (name, qualifier). Qualifier is none if missing
:raises: `ResourceNotFoundException` when the context's region differs from the ARN's region
:raises: `AccessDeniedException` when the context's account ID differs from the ARN's account ID
:raises: `ValidationExcpetion` when a function ARN/name or qualifier fails validation checks
:raises: `InvalidParameterValueException` when a qualified arn is provided and the qualifier does not match (but is given)
"""
function_name, arn_qualifier, _, arn_region = function_locators_from_arn(function_arn_or_name)
function_name, arn_qualifier, account, region = function_locators_from_arn(function_arn_or_name)
operation_type = context.operation.name

if operation_type not in _supported_resource_based_operations:
if account and account != context.account_id:
raise AccessDeniedException(None)

# TODO: should this only run if operation type is unsupported?
if region and region != context.region:
raise ResourceNotFoundException(
f"Functions from '{region}' are not reachable in this region ('{context.region}')",
Type="User",
)

validation_errors = []
if function_arn_or_name:
validation_errors.extend(validate_function_name(function_arn_or_name, operation_type))

if qualifier:
validation_errors.extend(validate_qualifier(qualifier))

is_only_function_name = function_arn_or_name == function_name
if validation_errors:
message = construct_validation_exception_message(validation_errors)
# Edge-case where the error type is not ValidationException
if (
operation_type == "CreateFunction"
and is_only_function_name
and arn_qualifier is None
and region is None
): # just name OR partial
raise InvalidParameterValueException(message=message, Type="User")
raise CommonServiceException(message=message, code="ValidationException")

if qualifier and arn_qualifier and arn_qualifier != qualifier:
raise InvalidParameterValueException(
"The derived qualifier from the function name does not match the specified qualifier.",
Type="User",
)
if arn_region and arn_region != context.region:
raise ResourceNotFoundException(
f"Functions from '{arn_region}' are not reachable in this region ('{context.region}')",
Type="User",
)

qualifier = qualifier or arn_qualifier
return function_name, qualifier

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


def validate_function_name(function_name):
return AWS_FUNCTION_NAME_REGEX.match(function_name)
# See Lambda API actions that support resource-based IAM policies
# https://docs.aws.amazon.com/lambda/latest/dg/access-control-resource-based.html#permissions-resource-api
_supported_resource_based_operations = {
"CreateAlias",
"DeleteAlias",
"DeleteFunction",
"DeleteFunctionConcurrency",
"DeleteFunctionEventInvokeConfig",
"DeleteProvisionedConcurrencyConfig",
"GetAlias",
"GetFunction",
"GetFunctionConcurrency",
"GetFunctionConfiguration",
"GetFunctionEventInvokeConfig",
"GetPolicy",
"GetProvisionedConcurrencyConfig",
"Invoke",
"ListAliases",
"ListFunctionEventInvokeConfigs",
"ListProvisionedConcurrencyConfigs",
"ListTags",
"ListVersionsByFunction",
"PublishVersion",
"PutFunctionConcurrency",
"PutFunctionEventInvokeConfig",
"PutProvisionedConcurrencyConfig",
"TagResource",
"UntagResource",
"UpdateAlias",
"UpdateFunctionCode",
"UpdateFunctionEventInvokeConfig",
}
60 changes: 16 additions & 44 deletions localstack-core/localstack/services/lambda_/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,10 +356,9 @@ def _get_function(function_name: str, account_id: str, region: str) -> Function:

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

@staticmethod
Expand Down Expand Up @@ -769,43 +768,13 @@ def create_function(
runtime = request.get("Runtime")
self._validate_runtime(package_type, runtime)

request_function_name = request["FunctionName"]
# Validate FunctionName:
# a) Function name: just function name (max 64 chars)
# b) Function ARN: unqualified arn (min 1, max 64 chars)
# c) Partial ARN: ACCOUNT_ID:function:FUNCTION_NAME
function_name, qualifier, account, region = function_locators_from_arn(
request_function_name
request_function_name = request.get("FunctionName")

function_name, *_ = api_utils.get_name_and_qualifier(
function_arn_or_name=request_function_name,
qualifier=None,
context=context,
)
if (
function_name and qualifier is None and account is None and region is None
): # just function name
pass
elif function_name and account and qualifier is None and region is None: # partial arn
if account != context_account_id:
raise AccessDeniedException(None)
elif function_name and account and region and qualifier is None: # unqualified arn
if len(request_function_name) > 140:
raise ValidationException(
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"
)
if region != context.region:
raise ResourceNotFoundException(
f"Functions from '{region}' are not reachable in this region ('{context.region}')",
Type="User",
)
if account != context_account_id:
raise AccessDeniedException(None)
else:
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-_]+))?"
raise ValidationException(
f"1 validation error detected: Value '{request_function_name}' at 'functionName' failed to satisfy constraint: Member must satisfy regular expression pattern: {pattern}"
)
if len(function_name) > 64:
raise InvalidParameterValueException(
f"1 validation error detected: Value '{function_name}' at 'functionName' failed to satisfy constraint: Member must have length less than or equal to 64",
Type="User",
)

if runtime in DEPRECATED_RUNTIMES:
LOG.warning(
Expand Down Expand Up @@ -1290,6 +1259,13 @@ def delete_function(
function_name, qualifier = api_utils.get_name_and_qualifier(
function_name, qualifier, context
)

if qualifier and api_utils.qualifier_is_alias(qualifier):
raise InvalidParameterValueException(
"Deletion of aliases is not currently supported.",
Type="User",
)

store = lambda_stores[account_id][region]
if qualifier == "$LATEST":
raise InvalidParameterValueException(
Expand Down Expand Up @@ -1371,6 +1347,7 @@ def get_function(
function_name, qualifier = api_utils.get_name_and_qualifier(
function_name, qualifier, context
)

fn = lambda_stores[account_id][region].functions.get(function_name)
if fn is None:
if qualifier is None:
Expand Down Expand Up @@ -1456,11 +1433,6 @@ def invoke(
**kwargs,
) -> InvocationResponse:
account_id, region = api_utils.get_account_and_region(function_name, context)
if not api_utils.validate_function_name(function_name):
raise ValidationException(
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-_]+))?"
)

function_name, qualifier = api_utils.get_name_and_qualifier(
function_name, qualifier, context
)
Expand Down
Loading
Loading
0