diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosItemSerializerTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosItemSerializerTest.java index fa2f7cbda337..b2612fa7f152 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosItemSerializerTest.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosItemSerializerTest.java @@ -6,6 +6,7 @@ package com.azure.cosmos; +import com.azure.cosmos.implementation.DefaultCosmosItemSerializer; import com.azure.cosmos.implementation.ImplementationBridgeHelpers; import com.azure.cosmos.implementation.TestConfigurations; import com.azure.cosmos.models.CosmosBatch; @@ -28,6 +29,7 @@ import com.azure.cosmos.models.FeedResponse; import com.azure.cosmos.models.PartitionKey; import com.azure.cosmos.rx.TestSuiteBase; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.DeserializationFeature; @@ -272,6 +274,42 @@ public void pointOperationsAndQueryWithObjectNode(CosmosItemSerializer requestLe ObjectNode.class); } + @Test(groups = { "fast", "emulator" }, dataProvider = "testConfigs_requestLevelSerializer", timeOut = TIMEOUT * 1000000) + public void replaceItemRespectsCustomObjectMapper(CosmosItemSerializer ignored) { + ObjectMapper customMapper = new ObjectMapper() + .setSerializationInclusion(JsonInclude.Include.NON_NULL); + CosmosItemSerializer customSerializer = new DefaultCosmosItemSerializer(customMapper); + CosmosItemRequestOptions options = new CosmosItemRequestOptions() + .setCustomItemSerializer(customSerializer); + + String id = UUID.randomUUID().toString(); + TestDocumentWithNullableField doc = new TestDocumentWithNullableField(); + doc.id = id; + doc.mypk = id; + doc.someValue = "hello"; + doc.nullableField = null; + + container.createItem(doc, new PartitionKey(id), options); + + ObjectNode rawAfterCreate = container + .readItem(id, new PartitionKey(id), options, ObjectNode.class) + .getItem(); + assertThat(rawAfterCreate.has("nullableField")) + .as("createItem should respect NON_NULL and omit null fields") + .isFalse(); + + doc.someValue = "updated"; + container.replaceItem(doc, id, new PartitionKey(id), options); + + ObjectNode rawAfterReplace = container + .readItem(id, new PartitionKey(id), options, ObjectNode.class) + .getItem(); + assertThat(rawAfterReplace.get("someValue").asText()).isEqualTo("updated"); + assertThat(rawAfterReplace.has("nullableField")) + .as("replaceItem should respect NON_NULL and omit null fields") + .isFalse(); + } + private void runPointOperationAndQueryTestCase( T doc, String id, @@ -878,6 +916,13 @@ private static void assertSameDocument(TestDocument doc, TestDocument deserializ } } + private static class TestDocumentWithNullableField { + public String id; + public String mypk; + public String someValue; + public String nullableField; + } + private static class TestDocumentWrappedInEnvelope { public String id; diff --git a/sdk/cosmos/azure-cosmos/CHANGELOG.md b/sdk/cosmos/azure-cosmos/CHANGELOG.md index 56c20fd411c9..1ce3e5eba0e9 100644 --- a/sdk/cosmos/azure-cosmos/CHANGELOG.md +++ b/sdk/cosmos/azure-cosmos/CHANGELOG.md @@ -11,6 +11,7 @@ #### Bugs Fixed * Fixed Remote Code Execution (RCE) vulnerability (CWE-502) by replacing Java deserialization with JSON-based serialization in `CosmosClientMetadataCachesSnapshot`, `AsyncCache`, and `DocumentCollection`. The metadata cache snapshot now uses Jackson for serialization/deserialization, eliminating the entire class of Java deserialization attacks. - [PR 47971](https://github.com/Azure/azure-sdk-for-java/pull/47971) * Fixed availability strategy for Gateway V2 (thin client) by ensuring `RegionalRoutingContext` identity is based only on the immutable gateway endpoint. - See [PR 48432](https://github.com/Azure/azure-sdk-for-java/pull/48432) +* Fixed an issue where `replaceItem` bypassed the `customItemSerializer`, serialising POJOs with the SDK's internal `ObjectMapper` instead of the user-configured one. - See [PR 48529](https://github.com/Azure/azure-sdk-for-java/pull/48529) #### Other Changes * Added `appendUserAgentSuffix` method to `AsyncDocumentClient` to allow downstream libraries to append to the user agent after client construction. - See [PR 48505](https://github.com/Azure/azure-sdk-for-java/pull/48505) diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/CosmosAsyncContainer.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/CosmosAsyncContainer.java index 087a13241aa3..b3888f1bad3a 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/CosmosAsyncContainer.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/CosmosAsyncContainer.java @@ -386,12 +386,12 @@ private static void mergeDiagnostics( private Mono> replaceItemWithTrackingId(Class itemType, String itemId, - Document doc, + Object item, RequestOptions requestOptions, String trackingId) { checkNotNull(trackingId, "Argument 'trackingId' must not be null."); - return replaceItemInternalCore(itemType, itemId, doc, requestOptions, trackingId) + return replaceItemInternalCore(itemType, itemId, item, requestOptions, trackingId) .onErrorResume(throwable -> { Throwable error = throwable instanceof CompletionException ? throwable.getCause() : throwable; @@ -1737,7 +1737,6 @@ public Mono> replaceItem(T item, String itemId, Partit public Mono> replaceItem( T item, String itemId, PartitionKey partitionKey, CosmosItemRequestOptions options) { - Document doc = InternalObjectNode.fromObject(item); if (options == null) { options = new CosmosItemRequestOptions(); } @@ -1745,7 +1744,7 @@ public Mono> replaceItem( @SuppressWarnings("unchecked") Class itemType = (Class) item.getClass(); final CosmosItemRequestOptions requestOptions = options; - return withContext(context -> replaceItemInternal(itemType, itemId, doc, requestOptions, context)); + return withContext(context -> replaceItemInternal(itemType, itemId, item, requestOptions, context)); } /** @@ -2228,7 +2227,7 @@ private Mono> deleteAllItemsByPartitionKeyInternal( private Mono> replaceItemInternalCore( Class itemType, String itemId, - Document doc, + Object item, RequestOptions requestOptions, String trackingId) { @@ -2236,7 +2235,7 @@ private Mono> replaceItemInternalCore( return this.getDatabase() .getDocClientWrapper() - .replaceDocument(getItemLink(itemId), doc, requestOptions) + .replaceDocument(getItemLink(itemId), item, requestOptions) .map(response -> itemResponseAccessor.createCosmosItemResponse(response, itemType, requestOptions.getEffectiveItemSerializer())) .single(); } @@ -2261,7 +2260,7 @@ private RequestOptions getEffectiveOptions( private Mono> replaceItemInternal( Class itemType, String itemId, - Document doc, + Object item, CosmosItemRequestOptions options, Context context) { @@ -2280,9 +2279,9 @@ private Mono> replaceItemInternal( String trackingId = null; if (nonIdempotentWriteRetryPolicy.isEnabled() && nonIdempotentWriteRetryPolicy.useTrackingIdProperty()) { trackingId = UUIDs.nonBlockingRandomUUID().toString(); - responseMono = this.replaceItemWithTrackingId(itemType, itemId, doc, requestOptions, trackingId); + responseMono = this.replaceItemWithTrackingId(itemType, itemId, item, requestOptions, trackingId); } else { - responseMono = this.replaceItemInternalCore(itemType, itemId, doc, requestOptions, null); + responseMono = this.replaceItemInternalCore(itemType, itemId, item, requestOptions, null); } CosmosAsyncClient client = database diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/RxDocumentClientImpl.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/RxDocumentClientImpl.java index 82de0e77bdfa..5555b3da671c 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/RxDocumentClientImpl.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/RxDocumentClientImpl.java @@ -3277,12 +3277,23 @@ private Mono> replaceDocumentInternal( throw new IllegalArgumentException("document"); } - Document typedDocument = Document.fromObject(document, options.getEffectiveItemSerializer()); + Document typedDocument; + boolean itemAlreadySerialized = false; + + if (document instanceof Document) { + typedDocument = (Document) document; + } else if (document instanceof byte[]) { + typedDocument = new Document((byte[]) document); + } else { + typedDocument = Document.fromObject(document, options.getEffectiveItemSerializer()); + itemAlreadySerialized = true; + } return this.replaceDocumentInternal( documentLink, typedDocument, options, + itemAlreadySerialized, retryPolicyInstance, clientContextOverride, requestReference, @@ -3358,6 +3369,7 @@ private Mono> replaceDocumentInternal( document.getSelfLink(), document, options, + false, retryPolicyInstance, clientContextOverride, requestReference, @@ -3373,6 +3385,7 @@ private Mono> replaceDocumentInternal( String documentLink, Document document, RequestOptions options, + boolean itemAlreadySerialized, DocumentClientRetryPolicy retryPolicyInstance, DiagnosticsClientContext clientContextOverride, AtomicReference requestReference, @@ -3396,7 +3409,13 @@ private Mono> replaceDocumentInternal( } } - ByteBuffer content = document.serializeJsonToByteBuffer(options.getEffectiveItemSerializer(), onAfterSerialization, false); + // When the item was already serialized via Document.fromObject with the effective + // serializer, use DEFAULT_SERIALIZER to convert the Document's propertyBag to bytes + // without re-applying the custom serializer (which would double-serialize). + CosmosItemSerializer serializerForContent = itemAlreadySerialized + ? CosmosItemSerializer.DEFAULT_SERIALIZER + : options.getEffectiveItemSerializer(); + ByteBuffer content = document.serializeJsonToByteBuffer(serializerForContent, onAfterSerialization, false); Instant serializationEndTime = Instant.now(); SerializationDiagnosticsContext.SerializationDiagnostics serializationDiagnostics = new SerializationDiagnosticsContext.SerializationDiagnostics(