Skip to content

Commit afa73c3

Browse files
Copilottvaron3Copilot
authored
Security: Fix RCE via unsafe deserialization in Cosmos metadata cache (Azure#47971)
* Initial plan * Add SafeObjectInputStream and fix unsafe deserialization vulnerabilities Co-authored-by: tvaron3 <70857381+tvaron3@users.noreply.github.com> * Add negative tests for deserialization security and update CHANGELOG Co-authored-by: tvaron3 <70857381+tvaron3@users.noreply.github.com> * Address code review feedback: improve comments and fix typo Co-authored-by: tvaron3 <70857381+tvaron3@users.noreply.github.com> * Fix code review issues: improve test assertions and add clarifying comments Co-authored-by: tvaron3 <70857381+tvaron3@users.noreply.github.com> * Address PR feedback: remove security tag, add PR link, clarify comments Co-authored-by: tvaron3 <70857381+tvaron3@users.noreply.github.com> * Fix allowlist in SafeObjectInputStream to include all transitively deserialized classes Co-authored-by: tvaron3 <70857381+tvaron3@users.noreply.github.com> * Fix RCE vulnerability by implementing SafeObjectInputStream with class allowlisting for secure deserialization * Fix RCE vulnerability by implementing SafeObjectInputStream with class allowlisting for secure deserialization * Fix RCE vulnerability in AsyncCache by implementing SafeObjectInputStream with class allowlisting for secure deserialization * Refactor serialization in Cosmos client metadata caches to use JSON instead of Java deserialization, eliminating RCE vulnerabilities * Fix missing altLink on SHARED_DATABASE_INTERNAL causing ConsistencyTests1 failure Add setAltLink for SHARED_DATABASE_INTERNAL in TestSuiteBase.beforeSuite(). The altLink was required by ConsistencyTestsBase.validateSessionContainerAfterCollectionCreateReplace which calls BridgeInternal.getAltLink(createdDatabase), but was missing from the TestSuiteBase consolidation (commit e44c129). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: tvaron3 <70857381+tvaron3@users.noreply.github.com> Co-authored-by: tvaron3 <tomas.varon1802@gmail.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 770a9d7 commit afa73c3

10 files changed

Lines changed: 308 additions & 240 deletions

File tree

sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/SerializableDocumentCollectionTests.java

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,20 @@
99
import com.azure.cosmos.models.PartitionKeyDefinition;
1010
import com.azure.cosmos.models.PartitionKeyDefinitionVersion;
1111
import com.azure.cosmos.models.SpatialSpec;
12+
import com.fasterxml.jackson.databind.node.ObjectNode;
1213
import org.testng.annotations.Test;
1314

14-
import java.io.ByteArrayInputStream;
15-
import java.io.ByteArrayOutputStream;
16-
import java.io.ObjectInputStream;
17-
import java.io.ObjectOutputStream;
1815
import java.util.ArrayList;
1916
import java.util.List;
2017
import java.util.UUID;
2118

22-
import static com.azure.cosmos.implementation.DocumentCollection.SerializableDocumentCollection;
2319
import static org.assertj.core.api.Assertions.assertThat;
20+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
2421

2522
public class SerializableDocumentCollectionTests {
2623

2724
@Test(groups = { "unit" })
28-
public void serialize_Deserialize() throws Exception {
25+
public void serialize_Deserialize_ViaJson() throws Exception {
2926
DocumentCollection collection = new DocumentCollection();
3027
PartitionKeyDefinition partitionKeyDefinition = new PartitionKeyDefinition();
3128
partitionKeyDefinition.setPaths(ImmutableList.of("/mypk"));
@@ -40,25 +37,33 @@ public void serialize_Deserialize() throws Exception {
4037
String altLink = UUID.randomUUID().toString();
4138
collection.setAltLink(altLink);
4239

43-
SerializableDocumentCollection serializableDocumentCollection = SerializableDocumentCollection.from(collection);
40+
// Serialize to JSON ObjectNode
41+
ObjectNode serialized = collection.toSerializableObjectNode();
4442

45-
// serialize
46-
ByteArrayOutputStream baos = new ByteArrayOutputStream();
47-
ObjectOutputStream objectOutputStream = new ObjectOutputStream(baos);
48-
objectOutputStream.writeObject(serializableDocumentCollection);
49-
objectOutputStream.flush();
50-
objectOutputStream.close();
43+
// Deserialize from JSON ObjectNode
44+
DocumentCollection deserialized = DocumentCollection.fromSerializableObjectNode(serialized);
5145

52-
// deserialize
53-
byte[] bytes = baos.toByteArray();
54-
ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
55-
ObjectInputStream ois = new ObjectInputStream(bais);
56-
SerializableDocumentCollection deserializedDocumentCollection = (SerializableDocumentCollection) ois.readObject();
57-
58-
// compare
59-
assertThat(deserializedDocumentCollection.getWrappedItem().getAltLink())
46+
// Compare
47+
assertThat(deserialized.getAltLink())
6048
.isEqualTo(collection.getAltLink())
6149
.isEqualTo(altLink);
62-
assertThat(deserializedDocumentCollection.getWrappedItem().toJson()).isEqualTo(collection.toJson());
50+
assertThat(deserialized.toJson()).isEqualTo(collection.toJson());
51+
}
52+
53+
@Test(groups = { "unit" })
54+
public void fromSerializableObjectNode_NullNode_ThrowsException() {
55+
assertThatThrownBy(() -> DocumentCollection.fromSerializableObjectNode(null))
56+
.isInstanceOf(IllegalArgumentException.class)
57+
.hasMessageContaining("must not be null");
58+
}
59+
60+
@Test(groups = { "unit" })
61+
public void fromSerializableObjectNode_MissingFields_ThrowsException() {
62+
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
63+
ObjectNode emptyNode = mapper.createObjectNode();
64+
65+
assertThatThrownBy(() -> DocumentCollection.fromSerializableObjectNode(emptyNode))
66+
.isInstanceOf(IllegalArgumentException.class)
67+
.hasMessageContaining("col");
6368
}
6469
}

sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/caches/SerializableAsyncCacheTest.java

Lines changed: 104 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT License.
33
package com.azure.cosmos.implementation.caches;
44

5+
import com.azure.cosmos.implementation.CosmosClientMetadataCachesSnapshot;
56
import com.azure.cosmos.implementation.DocumentCollection;
67
import com.azure.cosmos.implementation.apachecommons.lang.RandomStringUtils;
78
import com.azure.cosmos.implementation.directconnectivity.ReflectionUtils;
@@ -14,47 +15,40 @@
1415
import org.testng.annotations.Test;
1516
import reactor.core.publisher.Mono;
1617

17-
import java.io.ByteArrayInputStream;
18-
import java.io.ByteArrayOutputStream;
19-
import java.io.IOException;
20-
import java.io.ObjectInputStream;
21-
import java.io.ObjectOutputStream;
2218
import java.util.ArrayList;
2319
import java.util.List;
2420
import java.util.UUID;
2521
import java.util.concurrent.ConcurrentHashMap;
2622

27-
import static com.azure.cosmos.implementation.caches.AsyncCache.SerializableAsyncCache;
28-
import static com.azure.cosmos.implementation.caches.AsyncCache.SerializableAsyncCache.SerializableAsyncCollectionCache;
2923
import static org.assertj.core.api.Assertions.assertThat;
3024

3125
@SuppressWarnings("unchecked")
3226
public class SerializableAsyncCacheTest {
3327

3428
@Test(groups = { "unit" }, dataProvider = "numberOfCollections")
35-
public void serialize_Deserialize_AsyncCache(int cnt) throws Exception {
29+
public void serialize_Deserialize_AsyncCache_ViaJson(int cnt) throws Exception {
30+
// This test exercises the full production serialization/deserialization path
31+
// through CosmosClientMetadataCachesSnapshot, which now uses JSON.
3632
AsyncCache<String, DocumentCollection> collectionInfoByNameCache = new AsyncCache<>();
3733

3834
for (int i = 0; i < cnt; i++) {
3935
DocumentCollection collectionDef = generateDocumentCollectionDefinition();
4036
String collectionLink = "db/mydb/colls/" + collectionDef.getId();
41-
42-
// populate the cache
4337
collectionInfoByNameCache.getAsync(collectionLink, null,
4438
() -> Mono.just(collectionDef)).block();
4539
}
4640

4741
ConcurrentHashMap<String, AsyncLazy<DocumentCollection>> originalInternalCache =
4842
getInternalCache(collectionInfoByNameCache);
4943

50-
// serialize
51-
SerializableAsyncCache<String, DocumentCollection> serializableAsyncCache =
52-
SerializableAsyncCollectionCache.from(collectionInfoByNameCache, String.class, DocumentCollection.class);
53-
byte[] bytes = serializeObject(serializableAsyncCache);
44+
// Serialize through CosmosClientMetadataCachesSnapshot (now uses JSON)
45+
CosmosClientMetadataCachesSnapshot snapshot = new CosmosClientMetadataCachesSnapshot();
46+
snapshot.serializeCollectionInfoByNameCache(collectionInfoByNameCache);
47+
48+
// Deserialize through CosmosClientMetadataCachesSnapshot (JSON parsing + validation)
49+
AsyncCache<String, DocumentCollection> newAsyncCache = snapshot.getCollectionInfoByNameCache();
50+
assertThat(newAsyncCache).isNotNull();
5451

55-
// deserialize
56-
SerializableAsyncCollectionCache serializableAsyncCollectionCache = deserializeObject(bytes);
57-
AsyncCache<String, DocumentCollection> newAsyncCache = serializableAsyncCollectionCache.toAsyncCache();
5852
ConcurrentHashMap<String, AsyncLazy<DocumentCollection>> newInternalCache =
5953
getInternalCache(newAsyncCache);
6054

@@ -71,28 +65,104 @@ public void serialize_Deserialize_AsyncCache(int cnt) throws Exception {
7165
}
7266
}
7367

74-
@DataProvider
75-
public static Object[][] numberOfCollections() {
76-
return new Object[][] {
77-
{ 0 },
78-
{ 1 },
79-
{ 100 } };
68+
@Test(groups = { "unit" })
69+
public void deserialize_InvalidJson_ReturnsNull() {
70+
CosmosClientMetadataCachesSnapshot snapshot = new CosmosClientMetadataCachesSnapshot();
71+
snapshot.collectionInfoByNameCache = "not valid json {{{".getBytes();
72+
73+
AsyncCache<String, DocumentCollection> result = snapshot.getCollectionInfoByNameCache();
74+
assertThat(result).isNull();
8075
}
8176

82-
@SuppressWarnings("unchecked")
83-
private <T> T deserializeObject(byte[] objectSerializedAsBytes) throws IOException, ClassNotFoundException {
84-
ByteArrayInputStream bais = new ByteArrayInputStream(objectSerializedAsBytes);
85-
ObjectInputStream ois = new ObjectInputStream(bais);
77+
@Test(groups = { "unit" })
78+
public void deserialize_WrongVersion_ReturnsNull() {
79+
CosmosClientMetadataCachesSnapshot snapshot = new CosmosClientMetadataCachesSnapshot();
80+
snapshot.collectionInfoByNameCache = "{\"version\":999,\"entries\":{}}".getBytes();
8681

87-
return (T) ois.readObject();
82+
AsyncCache<String, DocumentCollection> result = snapshot.getCollectionInfoByNameCache();
83+
assertThat(result).isNull();
8884
}
8985

90-
private byte[] serializeObject(Object object) throws IOException {
91-
ByteArrayOutputStream baos = new ByteArrayOutputStream();
92-
ObjectOutputStream objectOutputStream = new ObjectOutputStream(baos);
93-
objectOutputStream.writeObject(object);
86+
@Test(groups = { "unit" })
87+
public void deserialize_MissingEntries_ReturnsNull() {
88+
CosmosClientMetadataCachesSnapshot snapshot = new CosmosClientMetadataCachesSnapshot();
89+
snapshot.collectionInfoByNameCache = "{\"version\":1}".getBytes();
90+
91+
AsyncCache<String, DocumentCollection> result = snapshot.getCollectionInfoByNameCache();
92+
assertThat(result).isNull();
93+
}
94+
95+
@Test(groups = { "unit" })
96+
public void deserialize_NullBytes_ReturnsNull() {
97+
CosmosClientMetadataCachesSnapshot snapshot = new CosmosClientMetadataCachesSnapshot();
98+
snapshot.collectionInfoByNameCache = null;
99+
100+
AsyncCache<String, DocumentCollection> result = snapshot.getCollectionInfoByNameCache();
101+
assertThat(result).isNull();
102+
}
103+
104+
@Test(groups = { "unit" })
105+
public void deserialize_OldJavaSerializationFormat_ReturnsNull() {
106+
CosmosClientMetadataCachesSnapshot snapshot = new CosmosClientMetadataCachesSnapshot();
107+
// Java serialization magic bytes (0xACED) are not valid JSON
108+
snapshot.collectionInfoByNameCache = new byte[] { (byte) 0xAC, (byte) 0xED, 0x00, 0x05 };
109+
110+
AsyncCache<String, DocumentCollection> result = snapshot.getCollectionInfoByNameCache();
111+
assertThat(result).isNull();
112+
}
113+
114+
@Test(groups = { "unit" }, dataProvider = "numberOfCollections")
115+
public void serialize_Deserialize_PreservesCollectionRidComparer(int cnt) throws Exception {
116+
// Verify that after JSON round-trip, the cache uses CollectionRidComparer
117+
AsyncCache<String, DocumentCollection> cache = new AsyncCache<>(
118+
new RxCollectionCache.CollectionRidComparer());
119+
120+
List<String> keys = new ArrayList<>();
121+
for (int i = 0; i < cnt; i++) {
122+
DocumentCollection collectionDef = generateDocumentCollectionDefinition();
123+
collectionDef.setResourceId("rid-" + i);
124+
String collectionLink = "db/mydb/colls/" + collectionDef.getId();
125+
keys.add(collectionLink);
126+
cache.getAsync(collectionLink, null, () -> Mono.just(collectionDef)).block();
127+
}
128+
129+
CosmosClientMetadataCachesSnapshot snapshot = new CosmosClientMetadataCachesSnapshot();
130+
snapshot.serializeCollectionInfoByNameCache(cache);
131+
132+
AsyncCache<String, DocumentCollection> newCache = snapshot.getCollectionInfoByNameCache();
133+
assertThat(newCache).isNotNull();
134+
135+
if (cnt > 0) {
136+
String firstKey = keys.get(0);
137+
DocumentCollection fromNewCache = newCache.getAsync(firstKey, null,
138+
() -> Mono.error(new RuntimeException("not expected"))).block();
139+
140+
// Create a collection with the same resourceId but different content
141+
DocumentCollection sameRid = new DocumentCollection();
142+
sameRid.setResourceId(fromNewCache.getResourceId());
143+
sameRid.setId("completely-different-id");
144+
145+
// CollectionRidComparer: same resourceId = equal → triggers refresh
146+
DocumentCollection refreshed = generateDocumentCollectionDefinition();
147+
refreshed.setResourceId("new-rid");
148+
final boolean[] initCalled = { false };
149+
newCache.getAsync(firstKey, sameRid, () -> {
150+
initCalled[0] = true;
151+
return Mono.just(refreshed);
152+
}).block();
153+
154+
assertThat(initCalled[0])
155+
.as("CollectionRidComparer should treat same-resourceId as equal, triggering refresh")
156+
.isTrue();
157+
}
158+
}
94159

95-
return baos.toByteArray();
160+
@DataProvider
161+
public static Object[][] numberOfCollections() {
162+
return new Object[][] {
163+
{ 0 },
164+
{ 1 },
165+
{ 100 } };
96166
}
97167

98168
@SuppressWarnings("unchecked")
@@ -116,6 +186,4 @@ private DocumentCollection generateDocumentCollectionDefinition() {
116186

117187
return collection;
118188
}
119-
}
120-
121-
189+
}

sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/rx/TestSuiteBase.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@ public void beforeSuite() {
285285
SHARED_DATABASE_INTERNAL.setId(databaseId);
286286
SHARED_DATABASE_INTERNAL.setResourceId(databaseResourceId);
287287
SHARED_DATABASE_INTERNAL.setSelfLink(String.format("dbs/%s", databaseId));
288+
SHARED_DATABASE_INTERNAL.setAltLink(String.format("dbs/%s", databaseId));
288289

289290
SHARED_MULTI_PARTITION_COLLECTION_INTERNAL = getInternalDocumentCollection(SHARED_MULTI_PARTITION_COLLECTION, databaseId);
290291
SHARED_SINGLE_PARTITION_COLLECTION_INTERNAL = getInternalDocumentCollection(SHARED_SINGLE_PARTITION_COLLECTION, databaseId);

sdk/cosmos/azure-cosmos/CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
#### Breaking Changes
99

1010
#### Bugs Fixed
11+
* 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)
1112

1213
#### Other Changes
1314

@@ -21,7 +22,7 @@
2122
* Fixed an issue where operation failed with `400` when configured with pre-trigger or post-trigger with non-ascii character. Only impact for gateway mode. See [PR 47881](https://github.com/Azure/azure-sdk-for-java/pull/47881)
2223

2324
#### Other Changes
24-
* Added `x-ms-hub-region-processing-only` header to allow hub-region stickiness when 404 `READ SESSION NOT AVAIALBLE` is hit for Single-Writer accounts. - [PR 47631](https://github.com/Azure/azure-sdk-for-java/pull/47631)
25+
* Added `x-ms-hub-region-processing-only` header to allow hub-region stickiness when 404 `READ SESSION NOT AVAILABLE` is hit for Single-Writer accounts. - [PR 47631](https://github.com/Azure/azure-sdk-for-java/pull/47631)
2526

2627
### 4.77.0 (2026-01-26)
2728

0 commit comments

Comments
 (0)