Skip to content

Commit 830fbcd

Browse files
Merge branch 'develop' into feat/openapi-response-fields-enhancement
2 parents cba8ed4 + 6e40b3d commit 830fbcd

File tree

7 files changed

+139
-53
lines changed

7 files changed

+139
-53
lines changed

CHANGELOG.md

Lines changed: 54 additions & 47 deletions
Large diffs are not rendered by default.

aws_lambda_powertools/utilities/parser/functions.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import logging
55
from typing import TYPE_CHECKING, Any
66

7-
from pydantic import TypeAdapter
7+
from pydantic import IPvAnyNetwork, TypeAdapter
88

99
from aws_lambda_powertools.shared.cache_dict import LRUDict
1010

@@ -82,3 +82,28 @@ def _parse_and_validate_event(data: dict[str, Any] | Any, adapter: TypeAdapter):
8282
data = json.loads(data)
8383

8484
return adapter.validate_python(data)
85+
86+
87+
def _validate_source_ip(value):
88+
"""
89+
Handle sourceIp that may come with port (e.g., "10.1.15.242:39870")
90+
in certain network configurations like Cloudflare + CloudFront + API Gateway.
91+
Validates the IP part while preserving the original format.
92+
See: https://github.com/aws-powertools/powertools-lambda-python/issues/7288
93+
"""
94+
95+
if value == "test-invoke-source-ip":
96+
return value
97+
98+
try:
99+
# The value is always an instance of str before Pydantic validation occurs.
100+
# So the first thing to do is try to convert it.
101+
IPvAnyNetwork(value)
102+
except ValueError:
103+
try:
104+
ip_part = value.split(":")[0]
105+
IPvAnyNetwork(ip_part)
106+
except (ValueError, IndexError) as e:
107+
raise ValueError(f"Invalid IP address in sourceIp: {ip_part}") from e
108+
109+
return value

aws_lambda_powertools/utilities/parser/models/apigw.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from datetime import datetime
22
from typing import Any, Dict, List, Literal, Optional, Type, Union
33

4-
from pydantic import BaseModel, model_validator
4+
from pydantic import BaseModel, field_validator, model_validator
55
from pydantic.networks import IPvAnyNetwork
66

7+
from aws_lambda_powertools.utilities.parser.functions import _validate_source_ip
8+
79

810
class ApiGatewayUserCertValidity(BaseModel):
911
notBefore: str
@@ -31,12 +33,17 @@ class APIGatewayEventIdentity(BaseModel):
3133
principalOrgId: Optional[str] = None
3234
# see #1562, temp workaround until API Gateway fixes it the Test button payload
3335
# removing it will not be considered a regression in the future
34-
sourceIp: Union[IPvAnyNetwork, Literal["test-invoke-source-ip"]]
36+
sourceIp: Union[IPvAnyNetwork, str]
3537
user: Optional[str] = None
3638
userAgent: Optional[str] = None
3739
userArn: Optional[str] = None
3840
clientCert: Optional[ApiGatewayUserCert] = None
3941

42+
@field_validator("sourceIp", mode="before")
43+
@classmethod
44+
def _validate_source_ip(cls, value):
45+
return _validate_source_ip(value=value)
46+
4047

4148
class APIGatewayEventAuthorizer(BaseModel):
4249
claims: Optional[Dict[str, Any]] = None

aws_lambda_powertools/utilities/parser/models/apigwv2.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from datetime import datetime
22
from typing import Any, Dict, List, Literal, Optional, Type, Union
33

4-
from pydantic import BaseModel, Field
4+
from pydantic import BaseModel, Field, field_validator
55
from pydantic.networks import IPvAnyNetwork
66

7+
from aws_lambda_powertools.utilities.parser.functions import _validate_source_ip
8+
79

810
class RequestContextV2AuthorizerIamCognito(BaseModel):
911
amr: List[str]
@@ -36,9 +38,14 @@ class RequestContextV2Http(BaseModel):
3638
method: Literal["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
3739
path: str
3840
protocol: str
39-
sourceIp: IPvAnyNetwork
41+
sourceIp: Union[IPvAnyNetwork, str]
4042
userAgent: str
4143

44+
@field_validator("sourceIp", mode="before")
45+
@classmethod
46+
def _validate_source_ip(cls, value):
47+
return _validate_source_ip(value=value)
48+
4249

4350
class RequestContextV2(BaseModel):
4451
accountId: str

mkdocs.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,14 @@ markdown_extensions:
200200
- pymdownx.tasklist:
201201
custom_checkbox: true
202202

203-
copyright: Copyright © 2023 Amazon Web Services
203+
copyright: |
204+
<div id="awsdocs-legal-zone-copyright">
205+
<a href="https://aws.amazon.com/privacy" target="_blank" rel="nofollow">Privacy</a> |
206+
<a href="https://aws.amazon.com/terms/" target="_blank" rel="nofollow">Site terms</a> |
207+
<span class="copyright">
208+
© 2025, Amazon Web Services, Inc. or its affiliates. All rights reserved.
209+
</span>
210+
</div>
204211
205212
plugins:
206213
- privacy

tests/unit/parser/_pydantic/test_apigw.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,21 @@ def test_apigw_event():
105105
assert identity.apiKeyId is None
106106

107107

108+
def test_apigw_event_and_source_ip_with_port():
109+
raw_event = load_event("apiGatewayProxyEvent.json")
110+
raw_event["requestContext"]["identity"]["sourceIp"] = "10.10.10.10:1235"
111+
112+
APIGatewayProxyEventModel(**raw_event)
113+
114+
115+
def test_apigw_event_and_source_ip_with_random_string():
116+
raw_event = load_event("apiGatewayProxyEvent.json")
117+
raw_event["requestContext"]["identity"]["sourceIp"] = "NON_IP_WITH_OR_WITHOUT_PORT_STRING"
118+
119+
with pytest.raises(ValidationError):
120+
APIGatewayProxyEventModel(**raw_event)
121+
122+
108123
def test_apigw_event_with_invalid_websocket_request():
109124
# GIVEN an event with an eventType != MESSAGE and has a messageId
110125
event = {

tests/unit/parser/_pydantic/test_apigwv2.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import pytest
2+
from pydantic import ValidationError
3+
14
from aws_lambda_powertools.utilities.parser import envelopes, parse
25
from aws_lambda_powertools.utilities.parser.models import (
36
ApiGatewayAuthorizerRequestV2,
@@ -71,6 +74,21 @@ def test_apigw_v2_event_empty_jwt_scopes():
7174
APIGatewayProxyEventV2Model(**raw_event)
7275

7376

77+
def test_apigw_v2_event_and_source_ip_with_port():
78+
raw_event = load_event("apiGatewayProxyV2Event.json")
79+
raw_event["requestContext"]["http"]["sourceIp"] = "10.10.10.10:1235"
80+
81+
APIGatewayProxyEventV2Model(**raw_event)
82+
83+
84+
def test_apigw_v2_event_and_source_ip_with_random_string():
85+
raw_event = load_event("apiGatewayProxyV2Event.json")
86+
raw_event["requestContext"]["http"]["sourceIp"] = "NON_IP_WITH_OR_WITHOUT_PORT_STRING"
87+
88+
with pytest.raises(ValidationError):
89+
APIGatewayProxyEventV2Model(**raw_event)
90+
91+
7492
def test_api_gateway_proxy_v2_event_lambda_authorizer():
7593
raw_event = load_event("apiGatewayProxyV2LambdaAuthorizerEvent.json")
7694
parsed_event: APIGatewayProxyEventV2Model = APIGatewayProxyEventV2Model(**raw_event)

0 commit comments

Comments
 (0)