CVE-2021-32634 (Java, published 2021-05-21T18:15Z)
Description: value: Emissary is a distributed, peer-to-peer, data-driven workflow framework. Emissary 6.4.0 is vulnerable to Unsafe Deserialization of post-authenticated requests to the [`WorkSpaceClientEnqueue.action`](https://github.com/NationalSecurityAgency/emissary/blob/30c54ef16c6eb6ed09604a929939fb9f66868382/src/main/java/emissary/server/mvc/internal/WorkSpaceClientEnqueueAction.java) REST endpoint. This issue may lead to post-auth Remote Code Execution. This issue has been patched in version 6.5.0. As a workaround, one can disable network access to Emissary from untrusted sources.
Severity: MEDIUM (CVSS v3 base score: 7.2)
Weakness: CWE-502 \u2014 Deserialization of Untrusted Data
The product deserializes untrusted data without sufficiently verifying that the resulting data will be valid.
Repository: https://github.com/NationalSecurityAgency/emissary
Files changed:
src/main/java/emissary/core/IMobileAgent.java
src/main/java/emissary/pickup/WorkBundle.java
src/main/java/emissary/pickup/WorkUnit.java
src/main/java/emissary/server/mvc/adapters/WorkSpaceAdapter.java
src/main/java/emissary/server/mvc/internal/WorkSpaceClientEnqueueAction.java
src/main/java/emissary/util/PayloadUtil.java
src/test/java/emissary/pickup/WorkBundleTest.java
src/test/java/emissary/util/PayloadUtilTest.java
Commit message: Merge pull request from GHSA-m5qf-gfmp-7638

* Remove unsafe serialization from PayloadUtil

* This code will likely be removed wholesale, but this change
  should be used as a departure point for future development
  if we end up re-implementing moveTo and friends.

* Removed vestigial MoveTo related code.

* Remove unsafe serialization in WorkSpace infra.

* Favor DataInput/DataOutputStream over ObjectInput/ObjectOutputStream
* Implement lightweight serialization in WorkBundle/WorkUnit

* Updates to WorkBundle serDe, added tests.

- set limit on number of WorkUnits per bundle. In practice these are
  commonly less than 1024.
- added null handling for WorkBundle/WorkUnit string fields.
- confirmed readUTF/writeUTF has a limit ensuring strings will
  be 65535 characters or less.

* Minor cleanup to WorkBundleTest

* Minor Change to WorkBundleTest

* Formatting updates

Vulnerable code:
import emissary.server.mvc.adapters.MoveToAdapter;

     * Get the payload as an object for serialization during transport Should only be called by the MoveToAdapter
     * 
     * @see MoveToAdapter
import java.io.Serializable;
public class WorkBundle implements Serializable, Comparable<WorkBundle> {

    // Serializable
    static final long serialVersionUID = 6339812801001572532L;
        workUnitList.add(workUnit);
        workUnitList.add(new WorkUnit(file));
        return size();
        for (int i = 0; file != null && i < file.length; i++) {
            workUnitList.add(new WorkUnit(file[i]));
            workUnitList.add(new WorkUnit(file));
public class WorkUnit {
package emissary.server.mvc.adapters;

import java.io.IOException;
import java.nio.charset.Charset;
import java.rmi.RemoteException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.core.MediaType;

import emissary.client.EmissaryClient;
import emissary.client.EmissaryResponse;
import emissary.config.ConfigUtil;
import emissary.config.Configurator;
import emissary.core.EmissaryException;
import emissary.core.IMobileAgent;
import emissary.core.Namespace;
import emissary.core.NamespaceException;
import emissary.directory.DirectoryEntry;
import emissary.directory.KeyManipulator;
import emissary.log.MDCConstants;
import emissary.place.IServiceProviderPlace;
import emissary.pool.AgentPool;
import emissary.util.PayloadUtil;
import org.apache.http.HttpStatus;
import org.apache.http.HttpVersion;
import org.apache.http.NameValuePair;
import org.apache.http.client.entity.EntityBuilder;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.impl.cookie.BasicClientCookie;
import org.apache.http.message.BasicHttpResponse;
import org.apache.http.message.BasicNameValuePair;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;

/**
 * Facilitate moving agents over HTTP
 */
public class MoveToAdapter extends EmissaryClient {

    private static final Logger logger = LoggerFactory.getLogger(MoveToAdapter.class);

    public static final String PLACE_NAME = "mtPlaceName";
    public static final String AGENT_OBJECT = "mtAgentObject";
    public static final String AGENT_SERIAL = "mtAgentSerialized";
    public static final String MOVE_ERROR_COUNT = "agentMoveErrorCount";
    public static final String ITINERARY_ITEM = "agentItineraryItem";

    private static String COOKIE_NAME = "PLACE";
    private static String COOKIE_DOMAIN = "www.example.com";
    private static String COOKIE_PATH = "/";
    private static String VIRTUAL_MOVETO_PROTOCOL = "http";
    private static String VIRTUAL_MOVETO_ADDR = null;

    // Initialize the VIRTUAL_MOVETO_ADDR IP and port once on class load
    static {
        reconfigure();
    }

    /**
     * Configure static stuff for this class Config items read here are
     * 
     * <ul>
     * <li>VIRTUAL_MOVETO_ADDR: default null for redirecting moveTo calls when dynamic loadbalancing is available</li>
     * <li>VIRTUAL_MOVETO_PROTOCOL: default http protocol for loadbalancing redirect request</li>
     * <li>VIRTUAL_COOKIE_NAME: name of coookie holding real destination place, default=PLACE</li>
     * <li>VIRTUAL_COOKIE_DOMAIN: name of cookie domain for redirect cookie, default=www.example.com</li>
     * <li>VIRTUAL_COOKIE_PATH: path set on redirect cookie, default=/</li>
     * </ul>
     */
    public static void reconfigure() {
        try {
            final Configurator conf = ConfigUtil.getConfigInfo(AgentPool.class);
            VIRTUAL_MOVETO_ADDR = conf.findStringEntry("VIRTUAL_MOVETO_ADDR", null);
            VIRTUAL_MOVETO_PROTOCOL = conf.findStringEntry("VIRTUAL_MOVETO_PROTOCOL", "http");
            COOKIE_NAME = conf.findStringEntry("VIRTUAL_COOKIE_NAME", COOKIE_NAME);
            COOKIE_DOMAIN = conf.findStringEntry("VIRTUAL_COOKIE_DOMAIN", COOKIE_DOMAIN);
            COOKIE_PATH = conf.findStringEntry("VIRTUAL_COOKIE_PATH", COOKIE_PATH);
        } catch (IOException e) {
            logger.warn("Cannot read config file, virtual hosting capability not available " + e.getMessage());
        }
    }

    /**
     * Public constructor
     */
    public MoveToAdapter() {}

    /**
     * moveTo call arriving on this server, translate to real call
     * 
     * @param req the HttpRequest with all needed parameters
     */
    public boolean inboundMoveTo(final HttpServletRequest req) throws RemoteException, NamespaceException {

        final MoveToRequestBean bean = new MoveToRequestBean(req);
        String placeKey = KeyManipulator.getServiceLocation(bean.getPlaceName());

        // Rewrite the placeKey to ignore the machine:port part of the key
        // since we may have come here on a redirect from the load
        // balancing module and it may not be the right host name anymore
        if (VIRTUAL_MOVETO_ADDR != null) {
            placeKey = KeyManipulator.getServiceClassname(bean.getPlaceName());
        }

        final IServiceProviderPlace place = (IServiceProviderPlace) Namespace.lookup(placeKey);
        if (place == null) {
            throw new NamespaceException("Nothing found for " + bean.getPlaceName() + " using " + placeKey + " as the lookup key");
        }

        try {
            emissary.pool.PayloadLauncher.launch(bean.getPayload(), place, bean.getErrorCount(), bean.getItineraryItems());
        } catch (EmissaryException ex) {
            logger.debug("Cannot launch incoming payload", ex);
            throw new RemoteException("Cannot launch payload", ex);
        } finally {
            MDC.remove(MDCConstants.SERVICE_LOCATION);
        }

        return true;
    }

    /**
     * Send a moveTo call to a remote machine
     * 
     * @param place the four-tuple of the place we are heading to
     * @param agent the MobileAgent that is moving
     * @return status of operation including body if successful
     */
    public EmissaryResponse outboundMoveTo(final String place, final IMobileAgent agent) {

        String url = null;

        // Move to actions can be load-balanced out to
        // a virtual IP address:port if so configured
        if (VIRTUAL_MOVETO_ADDR != null) {
            url = VIRTUAL_MOVETO_PROTOCOL + "://" + VIRTUAL_MOVETO_ADDR + "/";
        } else {
            url = KeyManipulator.getServiceHostURL(place);
        }
        url += CONTEXT + "/MoveTo.action";

        final HttpPost method = createHttpPost(url, CONTEXT, "/MoveTo.action");
        method.setHeader("Content-type", "application/x-www-form-urlencoded; charset=ISO-8859-1");
        final List<NameValuePair> nvps = new ArrayList<NameValuePair>();

        nvps.add(new BasicNameValuePair(PLACE_NAME, place));
        nvps.add(new BasicNameValuePair(MOVE_ERROR_COUNT, Integer.toString(agent.getMoveErrorCount())));

        final DirectoryEntry[] iq = agent.getItineraryQueueItems();
        for (int j = 0; j < iq.length; j++) {
            nvps.add(new BasicNameValuePair(ITINERARY_ITEM, iq[j].getKey()));
        }

        try {
            // This is an 8859_1 String
            final String agentData = PayloadUtil.serializeToString(agent.getPayloadForTransport());
            nvps.add(new BasicNameValuePair(AGENT_SERIAL, agentData));
        } catch (IOException iox) {
            // TODO This will probably need looked at when redoing the moveTo
            logger.error("Cannot serialize agent data", iox);
            BasicHttpResponse response =
                    new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_INTERNAL_SERVER_ERROR, "Cannot serialize agent data");
            response.setEntity(EntityBuilder.create().setText("").setContentEncoding(MediaType.TEXT_PLAIN).build());
            return new EmissaryResponse(response);
        }

        method.setEntity(new UrlEncodedFormEntity(nvps, Charset.forName("8859_1")));

        // Add a cookie to the outbound header if we are posting
        // to the virtual IP for load balancing
        if (VIRTUAL_MOVETO_ADDR != null) {
            final BasicClientCookie cookie = new BasicClientCookie(COOKIE_NAME, KeyManipulator.getServiceClassname(place));
            cookie.setDomain(VIRTUAL_MOVETO_ADDR.substring(0, VIRTUAL_MOVETO_ADDR.indexOf(":")));
            cookie.setPath(COOKIE_PATH);
            return send(method, cookie);
        }
        return send(method);
    }


    static class MoveToRequestBean {
        String placeName;
        Object payload;
        int errorCount;
        List<DirectoryEntry> itineraryItems = null;

        MoveToRequestBean() {}

        MoveToRequestBean(final HttpServletRequest req) {
            setPlaceName(RequestUtil.getParameter(req, PLACE_NAME));
            if (getPlaceName() == null) {
                throw new IllegalArgumentException("Missing place name");
            }

            final String agentData = RequestUtil.getParameter(req, AGENT_SERIAL);
            if (agentData == null) {
                throw new IllegalArgumentException("Missing serialized agent data");
            }
            setPayload(agentData);

            setErrorCount(RequestUtil.getIntParam(req, MOVE_ERROR_COUNT, 0));

            final String[] p = req.getParameterValues(ITINERARY_ITEM);
            if (p != null && p.length > 0) {
                this.itineraryItems = new ArrayList<DirectoryEntry>();
                for (int i = 0; i < p.length; i++) {
                    this.itineraryItems.add(new DirectoryEntry(p[i]));
                }
            }
        }

        void setPayload(final String s) {
            this.payload = PayloadUtil.deserialize(s);
        }

        /**
         * Get the place name for arrival
         */
        String getPlaceName() {
            return this.placeName;
        }

        /**
         * Set the place name
         * 
         * @param argPlaceName value of placeName
         */
        void setPlaceName(final String argPlaceName) {
            this.placeName = argPlaceName;
        }

        /**
         * Get the agent's payload data. The actual type will depend on the particular implementation of IMobileAgent that
         * serialized the data to us.
         */
        Object getPayload() {
            return this.payload;
        }

        void setErrorCount(final int c) {
            this.errorCount = c;
        }

        int getErrorCount() {
            return this.errorCount;
        }

        int getItineraryItemCount() {
            return this.itineraryItems == null ? 0 : this.itineraryItems.size();
        }

        /**
         * Return the transferred itinerary items as a list
         * 
         * @return list of DirectoryEntry
         */
        @SuppressWarnings("unchecked")
        List<DirectoryEntry> getItineraryItems() {
            return this.itineraryItems == null ? Collections.EMPTY_LIST : this.itineraryItems;
        }
    }
}
import java.io.ObjectInputStream;
                final ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(s.getBytes("8859_1")));
                this.paths = (WorkBundle) ois.readObject();
package emissary.server.mvc.internal;

import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.Response;

import emissary.server.mvc.adapters.MoveToAdapter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class MoveToAction {
    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    /*
     * <!-- Cause an agent to move to the remote machine/place --> <Use-Case source="*" action="/MoveTo.action"> <Work
     * type="Bean" target="emissary.comms.http.worker.LogWorker"/> <Work type="Bean"
     * target="emissary.comms.http.worker.ArrivalWorker"/> <View status="0" view="/success.jsp"/> <View status="-1"
     * view="/error.jsp"/> </Use-Case>
     */

    // TODO This is an initial crack at the new endpoint, I haven't seen it called an am unsure when/if it does
    public Response moveTo(@Context HttpServletRequest request) {
        // This is copy and pasted from the old ArrivalWorker class
        final MoveToAdapter mt = new MoveToAdapter();
        try {
            final boolean status = mt.inboundMoveTo(request);

            if (!status) {
                logger.error("MoveTo failed!");
                // return new WorkerStatus(WorkerStatus.FAILURE, "MoveTo failed");
                return Response.serverError().entity("MoveTo failed").build();
            }
        } catch (Exception e) {
            logger.error("Could not call moveTo", e);
            // return new WorkerStatus(WorkerStatus.FAILURE, "Could not call moveTo", e);
            return Response.serverError().entity("Could not call moveTo").build();
        }

        logger.debug("MoveTo succeeded");
        // return WORKER_SUCCESS;
        return Response.ok().entity("MoveTo succeeded").build();
    }

}
import java.io.ObjectInputStream;
            final ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(workBundleString.getBytes("8859_1")));
            WorkBundle paths = (WorkBundle) ois.readObject();
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.nio.ByteBuffer;
    public static ByteBuffer serializeToByteBuffer(final Object payload) throws IOException {
        return ByteBuffer.wrap(serializeToBytes(payload));
    }

    /**
     * Serialize a payload object to bytes
     */
    public static byte[] serializeToBytes(final Object payload) throws IOException {
        final ByteArrayOutputStream bos = new ByteArrayOutputStream();
        serializeToStream(bos, payload);
        return bos.toByteArray();
    }

    /**
     * Serialize a payload object to string
     */
    public static String serializeToString(final Object payload) throws IOException {
        final ByteArrayOutputStream bos = new ByteArrayOutputStream();
        serializeToStream(bos, payload);
        String agentData = null;
        try {
            agentData = bos.toString("8859_1");
        } catch (UnsupportedEncodingException e) {
            logger.warn("Should always support 8859_1", e);
            agentData = bos.toString();
        }
        return agentData;
    }

    /**
     * Serialize a payload object onto the specified stream
     */
    public static void serializeToStream(final OutputStream os, final Object payload) throws IOException {
        ObjectOutputStream oos = null;
        try {
            oos = new ObjectOutputStream(os);
            oos.writeObject(payload);
        } finally {
            if (oos != null) {
                try {
                    oos.close();
                } catch (IOException ignore) {
                    // empty catch block
                }
            }
        }
    }

    public static Object deserialize(final String s) {
        ObjectInputStream ois = null;
        try {
            ois = new ObjectInputStream(new ByteArrayInputStream(s.getBytes("8859_1")));
            return ois.readObject();
        } catch (Exception e) {
            logger.error("Cannot deserialize payload using " + (s == null ? -1 : s.length()) + " bytes", e);
            throw new IllegalArgumentException("Cannot deserialize payload");
        } finally {
            if (ois != null) {
                try {
                    ois.close();
                } catch (IOException ignore) {
                    // empty catch block
                }
            }
        }
    }

            m.setAttribute("name", key.toString());
        final Document doc = new Document(root);
        return doc;
        final Document doc = new Document(root);
        return doc;
            out.append(entry.getKey() + SEP + entry.getValue() + LS);
    @Test
    public void testByteSerialization() throws Exception {
        final IBaseDataObject d = DataObjectFactory.getInstance("abc".getBytes(), "testfile", Form.UNKNOWN);
        final byte[] bytes = PayloadUtil.serializeToBytes(d);
        final String s1 = new String(bytes);
        assertTrue("Serializedion must include data from payload", s1.indexOf("abc") > -1);
    }


Fixed code:
     * Get the payload as an object for serialization during transport.
     *
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
public final class WorkBundle implements Comparable<WorkBundle> {
    static final int MAX_UNITS = 1024;

    /**
     * Deserialize a WorkBundle from a DataInputStream
     *
     * @param in the stream to read from
     * @return the deserialized WorkBundle
     * @throws IOException if there is a problem reading the stream or it contains more than <code>MAX_UNITS</code> work
     *         units.
     */
    public static WorkBundle readFromStream(DataInputStream in) throws IOException {
        WorkBundle wb = new WorkBundle();
        wb.bundleId = readUTFOrNull(in);
        wb.outputRoot = readUTFOrNull(in);
        wb.eatPrefix = readUTFOrNull(in);
        wb.caseId = readUTFOrNull(in);
        wb.sentTo = readUTFOrNull(in);
        wb.errorCount = in.readInt();
        wb.priority = in.readInt();
        wb.simpleMode = in.readBoolean();
        wb.oldestFileModificationTime = in.readLong();
        wb.youngestFileModificationTime = in.readLong();
        wb.totalFileSize = in.readLong();
        int workUnitSize = in.readInt();
        if (workUnitSize > MAX_UNITS) {
            throw new IOException(
                    "Exception when reading: WorkBundle may not contain more then " + MAX_UNITS + " WorkUnits (saw: " + workUnitSize + ").");
        }
        for (int i = 0; i < workUnitSize; i++) {
            wb.addWorkUnit(WorkUnit.readFromStream(in));
        }
        return wb;
    }

    /**
     * Serialize this WorkBundle to a DataOutputStream
     *
     * @param out the stream to write to.
     * @throws IOException if there is a problem writing to the stream.
     */
    public void writeToStream(DataOutputStream out) throws IOException {
        writeUTFOrNull(bundleId, out);
        writeUTFOrNull(outputRoot, out);
        writeUTFOrNull(eatPrefix, out);
        writeUTFOrNull(caseId, out);
        writeUTFOrNull(sentTo, out);
        out.writeInt(errorCount);
        out.writeInt(priority);
        out.writeBoolean(simpleMode);
        out.writeLong(oldestFileModificationTime);
        out.writeLong(youngestFileModificationTime);
        out.writeLong(totalFileSize);
        out.writeInt(workUnitList.size());
        if (workUnitList.size() > MAX_UNITS) {
            throw new IOException(
                    "Exception when writing: WorkBundle may not contain more then " + MAX_UNITS + " WorkUnits (saw: " + workUnitList.size() + ").");
        }
        for (WorkUnit u : workUnitList) {
            u.writeToStream(out);
        }
    }

    static String readUTFOrNull(DataInputStream in) throws IOException {
        if (in.readBoolean()) {
            return in.readUTF();
        }
        return null;
    }

    static void writeUTFOrNull(String s, DataOutputStream out) throws IOException {
        out.writeBoolean(s != null);
        if (s != null) {
            out.writeUTF(s);
        }
    }

     * @throws IllegalStateException if adding the unit would cause the bundle to contain more than <code>MAX_UNITS</code>
     *         work units
        if (workUnitList.size() >= MAX_UNITS) {
            throw new IllegalStateException("WorkBundle may not contain more than " + MAX_UNITS + " WorkUnits.");
        }
     * @param fileSize the size of the file added.
     * @throws IllegalStateException if adding the unit would cause the bundle to contain more than <code>MAX_UNITS</code>
     *         work units
        addWorkUnit(workUnit);

     * 
     * @param list a list of WorkUnits to add to this bundle
     * @return the total size of WorkUnits in this bundle
     * @throws IllegalStateException if adding the units would cause the bundle to contain more than <code>MAX_UNITS</code>
     *         work units
        if (workUnitList.size() + list.size() > MAX_UNITS) {
            throw new IllegalStateException("WorkBundle may not contain more than " + MAX_UNITS + " WorkUnits.");
        }
     * @throws IllegalStateException if adding the file would cause the bundle to contain more than <code>MAX_UNITS</code>
     *         work units
        return addWorkUnit(new WorkUnit(file));
     * @param fileSize the size of the file being added
     * @throws IllegalStateException if adding the file would cause the bundle to contain more than <code>MAX_UNITS</code>
     *         work units
     * @throws IllegalStateException if adding the files would cause the bundle to contain more than <code>MAX_UNITS</code>
     *         work units
        for (String f : file) {
            addWorkUnit(new WorkUnit(f));
     * 
     * @param list the list of files to add
     * @throws IllegalStateException if adding the files would cause the bundle to contain more than <code>MAX_UNITS</code>
     *         work units
            addWorkUnit(new WorkUnit(file));
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;

public final class WorkUnit {
    public static WorkUnit readFromStream(DataInputStream in) throws IOException {
        final WorkUnit u = new WorkUnit(null);
        u.fileName = WorkBundle.readUTFOrNull(in);
        u.transactionId = WorkBundle.readUTFOrNull(in);
        u.failedToParse = in.readBoolean();
        u.failedToProcess = in.readBoolean();
        return u;
    }

    public void writeToStream(DataOutputStream out) throws IOException {
        WorkBundle.writeUTFOrNull(fileName, out);
        WorkBundle.writeUTFOrNull(transactionId, out);
        out.writeBoolean(failedToParse);
        out.writeBoolean(failedToProcess);
    }

import java.io.DataInputStream;
                final DataInputStream dis = new DataInputStream(new ByteArrayInputStream(s.getBytes("8859_1")));
                this.paths = WorkBundle.readFromStream(dis);
import java.io.DataInputStream;
            final DataInputStream ois = new DataInputStream(new ByteArrayInputStream(workBundleString.getBytes("8859_1")));
            WorkBundle paths = WorkBundle.readFromStream(ois);
            m.setAttribute("name", key);
        return new Document(root);
        return new Document(root);
            out.append(entry.getKey()).append(SEP).append(entry.getValue()).append(LS);
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.util.ArrayList;
    @Test
    public void testSerDe() throws IOException {
        WorkBundle w1 = new WorkBundle("/output/root", "/etc/prefix");
        w1.addFileName("file1.txt", 15L, 4L);
        w1.addFileName("<file2.txt&foo=bar>", 7L, 10L);

        ByteArrayOutputStream bout = new ByteArrayOutputStream();
        DataOutputStream out = new DataOutputStream(bout);
        w1.writeToStream(out);
        out.close();
        bout.close();

        byte[] b = bout.toByteArray();

        ByteArrayInputStream bin = new ByteArrayInputStream(b);
        DataInputStream in = new DataInputStream(bin);
        WorkBundle w2 = WorkBundle.readFromStream(in);

        assertEquals(0, w1.compareTo(w2));
    }

    @Test
    public void testLimitAdd() throws IOException {
        // generate test data.
        final List<WorkUnit> wul = new ArrayList<>();
        for (int i = 0; i < WorkBundle.MAX_UNITS + 2; i++) {
            String fileName = UUID.randomUUID().toString();
            wul.add(new WorkUnit(fileName));
        }

        // test add list of WorkUnits
        try {
            WorkBundle wb = new WorkBundle();
            wb.addWorkUnits(wul);
            fail("Did not catch expected exception when adding work unit list.");
        } catch (IllegalStateException e) {
            // this is the expected outcome.
        } catch (Throwable t) {
            fail("Unexpected Exception caught when adding work unit list: " + t);

        }

        // test add individual WorkUnits
        try {
            WorkBundle wb = new WorkBundle();
            wul.forEach(wb::addWorkUnit);
            fail("Did not catch expected exception when adding work unit.");
        } catch (IllegalStateException e) {
            // this is the expected outcome.
        } catch (Throwable t) {
            fail("Unexpected Exception caught when adding work unit: " + t);
        }
    }

    @Test
    public void testLimitSerDe() throws IOException {
        // Craft an illegal stream.
        ByteArrayOutputStream bout = new ByteArrayOutputStream();
        DataOutputStream out = new DataOutputStream(bout);
        WorkBundle wb1 = new WorkBundle();
        // generate test data.
        final List<WorkUnit> wul = new ArrayList<>();
        for (int i = 0; i < WorkBundle.MAX_UNITS + 2; i++) {
            String fileName = UUID.randomUUID().toString();
            wul.add(new WorkUnit(fileName));
        }

        WorkBundle.writeUTFOrNull(wb1.bundleId, out);
        WorkBundle.writeUTFOrNull(wb1.outputRoot, out);
        WorkBundle.writeUTFOrNull(wb1.eatPrefix, out);
        WorkBundle.writeUTFOrNull(wb1.caseId, out);
        WorkBundle.writeUTFOrNull(wb1.sentTo, out);
        out.writeInt(wb1.errorCount);
        out.writeInt(wb1.priority);
        out.writeBoolean(wb1.simpleMode);
        out.writeLong(wb1.oldestFileModificationTime);
        out.writeLong(wb1.youngestFileModificationTime);
        out.writeLong(wb1.totalFileSize);
        out.writeInt(WorkBundle.MAX_UNITS + 2);
        for (WorkUnit u : wul) {
            u.writeToStream(out);
        }

        out.close();
        bout.close();

        try {
            WorkBundle wb2 = WorkBundle.readFromStream(new DataInputStream(new ByteArrayInputStream(bout.toByteArray())));
            fail("Did not catch expected exception when deserializing work unit.");
        } catch (IOException e) {
            // expected
        } catch (Throwable t) {
            fail("Unexpected Exception caught when deserializing work unit: " + t);

        }
    }