Running on Java 19+36-2238 (Preview)
Home of The JavaSpecialists' Newsletter

304ObjectInputFilter

Author: Dr Heinz M. KabutzDate: 2022-10-31Java Version: 17+Category: Tips and Tricks
 

Abstract: JEP 290 introduced the ObjectInputFilter, which we can use to filter objects returned from an ObjectInputStream. We examine three ways of making filters, through subclassing, with factory methods and with text patterns, and then show some interesting edge cases.

 

Welcome to the 304th edition of The Java(tm) Specialists' Newsletter, once again sent from the wonderful Island of Crete. This is the week that tsikoudia (also called raki in Heraklion, a bit like the Italian grappa) is distilled in our village. They get a license to distill for one week, and they try to get as much distilled as possible. In between carrying heavy buckets of fermented grape skins, there is a lot of waiting involved, and that's a perfect time to chat and enjoy a shot of fresh tsikoudia. Attending a distillation in process should be on everyone's bucket list, even for teetotallers :-) There is something primal about sitting around a blazing fire sporting a hot copper couldron.

javaspecialists.teachable.com: Please visit our new self-study course catalog to see how you can upskill your Java knowledge.

Filtering ObjectInputStreams

Even at the end of 2022, we still have a fairly large number of companies running their Java systems on Java 8. Their systems work, they earn money from them every day, and usually there is some effort involved in migrating to more modern Java. But it is becoming increasingly difficult to find Java programmers willing to work with such outdated technology. Plus, we need to use pieces of wire and string to work around some issues that were solved years ago. One of our customers asked us to create a course for those wishing to master Java 11. I know, we already have Java 19, so why bother with Java 11? The step from Java 8 to Java 11 can be a big one, because an effective Java programmer should have a perfunctory understanding of how the Java Platform Module System (JPMS or Java Modules) work. That takes roughly a day to explain, together with copious exercises. We then spend another two days taking in the many other changes that Java 9, 10, and 11 gave us. Some of the features only warrant a brief overview and a pointer of where to find out more information. With others, we spend more time exploring and doing exercises. The course is not on my website yet, but if you are interested, please send me an email and I can send you the outline directly. Of course we are also working on upgrades to Java 17 and beyond, which we are creating JIT.

In this newsletter we will explore one of the lesser known features of Java 9, namely JEP 290: Filter Incoming Serialization Data. It is summarized as: "Allow incoming streams of object-serialization data to be filtered in order to improve both security and robustness." Java Object Serialization is rare nowadays, and thus we only give it a cursory glance in our "Mastering Java 11 Course". However, there are some fun features and I'd like to expand on them in this newsletter. Prior to Java 9, there was no way of stopping input from an ObjectInputStream. Thus if a rogue process sent us very large arrays or instances of classes that could be considered dangerous, we could perhaps check the generated instances using the ObjectInputValidation interface, but at that point the instance had already been constructed. We might already have run out of memory on the server or been hacked in some way.

Enter the Java 9 ObjectInputFilter. It has a method checkInput(FilterInfo), and the FilterInfo provides the following information:

  • serialClass(): the class that is supposed to be deserialized
  • arrayLength(): array length if the object is or contains an array
  • depth(): the depth of the object graph at that point
  • references(): the current number of object references
  • streamBytes(): the current number of bytes consumed

The method checkInput() returns either Status.UNDECIDED, Status.ALLOWED, or Status.REJECTED. As far as I could tell, we should use ALLOWED if we want the object to be accepted, no matter what anyone else wants. Similarly we would mark them as REJECTED if, no matter what other filters say, we want this to be rejected. UNDECIDED means we allow others to override our decision. This would probably be our default status.

For example, we all know how LinkedList should probably not be used in projects. Even the author Joshua Bloch famously tweeted "Does anyone actually use LinkedList? I wrote it, and I never use it." We could write an ObjectInputFilter that specifically disallows a LinkedList to be deserialized:

import java.io.ObjectInputFilter;
import java.util.LinkedList;

public class NoLinkedListFilter implements ObjectInputFilter {
  @Override
  public Status checkInput(FilterInfo filterInfo) {
    return filterInfo.serialClass() == LinkedList.class ?
        Status.REJECTED : Status.UNDECIDED;
  }
}

When we read in an ObjectInputStream, we simply set the ObjectInputFilter and this prevents LinkedList from being deserialized. We can set this on a per-stream basis or globally with ObjectInputFilter.Config.setSerialFilter(filter). Let's set up some helper classes and demos to make this easier to visualize. First we need a company with employees:

import java.io.Serial;
import java.io.Serializable;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;

public class Company implements Serializable {
  @Serial
  private static final long serialVersionUID = 0L;
  private final List<String> employees = new LinkedList<>();

  public void addEmployee(String name) {
    employees.add(name);
  }

  /**
   * Convenience method for you-know-who.
   */
  public void remove75PercentOfEmployees() {
    int pos = 0;
    for (var it = employees.iterator(); it.hasNext(); ) {
      it.next();
      if (pos++ % 4 != 0) it.remove();
    }
  }

  @Override
  public String toString() {
    return "Company{employees=" + employees + '}';
  }
}

We also write a SerializationHelper to make serializing and deserializing easier:

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InvalidClassException;
import java.io.ObjectInputFilter;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.util.ArrayList;
import java.util.List;

class SerializationHelper {
  static byte[] serialize(Object... objects) throws IOException {
    var bout = new ByteArrayOutputStream();
    try (var out = new ObjectOutputStream(bout)) {
      for (Object object : objects) {
        out.writeObject(object);
      }
      out.writeObject(null);
    }
    return bout.toByteArray();
  }

  static List<?> deserialize(byte[] bytes)
      throws IOException, ClassNotFoundException {
    return deserialize(null, bytes);
  }

  static List<?> deserialize(ObjectInputFilter filter, byte[] bytes)
      throws IOException, ClassNotFoundException {
    var new ArrayList<>();
    try (var in = new ObjectInputStream(
        new ByteArrayInputStream(bytes)
    )) {
      if (filter != null) in.setObjectInputFilter(filter);
      Object o;
      while ((o = in.readObject()) != null) {
        result.add(o);
      }
    } catch(InvalidClassException ex) {
      result.add(ex.toString());
    }
    return List.copyOf(result);
  }
}

Before we show a demo of how we can prevent a LinkedList from appearing from an ObjectInputStream, there are several ways in which we can create these filters. The first we saw already, via a subclass. Since it is also a @FunctionalInterface, we could also create a lambda. However, since Java 17, we have two new static factory methods in ObjectInputFilter called allowFilter() and rejectFilter(). They both take a Predicate for a Class and a status to return if we do not have a match. We have two other static methods merge() and rejectUndecidedClass(). We can use these to create a chain of filters, much like the Chain of Responsibility Pattern in the Gang-of-Four.

A third way of creating filters is via a text String and by calling ObjectInputFilter.Config.createFilter(String). In this case we can list the classes that we want to accept or reject, together with some more attributes we will look at later.

We now look at a demo, where we create several filters for reading our ObjectInputStream.

import java.io.IOException;
import java.io.ObjectInputFilter;
import java.io.UncheckedIOException;
import java.util.ArrayList;
import java.util.LinkedList;

public class ObjectInputFilterDemo {
  public static void main(String... args) throws Exception {
    Company company = new Company();
    company.addEmployee("Chris");
    company.addEmployee("Tony");
    company.addEmployee("John");
    company.addEmployee("Parag");

    byte[] bytes = SerializationHelper.serialize(company);

    // With no filter
    test(bytes, null); // allowed

    // With our custom filter
    var filter1 = new NoLinkedListFilter();
    test(bytes, filter1); // rejected

    // With a generated filter rejecting LinkedList
    var filter2 = ObjectInputFilter.rejectFilter(
        clazz -> clazz == LinkedList.class,
        ObjectInputFilter.Status.UNDECIDED);
    test(bytes, filter2);  // rejected

    // With a generated filter allowing ArrayList
    var filter3 = ObjectInputFilter.allowFilter(
        clazz -> clazz == ArrayList.class,
        ObjectInputFilter.Status.UNDECIDED);
    test(bytes, filter3); // allowed

    // Merging filter1 and filter3
    var filter4 = ObjectInputFilter.merge(filter1, filter3);
    test(bytes, filter4); // rejected

    // This would reject even the Company class
    var filter5 = ObjectInputFilter.rejectUndecidedClass(filter3);
    test(bytes, filter5); // rejected

    // Create a filter from a text pattern that rejects LinkedList
    var filter6 = ObjectInputFilter.Config.createFilter(
        "!java.util.LinkedList");
    test(bytes, filter6); // rejected

    // Create a filter from a text pattern that accepts Company,
    // LinkedList and rejects all other classes
    var filter7 = ObjectInputFilter.Config.createFilter(
        "Company;java.util.LinkedList;!*");
    test(bytes, filter7); // allowed

    ObjectInputFilter.Config.setSerialFilter(filter1);
    test(bytes, null); // rejected
  }

  private static void test(byte[] bytes, ObjectInputFilter filter)
      throws IOException, ClassNotFoundException {
    var result = SerializationHelper.deserialize(filter, bytes);
    System.out.println(result); // rejected
  }
}

The output is as follows:

[Company{employees=[Chris, Tony, John, Parag]}]
[java.io.InvalidClassException: filter status: REJECTED]
[java.io.InvalidClassException: filter status: REJECTED]
[Company{employees=[Chris, Tony, John, Parag]}]
[java.io.InvalidClassException: filter status: REJECTED]
[java.io.InvalidClassException: filter status: REJECTED]
[java.io.InvalidClassException: filter status: REJECTED]
[Company{employees=[Chris, Tony, John, Parag]}]
[java.io.InvalidClassException: filter status: REJECTED]

The easiest, and probably most versatile, of the ways of creating a filter is with a pattern. In addition to being able to list allowed classes, separated by semicolons, we can also add modules, match classes and subpackages with .** (e.g. java.util.** would allow anything in that package and also subpackages such as java.util.concurrent), .* for classes within a package, and * for all classes. Thus in my filter7, I am allowing LinkedList and Company, but no other classes, by ending the filter with !*. In addition to specifying what classes are allowed, we can also use the properties maxdepth, maxrefs, maxbytes, and maxarray, which correspond to the properties in the FilterInfo parameter.

Curiously, I could not find an easy way to protect against very long Strings (or any Strings for that matter) being sent to me. Here is a demo:

import java.io.IOException;
import java.io.ObjectInputFilter;
import java.io.ObjectStreamException;
import java.util.List;

public class NoStringsPlease {
  public static void main(String... args)
      throws IOException, ClassNotFoundException {
    byte[] bytes = SerializationHelper.serialize("Hello world");
    ObjectInputFilter filter1 = ObjectInputFilter.rejectFilter(
        c -> c == String.class, ObjectInputFilter.Status.ALLOWED
    );
    var result = SerializationHelper.deserialize(filter1, bytes);
    System.out.println(result);

    ObjectInputFilter filter2 =
        ObjectInputFilter.Config.createFilter("maxarray=10");
    result = SerializationHelper.deserialize(filter2, bytes);
    System.out.println(result);
  }
}

The code above runs without an issue and outputs:

[Hello world]
[Hello world]

The issue, as far as I can tell, is that Strings are not treated as normal Objects in Serialization. Instead, they are written as UTF. When they are deserialized, the filter is applied neither for the String class, nor for the byte[] or char[] inside the String. For example:

import java.io.IOException;
import java.io.ObjectInputFilter;
import java.io.ObjectStreamException;
import java.util.List;

public class NoStringsPlease {
  public static void main(String... args)
      throws IOException, ClassNotFoundException {
    byte[] bytes = SerializationHelper.serialize("Hello world");
    ObjectInputFilter filter1 = ObjectInputFilter.rejectFilter(
        c -> c == String.class, ObjectInputFilter.Status.ALLOWED
    );
    var result = SerializationHelper.deserialize(filter1, bytes);
    System.out.println(result);

    ObjectInputFilter filter2 =
        ObjectInputFilter.Config.createFilter("maxarray=10");
    result = SerializationHelper.deserialize(filter2, bytes);
    System.out.println(result);
  }
}

This outputs the following:

[Hello world]
[Hello world]

String and primitives are deliberately excluded from the filter, as described in JEP 290: "The filter is not called for primitives or java.lang.String instances that are encoded concretely in the stream."

We mentioned earlier that we can also create a filter from a text pattern. What would you expect "!java.lang.String" to filter, if anything? Turns out that if we deserialize an array, then the pattern is applied to the actual component type of the array. For multiple dimensional arrays, the lowest non-array type is used. Thus if we pass in a String[], then our pattern would reject that. For example:

import java.io.IOException;
import java.io.ObjectInputFilter;
import java.util.stream.Stream;

public class StrangeFilterDemo {
  public static void main(String... args)
      throws IOException, ClassNotFoundException {
    byte[] bytes = SerializationHelper.serialize((Object)
        Stream.of("Hello", "world").toArray(String[]::new));
    ObjectInputFilter filter1 =
        ObjectInputFilter.Config.createFilter("!java.lang.String");
    var result = SerializationHelper.deserialize(filter1, bytes);
    System.out.println(result);
  }
}

The output this time is

[java.io.InvalidClassException: filter status: REJECTED]

There is a lot more to write about this ObjectInputFilter, and it seems to be in use within the OpenJDK. Futhermore, new factory methods were added in Java 17. However, this is still a rather niche feature and not sure if I will use it in the real world.

Kind regards from Chorafakia

Heinz

 

Comments

We are always happy to receive comments from our readers. Feel free to send me a comment via email or discuss the newsletter in our JavaSpecialists Slack Channel (Get an invite here)

When you load these comments, you'll be connected to Disqus. Privacy Statement.

Related Articles

Browse the Newsletter Archive

About the Author

Heinz Kabutz Java Conference Speaker

Java Champion, author of the Javaspecialists Newsletter, conference speaking regular... About Heinz

Superpack '22

Superpack '22 Our entire Java Specialists Training in one huge bundle more...

Free Java Book

Dynamic Proxies in Java Book
Java Training

We deliver relevant courses, by top Java developers to produce more resourceful and efficient programmers within their organisations.

Java Consulting

We can help make your Java application run faster and trouble-shoot concurrency and performance bugs...

Java Emergency?

If your system is down, we will review it for 15 minutes and give you our findings for just 1 € without any obligation.