BasicPTVArrayComponentBypassTest.java

package tools.jackson.databind.jsontype.vld;

import java.util.ArrayList;
import java.util.List;

import org.junit.jupiter.api.Test;

import tools.jackson.databind.*;
import tools.jackson.databind.exc.InvalidTypeIdException;
import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import tools.jackson.databind.jsontype.PolymorphicTypeValidator;
import tools.jackson.databind.testutil.DatabindTestUtil;

import static org.junit.jupiter.api.Assertions.*;

/**
 * [databind#5981] {@code BasicPolymorphicTypeValidator.allowIfSubTypeIsArray()} must not
 * approve arrays whose component type would itself be denied by the configured
 * sub-class allow-list.
 */
public class BasicPTVArrayComponentBypassTest extends DatabindTestUtil
{
    // Fully-qualified name of this test class -- works as a name-prefix matcher
    // for every nested helper (SafePayload, FakeGadget, ObjectWrapper) via their
    // "outer.Name$Nested" form. Used in namePrefixAllowsBothElementAndArray.
    private static final String OWN_CLASS_NAME_PREFIX =
            BasicPTVArrayComponentBypassTest.class.getName();

    /**
     * Records every constructor invocation; lets the tests prove that an
     *  un-allow-listed type is not actually instantiated.
     */
    static final List<String> INSTANTIATIONS = new ArrayList<>();

    /**
     * Stand-in "unsafe" type. Completely benign in itself -- only side effect is
     * recording its own instantiation. {@code final} so default typing emits no
     * per-element type id, which is the configuration the bypass exploited.
     */
    static final class FakeGadget {
        public String cmd;
        public FakeGadget() {
            INSTANTIATIONS.add(FakeGadget.class.getName());
        }
    }

    /**
     * Type that IS in the allow-list, used to verify the positive path through
     * the unwrap-then-match fall-through.
     */
    static final class SafePayload {
        public int data;
        public SafePayload() { }
    }

    static final class ObjectWrapper {
        public Object value;
        protected ObjectWrapper() { }
    }

    // For [databind#5981]: with the validator engaged (no allowIfBaseType escape
    // hatch into LaissezFaire), a direct FakeGadget value must be denied AND the
    // same FakeGadget wrapped as FakeGadget[] must also be denied -- the element
    // type must not bypass the allow-list just because it is wrapped in an array.
    @Test
    public void directGadgetAndGadgetArrayBothDenied() throws Exception
    {
        ObjectMapper mapper = mapperWithSafePayloadAndArrays();

        // (a) Direct FakeGadget: denied. Sanity check that the validator is actually
        // engaged with this configuration (i.e. not swapped out for LaissezFaire).
        final String classId = FakeGadget.class.getName();
        final String directJson = "{\"value\":[\"" + classId + "\",{\"cmd\":\"x\"}]}";
        INSTANTIATIONS.clear();
        InvalidTypeIdException directDenied = assertThrows(InvalidTypeIdException.class,
                () -> mapper.readValue(directJson, ObjectWrapper.class),
                "Direct FakeGadget must be denied (no array, no SafePayload match)");
        verifyException(directDenied, classId);
        assertEquals(0, INSTANTIATIONS.size(),
                "FakeGadget must not be instantiated when its bare class is denied;"
                        + " observed=" + INSTANTIATIONS);

        // (b) FakeGadget wrapped as FakeGadget[]: pre-fix this slipped through,
        // because the array matcher approved every array regardless of element type.
        // Post-fix the validator unwraps the array and runs the innermost element
        // type through the same allow-list, which denies FakeGadget.
        final String arrayId = "[L" + classId + ";";
        final String arrayJson = "{\"value\":[\"" + arrayId + "\",[{\"cmd\":\"x\"}]]}";
        INSTANTIATIONS.clear();
        InvalidTypeIdException arrayDenied = assertThrows(InvalidTypeIdException.class,
                () -> mapper.readValue(arrayJson, ObjectWrapper.class),
                "FakeGadget[] must be denied because FakeGadget is not allow-listed");
        verifyException(arrayDenied, arrayId);
        assertEquals(0, INSTANTIATIONS.size(),
                "FakeGadget must not be instantiated when its array form is denied;"
                        + " observed=" + INSTANTIATIONS);
    }

    // For [databind#5981]: the unwrap loop in validateSubType() must recurse for
    // nested arrays so that FakeGadget[][] is denied for the same reason
    // FakeGadget[] is denied -- the innermost element is what matters.
    @Test
    public void nestedGadgetArrayAlsoDenied() throws Exception
    {
        ObjectMapper mapper = mapperWithSafePayloadAndArrays();

        final String classId = FakeGadget.class.getName();
        final String nestedArrayId = "[[L" + classId + ";";
        // FakeGadget[][] with a single inner FakeGadget[] containing one FakeGadget.
        // If the unwrap stopped at one level (e.g. FakeGadget[]), the outer match
        // would short-circuit ALLOWED; the recursive unwrap to FakeGadget is what
        // makes this denial correct.
        final String json = "{\"value\":[\"" + nestedArrayId + "\","
                + "[[{\"cmd\":\"x\"}]]]}";
        INSTANTIATIONS.clear();
        InvalidTypeIdException denied = assertThrows(InvalidTypeIdException.class,
                () -> mapper.readValue(json, ObjectWrapper.class),
                "FakeGadget[][] must be denied: innermost element FakeGadget is not allow-listed");
        verifyException(denied, nestedArrayId);
        assertEquals(0, INSTANTIATIONS.size(),
                "FakeGadget must not be instantiated when its nested-array form is denied;"
                        + " observed=" + INSTANTIATIONS);
    }

    // For [databind#5981]: positive path. Confirms the unwrap-then-match fall-through
    // works for an allow-listed concrete component type -- SafePayload[] is approved
    // because validateSubType unwraps to SafePayload and the existing allow-list
    // matcher matches it.
    @Test
    public void allowListedConcreteComponentArrayAccepted() throws Exception
    {
        ObjectMapper mapper = mapperWithSafePayloadAndArrays();

        final String arrayId = "[L" + SafePayload.class.getName() + ";";
        final String json = "{\"value\":[\"" + arrayId + "\",[{\"data\":42}]]}";

        ObjectWrapper out = mapper.readValue(json, ObjectWrapper.class);
        assertNotNull(out);
        assertNotNull(out.value);
        assertEquals(SafePayload[].class, out.value.getClass());
        SafePayload[] arr = (SafePayload[]) out.value;
        assertEquals(1, arr.length);
        assertEquals(42, arr[0].data);
    }

    // For [databind#5981]: primitive-element arrays are accepted via the primitive
    // exemption (no allow-list entry needed for {@code int}). Primitive arrays
    // cannot carry gadget chains, so this exemption is safe.
    @Test
    public void primitiveComponentArrayAccepted() throws Exception
    {
        ObjectMapper mapper = mapperWithSafePayloadAndArrays();

        // JVM internal name for int[] is "[I"
        final String json = "{\"value\":[\"[I\",[1,2,3]]}";
        ObjectWrapper out = mapper.readValue(json, ObjectWrapper.class);
        assertNotNull(out);
        assertEquals(int[].class, out.value.getClass());
        assertArrayEquals(new int[] { 1, 2, 3 }, (int[]) out.value);
    }

    // For [databind#5988]: name-prefix matchers must also apply to array element
    // types after unwrap. Previously validateSubType() consulted only the class-
    // based matchers, so allowIfSubType("tools.jackson...") (a name matcher)
    // would allow SafePayload directly but reject SafePayload[].
    @Test
    public void namePrefixAllowsBothElementAndArray() throws Exception
    {
        PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
                // Name prefix matcher only -- no class matcher is registered.
                .allowIfSubType(OWN_CLASS_NAME_PREFIX)
                .allowIfSubTypeIsArray()
                .build();
        ObjectMapper mapper = jsonMapperBuilder()
                .activateDefaultTyping(ptv, DefaultTyping.NON_FINAL)
                .build();

        final String arrayId = "[L" + SafePayload.class.getName() + ";";
        final String json = "{\"value\":[\"" + arrayId + "\",[{\"data\":42}]]}";

        ObjectWrapper out = mapper.readValue(json, ObjectWrapper.class);
        assertNotNull(out);
        assertEquals(SafePayload[].class, out.value.getClass());
        SafePayload[] arr = (SafePayload[]) out.value;
        assertEquals(1, arr.length);
        assertEquals(42, arr[0].data);
    }

    // For [databind#5988]: even after the name-matcher fix, an array whose element
    // type is NOT covered by the name prefix must still be denied.
    @Test
    public void namePrefixDeniesUnmatchedArrayElement() throws Exception
    {
        PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
                // Name prefix that intentionally does NOT cover FakeGadget.
                .allowIfSubType("nonexistent.package.")
                .allowIfSubTypeIsArray()
                .build();
        ObjectMapper mapper = jsonMapperBuilder()
                .activateDefaultTyping(ptv, DefaultTyping.NON_FINAL)
                .build();

        final String classId = FakeGadget.class.getName();
        final String arrayId = "[L" + classId + ";";
        final String json = "{\"value\":[\"" + arrayId + "\",[{\"cmd\":\"x\"}]]}";

        INSTANTIATIONS.clear();
        InvalidTypeIdException denied = assertThrows(InvalidTypeIdException.class,
                () -> mapper.readValue(json, ObjectWrapper.class),
                "FakeGadget[] must be denied: FakeGadget is not covered by name prefix");
        verifyException(denied, arrayId);
        assertEquals(0, INSTANTIATIONS.size());
    }

    private ObjectMapper mapperWithSafePayloadAndArrays() {
        PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
                .allowIfSubType(SafePayload.class)
                .allowIfSubTypeIsArray()
                .build();
        return jsonMapperBuilder()
                .activateDefaultTyping(ptv, DefaultTyping.NON_FINAL)
                .build();
    }
}