BasicPTVGenericParameterBypassTest.java
package tools.jackson.databind.jsontype.vld;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import org.junit.jupiter.api.Test;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonTypeInfo.As;
import com.fasterxml.jackson.annotation.JsonTypeInfo.Id;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.exc.InvalidTypeIdException;
import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import tools.jackson.databind.testutil.DatabindTestUtil;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* [databind#5988]: generic type IDs must not bypass {@link tools.jackson.databind.jsontype.PolymorphicTypeValidator}.
*<p>
* {@code DatabindContext._resolveAndValidateGeneric()} historically validated the raw
* container class name (before {@code '<'}) only; if approved, it constructed the full
* parameterized type and returned it without validating the type parameters. An attacker
* could supply a type ID such as {@code "java.util.ArrayList<EvilGadget>"} to smuggle a
* non-allow-listed element type past the PTV. The fix recursively validates each non-
* trivial type parameter (and array element types appearing as parameters).
*/
public class BasicPTVGenericParameterBypassTest extends DatabindTestUtil
{
// Fully-qualified name of this test class -- works as a name-prefix matcher
// for every nested helper (SafePayload, EvilGadget, Container) via their
// "outer.Name$Nested" form. Used in namePrefixAllowsBothContainerAndParameter.
private static final String OWN_CLASS_NAME_PREFIX =
BasicPTVGenericParameterBypassTest.class.getName();
/**
* Records every constructor invocation; lets the tests prove that a non-allow-listed
* type is not actually instantiated when the validator rejects it.
*/
static final List<String> INSTANTIATIONS = new ArrayList<>();
/** Stand-in "unsafe" type, never allow-listed. */
public static class EvilGadget {
public String secret;
public EvilGadget() {
INSTANTIATIONS.add(EvilGadget.class.getName());
}
}
/** Always allow-listed in these tests. */
public static class SafePayload {
public String data;
public SafePayload() {}
// String-arg constructor: doubles as Jackson's automatic Map-key
// deserializer when SafePayload appears as a Map key type
// (used by mapWithAllowedKeyAndValueAccepted).
public SafePayload(String d) { this.data = d; }
}
/** Plain enum used to verify the enum-exemption from type-parameter validation. */
public enum NonAllowListedEnum {
VALUE_A, VALUE_B
}
static class Container {
@JsonTypeInfo(use = Id.CLASS, include = As.WRAPPER_ARRAY)
public Object value;
public Container() {}
}
// NOTE: only polymorphicTypeValidator() is set (no activateDefaultTyping and no
// allowIfBaseType(Object)). validateBaseType returning INDETERMINATE keeps the
// original PTV in force; an ALLOWED return value would swap in
// LaissezFaireSubTypeValidator and would defeat the per-subtype check.
private ObjectMapper mapperWithArrayListAndSafePayload() {
BasicPolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
.allowIfSubType("java.util.ArrayList")
.allowIfSubType("java.util.HashMap")
.allowIfSubType(SafePayload.class)
.build();
return jsonMapperBuilder()
.polymorphicTypeValidator(ptv)
.build();
}
// (1) Sanity check: ArrayList<SafePayload> -- both container and element are allowed.
@Test
public void allowedGenericTypeAccepted() throws Exception
{
ObjectMapper mapper = mapperWithArrayListAndSafePayload();
String json = "{\"value\":[\"java.util.ArrayList<" + SafePayload.class.getName() + ">\","
+ "[{\"data\":\"hello\"}]]}";
Container result = mapper.readValue(json, Container.class);
assertNotNull(result.value);
assertEquals(ArrayList.class, result.value.getClass());
}
// (2) Issue reproduction: ArrayList<EvilGadget> -- container is allowed, element is not.
// Pre-fix this slipped past the PTV. Post-fix the element type is validated and denied.
@Test
public void genericTypeIdBypassesAllowlistDenied() throws Exception
{
ObjectMapper mapper = mapperWithArrayListAndSafePayload();
final String evilClass = EvilGadget.class.getName();
String json = "{\"value\":[\"java.util.ArrayList<" + evilClass + ">\","
+ "[{\"secret\":\"hacked\"}]]}";
INSTANTIATIONS.clear();
InvalidTypeIdException e = assertThrows(InvalidTypeIdException.class,
() -> mapper.readValue(json, Container.class),
"ArrayList<EvilGadget> must be denied because EvilGadget is not allow-listed");
verifyException(e, evilClass);
assertEquals(0, INSTANTIATIONS.size(),
"EvilGadget must not be instantiated when its element form is denied;"
+ " observed=" + INSTANTIATIONS);
}
// (3) Map value position: HashMap<String, EvilGadget> -- container allowed (HashMap),
// String key is a benign JDK type but not allow-listed -> denied at the key step.
@Test
public void mapValueGadgetDenied() throws Exception
{
ObjectMapper mapper = mapperWithArrayListAndSafePayload();
final String evilClass = EvilGadget.class.getName();
String json = "{\"value\":[\"java.util.HashMap<java.lang.String," + evilClass + ">\","
+ "{\"k\":{\"secret\":\"hacked\"}}]}";
INSTANTIATIONS.clear();
InvalidTypeIdException e = assertThrows(InvalidTypeIdException.class,
() -> mapper.readValue(json, Container.class),
"HashMap<String,EvilGadget> must be denied (neither String nor EvilGadget are allow-listed)");
// The PTV walks containedType(i) in order: key (String) is checked first and
// denied because String is not on the allow-list; this is still a correct
// denial of the overall type id. Either class name in the exception message is
// acceptable -- the iteration order is the only thing that picks one over the
// other.
final String msg = e.getMessage();
assertTrue(msg != null
&& (msg.contains("java.lang.String") || msg.contains(evilClass)),
"Denial message should reference the rejected parameter type;"
+ " was: " + msg);
assertEquals(0, INSTANTIATIONS.size());
}
// (4) Map key position: HashMap<EvilGadget, String> -- key denied.
@Test
public void mapKeyGadgetDenied() throws Exception
{
ObjectMapper mapper = mapperWithArrayListAndSafePayload();
final String evilClass = EvilGadget.class.getName();
String json = "{\"value\":[\"java.util.HashMap<" + evilClass + ",java.lang.String>\","
+ "{}]}";
INSTANTIATIONS.clear();
InvalidTypeIdException e = assertThrows(InvalidTypeIdException.class,
() -> mapper.readValue(json, Container.class),
"HashMap<EvilGadget,String> must be denied: key type EvilGadget is not allow-listed");
verifyException(e, evilClass);
assertEquals(0, INSTANTIATIONS.size());
}
// (5) Nested generics: ArrayList<ArrayList<EvilGadget>> -- inner element denied.
@Test
public void nestedGenericGadgetDenied() throws Exception
{
ObjectMapper mapper = mapperWithArrayListAndSafePayload();
final String evilClass = EvilGadget.class.getName();
String json = "{\"value\":[\"java.util.ArrayList<java.util.ArrayList<" + evilClass + ">>\","
+ "[[{\"secret\":\"hacked\"}]]]}";
INSTANTIATIONS.clear();
InvalidTypeIdException e = assertThrows(InvalidTypeIdException.class,
() -> mapper.readValue(json, Container.class),
"Nested ArrayList<ArrayList<EvilGadget>> must be denied at the innermost element");
verifyException(e, evilClass);
assertEquals(0, INSTANTIATIONS.size());
}
// (6) Sanity check for Map: HashMap<SafePayload, SafePayload> -- both key+value allowed.
@Test
public void mapWithAllowedKeyAndValueAccepted() throws Exception
{
ObjectMapper mapper = mapperWithArrayListAndSafePayload();
String safe = SafePayload.class.getName();
String json = "{\"value\":[\"java.util.HashMap<" + safe + "," + safe + ">\",{}]}";
Container result = mapper.readValue(json, Container.class);
assertNotNull(result.value);
assertEquals(HashMap.class, result.value.getClass());
}
// (7) Wildcards / Object resolve to Object.class via TypeFactory; that's the
// intentional escape hatch and must keep working.
@Test
public void objectTypeParameterAccepted() throws Exception
{
ObjectMapper mapper = mapperWithArrayListAndSafePayload();
// Use SafePayload as the element so deserialization itself can complete; the
// point of this test is purely that "java.util.ArrayList<java.lang.Object>" as
// a *type id* is not rejected by the generic-parameter validation pass.
String json = "{\"value\":[\"java.util.ArrayList<java.lang.Object>\",[]]}";
Container result = mapper.readValue(json, Container.class);
assertNotNull(result.value);
assertEquals(ArrayList.class, result.value.getClass());
}
// (8) Array as a generic parameter: ArrayList<EvilGadget[]> must be denied because
// the array's element type (EvilGadget) is not allow-listed. Exercises the
// isArrayType() recursion branch in _validateTypeParameter.
@Test
public void gadgetArrayAsGenericParameterDenied() throws Exception
{
ObjectMapper mapper = mapperWithArrayListAndSafePayload();
final String evilClass = EvilGadget.class.getName();
final String arrayId = "[L" + evilClass + ";";
// ArrayList<EvilGadget[]> type id, with an array containing one inner element.
String json = "{\"value\":[\"java.util.ArrayList<" + arrayId + ">\","
+ "[[{\"secret\":\"hacked\"}]]]}";
INSTANTIATIONS.clear();
InvalidTypeIdException e = assertThrows(InvalidTypeIdException.class,
() -> mapper.readValue(json, Container.class),
"ArrayList<EvilGadget[]> must be denied: array element EvilGadget is not allow-listed");
verifyException(e, evilClass);
assertEquals(0, INSTANTIATIONS.size());
}
// (9) Name-prefix PTV: allowIfSubType(String) registers a name matcher, not a class
// matcher. Type parameters should be approved by the same name-prefix rule used
// for the container -- otherwise a configuration intended to allow everything
// under "com.example." would reject "com.example.Foo" as a type parameter.
@Test
public void namePrefixAllowsBothContainerAndParameter() throws Exception
{
BasicPolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
.allowIfSubType("java.util.ArrayList")
// Allow SafePayload by its enclosing-class name prefix (name matcher
// only -- no class matcher is registered for SafePayload).
.allowIfSubType(OWN_CLASS_NAME_PREFIX)
.build();
ObjectMapper mapper = jsonMapperBuilder()
.polymorphicTypeValidator(ptv)
.build();
String json = "{\"value\":[\"java.util.ArrayList<" + SafePayload.class.getName() + ">\","
+ "[{\"data\":\"hello\"}]]}";
Container result = mapper.readValue(json, Container.class);
assertNotNull(result.value);
assertEquals(ArrayList.class, result.value.getClass());
}
// (10) Enum exemption: EnumSet<NonAllowListedEnum> -- container "java.util.EnumSet" is
// approved by the "java" name prefix, but the enum element class is not on the
// allow-list. Enum classes are JVM-managed singletons resolved by name lookup
// (no attacker-controlled instantiation), so the type-parameter validation
// exempts them -- matching the exemption that already applies to Object. Without
// this exemption, a reasonable name-prefix PTV would break legitimate
// EnumSet/EnumMap deserialization (see [databind#4849]-style regression).
@Test
public void enumTypeParameterAccepted() throws Exception
{
BasicPolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
.allowIfSubType("java.")
.build();
ObjectMapper mapper = jsonMapperBuilder()
.polymorphicTypeValidator(ptv)
.build();
String json = "{\"value\":[\"java.util.EnumSet<" + NonAllowListedEnum.class.getName() + ">\","
+ "[\"VALUE_A\"]]}";
Container result = mapper.readValue(json, Container.class);
assertNotNull(result.value);
assertEquals(EnumSet.of(NonAllowListedEnum.VALUE_A), result.value);
}
}