From 54f5006a201d4ec9a66e349e04af40ef83fa63ba Mon Sep 17 00:00:00 2001 From: hardik-devops Date: Mon, 6 Jul 2026 12:45:01 +0530 Subject: [PATCH 1/2] fix(local-apigw): apply GatewayResponses headers to authorizer and Lambda-exception error responses sam local start-api never read the GatewayResponses property on AWS::Serverless::Api, so responses generated by the local emulator itself (Lambda Authorizer denials, missing identity sources, unhandled Lambda exceptions) never carried any of the headers a customer configured there. Real API Gateway applies GatewayResponses (including CORS headers) to exactly these cases, so local behavior diverged from deployed behavior with no way to work around it. Root cause: SamApiProvider extracted Cors/Auth/StageName from the Api resource but silently dropped GatewayResponses, and none of the ServiceErrorResponses factory methods accepted extra headers, so there was no path for configured headers to reach the Flask response object. Fix: - SamApiProvider now extracts GatewayResponses' ResponseParameters.Headers per response type (matching the same "gatewayresponse.header.X"/quoted string schema samtranslator uses), stored on the Api model. - Api.get_gateway_response_headers(response_type, status_code) resolves headers for a specific response type, falling back to DEFAULT_4XX/ DEFAULT_5XX the same way API Gateway itself does when a specific type isn't configured. - ServiceErrorResponses methods accept an optional headers dict and merge it onto the Flask response. - LocalApigwService wires the ACCESS_DENIED, UNAUTHORIZED, and DEFAULT_5XX response types into the corresponding authorizer/lambda-failure error paths in _request_handler. Fixes aws/aws-sam-cli#9064. Verified with real Flask test-client requests (authorizer deny -> 403 with configured CORS header; unhandled Lambda exception -> 500 with configured header), plus unit tests for extraction, fallback resolution, and header propagation. --- samcli/lib/providers/api_collector.py | 2 + samcli/lib/providers/provider.py | 34 +++++ samcli/lib/providers/sam_api_provider.py | 57 ++++++++ samcli/local/apigw/local_apigw_service.py | 25 +++- samcli/local/apigw/service_error_responses.py | 35 +++-- .../unit/commands/local/lib/test_provider.py | 30 +++++ .../local/lib/test_sam_api_provider.py | 124 ++++++++++++++++++ .../local/apigw/test_local_apigw_service.py | 83 +++++++++++- .../apigw/test_service_error_responses.py | 60 +++++++++ 9 files changed, 435 insertions(+), 15 deletions(-) diff --git a/samcli/lib/providers/api_collector.py b/samcli/lib/providers/api_collector.py index 3c71bbcf075..b468d400814 100644 --- a/samcli/lib/providers/api_collector.py +++ b/samcli/lib/providers/api_collector.py @@ -34,6 +34,7 @@ def __init__(self) -> None: self.stage_name: Optional[str] = None self.stage_variables: Optional[Dict] = None self.cors: Optional[Cors] = None + self.gateway_responses: Optional[Dict[str, Dict[str, str]]] = None def __iter__(self) -> Iterator[Tuple[str, List[Route]]]: """ @@ -200,6 +201,7 @@ def get_api(self) -> Api: api.stage_name = self.stage_name api.stage_variables = self.stage_variables api.cors = self.cors + api.gateway_responses = self.gateway_responses for authorizers in self._authorizers_per_resources.values(): if len(authorizers): diff --git a/samcli/lib/providers/provider.py b/samcli/lib/providers/provider.py index a200465fc41..e85968923f4 100644 --- a/samcli/lib/providers/provider.py +++ b/samcli/lib/providers/provider.py @@ -499,6 +499,9 @@ def __eq__(self, other: object) -> bool: class Api: + _HTTP_STATUS_CODE_4XX_LOWER_BOUND = 400 + _HTTP_STATUS_CODE_4XX_UPPER_BOUND = 500 + def __init__(self, routes: Optional[Union[List["Route"], Set[str]]] = None) -> None: if routes is None: routes = [] @@ -517,6 +520,11 @@ def __init__(self, routes: Optional[Union[List["Route"], Set[str]]] = None) -> N self.stage_name: Optional[str] = None self.stage_variables: Optional[Dict] = None + # Headers configured through the API's GatewayResponses property, keyed by response type + # (e.g. "UNAUTHORIZED", "ACCESS_DENIED", "DEFAULT_5XX"). Only REST APIs (AWS::Serverless::Api) + # support GatewayResponses. + self.gateway_responses: Optional[Dict[str, Dict[str, str]]] = None + def __hash__(self) -> int: # Other properties are not a part of the hash return hash(self.routes) * hash(self.cors) * hash(self.binary_media_types_set) @@ -525,6 +533,32 @@ def __hash__(self) -> int: def binary_media_types(self) -> List[str]: return list(self.binary_media_types_set) + def get_gateway_response_headers(self, response_type: str, status_code: int) -> Dict[str, str]: + """ + Returns the headers configured through this API's GatewayResponses property for the given + response type. If the specific type isn't configured, falls back to the generic DEFAULT_4XX + or DEFAULT_5XX response (matching the fallback behavior of API Gateway itself). + + Parameters + ---------- + response_type : str + The API Gateway response type this response corresponds to (e.g. "UNAUTHORIZED") + status_code : int + The HTTP status code being returned, used to select the DEFAULT_4XX/DEFAULT_5XX fallback + + Returns + ------- + dict + Headers configured for this response type, or an empty dict if none are configured + """ + if not self.gateway_responses: + return {} + + is_4xx = self._HTTP_STATUS_CODE_4XX_LOWER_BOUND <= status_code < self._HTTP_STATUS_CODE_4XX_UPPER_BOUND + default_response_type = "DEFAULT_4XX" if is_4xx else "DEFAULT_5XX" + headers = self.gateway_responses.get(response_type) or self.gateway_responses.get(default_response_type) + return dict(headers) if headers else {} + _CorsTuple = namedtuple( "_CorsTuple", ["allow_origin", "allow_methods", "allow_headers", "allow_credentials", "max_age"] diff --git a/samcli/lib/providers/sam_api_provider.py b/samcli/lib/providers/sam_api_provider.py index aec497429b9..98bcab3a580 100644 --- a/samcli/lib/providers/sam_api_provider.py +++ b/samcli/lib/providers/sam_api_provider.py @@ -140,6 +140,7 @@ def _extract_from_serverless_api( cors = self.extract_cors(properties.get("Cors", {})) stage_name = properties.get("StageName") stage_variables = properties.get("Variables") + gateway_responses = self._extract_gateway_responses(logical_id, properties.get("GatewayResponses", {})) if not body and not uri: # Swagger is not found anywhere. LOG.debug( @@ -152,6 +153,7 @@ def _extract_from_serverless_api( collector.stage_name = stage_name collector.stage_variables = stage_variables collector.cors = cors + collector.gateway_responses = gateway_responses auth = properties.get(SamApiProvider._AUTH, {}) if not auth or disable_authorizer: @@ -164,6 +166,61 @@ def _extract_from_serverless_api( self._extract_authorizers_from_props(logical_id, auth, collector, Route.API) + @staticmethod + def _extract_gateway_responses(logical_id: str, gateway_responses: Dict) -> Dict[str, Dict[str, str]]: + """ + Extracts the Headers configured under each GatewayResponse's ResponseParameters so they can be + applied to local error responses (e.g. Lambda Authorizer failures, unhandled Lambda exceptions) + the same way API Gateway applies them. + + Parameters + ---------- + logical_id: str + Logical ID of the AWS::Serverless::Api resource, used for warning messages + gateway_responses: dict + The GatewayResponses property dictionary, keyed by response type (e.g. "UNAUTHORIZED") + + Returns + ------- + dict + Dictionary of response type to a dictionary of header name/value pairs + """ + if not gateway_responses or not isinstance(gateway_responses, dict): + return {} + + extracted_responses: Dict[str, Dict[str, str]] = {} + + for response_type, response in gateway_responses.items(): + if not isinstance(response, dict): + LOG.warning( + "Ignoring GatewayResponse '%s' on resource '%s', must be an object", response_type, logical_id + ) + continue + + response_headers = response.get("ResponseParameters", {}).get("Headers", {}) + if not isinstance(response_headers, dict): + continue + + extracted_headers = {} + for header_name, header_value in response_headers.items(): + if not isinstance(header_value, str) or not ( + header_value.startswith("'") and header_value.endswith("'") + ): + LOG.warning( + "Ignoring GatewayResponses header '%s' for '%s' on resource '%s'. Header values must be " + 'a quoted string (i.e. "\'value\'" is correct, but "value" is not).', + header_name, + response_type, + logical_id, + ) + continue + extracted_headers[header_name] = header_value.strip("'") + + if extracted_headers: + extracted_responses[response_type] = extracted_headers + + return extracted_responses + @staticmethod def _extract_request_lambda_authorizer( auth_name: str, function_name: str, prefix: str, properties: dict, event_type: str diff --git a/samcli/local/apigw/local_apigw_service.py b/samcli/local/apigw/local_apigw_service.py index 9a4d5c89840..4591cb46fbd 100644 --- a/samcli/local/apigw/local_apigw_service.py +++ b/samcli/local/apigw/local_apigw_service.py @@ -678,7 +678,9 @@ def _request_handler(self, **kwargs): # check for LambdaAuthorizer since that is the only authorizer we currently support if isinstance(lambda_authorizer, LambdaAuthorizer) and not self._valid_identity_sources(request, route): - return ServiceErrorResponses.missing_lambda_auth_identity_sources() + return ServiceErrorResponses.missing_lambda_auth_identity_sources( + headers=self.api.get_gateway_response_headers("UNAUTHORIZED", 401) + ) try: route_lambda_event = self._generate_lambda_event(request, route, method, endpoint) @@ -688,7 +690,9 @@ def _request_handler(self, **kwargs): auth_lambda_event = self._generate_lambda_authorizer_event(request, route, lambda_authorizer) except UnicodeDecodeError as error: LOG.error("UnicodeDecodeError while processing HTTP request: %s", error) - return ServiceErrorResponses.lambda_failure_response() + return ServiceErrorResponses.lambda_failure_response( + headers=self.api.get_gateway_response_headers("DEFAULT_5XX", 502) + ) lambda_authorizer_exception = None try: @@ -697,10 +701,14 @@ def _request_handler(self, **kwargs): if lambda_authorizer: self._invoke_parse_lambda_authorizer(lambda_authorizer, auth_lambda_event, route_lambda_event, route) except AuthorizerUnauthorizedRequest as ex: - auth_service_error = ServiceErrorResponses.lambda_authorizer_unauthorized() + auth_service_error = ServiceErrorResponses.lambda_authorizer_unauthorized( + headers=self.api.get_gateway_response_headers("ACCESS_DENIED", 403) + ) lambda_authorizer_exception = ex except InvalidLambdaAuthorizerResponse as ex: - auth_service_error = ServiceErrorResponses.lambda_failure_response() + auth_service_error = ServiceErrorResponses.lambda_failure_response( + headers=self.api.get_gateway_response_headers("DEFAULT_5XX", 502) + ) lambda_authorizer_exception = ex except FunctionNotFound as ex: lambda_authorizer_exception = ex @@ -750,12 +758,15 @@ def _request_handler(self, **kwargs): "Inline code is not supported for sam local commands. Please write your code in a separate file." ) except LambdaResponseParseException: - endpoint_service_error = ServiceErrorResponses.lambda_body_failure_response() + endpoint_service_error = ServiceErrorResponses.lambda_body_failure_response( + headers=self.api.get_gateway_response_headers("DEFAULT_5XX", 500) + ) except DockerContainerCreationFailedException as ex: endpoint_service_error = ServiceErrorResponses.container_creation_failed(ex.message) except MissingFunctionNameException as ex: endpoint_service_error = ServiceErrorResponses.lambda_failure_response( f"Failed to execute endpoint. Got an invalid function name ({str(ex)})", + headers=self.api.get_gateway_response_headers("DEFAULT_5XX", 502), ) if endpoint_service_error: @@ -774,7 +785,9 @@ def _request_handler(self, **kwargs): ) except LambdaResponseParseException as ex: LOG.error("Invalid lambda response received: %s", ex) - return ServiceErrorResponses.lambda_failure_response() + return ServiceErrorResponses.lambda_failure_response( + headers=self.api.get_gateway_response_headers("DEFAULT_5XX", 502) + ) # Add CORS headers to the response headers.update(cors_headers) diff --git a/samcli/local/apigw/service_error_responses.py b/samcli/local/apigw/service_error_responses.py index 5a745e1ea5c..fc35d44fadf 100644 --- a/samcli/local/apigw/service_error_responses.py +++ b/samcli/local/apigw/service_error_responses.py @@ -1,6 +1,7 @@ """Class container to hold common Service Responses""" import logging +from typing import Optional from flask import Response, jsonify, make_response @@ -22,7 +23,21 @@ class ServiceErrorResponses: HTTP_STATUS_CODE_400 = 400 @staticmethod - def lambda_authorizer_unauthorized() -> Response: + def _add_headers(response: Response, headers: Optional[dict]) -> Response: + """ + Adds the given headers (e.g. from a template's GatewayResponses configuration) to a Flask response + + Returns + ------- + Response + The same Flask Response object, with headers added + """ + if headers: + response.headers.update(headers) + return response + + @staticmethod + def lambda_authorizer_unauthorized(headers: Optional[dict] = None) -> Response: """ Constructs a Flask response for when a route invokes a Lambda Authorizer, but is the identity sources provided are not authorized for that method @@ -33,10 +48,11 @@ def lambda_authorizer_unauthorized() -> Response: A Flask Response object """ response_data = jsonify(ServiceErrorResponses._LAMBDA_AUTHORIZER_NOT_AUTHORIZED) - return make_response(response_data, ServiceErrorResponses.HTTP_STATUS_CODE_403) + response = make_response(response_data, ServiceErrorResponses.HTTP_STATUS_CODE_403) + return ServiceErrorResponses._add_headers(response, headers) @staticmethod - def missing_lambda_auth_identity_sources() -> Response: + def missing_lambda_auth_identity_sources(headers: Optional[dict] = None) -> Response: """ Constructs a Flask response for when a route contains a Lambda Authorizer but is missing the required identity services @@ -47,10 +63,11 @@ def missing_lambda_auth_identity_sources() -> Response: A Flask Response object """ response_data = jsonify(ServiceErrorResponses._MISSING_LAMBDA_AUTH_IDENTITY_SOURCES) - return make_response(response_data, ServiceErrorResponses.HTTP_STATUS_CODE_401) + response = make_response(response_data, ServiceErrorResponses.HTTP_STATUS_CODE_401) + return ServiceErrorResponses._add_headers(response, headers) @staticmethod - def lambda_failure_response(*args): + def lambda_failure_response(*args, headers: Optional[dict] = None): """ Helper function to create a Lambda Failure Response @@ -58,17 +75,19 @@ def lambda_failure_response(*args): """ LOG.debug("Lambda execution failed %s", args) response_data = jsonify(ServiceErrorResponses._LAMBDA_FAILURE) - return make_response(response_data, ServiceErrorResponses.HTTP_STATUS_CODE_502) + response = make_response(response_data, ServiceErrorResponses.HTTP_STATUS_CODE_502) + return ServiceErrorResponses._add_headers(response, headers) @staticmethod - def lambda_body_failure_response(*args): + def lambda_body_failure_response(*args, headers: Optional[dict] = None): """ Helper function to create a Lambda Body Failure Response :return: A Flask Response """ response_data = jsonify(ServiceErrorResponses._LAMBDA_FAILURE) - return make_response(response_data, ServiceErrorResponses.HTTP_STATUS_CODE_500) + response = make_response(response_data, ServiceErrorResponses.HTTP_STATUS_CODE_500) + return ServiceErrorResponses._add_headers(response, headers) @staticmethod def not_implemented_locally(message): diff --git a/tests/unit/commands/local/lib/test_provider.py b/tests/unit/commands/local/lib/test_provider.py index 0e48c1d9e91..41e4a64b7b3 100644 --- a/tests/unit/commands/local/lib/test_provider.py +++ b/tests/unit/commands/local/lib/test_provider.py @@ -8,6 +8,7 @@ from samcli.lib.utils.architecture import X86_64, ARM64 from samcli.lib.providers.provider import ( + Api, LayerVersion, ResourceIdentifier, Stack, @@ -863,6 +864,35 @@ def test_get_resource_full_path_by_id(self, resource_id, expected_full_path): self.assertEqual(expected_full_path, full_path) +class TestApiGetGatewayResponseHeaders(TestCase): + def test_no_gateway_responses_configured(self): + api = Api() + self.assertEqual(api.get_gateway_response_headers("UNAUTHORIZED", 401), {}) + + def test_specific_response_type_headers_returned(self): + api = Api() + api.gateway_responses = { + "UNAUTHORIZED": {"Access-Control-Allow-Origin": "*"}, + "DEFAULT_4XX": {"Access-Control-Allow-Origin": "should-not-be-used"}, + } + self.assertEqual(api.get_gateway_response_headers("UNAUTHORIZED", 401), {"Access-Control-Allow-Origin": "*"}) + + def test_falls_back_to_default_4xx(self): + api = Api() + api.gateway_responses = {"DEFAULT_4XX": {"Access-Control-Allow-Origin": "*"}} + self.assertEqual(api.get_gateway_response_headers("ACCESS_DENIED", 403), {"Access-Control-Allow-Origin": "*"}) + + def test_falls_back_to_default_5xx(self): + api = Api() + api.gateway_responses = {"DEFAULT_5XX": {"Access-Control-Allow-Origin": "*"}} + self.assertEqual(api.get_gateway_response_headers("DEFAULT_5XX", 502), {"Access-Control-Allow-Origin": "*"}) + + def test_no_matching_response_type_or_default(self): + api = Api() + api.gateway_responses = {"DEFAULT_5XX": {"Access-Control-Allow-Origin": "*"}} + self.assertEqual(api.get_gateway_response_headers("ACCESS_DENIED", 403), {}) + + class TestGetStack(TestCase): root_stack = Stack("", "Root", "template.yaml", None, {}) child_stack = Stack("Root", "Child", "root_stack/template.yaml", None, {}) 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..c24bd2a1450 100644 --- a/tests/unit/commands/local/lib/test_sam_api_provider.py +++ b/tests/unit/commands/local/lib/test_sam_api_provider.py @@ -1393,6 +1393,130 @@ def test_global_cors(self): self.assertEqual(provider.api.cors, cors) +class TestSamGatewayResponses(TestCase): + def test_gateway_responses_headers_are_extracted(self): + template = { + "Resources": { + "TestApi": { + "Type": "AWS::Serverless::Api", + "Properties": { + "StageName": "Prod", + "GatewayResponses": { + "UNAUTHORIZED": { + "ResponseParameters": { + "Headers": { + "Access-Control-Allow-Origin": "'*'", + "Access-Control-Allow-Headers": "'*'", + } + } + }, + "DEFAULT_5XX": {"ResponseParameters": {"Headers": {"Access-Control-Allow-Origin": "'*'"}}}, + }, + "DefinitionBody": { + "swagger": "2.0", + "paths": { + "/path": { + "get": { + "x-amazon-apigateway-integration": { + "type": "aws_proxy", + "uri": { + "Fn::Sub": "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31" + "/functions/${NoApiEventFunction.Arn}/invocations" + }, + "responses": {}, + } + } + } + }, + }, + }, + } + } + } + + provider = ApiProvider(make_mock_stacks_from_template(template)) + + self.assertEqual( + provider.api.gateway_responses, + { + "UNAUTHORIZED": { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "*", + }, + "DEFAULT_5XX": {"Access-Control-Allow-Origin": "*"}, + }, + ) + + def test_gateway_responses_not_set_results_in_empty_dict(self): + template = { + "Resources": { + "TestApi": { + "Type": "AWS::Serverless::Api", + "Properties": { + "StageName": "Prod", + "DefinitionBody": { + "swagger": "2.0", + "paths": { + "/path": { + "get": { + "x-amazon-apigateway-integration": { + "type": "aws_proxy", + "uri": { + "Fn::Sub": "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31" + "/functions/${NoApiEventFunction.Arn}/invocations" + }, + "responses": {}, + } + } + } + }, + }, + }, + } + } + } + + provider = ApiProvider(make_mock_stacks_from_template(template)) + + self.assertEqual(provider.api.gateway_responses, {}) + + def test_gateway_responses_header_not_single_quoted_is_ignored(self): + template = { + "Resources": { + "TestApi": { + "Type": "AWS::Serverless::Api", + "Properties": { + "StageName": "Prod", + "GatewayResponses": { + "UNAUTHORIZED": {"ResponseParameters": {"Headers": {"Access-Control-Allow-Origin": "*"}}}, + }, + "DefinitionBody": { + "swagger": "2.0", + "paths": { + "/path": { + "get": { + "x-amazon-apigateway-integration": { + "type": "aws_proxy", + "uri": { + "Fn::Sub": "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31" + "/functions/${NoApiEventFunction.Arn}/invocations" + }, + "responses": {}, + } + } + } + }, + }, + }, + } + } + } + + provider = ApiProvider(make_mock_stacks_from_template(template)) + + self.assertEqual(provider.api.gateway_responses, {}) + + class TestSamHttpApiCors(TestCase): def test_provider_parse_cors_with_unresolved_intrinsic(self): template = { diff --git a/tests/unit/local/apigw/test_local_apigw_service.py b/tests/unit/local/apigw/test_local_apigw_service.py index dddc065c1e0..5153cc29766 100644 --- a/tests/unit/local/apigw/test_local_apigw_service.py +++ b/tests/unit/local/apigw/test_local_apigw_service.py @@ -523,6 +523,36 @@ def test_request_throws_when_invoke_fails(self, request_mock): with self.assertRaises(Exception): self.api_service._request_handler() + @patch.object(LocalApigwService, "get_request_methods_endpoints") + @patch("samcli.local.apigw.local_apigw_service.ServiceErrorResponses") + @patch.object(LocalApigwService, "_invoke_lambda_function") + @patch("samcli.local.apigw.local_apigw_service.LocalApigwService._generate_lambda_event") + def test_request_handler_applies_gateway_response_headers_on_unhandled_lambda_exception( + self, generate_mock, invoke_mock, service_error_responses_patch, request_mock + ): + # Reproduces https://github.com/aws/aws-sam-cli/issues/9064: an unhandled exception raised by the + # Lambda function should still honor headers configured via a DEFAULT_5XX GatewayResponse. + self.api.gateway_responses = {"DEFAULT_5XX": {"Access-Control-Allow-Origin": "*"}} + + generate_mock.return_value = {} + invoke_mock.side_effect = LambdaResponseParseException() + + body_failure_response_mock = Mock() + service_error_responses_patch.lambda_body_failure_response.return_value = body_failure_response_mock + + self.api_service._get_current_route = MagicMock() + self.api_service._get_current_route.return_value.payload_format_version = "2.0" + self.api_service._get_current_route.return_value.authorizer_object = None + self.api_service._get_current_route.methods = [] + + request_mock.return_value = ("test", "test") + response = self.api_service._request_handler() + + self.assertEqual(response, body_failure_response_mock) + service_error_responses_patch.lambda_body_failure_response.assert_called_once_with( + headers={"Access-Control-Allow-Origin": "*"} + ) + @patch.object(LocalApigwService, "get_request_methods_endpoints") @patch("samcli.local.apigw.local_apigw_service.ServiceErrorResponses") @patch("samcli.local.apigw.local_apigw_service.LocalApigwService._generate_lambda_event") @@ -993,6 +1023,56 @@ def test_lambda_auth_unauthorized_response( result = self.api_service._request_handler() self.assertEqual(result, unauth_mock) + service_err_mock.lambda_authorizer_unauthorized.assert_called_once_with(headers={}) + + @patch.object(LocalApigwService, "get_request_methods_endpoints") + @patch.object(LocalApigwService, "_create_method_arn") + @patch.object(LocalApigwService, "_generate_lambda_authorizer_event") + @patch.object(LocalApigwService, "_valid_identity_sources") + @patch.object(LocalApigwService, "_invoke_lambda_function") + @patch("samcli.local.apigw.local_apigw_service.ServiceErrorResponses") + @patch("samcli.local.apigw.local_apigw_service.construct_v1_event") + @patch("samcli.local.apigw.local_apigw_service.construct_v2_event_http") + def test_lambda_auth_unauthorized_response_applies_gateway_response_headers( + self, + v2_event_mock, + v1_event_mock, + service_err_mock, + invoke_mock, + validate_id_mock, + gen_auth_event_mock, + method_arn_mock, + request_mock, + ): + # Reproduces https://github.com/aws/aws-sam-cli/issues/9064: a GatewayResponse configured for + # ACCESS_DENIED (Lambda Authorizer explicit deny) should have its headers applied locally too. + self.api.gateway_responses = {"ACCESS_DENIED": {"Access-Control-Allow-Origin": "*"}} + + make_response_mock = Mock() + validate_id_mock.return_value = True + + auth = LambdaAuthorizer(Mock(), Mock(), "auth_lambda", [], Mock(), Mock(), Mock()) + auth.is_valid_response = Mock(return_value=False) + self.api_gateway_route.authorizer_object = auth + + self.api_service.service_response = make_response_mock + self.api_service._get_current_route = MagicMock() + self.api_service._get_current_route.return_value = self.api_gateway_route + self.api_service._get_current_route.methods = [] + self.api_service._get_current_route.return_value.payload_format_version = "2.0" + v1_event_mock.return_value = {} + + request_mock.return_value = ("test", "test") + method_arn_mock.return_value = "arn" + + mock_context = {"key": "value"} + invoke_mock.side_effect = [{"context": mock_context}, Mock()] + + self.api_service._request_handler() + + service_err_mock.lambda_authorizer_unauthorized.assert_called_once_with( + headers={"Access-Control-Allow-Origin": "*"} + ) @patch.object(LocalApigwService, "_invoke_lambda_function") @patch.object(LocalApigwService, "_create_method_arn") @@ -1113,7 +1193,8 @@ def test_lambda_invoke_fails_no_function_name_exception( service_mock.lambda_failure_response.assert_called_once_with( "Failed to execute endpoint. Got an invalid function name " - "(Unable to get Lambda function because the function identifier is not defined.)" + "(Unable to get Lambda function because the function identifier is not defined.)", + headers={}, ) @patch("samcli.local.apigw.local_apigw_service.request") diff --git a/tests/unit/local/apigw/test_service_error_responses.py b/tests/unit/local/apigw/test_service_error_responses.py index f42314e0aa1..e14afde08db 100644 --- a/tests/unit/local/apigw/test_service_error_responses.py +++ b/tests/unit/local/apigw/test_service_error_responses.py @@ -45,3 +45,63 @@ def test_route_not_found(self, jsonify_patch, make_response_patch): jsonify_patch.assert_called_with({"message": "Missing Authentication Token"}) make_response_patch.assert_called_with({"json": "Response"}, 403) + + @patch("samcli.local.apigw.service_error_responses.make_response") + @patch("samcli.local.apigw.service_error_responses.jsonify") + def test_lambda_failure_response_applies_gateway_response_headers(self, jsonify_patch, make_response_patch): + response_mock = Mock() + make_response_patch.return_value = response_mock + + headers = {"Access-Control-Allow-Origin": "*"} + response = ServiceErrorResponses.lambda_failure_response(headers=headers) + + self.assertEqual(response, response_mock) + response_mock.headers.update.assert_called_once_with(headers) + + @patch("samcli.local.apigw.service_error_responses.make_response") + @patch("samcli.local.apigw.service_error_responses.jsonify") + def test_lambda_body_failure_response_applies_gateway_response_headers(self, jsonify_patch, make_response_patch): + response_mock = Mock() + make_response_patch.return_value = response_mock + + headers = {"Access-Control-Allow-Origin": "*"} + response = ServiceErrorResponses.lambda_body_failure_response(headers=headers) + + self.assertEqual(response, response_mock) + response_mock.headers.update.assert_called_once_with(headers) + + @patch("samcli.local.apigw.service_error_responses.make_response") + @patch("samcli.local.apigw.service_error_responses.jsonify") + def test_lambda_authorizer_unauthorized_applies_gateway_response_headers(self, jsonify_patch, make_response_patch): + response_mock = Mock() + make_response_patch.return_value = response_mock + + headers = {"Access-Control-Allow-Origin": "*"} + response = ServiceErrorResponses.lambda_authorizer_unauthorized(headers=headers) + + self.assertEqual(response, response_mock) + response_mock.headers.update.assert_called_once_with(headers) + + @patch("samcli.local.apigw.service_error_responses.make_response") + @patch("samcli.local.apigw.service_error_responses.jsonify") + def test_missing_lambda_auth_identity_sources_applies_gateway_response_headers( + self, jsonify_patch, make_response_patch + ): + response_mock = Mock() + make_response_patch.return_value = response_mock + + headers = {"Access-Control-Allow-Origin": "*"} + response = ServiceErrorResponses.missing_lambda_auth_identity_sources(headers=headers) + + self.assertEqual(response, response_mock) + response_mock.headers.update.assert_called_once_with(headers) + + @patch("samcli.local.apigw.service_error_responses.make_response") + @patch("samcli.local.apigw.service_error_responses.jsonify") + def test_no_headers_leaves_response_untouched(self, jsonify_patch, make_response_patch): + response_mock = Mock() + make_response_patch.return_value = response_mock + + ServiceErrorResponses.lambda_failure_response() + + response_mock.headers.update.assert_not_called() From bd991d55021060c28ee8bff0fd467162eebe6e7a Mon Sep 17 00:00:00 2001 From: hardik-devops Date: Mon, 6 Jul 2026 13:10:48 +0530 Subject: [PATCH 2/2] fix: address review feedback on GatewayResponses headers fix - Fix crash: response.get("ResponseParameters", {}).get("Headers", {}) raised AttributeError when a template set "ResponseParameters:" with no value, since YAML parses that as an explicit None (not a missing key), so the dict.get default was never used. Guard both ResponseParameters and Headers with "or {}" and re-validate they're dicts before use. - Fix inconsistency: lambda_not_found_response (502), not_implemented_locally (501), container_creation_failed (501), and tenant_id_validation_error (400) were left out of the GatewayResponses wiring even though they're sibling error paths in the same _request_handler try/except block as the ones that were wired up. From a caller's perspective these are indistinguishable 4xx/5xx failures, so a customer's DEFAULT_4XX/DEFAULT_5XX GatewayResponse would apply to some of their local error responses but not others - the same kind of divergence-from-cloud-behavior this fix is meant to resolve. All endpoint-side error paths now resolve headers the same way. --- samcli/lib/providers/sam_api_provider.py | 6 ++- samcli/local/apigw/local_apigw_service.py | 15 ++++-- samcli/local/apigw/service_error_responses.py | 20 ++++---- .../local/lib/test_sam_api_provider.py | 39 +++++++++++++++ .../apigw/test_service_error_responses.py | 48 +++++++++++++++++++ 5 files changed, 115 insertions(+), 13 deletions(-) diff --git a/samcli/lib/providers/sam_api_provider.py b/samcli/lib/providers/sam_api_provider.py index 98bcab3a580..cac676dac98 100644 --- a/samcli/lib/providers/sam_api_provider.py +++ b/samcli/lib/providers/sam_api_provider.py @@ -197,7 +197,11 @@ def _extract_gateway_responses(logical_id: str, gateway_responses: Dict) -> Dict ) continue - response_headers = response.get("ResponseParameters", {}).get("Headers", {}) + response_parameters = response.get("ResponseParameters") or {} + if not isinstance(response_parameters, dict): + continue + + response_headers = response_parameters.get("Headers") or {} if not isinstance(response_headers, dict): continue diff --git a/samcli/local/apigw/local_apigw_service.py b/samcli/local/apigw/local_apigw_service.py index 4591cb46fbd..cf417b72010 100644 --- a/samcli/local/apigw/local_apigw_service.py +++ b/samcli/local/apigw/local_apigw_service.py @@ -750,19 +750,26 @@ def _request_handler(self, **kwargs): # invoke the route's Lambda function lambda_response = self._invoke_lambda_function(route.function_name, route_lambda_event, tenant_id) except TenantIdValidationError as e: - endpoint_service_error = ServiceErrorResponses.tenant_id_validation_error(str(e)) + endpoint_service_error = ServiceErrorResponses.tenant_id_validation_error( + str(e), headers=self.api.get_gateway_response_headers("DEFAULT_4XX", 400) + ) except FunctionNotFound: - endpoint_service_error = ServiceErrorResponses.lambda_not_found_response() + endpoint_service_error = ServiceErrorResponses.lambda_not_found_response( + headers=self.api.get_gateway_response_headers("DEFAULT_5XX", 502) + ) except UnsupportedInlineCodeError: endpoint_service_error = ServiceErrorResponses.not_implemented_locally( - "Inline code is not supported for sam local commands. Please write your code in a separate file." + "Inline code is not supported for sam local commands. Please write your code in a separate file.", + headers=self.api.get_gateway_response_headers("DEFAULT_5XX", 501), ) except LambdaResponseParseException: endpoint_service_error = ServiceErrorResponses.lambda_body_failure_response( headers=self.api.get_gateway_response_headers("DEFAULT_5XX", 500) ) except DockerContainerCreationFailedException as ex: - endpoint_service_error = ServiceErrorResponses.container_creation_failed(ex.message) + endpoint_service_error = ServiceErrorResponses.container_creation_failed( + ex.message, headers=self.api.get_gateway_response_headers("DEFAULT_5XX", 501) + ) except MissingFunctionNameException as ex: endpoint_service_error = ServiceErrorResponses.lambda_failure_response( f"Failed to execute endpoint. Got an invalid function name ({str(ex)})", diff --git a/samcli/local/apigw/service_error_responses.py b/samcli/local/apigw/service_error_responses.py index fc35d44fadf..7a08b46fd05 100644 --- a/samcli/local/apigw/service_error_responses.py +++ b/samcli/local/apigw/service_error_responses.py @@ -90,7 +90,7 @@ def lambda_body_failure_response(*args, headers: Optional[dict] = None): return ServiceErrorResponses._add_headers(response, headers) @staticmethod - def not_implemented_locally(message): + def not_implemented_locally(message, headers: Optional[dict] = None): """ Constructs a Flask Response for for when a Lambda function functionality is not implemented @@ -99,17 +99,19 @@ def not_implemented_locally(message): """ exception_dict = {"message": message} response_data = jsonify(exception_dict) - return make_response(response_data, ServiceErrorResponses.HTTP_STATUS_CODE_501) + response = make_response(response_data, ServiceErrorResponses.HTTP_STATUS_CODE_501) + return ServiceErrorResponses._add_headers(response, headers) @staticmethod - def lambda_not_found_response(*args): + def lambda_not_found_response(*args, headers: Optional[dict] = None): """ Constructs a Flask Response for when a Lambda function is not found for an endpoint :return: a Flask Response """ response_data = jsonify(ServiceErrorResponses._NO_LAMBDA_INTEGRATION) - return make_response(response_data, ServiceErrorResponses.HTTP_STATUS_CODE_502) + response = make_response(response_data, ServiceErrorResponses.HTTP_STATUS_CODE_502) + return ServiceErrorResponses._add_headers(response, headers) @staticmethod def route_not_found(*args): @@ -123,16 +125,17 @@ def route_not_found(*args): return make_response(response_data, ServiceErrorResponses.HTTP_STATUS_CODE_403) @staticmethod - def container_creation_failed(message): + def container_creation_failed(message, headers: Optional[dict] = None): """ Constuct a Flask Response for when container creation fails for a Lambda Function :return: a Flask Response """ response_data = jsonify({"message": message}) - return make_response(response_data, ServiceErrorResponses.HTTP_STATUS_CODE_501) + response = make_response(response_data, ServiceErrorResponses.HTTP_STATUS_CODE_501) + return ServiceErrorResponses._add_headers(response, headers) @staticmethod - def tenant_id_validation_error(message: str) -> Response: + def tenant_id_validation_error(message: str, headers: Optional[dict] = None) -> Response: """ Constructs a Flask Response for when tenant ID validation fails @@ -147,4 +150,5 @@ def tenant_id_validation_error(message: str) -> Response: A Flask Response object with HTTP 400 status """ response_data = jsonify({"message": message}) - return make_response(response_data, ServiceErrorResponses.HTTP_STATUS_CODE_400) + response = make_response(response_data, ServiceErrorResponses.HTTP_STATUS_CODE_400) + return ServiceErrorResponses._add_headers(response, headers) 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 c24bd2a1450..4d4c795136d 100644 --- a/tests/unit/commands/local/lib/test_sam_api_provider.py +++ b/tests/unit/commands/local/lib/test_sam_api_provider.py @@ -1516,6 +1516,45 @@ def test_gateway_responses_header_not_single_quoted_is_ignored(self): self.assertEqual(provider.api.gateway_responses, {}) + def test_gateway_responses_with_empty_response_parameters_does_not_crash(self): + # ResponseParameters: (no value) parses to None, not a missing key, so a plain .get(key, {}) + # chain would raise AttributeError. Reproduces the crash reported in review of PR for #9064. + template = { + "Resources": { + "TestApi": { + "Type": "AWS::Serverless::Api", + "Properties": { + "StageName": "Prod", + "GatewayResponses": { + "UNAUTHORIZED": {"ResponseParameters": None}, + "ACCESS_DENIED": {"ResponseParameters": {"Headers": None}}, + }, + "DefinitionBody": { + "swagger": "2.0", + "paths": { + "/path": { + "get": { + "x-amazon-apigateway-integration": { + "type": "aws_proxy", + "uri": { + "Fn::Sub": "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31" + "/functions/${NoApiEventFunction.Arn}/invocations" + }, + "responses": {}, + } + } + } + }, + }, + }, + } + } + } + + provider = ApiProvider(make_mock_stacks_from_template(template)) + + self.assertEqual(provider.api.gateway_responses, {}) + class TestSamHttpApiCors(TestCase): def test_provider_parse_cors_with_unresolved_intrinsic(self): diff --git a/tests/unit/local/apigw/test_service_error_responses.py b/tests/unit/local/apigw/test_service_error_responses.py index e14afde08db..7c8ab605d34 100644 --- a/tests/unit/local/apigw/test_service_error_responses.py +++ b/tests/unit/local/apigw/test_service_error_responses.py @@ -105,3 +105,51 @@ def test_no_headers_leaves_response_untouched(self, jsonify_patch, make_response ServiceErrorResponses.lambda_failure_response() response_mock.headers.update.assert_not_called() + + @patch("samcli.local.apigw.service_error_responses.make_response") + @patch("samcli.local.apigw.service_error_responses.jsonify") + def test_lambda_not_found_response_applies_gateway_response_headers(self, jsonify_patch, make_response_patch): + response_mock = Mock() + make_response_patch.return_value = response_mock + + headers = {"Access-Control-Allow-Origin": "*"} + response = ServiceErrorResponses.lambda_not_found_response(headers=headers) + + self.assertEqual(response, response_mock) + response_mock.headers.update.assert_called_once_with(headers) + + @patch("samcli.local.apigw.service_error_responses.make_response") + @patch("samcli.local.apigw.service_error_responses.jsonify") + def test_not_implemented_locally_applies_gateway_response_headers(self, jsonify_patch, make_response_patch): + response_mock = Mock() + make_response_patch.return_value = response_mock + + headers = {"Access-Control-Allow-Origin": "*"} + response = ServiceErrorResponses.not_implemented_locally("message", headers=headers) + + self.assertEqual(response, response_mock) + response_mock.headers.update.assert_called_once_with(headers) + + @patch("samcli.local.apigw.service_error_responses.make_response") + @patch("samcli.local.apigw.service_error_responses.jsonify") + def test_container_creation_failed_applies_gateway_response_headers(self, jsonify_patch, make_response_patch): + response_mock = Mock() + make_response_patch.return_value = response_mock + + headers = {"Access-Control-Allow-Origin": "*"} + response = ServiceErrorResponses.container_creation_failed("message", headers=headers) + + self.assertEqual(response, response_mock) + response_mock.headers.update.assert_called_once_with(headers) + + @patch("samcli.local.apigw.service_error_responses.make_response") + @patch("samcli.local.apigw.service_error_responses.jsonify") + def test_tenant_id_validation_error_applies_gateway_response_headers(self, jsonify_patch, make_response_patch): + response_mock = Mock() + make_response_patch.return_value = response_mock + + headers = {"Access-Control-Allow-Origin": "*"} + response = ServiceErrorResponses.tenant_id_validation_error("message", headers=headers) + + self.assertEqual(response, response_mock) + response_mock.headers.update.assert_called_once_with(headers)