This is an automated email from the ASF dual-hosted git repository.
mawiesne pushed a commit to branch opennlp-2.x
in repository https://gitbox.apache.org/repos/asf/opennlp.git
The following commit(s) were added to refs/heads/opennlp-2.x by this push:
new 1b6bba291 [2.x] OPENNLP-1891: Harden Deserialization in BaseModel
(#1186)
1b6bba291 is described below
commit 1b6bba291f11c3ce906830037ac4c4a7ddae7c31
Author: Richard Zowalla <[email protected]>
AuthorDate: Sat Jul 18 13:45:10 2026 +0200
[2.x] OPENNLP-1891: Harden Deserialization in BaseModel (#1186)
* Backport of #1185 to the 2.x branch. Adds a filtered
BaseModel.deserialize(Class, InputStream)
helper that installs an ObjectInputFilter allowlist before reading, since a
filter cannot be
installed from within readObject for the top-level object.
* Javadoc adapted for 2.x, which has
no SvmDoccatModel.
* Train test model on demand instead of using a 1.x model file
---------
Co-authored-by: subbudvk <[email protected]>
---
.../java/opennlp/tools/util/model/BaseModel.java | 40 +++++++++-
.../opennlp/tools/util/model/BaseModelTest.java | 90 ++++++++++++++++++++++
2 files changed, 129 insertions(+), 1 deletion(-)
diff --git
a/opennlp-tools/src/main/java/opennlp/tools/util/model/BaseModel.java
b/opennlp-tools/src/main/java/opennlp/tools/util/model/BaseModel.java
index 35e477634..5660a3a33 100644
--- a/opennlp-tools/src/main/java/opennlp/tools/util/model/BaseModel.java
+++ b/opennlp-tools/src/main/java/opennlp/tools/util/model/BaseModel.java
@@ -24,6 +24,7 @@ import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
+import java.io.ObjectInputFilter;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.OutputStream;
@@ -685,9 +686,46 @@ public abstract class BaseModel implements
ArtifactProvider, Serializable {
this.serialize(out);
}
+ /**
+ * Allowlist for classes that may legitimately appear while deserializing a
+ * {@link BaseModel} via the standard {@link Serializable} mechanism.
+ */
+ private static final ObjectInputFilter DESERIALIZE_FILTER =
ObjectInputFilter.Config.createFilter(
+ "opennlp.tools.**;" +
+ "java.util.HashMap;" +
+ "java.lang.String;" +
+ "!*");
+
+ /**
+ * Deserializes a {@link BaseModel} from the given {@link InputStream} using
the
+ * standard {@link Serializable} mechanism, with {@link #DESERIALIZE_FILTER}
applied
+ * to reject any class outside the allowlist - including the top-level
object itself.
+ * <p>
+ * Prefer this method (or the {@code BaseModel(String,
InputStream|File|Path|URL)}
+ * constructors) over calling {@code new ObjectInputStream(in).readObject()}
directly,
+ * since a filter installed inside {@link #readObject} cannot protect the
top-level
+ * object - the JDK only allows an {@link ObjectInputFilter} to be set on a
stream
+ * before the first object is read.
+ *
+ * @param modelClass The expected {@link BaseModel} subclass.
+ * @param in The {@link InputStream} to read from. Must not be {@code null}.
+ * @param <T> The type of the expected model.
+ * @return A valid {@link BaseModel} instance of type {@code T}.
+ *
+ * @throws IOException Thrown if IO errors occurred, including a rejection by
+ * {@link #DESERIALIZE_FILTER}.
+ * @throws ClassNotFoundException Thrown if required classes are not found.
+ */
+ public static <T extends BaseModel> T deserialize(Class<T> modelClass,
InputStream in)
+ throws IOException, ClassNotFoundException {
+ try (ObjectInputStream ois = new ObjectInputStream(in)) {
+ ois.setObjectInputFilter(DESERIALIZE_FILTER);
+ return modelClass.cast(ois.readObject());
+ }
+ }
+
@Serial
private void readObject(final ObjectInputStream in) throws IOException {
-
isLoadedFromSerialized = true;
artifactSerializers = new HashMap<>();
artifactMap = new HashMap<>();
diff --git
a/opennlp-tools/src/test/java/opennlp/tools/util/model/BaseModelTest.java
b/opennlp-tools/src/test/java/opennlp/tools/util/model/BaseModelTest.java
new file mode 100644
index 000000000..641b300c0
--- /dev/null
+++ b/opennlp-tools/src/test/java/opennlp/tools/util/model/BaseModelTest.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package opennlp.tools.util.model;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InvalidClassException;
+import java.io.ObjectOutputStream;
+import java.nio.charset.StandardCharsets;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import opennlp.tools.chunker.ChunkSample;
+import opennlp.tools.chunker.ChunkSampleStream;
+import opennlp.tools.chunker.ChunkerFactory;
+import opennlp.tools.chunker.ChunkerME;
+import opennlp.tools.chunker.ChunkerModel;
+import opennlp.tools.formats.ResourceAsStreamFactory;
+import opennlp.tools.util.ObjectStream;
+import opennlp.tools.util.PlainTextByLineStream;
+import opennlp.tools.util.TrainingParameters;
+
+/**
+ * Tests {@link BaseModel#deserialize(Class, java.io.InputStream)} and the
+ * {@link java.io.ObjectInputFilter} it installs.
+ */
+public class BaseModelTest {
+
+ private static ChunkerModel model;
+
+ @BeforeAll
+ static void trainModel() throws IOException {
+ ResourceAsStreamFactory in = new
ResourceAsStreamFactory(BaseModelTest.class,
+ "/opennlp/tools/chunker/test.txt");
+
+ ObjectStream<ChunkSample> sampleStream = new ChunkSampleStream(
+ new PlainTextByLineStream(in, StandardCharsets.UTF_8));
+
+ TrainingParameters params = new TrainingParameters();
+ params.put(TrainingParameters.ITERATIONS_PARAM, 5);
+ params.put(TrainingParameters.CUTOFF_PARAM, 1);
+
+ model = ChunkerME.train("eng", sampleStream, params, new ChunkerFactory());
+ }
+
+ @Test
+ void testDeserializeRoundTrip() throws Exception {
+
+ final ByteArrayOutputStream bytesOut = new ByteArrayOutputStream();
+ try (ObjectOutputStream oos = new ObjectOutputStream(bytesOut)) {
+ oos.writeObject(model);
+ }
+
+ final ChunkerModel deserialized = BaseModel.deserialize(
+ ChunkerModel.class, new ByteArrayInputStream(bytesOut.toByteArray()));
+ Assertions.assertNotNull(deserialized);
+ Assertions.assertEquals(model.getLanguage(), deserialized.getLanguage());
+ }
+
+ @Test
+ void testDeserializeRejectsDisallowedTopLevelClass() throws Exception {
+ final ByteArrayOutputStream bytesOut = new ByteArrayOutputStream();
+ try (ObjectOutputStream oos = new ObjectOutputStream(bytesOut)) {
+ // Not on the DESERIALIZE_FILTER allowlist - simulates an attacker
substituting
+ // an entirely different top-level class for the expected model.
+ oos.writeObject(new java.util.ArrayList<>());
+ }
+
+ Assertions.assertThrows(InvalidClassException.class, () ->
+ BaseModel.deserialize(ChunkerModel.class, new
ByteArrayInputStream(bytesOut.toByteArray())));
+ }
+}