Skip to content

Commit 93e577c

Browse files
fix(event_handler): route VPC Lattice requests that carry a query string (#8572)
VPC Lattice sends the query string inside the request path (V1 raw_path, V2 path) as well as parsed. VPCLatticeEvent.path and VPCLatticeEventV2.path returned that value verbatim, so a request like /echo?QS1=value1 never matched the anchored route for /echo and got a 404 on both resolvers. The same value broke strip_prefixes and Swagger's ?format=json. Strip the query in the data classes so path is the URL path without the query string, as for the other BaseProxyEvent sources. Split on the first "?" (RFC 3986), so an encoded %3F stays in the path. V1 raw_path and the raw event keep the original value. Co-authored-by: Leandro Damascena <lcdama@amazon.pt>
1 parent f58cd87 commit 93e577c

8 files changed

Lines changed: 245 additions & 3 deletions

File tree

‎aws_lambda_powertools/utilities/data_classes/vpc_lattice.py‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717

1818
class VPCLatticeEventBase(BaseProxyEvent):
19-
# is_base64_encoded and path are inherited from BaseProxyEvent class.
19+
# is_base64_encoded is inherited from BaseProxyEvent class.
2020

2121
@property
2222
def body(self) -> str:
@@ -57,7 +57,7 @@ def header_serializer(self) -> BaseHeadersSerializer:
5757
class VPCLatticeEvent(VPCLatticeEventBase):
5858
@property
5959
def raw_path(self) -> str:
60-
"""The raw VPC Lattice request path."""
60+
"""The raw VPC Lattice request path, including the query string."""
6161
return self["raw_path"]
6262

6363
@property
@@ -69,7 +69,8 @@ def is_base64_encoded(self) -> bool:
6969
# Added here for consistency with the BaseProxyEvent class
7070
@property
7171
def path(self) -> str:
72-
return self["raw_path"]
72+
"""The request path, without the query string that VPC Lattice includes in `raw_path`."""
73+
return self["raw_path"].partition("?")[0]
7374

7475
@property
7576
def query_string_parameters(self) -> dict[str, str]:
@@ -176,6 +177,11 @@ def request_context(self) -> vpcLatticeEventV2RequestContext:
176177
"""The VPC Lattice v2 Event request context."""
177178
return vpcLatticeEventV2RequestContext(self["requestContext"])
178179

180+
@property
181+
def path(self) -> str:
182+
"""The request path, without the query string that VPC Lattice includes in `path`."""
183+
return self["path"].partition("?")[0]
184+
179185
@cached_property
180186
def query_string_parameters(self) -> dict[str, str]:
181187
"""The request query string parameters.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"raw_path": "/testpath?order-id=1",
3+
"method": "GET",
4+
"headers": {
5+
"user_agent": "curl/7.64.1",
6+
"x-forwarded-for": "10.213.229.10",
7+
"host": "test-lambda-service-3908sdf9u3u.dkfjd93.vpc-lattice-svcs.us-east-2.on.aws",
8+
"accept": "*/*"
9+
},
10+
"query_string_parameters": {
11+
"order-id": "1"
12+
},
13+
"body": "eyJ0ZXN0IjogImV2ZW50In0=",
14+
"is_base64_encoded": true
15+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"version": "2.0",
3+
"path": "/echo?QS1=value1&QS1=value2&single=one",
4+
"method": "POST",
5+
"headers": {
6+
"x-amzn-requestid": ["6ccfc913-b027-4fdc-be66-a07809329ee3"],
7+
"content-type": ["application/json"],
8+
"x-forwarded-for": ["10.42.0.189"],
9+
"header1": ["value1", "value2"],
10+
"host": ["my-service-0123456789abcdef0.7d67968.vpc-lattice-svcs.eu-west-1.on.aws"]
11+
},
12+
"queryStringParameters": {
13+
"QS1": ["value1", "value2"],
14+
"single": ["one"]
15+
},
16+
"body": "{\"hello\":\"world\"}",
17+
"requestContext": {
18+
"serviceNetworkArn": "arn:aws:vpc-lattice:eu-west-1:123456789012:servicenetwork/sn-0123456789abcdef0",
19+
"serviceArn": "arn:aws:vpc-lattice:eu-west-1:123456789012:service/svc-0123456789abcdef0",
20+
"targetGroupArn": "arn:aws:vpc-lattice:eu-west-1:123456789012:targetgroup/tg-0123456789abcdef0",
21+
"identity": {
22+
"sourceVpcArn": "arn:aws:ec2:eu-west-1:123456789012:vpc/vpc-0123456789abcdef0"
23+
},
24+
"region": "eu-west-1",
25+
"timeEpoch": "1790935900260914"
26+
},
27+
"requestId": "6ccfc913-b027-4fdc-be66-a07809329ee3"
28+
}

‎tests/functional/event_handler/_pydantic/test_openapi_validation_middleware.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1255,6 +1255,42 @@ def handler3():
12551255
assert any(text in result["body"] for text in expected_error_text)
12561256

12571257

1258+
def test_validation_query_string_in_path_with_vpc_lattice_v1_resolver(gw_event_vpc_lattice_v1):
1259+
# GIVEN a VPCLatticeResolver with validation enabled
1260+
app = VPCLatticeResolver(enable_validation=True)
1261+
1262+
@app.get("/orders")
1263+
def handler(order_id: Annotated[int, Query(alias="order-id")]):
1264+
return {"order_id": order_id}
1265+
1266+
# WHEN VPC Lattice sends the query string in raw_path, and parsed in query_string_parameters
1267+
gw_event_vpc_lattice_v1["raw_path"] = "/orders?order-id=1"
1268+
gw_event_vpc_lattice_v1["query_string_parameters"] = {"order-id": "1"}
1269+
result = app(gw_event_vpc_lattice_v1, {})
1270+
1271+
# THEN the route matches and the query string parameter is validated
1272+
assert result["statusCode"] == 200
1273+
assert json.loads(result["body"]) == {"order_id": 1}
1274+
1275+
1276+
def test_validation_query_string_in_path_with_vpc_lattice_v2_resolver(gw_event_vpc_lattice):
1277+
# GIVEN a VPCLatticeV2Resolver with validation enabled
1278+
app = VPCLatticeV2Resolver(enable_validation=True)
1279+
1280+
@app.get("/orders")
1281+
def handler(order_id: Annotated[int, Query(alias="order-id")]):
1282+
return {"order_id": order_id}
1283+
1284+
# WHEN VPC Lattice sends the query string in path, and parsed in queryStringParameters
1285+
gw_event_vpc_lattice["path"] = "/orders?order-id=1"
1286+
gw_event_vpc_lattice["queryStringParameters"] = {"order-id": ["1"]}
1287+
result = app(gw_event_vpc_lattice, {})
1288+
1289+
# THEN the route matches and the query string parameter is validated
1290+
assert result["statusCode"] == 200
1291+
assert json.loads(result["body"]) == {"order_id": 1}
1292+
1293+
12581294
########### TEST WITH HEADER PARAMS
12591295
@pytest.mark.parametrize(
12601296
"handler_func, expected_status_code, expected_error_text",

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
import json
4+
35
from aws_lambda_powertools.event_handler import (
46
Response,
57
VPCLatticeResolver,
@@ -50,6 +52,42 @@ def foo():
5052
assert result["body"] == json_dump(expected)
5153

5254

55+
def test_vpclattice_event_with_query_string():
56+
# GIVEN a VPC Lattice event whose raw_path carries the query string
57+
app = VPCLatticeResolver()
58+
59+
@app.get("/testpath")
60+
def foo():
61+
return {"order_id": app.current_event.query_string_parameters["order-id"]}
62+
63+
# WHEN calling the event handler
64+
result = app(load_event("vpcLatticeEventWithQueryString.json"), {})
65+
66+
# THEN the route matches the path without the query string
67+
assert result["statusCode"] == 200
68+
assert json.loads(result["body"]) == {"order_id": "1"}
69+
70+
71+
def test_vpclattice_dynamic_route_with_query_string():
72+
# GIVEN a VPC Lattice event for a dynamic route, with the query string in raw_path
73+
app = VPCLatticeResolver()
74+
75+
@app.get("/orders/<order_id>")
76+
def get_order(order_id: str):
77+
return {"order_id": order_id, "expand": app.current_event.query_string_parameters["expand"]}
78+
79+
event = load_event("vpcLatticeEvent.json")
80+
event["raw_path"] = "/orders/42?expand=items"
81+
event["query_string_parameters"] = {"expand": "items"}
82+
83+
# WHEN calling the event handler
84+
result = app(event, {})
85+
86+
# THEN the path parameter does not include the query string
87+
assert result["statusCode"] == 200
88+
assert json.loads(result["body"]) == {"order_id": "42", "expand": "items"}
89+
90+
5391
def test_cors_preflight_body_is_empty_not_null():
5492
# GIVEN CORS is configured
5593
app = VPCLatticeResolver(cors=CORSConfig())

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
from __future__ import annotations
22

3+
import asyncio
4+
import json
5+
36
from aws_lambda_powertools.event_handler import (
47
Response,
58
VPCLatticeV2Resolver,
@@ -50,6 +53,61 @@ def foo():
5053
assert result["body"] == json_dump(expected)
5154

5255

56+
def test_vpclatticev2_event_with_query_string():
57+
# GIVEN an event captured from a VPC Lattice V2 target group, whose path carries the query string
58+
app = VPCLatticeV2Resolver()
59+
60+
@app.post("/echo")
61+
def echo():
62+
return {"query": app.current_event.query_string_parameters, "body": app.current_event.json_body}
63+
64+
# WHEN calling the event handler
65+
result = app(load_event("vpcLatticeV2EventWithQueryString.json"), {})
66+
67+
# THEN the route matches the path without the query string
68+
assert result["statusCode"] == 200
69+
assert json.loads(result["body"]) == {
70+
"query": {"QS1": "value1,value2", "single": "one"},
71+
"body": {"hello": "world"},
72+
}
73+
74+
75+
def test_vpclatticev2_dynamic_route_with_query_string():
76+
# GIVEN a VPC Lattice V2 event for a dynamic route, with the query string in path
77+
app = VPCLatticeV2Resolver()
78+
79+
@app.get("/orders/<order_id>")
80+
def get_order(order_id: str):
81+
return {"order_id": order_id, "expand": app.current_event.query_string_parameters["expand"]}
82+
83+
event = load_event("vpcLatticeV2Event.json")
84+
event["path"] = "/orders/42?expand=items"
85+
event["queryStringParameters"] = {"expand": ["items"]}
86+
87+
# WHEN calling the event handler
88+
result = app(event, {})
89+
90+
# THEN the path parameter does not include the query string
91+
assert result["statusCode"] == 200
92+
assert json.loads(result["body"]) == {"order_id": "42", "expand": "items"}
93+
94+
95+
def test_vpclatticev2_resolve_async_with_query_string():
96+
# GIVEN an async handler for a VPC Lattice V2 event whose path carries the query string
97+
app = VPCLatticeV2Resolver()
98+
99+
@app.post("/echo")
100+
async def echo():
101+
return {"single": app.current_event.query_string_parameters["single"]}
102+
103+
# WHEN resolving the event asynchronously
104+
result = asyncio.run(app.resolve_async(load_event("vpcLatticeV2EventWithQueryString.json"), {}))
105+
106+
# THEN the route matches the path without the query string
107+
assert result["statusCode"] == 200
108+
assert json.loads(result["body"]) == {"single": "one"}
109+
110+
53111
def test_cors_preflight_body_is_empty_not_null():
54112
# GIVEN CORS is configured
55113
app = VPCLatticeV2Resolver(cors=CORSConfig())

‎tests/unit/data_classes/required_dependencies/test_vpc_lattice_event.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
import pytest
4+
35
from aws_lambda_powertools.utilities.data_classes.vpc_lattice import VPCLatticeEvent
46
from tests.functional.utils import load_event
57

@@ -9,6 +11,7 @@ def test_vpc_lattice_event():
911
parsed_event = VPCLatticeEvent(raw_event)
1012

1113
assert parsed_event.raw_path == raw_event["raw_path"]
14+
assert parsed_event.path == raw_event["raw_path"]
1215
assert parsed_event.query_string_parameters["order-id"] == "1"
1316
assert parsed_event.headers["user_agent"] == "curl/7.64.1"
1417
assert parsed_event.decoded_body == '{"test": "event"}'
@@ -18,3 +21,32 @@ def test_vpc_lattice_event():
1821
assert parsed_event.query_string_parameters == raw_event["query_string_parameters"]
1922
assert parsed_event.body == raw_event["body"]
2023
assert parsed_event.is_base64_encoded == raw_event["is_base64_encoded"]
24+
25+
26+
def test_vpc_lattice_event_with_query_string():
27+
# VPC Lattice sends the query string in raw_path, and parsed in query_string_parameters
28+
raw_event = load_event("vpcLatticeEventWithQueryString.json")
29+
parsed_event = VPCLatticeEvent(raw_event)
30+
31+
assert parsed_event.path == "/testpath"
32+
assert parsed_event.raw_path == "/testpath?order-id=1"
33+
assert parsed_event.query_string_parameters == {"order-id": "1"}
34+
35+
36+
@pytest.mark.parametrize(
37+
"raw_path, expected_path",
38+
[
39+
("/?query1=value1&query2=value2", "/"),
40+
("/testpath?", "/testpath"),
41+
("/testpath?next=/orders?page=2", "/testpath"),
42+
("/files/report%3Fv2?download=true", "/files/report%3Fv2"),
43+
],
44+
ids=["root_path", "empty_query", "question_mark_in_query", "encoded_question_mark_in_path"],
45+
)
46+
def test_vpc_lattice_event_path_excludes_query_string(raw_path, expected_path):
47+
raw_event = load_event("vpcLatticeEvent.json")
48+
raw_event["raw_path"] = raw_path
49+
parsed_event = VPCLatticeEvent(raw_event)
50+
51+
assert parsed_event.path == expected_path
52+
assert parsed_event.raw_path == raw_path

‎tests/unit/data_classes/required_dependencies/test_vpc_lattice_eventv2.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,32 @@ def test_vpc_lattice_v2_event():
4444
assert parsed_event.request_context.identity.x509_san_uri is None
4545
assert parsed_event.request_context.identity.x509_subject_cn is None
4646
assert parsed_event.request_context.identity.principal_org_id is None
47+
48+
49+
def test_vpc_lattice_v2_event_with_query_string():
50+
# Event captured from a V2 target group: path carries the query string, which is also sent parsed
51+
raw_event = load_event("vpcLatticeV2EventWithQueryString.json")
52+
parsed_event = VPCLatticeEventV2(raw_event)
53+
54+
assert parsed_event.path == "/echo"
55+
assert parsed_event.raw_event["path"] == "/echo?QS1=value1&QS1=value2&single=one"
56+
assert parsed_event.query_string_parameters == {"QS1": "value1,value2", "single": "one"}
57+
58+
59+
@pytest.mark.parametrize(
60+
"event_path, expected_path",
61+
[
62+
("/?query1=value1&query2=value2", "/"),
63+
("/newpath?", "/newpath"),
64+
("/newpath?next=/orders?page=2", "/newpath"),
65+
("/files/report%3Fv2?download=true", "/files/report%3Fv2"),
66+
],
67+
ids=["root_path", "empty_query", "question_mark_in_query", "encoded_question_mark_in_path"],
68+
)
69+
def test_vpc_lattice_v2_event_path_excludes_query_string(event_path, expected_path):
70+
raw_event = load_event("vpcLatticeV2Event.json")
71+
raw_event["path"] = event_path
72+
parsed_event = VPCLatticeEventV2(raw_event)
73+
74+
assert parsed_event.path == expected_path
75+
assert parsed_event.raw_event["path"] == event_path

0 commit comments

Comments
 (0)