Skip to content

Commit 6d40bc0

Browse files
committed
OPENNLP-1885: Guard tokenizer deserialization with an allow-listing ObjectInputFilter
SentencePieceTokenizer gains serialize(OutputStream) and deserialize(InputStream) methods. Reads are filtered through an ObjectInputFilter that allow-lists only the classes reachable from a legitimate tokenizer graph and bounds graph depth, references, and array length; foreign payloads are rejected with InvalidClassException before being materialised. Limits are adjustable through a DeserializationLimits record for unusually large vocabularies; the allow-list is not configurable. The serialVersionUID is recomputed for the new public methods.
1 parent 0f1e2c8 commit 6d40bc0

2 files changed

Lines changed: 286 additions & 1 deletion

File tree

opennlp-extensions/opennlp-subword/src/main/java/opennlp/subword/sentencepiece/SentencePieceTokenizer.java

Lines changed: 200 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818

1919
import java.io.IOException;
2020
import java.io.InputStream;
21+
import java.io.ObjectInputFilter;
22+
import java.io.ObjectInputStream;
23+
import java.io.ObjectOutputStream;
24+
import java.io.OutputStream;
2125
import java.nio.charset.StandardCharsets;
2226
import java.nio.file.Files;
2327
import java.nio.file.Path;
@@ -26,6 +30,7 @@
2630
import java.util.HashMap;
2731
import java.util.List;
2832
import java.util.Map;
33+
import java.util.Set;
2934
import java.util.function.IntUnaryOperator;
3035

3136
import opennlp.tools.tokenize.SubwordPiece;
@@ -46,6 +51,12 @@
4651
*
4752
* <p>Instances are immutable after loading and safe for concurrent use by multiple threads.</p>
4853
*
54+
* <p>Beyond {@link #load(Path) loading} the native {@code .model} format, a tokenizer can be
55+
* persisted with {@link #serialize(OutputStream)} and read back with
56+
* {@link #deserialize(InputStream)}. Reads are guarded by an {@link java.io.ObjectInputFilter}
57+
* that allow-lists only the classes reachable from a legitimate tokenizer graph and bounds graph
58+
* depth, references, and array length.</p>
59+
*
4960
* @see <a href="https://github.com/google/sentencepiece">SentencePiece</a>
5061
* @see <a href="https://aclanthology.org/D18-2012/">Kudo &amp; Richardson (EMNLP 2018),
5162
* "SentencePiece: A simple and language independent subword tokenizer and detokenizer for
@@ -54,7 +65,7 @@
5465
public final class SentencePieceTokenizer implements SubwordTokenizer, OffsetAwareNormalizer {
5566

5667
// Serializable through the OffsetAwareNormalizer contract.
57-
private static final long serialVersionUID = -7114394869301531147L;
68+
private static final long serialVersionUID = -4472058014098085134L;
5869

5970
/** The segmentation algorithm a model was trained with. */
6071
public enum Algorithm {
@@ -267,6 +278,194 @@ public static SentencePieceTokenizer load(InputStream in) throws IOException {
267278
return new SentencePieceTokenizer(ModelProtoReader.read(in.readAllBytes()));
268279
}
269280

281+
/**
282+
* Serializes this tokenizer to the given {@link OutputStream} using Java object serialization.
283+
* The resulting stream can be read back with {@link #deserialize(InputStream)}.
284+
*
285+
* @param out The {@link OutputStream} to write to; must not be null.
286+
* @throws IOException Thrown if IO errors occurred during serialization.
287+
* @throws IllegalArgumentException Thrown if {@code out} is null.
288+
*/
289+
public void serialize(OutputStream out) throws IOException {
290+
if (out == null) {
291+
throw new IllegalArgumentException("The output stream must not be null.");
292+
}
293+
try (ObjectOutputStream oos = new ObjectOutputStream(out)) {
294+
oos.writeObject(this);
295+
}
296+
}
297+
298+
/**
299+
* Deserializes a {@link SentencePieceTokenizer} from the given {@link InputStream} using
300+
* {@link DeserializationLimits#DEFAULT default} resource limits.
301+
*
302+
* <p>The stream is filtered via an {@link ObjectInputFilter} that allow-lists only the classes
303+
* required to reconstruct a {@link SentencePieceTokenizer}, plus resource limits on graph depth,
304+
* references, and array length. Foreign payloads are rejected with
305+
* {@link java.io.InvalidClassException} before {@link ObjectInputStream#readObject()}
306+
* returns.</p>
307+
*
308+
* <p>Callers should still treat this method as defense-in-depth: only invoke it on streams from
309+
* trusted sources. If the default limits are too tight for an unusually large model, use
310+
* {@link #deserialize(InputStream, DeserializationLimits)} to supply higher limits. The class
311+
* allow-list is intentionally not configurable; loosening it would defeat the purpose of the
312+
* filter.</p>
313+
*
314+
* @param in The {@link InputStream} to read from; must not be null.
315+
* @return The reconstructed tokenizer.
316+
* @throws IOException Thrown if IO errors occurred during deserialization, including
317+
* {@link java.io.InvalidClassException} when the stream contains a class outside the
318+
* allow-list or exceeds a resource limit.
319+
* @throws ClassNotFoundException Thrown if required classes are not found.
320+
* @throws IllegalArgumentException Thrown if {@code in} is null.
321+
*/
322+
public static SentencePieceTokenizer deserialize(InputStream in)
323+
throws IOException, ClassNotFoundException {
324+
return deserialize(in, DeserializationLimits.DEFAULT);
325+
}
326+
327+
/**
328+
* Deserializes a {@link SentencePieceTokenizer} from the given {@link InputStream} using the
329+
* supplied {@link DeserializationLimits resource limits}.
330+
*
331+
* <p>Use this overload when the {@link DeserializationLimits#DEFAULT default limits} reject a
332+
* legitimate model, for example one with a very large vocabulary. The class allow-list applied
333+
* to the stream is the same as for {@link #deserialize(InputStream)}; only the numeric limits
334+
* change.</p>
335+
*
336+
* @param in The {@link InputStream} to read from; must not be null.
337+
* @param limits The {@link DeserializationLimits} to apply; must not be null.
338+
* @return The reconstructed tokenizer.
339+
* @throws IOException Thrown if IO errors occurred during deserialization, including
340+
* {@link java.io.InvalidClassException} when the stream contains a class outside the
341+
* allow-list or exceeds one of the supplied limits.
342+
* @throws ClassNotFoundException Thrown if required classes are not found.
343+
* @throws IllegalArgumentException Thrown if {@code in} or {@code limits} is null.
344+
*/
345+
public static SentencePieceTokenizer deserialize(InputStream in, DeserializationLimits limits)
346+
throws IOException, ClassNotFoundException {
347+
if (in == null) {
348+
throw new IllegalArgumentException("The input stream must not be null.");
349+
}
350+
if (limits == null) {
351+
throw new IllegalArgumentException("The limits must not be null.");
352+
}
353+
try (ObjectInputStream ois = new ObjectInputStream(in)) {
354+
ois.setObjectInputFilter(buildFilter(limits));
355+
return (SentencePieceTokenizer) ois.readObject();
356+
}
357+
}
358+
359+
/**
360+
* Resource limits applied by the {@link ObjectInputFilter} used by
361+
* {@link SentencePieceTokenizer#deserialize(InputStream, DeserializationLimits)}.
362+
*
363+
* <p>The limits bound graph traversal regardless of the class allow-list and provide
364+
* defense-in-depth against pathological streams. The {@linkplain #DEFAULT default values} are
365+
* generous enough for typical production models; raise them only if a legitimate model is
366+
* rejected.</p>
367+
*
368+
* @param maxDepth Maximum object-graph nesting depth. Must be {@code > 0}.
369+
* @param maxRefs Maximum number of internal references the stream may create.
370+
* Must be {@code > 0}.
371+
* @param maxArrayLength Maximum length of any single array allocation requested by the stream.
372+
* Must be {@code > 0}.
373+
*/
374+
public record DeserializationLimits(long maxDepth, long maxRefs, long maxArrayLength) {
375+
376+
/**
377+
* Default limits. Sized so that models with vocabularies of several hundred thousand pieces
378+
* round-trip while pathological streams stay bounded.
379+
*/
380+
public static final DeserializationLimits DEFAULT =
381+
new DeserializationLimits(MAX_DEPTH_DEFAULT, MAX_REFS_DEFAULT, MAX_ARRAY_DEFAULT);
382+
383+
/**
384+
* Validates the limits.
385+
*
386+
* @throws IllegalArgumentException Thrown if any of {@code maxDepth}, {@code maxRefs}, or
387+
* {@code maxArrayLength} is {@code <= 0}.
388+
*/
389+
public DeserializationLimits {
390+
if (maxDepth <= 0) {
391+
throw new IllegalArgumentException("maxDepth must be > 0");
392+
}
393+
if (maxRefs <= 0) {
394+
throw new IllegalArgumentException("maxRefs must be > 0");
395+
}
396+
if (maxArrayLength <= 0) {
397+
throw new IllegalArgumentException("maxArrayLength must be > 0");
398+
}
399+
}
400+
}
401+
402+
private static final long MAX_DEPTH_DEFAULT = 64;
403+
private static final long MAX_REFS_DEFAULT = 5_000_000;
404+
private static final long MAX_ARRAY_DEFAULT = 10_000_000;
405+
406+
// Allow-list of fully qualified class names that may appear in the serialized graph of a
407+
// SentencePieceTokenizer. Anything else is rejected.
408+
private static final Set<String> ALLOWED_CLASSES = Set.of(
409+
"opennlp.subword.sentencepiece.SentencePieceTokenizer",
410+
"opennlp.subword.sentencepiece.SentencePieceTokenizer$Algorithm",
411+
"opennlp.subword.sentencepiece.SentencePieceNormalizer",
412+
"opennlp.subword.sentencepiece.UnigramEncoder",
413+
"opennlp.subword.sentencepiece.BpeEncoder",
414+
"opennlp.subword.sentencepiece.PieceTrie",
415+
"opennlp.subword.sentencepiece.DoubleArrayTrie",
416+
// JDK types used in field declarations. ObjectInputStream invokes the filter for every
417+
// class descriptor in the inheritance chain, not only for the runtime class - so the
418+
// abstract superclasses java.lang.Number (super of Integer) and java.lang.Enum (super of
419+
// Algorithm) must be allow-listed even though no instance of either appears in the stream.
420+
"java.lang.String",
421+
"java.lang.Number",
422+
"java.lang.Integer",
423+
"java.lang.Enum",
424+
"java.util.HashMap",
425+
// HashMap.readObject() requests permission to allocate a Map.Entry[] before reading
426+
// entries; the array type itself never appears as a value in the stream.
427+
"java.util.Map$Entry",
428+
// The unmodifiable lists created by List.copyOf serialize through the CollSer proxy,
429+
// which requests an Object[] allocation for the elements, and the filter is also invoked
430+
// for the concrete list class the proxy resolves to.
431+
"java.util.CollSer",
432+
"java.util.ImmutableCollections$List12",
433+
"java.util.ImmutableCollections$ListN",
434+
"java.lang.Object"
435+
);
436+
437+
/**
438+
* Builds the {@link ObjectInputFilter} enforcing the class allow-list and the given limits.
439+
*
440+
* @param limits The resource limits to enforce; never null here.
441+
* @return The filter to install on the reading {@link ObjectInputStream}.
442+
*/
443+
private static ObjectInputFilter buildFilter(DeserializationLimits limits) {
444+
return info -> {
445+
if (info.depth() > limits.maxDepth()
446+
|| info.references() > limits.maxRefs()
447+
|| info.arrayLength() > limits.maxArrayLength()) {
448+
return ObjectInputFilter.Status.REJECTED;
449+
}
450+
451+
final Class<?> serialClass = info.serialClass();
452+
if (serialClass == null) {
453+
return ObjectInputFilter.Status.UNDECIDED;
454+
}
455+
456+
Class<?> componentType = serialClass;
457+
while (componentType.isArray()) {
458+
componentType = componentType.getComponentType();
459+
}
460+
if (componentType.isPrimitive()) {
461+
return ObjectInputFilter.Status.ALLOWED;
462+
}
463+
return ALLOWED_CLASSES.contains(componentType.getName())
464+
? ObjectInputFilter.Status.ALLOWED
465+
: ObjectInputFilter.Status.REJECTED;
466+
};
467+
}
468+
270469
/** {@inheritDoc} */
271470
@Override
272471
public List<SubwordPiece> encode(CharSequence text) {

opennlp-extensions/opennlp-subword/src/test/java/opennlp/subword/sentencepiece/SentencePieceTokenizerSerializationTest.java

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,27 @@
1919
import java.io.ByteArrayInputStream;
2020
import java.io.ByteArrayOutputStream;
2121
import java.io.IOException;
22+
import java.io.InputStream;
23+
import java.io.InvalidClassException;
2224
import java.io.ObjectInputStream;
2325
import java.io.ObjectOutputStream;
26+
import java.util.ArrayList;
2427

28+
import org.junit.jupiter.api.Test;
2529
import org.junit.jupiter.params.ParameterizedTest;
2630
import org.junit.jupiter.params.provider.ValueSource;
2731

2832
import static org.junit.jupiter.api.Assertions.assertEquals;
2933
import static org.junit.jupiter.api.Assertions.assertIterableEquals;
34+
import static org.junit.jupiter.api.Assertions.assertThrows;
3035

3136
/**
3237
* Asserts the {@code Serializable} contract inherited through
3338
* {@code opennlp.tools.util.normalizer.CharSequenceNormalizer}: a tokenizer round-tripped
3439
* through Java object serialization must encode and normalize exactly like the original.
40+
* Also asserts the guarded read path of
41+
* {@link SentencePieceTokenizer#deserialize(InputStream)}: foreign payloads and streams
42+
* exceeding the resource limits are rejected before materialisation.
3543
*/
3644
class SentencePieceTokenizerSerializationTest {
3745

@@ -68,4 +76,82 @@ void testRoundTripPreservesEncoding(String model) throws IOException, ClassNotFo
6876
context + " normalized form");
6977
}
7078
}
79+
80+
/**
81+
* Serializes the tokenizer of the given fixture model through
82+
* {@link SentencePieceTokenizer#serialize(OutputStream)}.
83+
*
84+
* @param model The fixture model name.
85+
* @return The serialized bytes.
86+
*/
87+
private static byte[] serialized(String model) throws IOException {
88+
final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
89+
SentencePieceParityTest.tokenizer(model).serialize(bytes);
90+
return bytes.toByteArray();
91+
}
92+
93+
@ParameterizedTest
94+
@ValueSource(strings = {"tiny-unigram", "tiny-unigram-bytefb", "tiny-bpe",
95+
"tiny-unigram-identity", "tiny-unigram-suffix"})
96+
void testGuardedDeserializePreservesEncoding(String model)
97+
throws IOException, ClassNotFoundException {
98+
final SentencePieceTokenizer original = SentencePieceParityTest.tokenizer(model);
99+
final SentencePieceTokenizer copy =
100+
SentencePieceTokenizer.deserialize(new ByteArrayInputStream(serialized(model)));
101+
102+
assertEquals(original.algorithm(), copy.algorithm(), model + " algorithm");
103+
for (final String input : INPUTS) {
104+
final String context = model + " input <" + input + ">";
105+
assertIterableEquals(original.encode(input), copy.encode(input), context + " pieces");
106+
}
107+
}
108+
109+
/**
110+
* Verifies that a stream whose top-level object is not on the allow-list is rejected
111+
* before it is materialised, even though its classes are harmless JDK types.
112+
*/
113+
@Test
114+
void testForeignPayloadIsRejected() throws IOException {
115+
final ByteArrayOutputStream bytes = new ByteArrayOutputStream();
116+
try (ObjectOutputStream out = new ObjectOutputStream(bytes)) {
117+
final ArrayList<String> foreign = new ArrayList<>();
118+
foreign.add("not a tokenizer");
119+
out.writeObject(foreign);
120+
}
121+
assertThrows(InvalidClassException.class, () ->
122+
SentencePieceTokenizer.deserialize(new ByteArrayInputStream(bytes.toByteArray())));
123+
}
124+
125+
/**
126+
* Verifies that a legitimate stream is rejected when it exceeds the supplied resource
127+
* limits, so the limits bound the graph regardless of the class allow-list.
128+
*/
129+
@Test
130+
void testStreamExceedingLimitsIsRejected() throws IOException {
131+
final byte[] legitimate = serialized("tiny-unigram");
132+
final SentencePieceTokenizer.DeserializationLimits tight =
133+
new SentencePieceTokenizer.DeserializationLimits(1, 1, 1);
134+
assertThrows(InvalidClassException.class, () ->
135+
SentencePieceTokenizer.deserialize(new ByteArrayInputStream(legitimate), tight));
136+
}
137+
138+
/**
139+
* Verifies that null arguments are rejected with {@link IllegalArgumentException} at the
140+
* API boundary.
141+
*/
142+
@Test
143+
void testNullArgumentsAreRejected() throws IOException {
144+
final SentencePieceTokenizer tokenizer = SentencePieceParityTest.tokenizer("tiny-unigram");
145+
assertThrows(IllegalArgumentException.class, () -> tokenizer.serialize(null));
146+
assertThrows(IllegalArgumentException.class, () ->
147+
SentencePieceTokenizer.deserialize(null));
148+
assertThrows(IllegalArgumentException.class, () ->
149+
SentencePieceTokenizer.deserialize(new ByteArrayInputStream(new byte[0]), null));
150+
assertThrows(IllegalArgumentException.class, () ->
151+
new SentencePieceTokenizer.DeserializationLimits(0, 1, 1));
152+
assertThrows(IllegalArgumentException.class, () ->
153+
new SentencePieceTokenizer.DeserializationLimits(1, 0, 1));
154+
assertThrows(IllegalArgumentException.class, () ->
155+
new SentencePieceTokenizer.DeserializationLimits(1, 1, 0));
156+
}
71157
}

0 commit comments

Comments
 (0)