From 5208aa585573b33b89cabf297bbb0939fddf7ba1 Mon Sep 17 00:00:00 2001 From: Anindya Chatterjee Date: Thu, 12 Nov 2026 13:40:28 +0530 Subject: [PATCH 1/2] fix(rocksdb): filter native deserialization of DBValue/DBNull index keys Replace kryo's stock JavaSerializer for DBValue/DBNull with a variant that reads through ObjectUtils.createFilteredInputStream (GHSA-463g-rx95-c9fp). Wire format unchanged. Co-Authored-By: Claude Opus 5.5 --- .../rocksdb/formatter/NitriteSerializers.java | 30 +++++++++++++- .../src/test/java/com/evil/SerialGadget.java | 20 ++++++++++ .../DBValueSerializerFilterTest.java | 39 +++++++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 nitrite-rocksdb-adapter/src/test/java/com/evil/SerialGadget.java create mode 100644 nitrite-rocksdb-adapter/src/test/java/org/dizitart/no2/rocksdb/formatter/DBValueSerializerFilterTest.java diff --git a/nitrite-rocksdb-adapter/src/main/java/org/dizitart/no2/rocksdb/formatter/NitriteSerializers.java b/nitrite-rocksdb-adapter/src/main/java/org/dizitart/no2/rocksdb/formatter/NitriteSerializers.java index aadee87e..0aa1d6c4 100644 --- a/nitrite-rocksdb-adapter/src/main/java/org/dizitart/no2/rocksdb/formatter/NitriteSerializers.java +++ b/nitrite-rocksdb-adapter/src/main/java/org/dizitart/no2/rocksdb/formatter/NitriteSerializers.java @@ -7,10 +7,13 @@ import com.esotericsoftware.kryo.kryo5.io.Output; import com.esotericsoftware.kryo.kryo5.serializers.JavaSerializer; import com.esotericsoftware.kryo.kryo5.serializers.MapSerializer; +import com.esotericsoftware.kryo.kryo5.util.ObjectMap; import org.dizitart.no2.collection.Document; import org.dizitart.no2.collection.NitriteId; import org.dizitart.no2.common.DBNull; import org.dizitart.no2.common.DBValue; +import org.dizitart.no2.common.util.ObjectUtils; +import org.dizitart.no2.exceptions.NitriteIOException; import org.dizitart.no2.index.IndexEntryKey; import org.dizitart.no2.common.Fields; import org.dizitart.no2.common.meta.Attributes; @@ -21,6 +24,7 @@ import org.dizitart.no2.index.IndexMeta; import org.dizitart.no2.store.UserCredential; +import java.io.ObjectInputStream; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -224,6 +228,28 @@ public SpatialKey read(Kryo kryo, Input input, Class aClas } } + /** + * {@link JavaSerializer} whose read side only resolves allowlisted classes (CWE-502, + * GHSA-463g-rx95-c9fp). The wire format is unchanged. + */ + static class FilteredJavaSerializer extends JavaSerializer { + @Override + public Object read(Kryo kryo, Input input, Class type) { + try { + // like JavaSerializer: one stream per kryo graph, since write shares one too + ObjectMap graphContext = kryo.getGraphContext(); + ObjectInputStream ois = (ObjectInputStream) graphContext.get(this); + if (ois == null) { + ois = ObjectUtils.createFilteredInputStream(input, null); + graphContext.put(this, ois); + } + return ois.readObject(); + } catch (Exception e) { + throw new NitriteIOException("Failed to deserialize " + type.getName(), e); + } + } + } + public static void registerAll(KryoObjectFormatter kryoObjectFormatter) { kryoObjectFormatter.registerSerializer(NitriteId.class, new NitriteIdSerializer()); kryoObjectFormatter.registerSerializer(Pair.class, new PairSerializer()); @@ -233,12 +259,12 @@ public static void registerAll(KryoObjectFormatter kryoObjectFormatter) { kryoObjectFormatter.registerSerializer(UserCredential.class, new UserCredentialSerializer()); kryoObjectFormatter.registerSerializer(Attributes.class, new AttributesSerializer()); kryoObjectFormatter.registerSerializer(Fields.class, new FieldsSerializer()); - kryoObjectFormatter.registerSerializer(DBValue.class, new JavaSerializer()); + kryoObjectFormatter.registerSerializer(DBValue.class, new FilteredJavaSerializer()); // DBNull must use the same self-describing serializer as DBValue: it appears as an // index key for null values and as the navigation probe for range scans. The java // serialized class name also makes it sort before every DBValue key bytewise, // preserving the null-first index order. - kryoObjectFormatter.registerSerializer(DBNull.class, new JavaSerializer()); + kryoObjectFormatter.registerSerializer(DBNull.class, new FilteredJavaSerializer()); kryoObjectFormatter.registerSerializer(IndexEntryKey.class, new IndexEntryKeySerializer()); kryoObjectFormatter.registerSerializer(BoundingBox.class, new BoundingBoxSerializer()); kryoObjectFormatter.registerSerializer(SpatialKey.class, new SpatialKeySerializer()); diff --git a/nitrite-rocksdb-adapter/src/test/java/com/evil/SerialGadget.java b/nitrite-rocksdb-adapter/src/test/java/com/evil/SerialGadget.java new file mode 100644 index 00000000..c0408940 --- /dev/null +++ b/nitrite-rocksdb-adapter/src/test/java/com/evil/SerialGadget.java @@ -0,0 +1,20 @@ +package com.evil; + +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.Serializable; + +/** Test gadget whose readObject records that native deserialization ran. */ +public class SerialGadget implements Serializable, Comparable { + public static volatile boolean deserialized; + + private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { + in.defaultReadObject(); + deserialized = true; + } + + @Override + public int compareTo(SerialGadget o) { + return 0; + } +} diff --git a/nitrite-rocksdb-adapter/src/test/java/org/dizitart/no2/rocksdb/formatter/DBValueSerializerFilterTest.java b/nitrite-rocksdb-adapter/src/test/java/org/dizitart/no2/rocksdb/formatter/DBValueSerializerFilterTest.java new file mode 100644 index 00000000..78301802 --- /dev/null +++ b/nitrite-rocksdb-adapter/src/test/java/org/dizitart/no2/rocksdb/formatter/DBValueSerializerFilterTest.java @@ -0,0 +1,39 @@ +package org.dizitart.no2.rocksdb.formatter; + +import com.evil.SerialGadget; +import org.dizitart.no2.common.DBNull; +import org.dizitart.no2.common.DBValue; +import org.dizitart.no2.exceptions.NitriteIOException; +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * GHSA-463g-rx95-c9fp: DBValue/DBNull index keys must not natively deserialize unlisted classes. + */ +public class DBValueSerializerFilterTest { + + @Test + public void testGadgetInIndexKeyIsRejected() { + KryoObjectFormatter formatter = new KryoObjectFormatter(); + byte[] bytes = formatter.encodeKey(new DBValue(new SerialGadget())); + + SerialGadget.deserialized = false; + try { + formatter.decodeKey(bytes, DBValue.class); + fail("unlisted class should be rejected"); + } catch (NitriteIOException expected) { + // rejected before readObject ran + } + assertFalse(SerialGadget.deserialized); + } + + @Test + public void testLegitKeysRoundTrip() { + KryoObjectFormatter formatter = new KryoObjectFormatter(); + DBValue value = new DBValue("abc"); + assertEquals(value, formatter.decodeKey(formatter.encodeKey(value), DBValue.class)); + DBNull nul = DBNull.getInstance(); + assertNotNull(formatter.decodeKey(formatter.encodeKey(nul), DBNull.class)); + } +} From 5e6e7fbd1b95af1f408187bdf6d74f350f710b62 Mon Sep 17 00:00:00 2001 From: Anindya Chatterjee Date: Thu, 12 Nov 2026 13:45:40 +0530 Subject: [PATCH 2/2] test: fail the build on any unreviewed native deserialization sink Scans every module's main sources for ObjectInputStream construction, kryo JavaSerializer/ExternalizableSerializer, XMLDecoder and readUnshared; only the four reviewed, allowlisted files may contain them. Co-Authored-By: Claude Opus 5.5 --- .../util/DeserializationSinkGuardTest.java | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 nitrite/src/test/java/org/dizitart/no2/common/util/DeserializationSinkGuardTest.java diff --git a/nitrite/src/test/java/org/dizitart/no2/common/util/DeserializationSinkGuardTest.java b/nitrite/src/test/java/org/dizitart/no2/common/util/DeserializationSinkGuardTest.java new file mode 100644 index 00000000..c22acb60 --- /dev/null +++ b/nitrite/src/test/java/org/dizitart/no2/common/util/DeserializationSinkGuardTest.java @@ -0,0 +1,58 @@ +package org.dizitart.no2.common.util; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.junit.Assert.assertTrue; + +/** + * CWE-502 guard (GHSA-9297-g93h-86gg, GHSA-7w7v-2j76-mqwp, GHSA-q86x-r2q3-vf4p, + * GHSA-463g-rx95-c9fp): every native java deserialization sink in main sources must + * go through an allowlist. A new sink fails this test until it is filtered + * (use {@link ObjectUtils#createFilteredInputStream}) and its file reviewed and listed below. + */ +public class DeserializationSinkGuardTest { + private static final Pattern SINK = Pattern.compile( + "new ObjectInputStream\\s*\\(|extends ObjectInputStream|\\bJavaSerializer\\b|\\bExternalizableSerializer\\b" + + "|XMLDecoder|readUnshared\\s*\\("); + + // reviewed: each of these installs an allowlist, or only reads bytes it just wrote itself + private static final Set REVIEWED = new HashSet<>(Arrays.asList( + "ObjectUtils.java", // createFilteredInputStream; deepCopy reads its own bytes + "FilteredObjectDataType.java", // mvstore, JEP 290 filter + "NitriteObjectInputStream.java", // legacy v1 migration, JEP 290 filter + "NitriteSerializers.java" // rocksdb FilteredJavaSerializer + )); + + @Test + public void testNoUnreviewedDeserializationSink() throws IOException { + Path root = Paths.get("").toAbsolutePath().getParent(); + List offenders; + try (Stream files = Files.walk(root)) { + offenders = files + .filter(p -> p.toString().endsWith(".java") || p.toString().endsWith(".kt")) + .filter(p -> p.toString().contains("/src/main/")) + .filter(p -> !REVIEWED.contains(p.getFileName().toString())) + .filter(DeserializationSinkGuardTest::hasSink) + .map(p -> root.relativize(p).toString()) + .collect(Collectors.toList()); + } + assertTrue("Unfiltered deserialization sink(s): " + offenders, offenders.isEmpty()); + } + + private static boolean hasSink(Path path) { + try { + return SINK.matcher(new String(Files.readAllBytes(path))).find(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +}