Skip to content

Commit 812ab3f

Browse files
authored
fix(idempotency): validate before saving to cache (aws-powertools#3822)
* fix(parameters): make cache aware of single vs multiple calls Signed-off-by: heitorlessa <[email protected]> * chore: cleanup, add test for single and nested Signed-off-by: heitorlessa <[email protected]> * refactor: validate before saving to cache * fix: use multiprocess LIB to allow local functions by using dill over pickle * chore: add test to prevent regression Signed-off-by: heitorlessa <[email protected]> --------- Signed-off-by: heitorlessa <[email protected]>
1 parent 8ed384d commit 812ab3f

File tree

6 files changed

+118
-40
lines changed

6 files changed

+118
-40
lines changed

aws_lambda_powertools/utilities/idempotency/persistence/base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""
22
Persistence layers supporting idempotency
33
"""
4+
45
import datetime
56
import hashlib
67
import json
@@ -383,9 +384,9 @@ def get_record(self, data: Dict[str, Any]) -> Optional[DataRecord]:
383384

384385
record = self._get_record(idempotency_key=idempotency_key)
385386

387+
self._validate_payload(data_payload=data, stored_data_record=record)
386388
self._save_to_cache(data_record=record)
387389

388-
self._validate_payload(data_payload=data, stored_data_record=record)
389390
return record
390391

391392
@abstractmethod

aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,10 +259,10 @@ def _put_record(self, data_record: DataRecord) -> None:
259259
f"expiry_timestamp: {old_data_record.expiry_timestamp}, "
260260
f"and in_progress_expiry_timestamp: {old_data_record.in_progress_expiry_timestamp}",
261261
)
262-
self._save_to_cache(data_record=old_data_record)
263262

264263
try:
265264
self._validate_payload(data_payload=data_record, stored_data_record=old_data_record)
265+
self._save_to_cache(data_record=old_data_record)
266266
except IdempotencyValidationError as idempotency_validation_error:
267267
raise idempotency_validation_error from exc
268268

poetry.lock

Lines changed: 40 additions & 23 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ retry2 = "^0.9.5"
120120
pytest-socket = ">=0.6,<0.8"
121121
types-redis = "^4.6.0.7"
122122
testcontainers = { extras = ["redis"], version = "^3.7.1" }
123+
multiprocess = "^0.70.16"
123124

124125
[tool.coverage.run]
125126
source = ["aws_lambda_powertools"]

tests/functional/idempotency/persistence/test_redis_layer.py

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,33 @@
11
# ruff: noqa
22
import copy
3+
import datetime
34
import json
45
import time as t
6+
from unittest import mock
57

68
import pytest
7-
from unittest.mock import patch
8-
9-
from aws_lambda_powertools.utilities.idempotency.persistence.redis import (
10-
RedisCachePersistenceLayer,
11-
)
12-
import datetime
9+
from multiprocess import Lock, Manager, Process
1310

14-
from aws_lambda_powertools.utilities.idempotency.persistence.base import (
15-
STATUS_CONSTANTS,
16-
DataRecord,
17-
)
18-
19-
from unittest import mock
20-
from multiprocessing import Process, Manager, Lock
2111
from aws_lambda_powertools.utilities.idempotency.exceptions import (
2212
IdempotencyAlreadyInProgressError,
2313
IdempotencyItemAlreadyExistsError,
2414
IdempotencyItemNotFoundError,
25-
IdempotencyPersistenceConnectionError,
2615
IdempotencyPersistenceConfigError,
16+
IdempotencyPersistenceConnectionError,
2717
IdempotencyPersistenceConsistencyError,
2818
IdempotencyValidationError,
2919
)
3020
from aws_lambda_powertools.utilities.idempotency.idempotency import (
21+
IdempotencyConfig,
3122
idempotent,
3223
idempotent_function,
33-
IdempotencyConfig,
24+
)
25+
from aws_lambda_powertools.utilities.idempotency.persistence.base import (
26+
STATUS_CONSTANTS,
27+
DataRecord,
28+
)
29+
from aws_lambda_powertools.utilities.idempotency.persistence.redis import (
30+
RedisCachePersistenceLayer,
3431
)
3532

3633
redis_badhost = "badhost"
@@ -557,6 +554,7 @@ def test_redis_orphan_record_race_condition(lambda_context):
557554
port="63005",
558555
mock_latency_ms=50,
559556
)
557+
560558
manager = Manager()
561559
# use a thread safe dict
562560
redis_client.expire_dict = manager.dict()
@@ -576,11 +574,13 @@ def lambda_handler(event, context):
576574

577575
# run handler for the first time to create a valid record in cache
578576
lambda_handler(mock_event, lambda_context)
577+
579578
# modify the cache expiration to create the orphan record
580579
for key, item in redis_client.cache.items():
581580
json_dict = json.loads(item)
582581
json_dict["expiration"] = int(t.time()) - 4000
583582
redis_client.cache[key] = json.dumps(json_dict).encode()
583+
584584
# Given orphan idempotency record with same payload already in Redis
585585
# When running two lambda handler at the same time
586586
redis_client.cache["exec_count"] = 0
@@ -590,6 +590,7 @@ def lambda_handler(event, context):
590590
p2.start()
591591
p1.join()
592592
p2.join()
593+
593594
# Then only one handler will actually run
594595
assert redis_client.cache["exec_count"] == 1
595596

tests/functional/idempotency/test_idempotency.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from botocore.config import Config
1010
from pydantic import BaseModel
1111
from pytest import FixtureRequest
12+
from pytest_mock import MockerFixture
1213

1314
from aws_lambda_powertools.utilities.data_classes import (
1415
APIGatewayProxyEventV2,
@@ -1928,3 +1929,60 @@ def lambda_handler(event, context):
19281929

19291930
stubber.assert_no_pending_responses()
19301931
stubber.deactivate()
1932+
1933+
1934+
def test_idempotency_cache_with_payload_tampering(
1935+
persistence_store: DynamoDBPersistenceLayer,
1936+
timestamp_future,
1937+
lambda_context,
1938+
request: FixtureRequest,
1939+
mocker: MockerFixture,
1940+
):
1941+
# GIVEN an idempotency config with a compound idempotency key (refund, customer_id)
1942+
# AND with payload validation key to prevent tampering
1943+
1944+
cache_spy = mocker.spy(persistence_store, "_save_to_cache")
1945+
1946+
validation_key = "amount"
1947+
idempotency_config = IdempotencyConfig(
1948+
event_key_jmespath='["refund_id", "customer_id"]',
1949+
payload_validation_jmespath=validation_key,
1950+
use_local_cache=True,
1951+
)
1952+
1953+
# AND a previous transaction already processed in the persistent store
1954+
transaction = {
1955+
"refund_id": "ffd11882-d476-4598-bbf1-643f2be5addf",
1956+
"customer_id": "9e9fc440-9e65-49b5-9e71-1382ea1b1658",
1957+
"amount": 100,
1958+
}
1959+
1960+
stubber = stub.Stubber(persistence_store.client)
1961+
ddb_response = build_idempotency_put_item_response_stub(
1962+
data=transaction,
1963+
expiration=timestamp_future,
1964+
status="COMPLETED",
1965+
request=request,
1966+
validation_data=transaction[validation_key],
1967+
)
1968+
1969+
stubber.add_client_error("put_item", "ConditionalCheckFailedException", modeled_fields=ddb_response)
1970+
stubber.activate()
1971+
1972+
# AND an upcoming tampered transaction
1973+
tampered_transaction = copy.deepcopy(transaction)
1974+
tampered_transaction["amount"] = 10_000
1975+
1976+
@idempotent(config=idempotency_config, persistence_store=persistence_store)
1977+
def lambda_handler(event, context):
1978+
return event
1979+
1980+
# WHEN the tampered request is made
1981+
with pytest.raises(IdempotencyValidationError):
1982+
lambda_handler(tampered_transaction, lambda_context)
1983+
1984+
stubber.assert_no_pending_responses()
1985+
stubber.deactivate()
1986+
1987+
# THEN we should not cache a transaction that failed validation
1988+
assert cache_spy.call_count == 0

0 commit comments

Comments
 (0)