Repository navigation
Bug: Idempotency payload hashing is sensitive to JSON key ordering #2721
Description
Expected Behaviour
Two payloads that are the same JSON object, differing only in the order of object members, should produce the same idempotency key (and the same payload-validation hash). JSON objects are unordered (RFC 8259 §4), and the other Powertools runtimes already hash a key-order-independent form (Python: json.dumps(..., sort_keys=True); TypeScript: deepSort, see Possible Solution).
Array element order should keep mattering; only object member order is in question.
Current Behaviour
Location (aws-powertools/powertools-lambda-java @ default branch 6455f45):
BasePersistenceStore.java:301-305—generateHashhashes container nodes viadata.toString().- Used for the idempotency key (
getHashedIdempotencyKey) and the payload validation hash.
Current code:
String generateHash(JsonNode data) {
Object node;
// if array or object, use the json string representation, otherwise get the real value
if (data.isContainerNode()) {
node = data.toString();Problem: data.toString() serializes object members in the order Jackson read them, so {"name":"John","age":30} and {"age":30,"name":"John"} are hashed as two different requests, at any nesting level (including objects inside arrays). Because generateHash feeds both the idempotency key and the payload-validation hash, a retry whose producer re-serializes the payload with a different member order is not recognized as a duplicate.
This is a correctness issue in key derivation; I am not reporting an observed production incident.
Code snippet
// package software.amazon.lambda.powertools.idempotency.persistence (test scope)
ObjectMapper mapper = new ObjectMapper();
JsonNode a = mapper.readTree("{\"name\":\"John\",\"age\":30,\"city\":\"New York\"}");
JsonNode b = mapper.readTree("{\"city\":\"New York\",\"name\":\"John\",\"age\":30}");
persistenceStore.configure(IdempotencyConfig.builder().build(), null);
assertThat(persistenceStore.generateHash(a)).isEqualTo(persistenceStore.generateHash(b)); // fails
// Same through the public flow: two saveInProgress() calls produce different idempotency keys
persistenceStore.saveInProgress(a, Instant.now(), OptionalInt.empty());
String keyA = dr.getIdempotencyKey();
persistenceStore.saveInProgress(b, Instant.now(), OptionalInt.empty());
String keyB = dr.getIdempotencyKey();
assertThat(keyA).isEqualTo(keyB); // failsSteps to Reproduce
Reproduction
Complete JUnit test (drop into powertools-idempotency-core/src/test/java/.../idempotency/persistence/)
package software.amazon.lambda.powertools.idempotency.persistence;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.OptionalInt;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import software.amazon.lambda.powertools.idempotency.IdempotencyConfig;
class BasePersistenceStoreKeyOrderTest {
private DataRecord dr;
private BasePersistenceStore persistenceStore;
@BeforeEach
void setup() {
persistenceStore = new BasePersistenceStore() {
@Override
public DataRecord getRecord(String key) {
return new DataRecord(key, DataRecord.Status.INPROGRESS,
Instant.now().plus(3600, ChronoUnit.SECONDS).getEpochSecond(), "Response", null);
}
@Override public void putRecord(DataRecord r, Instant now) { dr = r; }
@Override public void updateRecord(DataRecord r) { dr = r; }
@Override public void deleteRecord(String key) { dr = null; }
};
persistenceStore.configure(IdempotencyConfig.builder().build(), null);
}
@Test
void flatObject() throws Exception {
ObjectMapper m = new ObjectMapper();
JsonNode a = m.readTree("{\"name\":\"John\",\"age\":30,\"city\":\"New York\"}");
JsonNode b = m.readTree("{\"city\":\"New York\",\"name\":\"John\",\"age\":30}");
assertThat(persistenceStore.generateHash(a)).isEqualTo(persistenceStore.generateHash(b));
}
@Test
void objectsInsideArrays() throws Exception {
ObjectMapper m = new ObjectMapper();
JsonNode a = m.readTree("{\"z\":[{\"b\":{\"d\":4,\"c\":3},\"a\":{\"f\":6,\"e\":5}}],\"a\":{\"c\":3,\"b\":2,\"a\":1}}");
JsonNode b = m.readTree("{\"a\":{\"a\":1,\"b\":2,\"c\":3},\"z\":[{\"a\":{\"e\":5,\"f\":6},\"b\":{\"c\":3,\"d\":4}}]}");
assertThat(persistenceStore.generateHash(a)).isEqualTo(persistenceStore.generateHash(b));
}
@Test
void idempotencyKey() throws Exception {
ObjectMapper m = new ObjectMapper();
JsonNode a = m.readTree("{\"name\":\"John\",\"age\":30,\"city\":\"New York\",\"address\":{\"street\":\"5th Avenue\",\"number\":123}}");
JsonNode b = m.readTree("{\"city\":\"New York\",\"name\":\"John\",\"age\":30,\"address\":{\"number\":123,\"street\":\"5th Avenue\"}}");
Instant now = Instant.now();
persistenceStore.saveInProgress(a, now, OptionalInt.empty());
String keyA = dr.getIdempotencyKey();
persistenceStore.saveInProgress(b, now, OptionalInt.empty());
assertThat(dr.getIdempotencyKey()).isEqualTo(keyA);
}
}- Check out
mainatd767ff5. - Add the test above under
powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/persistence/. mvn -pl powertools-idempotency/powertools-idempotency-core -am test -Dtest=BasePersistenceStoreKeyOrderTest -Dsurefire.failIfNoSpecifiedTests=false
Failing output (from my original run of the same four cases, class name BasePersistenceStoreDeepSortTest):
[ERROR] Failures:
[ERROR] BasePersistenceStoreDeepSortTest.generateHash_deeplyNestedWithDifferentKeyOrder_shouldProduceSameHash:112
expected: "2073fda3eb263bef532ae3efeb6e6a16"
but was: "61f65d60117022d960c77e1e317cff27"
[ERROR] BasePersistenceStoreDeepSortTest.generateHash_nestedObjectsWithDifferentKeyOrder_shouldProduceSameHash:96
expected: "163d67594b9f5cad9ee9b8f9b45f4a4c"
but was: "426e5b683a800fd39648c585066f3e53"
[ERROR] BasePersistenceStoreDeepSortTest.generateHash_objectsWithDifferentKeyOrder_shouldProduceSameHash:80
expected: "f16ad52c441d68260fa08eac10f01803"
but was: "79244cc379c265701720efc3033ec5f7"
[ERROR] BasePersistenceStoreDeepSortTest.saveInProgress_objectsWithDifferentKeyOrder_shouldProduceSameIdempotencyKey:132
expected: "testFunction#163d67594b9f5cad9ee9b8f9b45f4a4c"
but was: "testFunction#426e5b683a800fd39648c585066f3e53"
[ERROR] Tests run: 52, Failures: 4, Errors: 0, Skipped: 0Environment:
- Commit:
d767ff5(main); currentmain6455f45differs only by docs dependency bumps and apowertools-batchMultiThreadMDCchange, so the code paths above are unchanged. - Toolchain: OpenJDK 25.0.5, Apache Maven 3.9.16 (unit test, not deployed to Lambda)
Possible Solution
Already fixed in aws-powertools/powertools-lambda-typescript
PR: aws-powertools/powertools-lambda-typescript#2570 — "fix(idempotency): deep sort payload during hashing" (merged 2024-06-03T12:04:42Z, merge commit 6765f35e98e).
The TypeScript runtime had the same problem and fixed it by recursively sorting object keys (keeping array element order) before serializing and hashing, for both the idempotency key and the payload-validation hash.
How powertools-lambda-typescript fixed it:
--- a/packages/idempotency/src/deepSort.ts
+++ b/packages/idempotency/src/deepSort.ts
@@ -0,0 +1,46 @@
...
+const sortObject = (object: JSONObject): JSONObject =>
+ Object.keys(object)
+ .sort((a, b) => (a.toLowerCase() < b.toLowerCase() ? -1 : 1))
+ .reduce((acc, key) => {
+ acc[key] = deepSort(object[key]);
+
+ return acc;
+ }, {} as JSONObject);
...
+const deepSort = (data: JSONValue): JSONValue => {
+ const type = getType(data);
+ if (type === 'object') {
+ return sortObject(data as JSONObject);
+ } else if (type === 'array') {
+ return (data as JSONArray).map(deepSort);
+ }
+
+ return data;
+};--- a/packages/idempotency/src/persistence/BasePersistenceLayer.ts
+++ b/packages/idempotency/src/persistence/BasePersistenceLayer.ts
@@ -301,7 +302,7 @@ abstract class BasePersistenceLayer implements BasePersistenceLayerInterface {
}
return `${this.idempotencyKeyPrefix}#${this.generateHash(
- JSON.stringify(data)
+ JSON.stringify(deepSort(data))
)}`;
}
@@ -318,7 +319,7 @@ abstract class BasePersistenceLayer implements BasePersistenceLayerInterface {
this.#jmesPathOptions
) as JSONValue;
- return this.generateHash(JSON.stringify(data));
+ return this.generateHash(JSON.stringify(deepSort(data)));
} else {
return '';
}Suggested fix
Before hashing a container node, generateHash now builds a copy in which object members are sorted by name recursively while array elements keep their order, mirroring the TypeScript fix; scalars are hashed as before. This changes the hash for payloads whose members were not already sorted, so records written by the previous version will not match until they expire, which is worth a line in the upgrade notes:
diff --git a/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/persistence/BasePersistenceStore.java b/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/persistence/BasePersistenceStore.java
--- a/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/persistence/BasePersistenceStore.java
+++ b/powertools-idempotency/powertools-idempotency-core/src/main/java/software/amazon/lambda/powertools/idempotency/persistence/BasePersistenceStore.java
@@ -22,11 +22,13 @@ import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
+import java.util.Map;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.OptionalLong;
import java.util.Spliterator;
import java.util.Spliterators;
+import java.util.TreeMap;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
@@ -36,6 +38,9 @@ import org.slf4j.LoggerFactory;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectWriter;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.ObjectNode;
import io.burt.jmespath.Expression;
import software.amazon.lambda.powertools.idempotency.IdempotencyConfig;
@@ -300,9 +305,10 @@ public abstract class BasePersistenceStore implements PersistenceStore {
*/
String generateHash(JsonNode data) {
Object node;
- // if array or object, use the json string representation, otherwise get the real value
+ // if array or object, use the json string representation with object members sorted by name,
+ // otherwise get the real value
if (data.isContainerNode()) {
- node = data.toString();
+ node = sortObjectMembers(data).toString();
} else if (data.isTextual()) {
node = data.asText();
} else if (data.isInt()) {
@@ -328,6 +334,29 @@ public abstract class BasePersistenceStore implements PersistenceStore {
return String.format("%032x", new BigInteger(1, digest));
}
+ /**
+ * Return a copy of the provided node in which object members are sorted by name, recursively.
+ * Array elements keep their order.
+ *
+ * @param data node to copy
+ * @return canonical copy of the node
+ */
+ private static JsonNode sortObjectMembers(JsonNode data) {
+ if (data.isObject()) {
+ Map<String, JsonNode> sortedMembers = new TreeMap<>();
+ data.properties().forEach(member -> sortedMembers.put(member.getKey(), member.getValue()));
+ ObjectNode sorted = JsonNodeFactory.instance.objectNode();
+ sortedMembers.forEach((name, value) -> sorted.set(name, sortObjectMembers(value)));
+ return sorted;
+ }
+ if (data.isArray()) {
+ ArrayNode copy = JsonNodeFactory.instance.arrayNode(data.size());
+ data.forEach(element -> copy.add(sortObjectMembers(element)));
+ return copy;
+ }
+ return data;
+ }
+
@SuppressWarnings("java:S4790") // Usage of MessageDigest is OK
private MessageDigest getHashAlgorithm() {
MessageDigest hashAlgorithm;Verified on d767ff5 (OpenJDK 25.0.5, Maven 3.9.16): the test above fails without this patch and passes with it; no other test results change.
I can submit a PR for this.
Powertools for AWS Lambda (Java) version
2.11.0 (main at d767ff5; same code on current main 6455f45)
AWS Lambda function runtime
Java 25 (reproduced as a unit test on OpenJDK 25.0.5, Apache Maven 3.9.16 — not deployed to Lambda)
Debugging logs
See the failing output under Steps to Reproduce.
Related issues checked
Checked: #2718 / #2720 are about a custom key prefix and #1800 about the DynamoDB composite-key error message; none cover payload canonicalization.
Activity