Skip to content

Bug: Idempotency payload hashing is sensitive to JSON key ordering #2721

Open

Description

@hidingmyname

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):

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); // fails

Steps 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);
    }
}
  1. Check out main at d767ff5.
  2. Add the test above under powertools-idempotency/powertools-idempotency-core/src/test/java/software/amazon/lambda/powertools/idempotency/persistence/.
  3. 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: 0

Environment:

  • Commit: d767ff5 (main); current main 6455f45 differs only by docs dependency bumps and a powertools-batch MultiThreadMDC change, 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions