Skip to content

Commit 45f7a4d

Browse files
fix(event_handler): fix prefix stripping, gzip negotiation, JSON detection and CORS Vary (#8638)
* fix(event_handler): only strip a leading match for regex strip_prefixes * fix(event_handler): match literal characters in route rules literally * fix(event_handler): respect q=0 and casing in Accept-Encoding before gzipping * fix(event_handler): base64 encode bytes bodies that have a JSON content type * fix(event_handler): detect the JSON Content-Type header in any casing * fix(event_handler): add Vary: Origin when CORS echoes a specific origin * Revert "fix(event_handler): match literal characters in route rules literally" This reverts commit 9aa1344. * test(event_handler): cover documented catch-all regex routes --------- Co-authored-by: Leandro Damascena <lcdama@amazon.pt>
1 parent 05877b2 commit 45f7a4d

2 files changed

Lines changed: 200 additions & 10 deletions

File tree

‎aws_lambda_powertools/event_handler/api_gateway.py‎

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,9 @@ def to_dict(self, origin: str | None) -> dict[str, str]:
242242
headers["Access-Control-Max-Age"] = str(self.max_age)
243243
if origin != "*" and self.allow_credentials is True:
244244
headers["Access-Control-Allow-Credentials"] = "true"
245+
if origin != "*":
246+
# The response echoes the request Origin, so shared caches must not reuse it for other origins
247+
headers["Vary"] = "Origin"
245248
return headers
246249

247250
def allowed_origin(self, extracted_origin: str) -> str | None:
@@ -362,7 +365,8 @@ def is_json(self) -> bool:
362365
"""
363366
Returns True if the response is JSON, based on the Content-Type.
364367
"""
365-
content_type = self.headers.get("Content-Type", "")
368+
# Header names are case-insensitive, so match "content-type" in any casing
369+
content_type = next((value for key, value in self.headers.items() if key.lower() == "content-type"), "")
366370
if isinstance(content_type, list):
367371
content_type = content_type[0]
368372
return content_type.startswith(DEFAULT_CONTENT_TYPE)
@@ -746,6 +750,20 @@ def _generate_operation_id(self) -> str:
746750
return operation_id
747751

748752

753+
def _accepts_gzip(accept_encoding: str) -> bool:
754+
"""Returns True if an Accept-Encoding header accepts gzip, honouring casing and a zero q-value"""
755+
for coding in accept_encoding.split(","):
756+
name, _, params = coding.partition(";")
757+
if name.strip().lower() != "gzip":
758+
continue
759+
_, _, weight = params.replace(" ", "").lower().partition("q=")
760+
try:
761+
return float(weight) > 0 if weight else True
762+
except ValueError:
763+
return False
764+
return False
765+
766+
749767
class ResponseBuilder(Generic[ResponseEventT]):
750768
"""Internally used Response builder"""
751769

@@ -765,7 +783,14 @@ def _add_cors(self, event: ResponseEventT, cors: CORSConfig):
765783

766784
origin = cors.allowed_origin(extracted_origin_header)
767785
if origin is not None:
768-
self.response.headers.update(cors.to_dict(origin))
786+
cors_headers = cors.to_dict(origin)
787+
# Keep any Vary value the route already set, and add Origin to it
788+
existing_vary = self.response.headers.get("Vary")
789+
if isinstance(existing_vary, list):
790+
existing_vary = ", ".join(existing_vary)
791+
if existing_vary and "Vary" in cors_headers:
792+
cors_headers["Vary"] = f"{existing_vary}, Origin"
793+
self.response.headers.update(cors_headers)
769794

770795
def _add_cache_control(self, cache_control: str):
771796
"""Set the specified cache control headers for 200 http responses. For non-200 `no-cache` is used."""
@@ -798,11 +823,9 @@ def _has_compression_enabled(
798823
True if compression is enabled and the "gzip" encoding is accepted, False otherwise.
799824
"""
800825
encoding = event.resolved_headers_field.get("accept-encoding", "")
801-
gzip_accepted = False
802-
if isinstance(encoding, str):
803-
gzip_accepted = "gzip" in encoding
804-
elif isinstance(encoding, list):
805-
gzip_accepted = "gzip" in ",".join(encoding)
826+
if isinstance(encoding, list):
827+
encoding = ",".join(encoding)
828+
gzip_accepted = isinstance(encoding, str) and _accepts_gzip(encoding)
806829

807830
if gzip_accepted:
808831
if response_compression is not None:
@@ -845,8 +868,8 @@ def build(self, event: ResponseEventT, cors: CORSConfig | None = None) -> dict[s
845868
"""Build the full response dict to be returned by the lambda"""
846869

847870
# We only apply the serializer when the content type is JSON and the
848-
# body is not a str, to avoid double encoding
849-
if self.response.is_json() and not isinstance(self.response.body, str):
871+
# body is not a str or bytes, to avoid double encoding (bytes are base64 encoded below)
872+
if self.response.is_json() and not isinstance(self.response.body, (str, bytes)):
850873
self.response.body = self.serializer(self.response.body)
851874

852875
self._route(event, cors)
@@ -2883,7 +2906,10 @@ def _remove_prefix(self, path: str) -> str:
28832906
return path[len(prefix) :]
28842907

28852908
if isinstance(prefix, Pattern):
2886-
path = re.sub(prefix, "", path)
2909+
# Only strip a match at the start of the path, and only once
2910+
match = prefix.match(path)
2911+
if match:
2912+
path = path[match.end() :]
28872913

28882914
# When using regexes, we might get into a point where everything is removed
28892915
# from the string, so we check if it's empty and return /, since there's nothing

‎tests/functional/event_handler/required_dependencies/test_api_gateway.py‎

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,39 @@ def test_response_is_json_with_multiple_json_content_types():
523523
assert response.is_json() is True
524524

525525

526+
def test_response_with_bytes_body_and_json_content_type_is_base64_encoded():
527+
# GIVEN a route that returns a bytes body with a JSON content type
528+
app = ApiGatewayResolver()
529+
body = b'{"a":1}'
530+
531+
@app.get("/my/path")
532+
def get_bytes() -> Response:
533+
return Response(200, content_types.APPLICATION_JSON, body)
534+
535+
# WHEN calling the event handler
536+
result = app({"path": "/my/path", "httpMethod": "GET"}, None)
537+
538+
# THEN the bytes are base64 encoded instead of being passed to the JSON serializer
539+
assert result["isBase64Encoded"] is True
540+
assert base64.b64decode(result["body"]) == body
541+
542+
543+
def test_response_with_lowercase_content_type_header_serializes_json_body():
544+
# GIVEN a route that sets the JSON content type with a lowercase header name
545+
app = ApiGatewayResolver()
546+
547+
@app.get("/my/path")
548+
def get_lowercase_header() -> Response:
549+
return Response(200, headers={"content-type": content_types.APPLICATION_JSON}, body={"a": 1})
550+
551+
# WHEN calling the event handler
552+
result = app({"path": "/my/path", "httpMethod": "GET"}, None)
553+
554+
# THEN the response is recognised as JSON and the body is serialized
555+
assert isinstance(result["body"], str)
556+
assert json.loads(result["body"]) == {"a": 1}
557+
558+
526559
def test_compress():
527560
# GIVEN a function that has compress=True
528561
# AND an event with a "Accept-Encoding" that include gzip
@@ -588,6 +621,32 @@ def return_text() -> Response:
588621
assert result["body"] == expected_value
589622

590623

624+
@pytest.mark.parametrize(
625+
"accept_encoding, expected_compressed",
626+
[
627+
pytest.param("gzip;q=0", False, id="gzip refused with a zero weight"),
628+
pytest.param("deflate, gzip; q=0.0", False, id="gzip refused with a zero weight and spaces"),
629+
pytest.param("GZIP", True, id="gzip accepted in uppercase"),
630+
pytest.param("deflate, gzip;q=0.5", True, id="gzip accepted with a non-zero weight"),
631+
],
632+
)
633+
def test_compress_respects_accept_encoding_weights_and_casing(accept_encoding: str, expected_compressed: bool):
634+
# GIVEN a function with compress=True
635+
app = ApiGatewayResolver()
636+
637+
@app.get("/my/path", compress=True)
638+
def return_text() -> Response:
639+
return Response(200, content_types.TEXT_PLAIN, "Foo")
640+
641+
# WHEN the client sends an Accept-Encoding header with a weight or different casing
642+
event = {"path": "/my/path", "httpMethod": "GET", "headers": {"Accept-Encoding": accept_encoding}}
643+
result = app(event, None)
644+
645+
# THEN the response is only gzipped when the client accepts gzip
646+
assert result["isBase64Encoded"] is expected_compressed
647+
assert ("Content-Encoding" in result["multiValueHeaders"]) is expected_compressed
648+
649+
591650
def test_compress_no_accept_encoding_null_headers():
592651
# GIVEN a function with compress=True
593652
# AND the request has no headers
@@ -784,6 +843,59 @@ def get_with_cors():
784843
assert "Access-Control-Allow-Origin" not in headers
785844

786845

846+
def test_cors_adds_vary_origin_when_echoing_a_specific_origin():
847+
# GIVEN a cors configuration with more than one allowed origin
848+
cors_config = CORSConfig(allow_origin="https://a.com", extra_origins=["https://b.com"])
849+
app = ApiGatewayResolver(cors=cors_config)
850+
851+
@app.get("/cors")
852+
def get_with_cors():
853+
return {}
854+
855+
# WHEN calling the event handler with one of the allowed origins
856+
event = {"path": "/cors", "httpMethod": "GET", "headers": {"Origin": "https://a.com"}}
857+
result = app(event, None)
858+
859+
# THEN the echoed origin comes with Vary: Origin so shared caches don't reuse it for other origins
860+
headers = result["multiValueHeaders"]
861+
assert headers["Access-Control-Allow-Origin"] == ["https://a.com"]
862+
assert headers["Vary"] == ["Origin"]
863+
864+
865+
def test_cors_keeps_existing_vary_header_when_adding_origin():
866+
# GIVEN a route that already sets a Vary header
867+
app = ApiGatewayResolver(cors=CORSConfig(allow_origin="https://a.com"))
868+
869+
@app.get("/cors")
870+
def get_with_vary() -> Response:
871+
return Response(200, content_types.APPLICATION_JSON, "{}", headers={"Vary": "Accept-Encoding"})
872+
873+
# WHEN calling the event handler with the allowed origin
874+
event = {"path": "/cors", "httpMethod": "GET", "headers": {"Origin": "https://a.com"}}
875+
result = app(event, None)
876+
877+
# THEN Origin is added to the existing Vary value instead of replacing it
878+
assert result["multiValueHeaders"]["Vary"] == ["Accept-Encoding, Origin"]
879+
880+
881+
def test_cors_does_not_add_vary_origin_for_wildcard_origin():
882+
# GIVEN a cors configuration that allows every origin
883+
app = ApiGatewayResolver(cors=CORSConfig(allow_origin="*"))
884+
885+
@app.get("/cors")
886+
def get_with_cors():
887+
return {}
888+
889+
# WHEN calling the event handler with any origin
890+
event = {"path": "/cors", "httpMethod": "GET", "headers": {"Origin": "https://a.com"}}
891+
result = app(event, None)
892+
893+
# THEN the wildcard response is the same for every origin, so no Vary header is needed
894+
headers = result["multiValueHeaders"]
895+
assert headers["Access-Control-Allow-Origin"] == ["*"]
896+
assert "Vary" not in headers
897+
898+
787899
def test_custom_cors_config():
788900
# GIVEN a custom cors configuration
789901
allow_header = ["foo2"]
@@ -1146,6 +1258,35 @@ def test_debug_print_event(capsys):
11461258
assert json.loads(output) == event
11471259

11481260

1261+
@pytest.mark.parametrize(
1262+
"rule, path, expected_route_keys",
1263+
[
1264+
pytest.param(".+", "/any/deep/path", {}, id="catch-all matches any path"),
1265+
pytest.param("/files/.+", "/files/reports/2026/summary.pdf", {}, id="catch-all after a static prefix"),
1266+
pytest.param(
1267+
"/users/<user_id>/files/.+",
1268+
"/users/123/files/docs/report.txt",
1269+
{"user_id": "123"},
1270+
id="catch-all after a dynamic parameter",
1271+
),
1272+
],
1273+
)
1274+
def test_route_rule_supports_documented_catch_all_regex(rule: str, path: str, expected_route_keys: dict):
1275+
# GIVEN a route that uses a regex catch-all, as shown in the "Catch-all routes" docs
1276+
app = APIGatewayRestResolver()
1277+
1278+
@app.get(rule)
1279+
def handler(**route_keys):
1280+
return route_keys
1281+
1282+
# WHEN calling the event handler with a nested path
1283+
result = app({"httpMethod": "GET", "path": path}, None)
1284+
1285+
# THEN the regex in the rule matches the whole path and dynamic parameters are still captured
1286+
assert result["statusCode"] == 200
1287+
assert json.loads(result["body"]) == expected_route_keys
1288+
1289+
11491290
def test_similar_dynamic_routes():
11501291
# GIVEN
11511292
app = ApiGatewayResolver()
@@ -1323,6 +1464,29 @@ def foo(): ...
13231464
assert response["statusCode"] == 200
13241465

13251466

1467+
@pytest.mark.parametrize(
1468+
"path",
1469+
[
1470+
pytest.param("/v1/users/v2", id="prefix pattern also appears later in the path"),
1471+
pytest.param("/users/v2", id="prefix pattern only appears later in the path"),
1472+
],
1473+
)
1474+
def test_remove_prefix_by_regex_only_strips_leading_match(path: str):
1475+
# GIVEN a regex strip_prefix that could also match a later path segment
1476+
app = ApiGatewayResolver(strip_prefixes=[re.compile(r"/v[1-9]+")])
1477+
1478+
@app.get("/users/<name>")
1479+
def get_user(name: str):
1480+
return {"name": name}
1481+
1482+
# WHEN calling the handler with the pattern repeated later in the path
1483+
response = app({"httpMethod": "GET", "path": path}, None)
1484+
1485+
# THEN only a leading match is stripped, so the route matches and keeps the later segment
1486+
assert response["statusCode"] == 200
1487+
assert json.loads(response["body"]) == {"name": "v2"}
1488+
1489+
13261490
def test_empty_path_when_using_regexes():
13271491
app = ApiGatewayResolver(strip_prefixes=[re.compile(r"/(dev|stg)")])
13281492

0 commit comments

Comments
 (0)