Skip to content

GenericJacksonJsonRedisSerializer throws NPE deserializing duplicate properties #3396

Description

@ctomc

Summary

GenericJacksonJsonRedisSerializer (the Jackson 3 serializer, with default typing enabled) throws a NullPointerException when deserializing any JSON object that contains a duplicate property name. The failure is in GenericJacksonJsonRedisSerializer.TypeResolver.readTree(byte[]), which builds a JsonNode tree using a DeserializationContext obtained from mapper._deserializationContext() — a context that is never bound to the parser, so its _readCapabilities is null. As soon as the tree contains a duplicate key, BaseNodeDeserializer._handleDuplicateProperty calls ctxt.isEnabled(StreamReadCapability.DUPLICATE_PROPERTIES) and NPEs.

Duplicate names are permitted by JSON (RFC 8259) and Jackson's own tree deserializer normally handles them gracefully (last value wins, unless FAIL_ON_READING_DUP_TREE_KEY is set). Here they crash instead.

Affected versions

  • spring-data-redis 4.1.0 (Spring Data BOM 2026.0.0), Jackson 3.2.0 (tools.jackson).
  • The offending code is unchanged on main (TypeResolver.readTree still uses mapper._deserializationContext() directly).

Minimal reproduction

No custom mapper, no special types — a default-typing serializer and a two-line JSON with a duplicate key:

import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer;
import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator;

var validator = BasicPolymorphicTypeValidator.builder().allowIfSubType(Object.class).build();
var serializer = GenericJacksonJsonRedisSerializer.builder()
        .enableDefaultTyping(validator)
        .build();

// Any JSON object with a duplicate property name triggers it:
serializer.deserialize("{\"a\":1,\"a\":2}".getBytes());

Actual behaviour

org.springframework.data.redis.serializer.SerializationException: Could not read JSON:Cannot invoke
"tools.jackson.core.util.JacksonFeatureSet.isEnabled(tools.jackson.core.util.JacksonFeature)" because "this._readCapabilities" is null
Caused by: java.lang.NullPointerException: Cannot invoke
"tools.jackson.core.util.JacksonFeatureSet.isEnabled(tools.jackson.core.util.JacksonFeature)" because "this._readCapabilities" is null
    at tools.jackson.databind.DeserializationContext.isEnabled(DeserializationContext.java:470)
    at tools.jackson.databind.deser.jackson.BaseNodeDeserializer._handleDuplicateProperty(BaseNodeDeserializer.java:148)
    at tools.jackson.databind.deser.jackson.BaseNodeDeserializer._deserializeContainerNoRecursion(BaseNodeDeserializer.java:375)
    at tools.jackson.databind.deser.jackson.JsonNodeDeserializer.deserialize(JsonNodeDeserializer.java:94)
    at tools.jackson.databind.deser.jackson.JsonNodeDeserializer.deserialize(JsonNodeDeserializer.java:12)
    at org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer$TypeResolver.readTree(GenericJacksonJsonRedisSerializer.java:514)
    at org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer$TypeResolver.resolveType(GenericJacksonJsonRedisSerializer.java:478)
    at org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer.resolveType(GenericJacksonJsonRedisSerializer.java:222)
    at org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer.deserialize(GenericJacksonJsonRedisSerializer.java:210)

Expected behaviour

The value should deserialize the same way Jackson's tree reader would (last value wins by default), or at least raise a meaningful SerializationException — not a NullPointerException from an uninitialized DeserializationContext.

Root cause

TypeResolver.readTree(byte[]):

private JsonNode readTree(byte[] source) throws IOException {
    BaseNodeDeserializer<?> deserializer = JsonNodeDeserializer.getDeserializer(JsonNode.class);
    DeserializationConfig cfg = mapper.deserializationConfig();
    try (JsonParser parser = createParser(source)) {
        JsonToken t = parser.currentToken();
        if (t == null) {
            t = parser.nextToken();
            if (t == null) {
                return cfg.getNodeFactory().missingNode();
            }
        }
        DeserializationContext ctxt = mapper._deserializationContext();   // <-- not bound to `parser`
        if (t == JsonToken.VALUE_NULL) {
            return cfg.getNodeFactory().nullNode();
        } else {
            return deserializer.deserialize(parser, ctxt);
        }
    }
}

mapper._deserializationContext() returns a context whose per-read state (including _readCapabilities, normally derived from JsonParser.streamReadCapabilities()) has not been initialized, because the parser is never assigned to it. Any code path that consults a StreamReadCapability then dereferences null_handleDuplicateProperty is the first place that happens, so the NPE only surfaces for input containing a duplicate property. A context created and bound to the parser through the normal read path would have _readCapabilities populated and would apply the existing duplicate-key handling.

Suggested fix

Bind the parser to the deserialization context before deserializing (so stream-read capabilities are populated), or read the tree through a parser-aware path (e.g. an ObjectReader/ObjectMapper.readTree(JsonParser) variant) rather than invoking deserializer.deserialize(parser, ctxt) with an unbound mapper._deserializationContext().

Impact

Any cached value whose JSON contains a duplicate property crashes on read instead of being handled. This is easy to hit in practice, for example:

  • values written by an earlier Jackson 2 build (GenericJackson2JsonRedisSerializer), which was more lenient about emitting duplicate keys; and
  • polymorphic types where the @JsonTypeInfo(include = As.PROPERTY, property = "…") type-id name coincides with a bean property of the same name (Jackson 2 emitted both as duplicate keys). Such entries can no longer be read back — even to be evicted — under the Jackson 3 serializer.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions