Skip to content

[Bug] Generic invocation without class key bypasses Serializable check on PojoUtils.realize #16270

@uuuyuqi

Description

@uuuyuqi

Pre-check

  • I am sure that all the content I provide is in English.

Search before asking

  • I had searched in the issues and found no similar issues.

Apache Dubbo Component

Java SDK (apache/dubbo)

Dubbo Version

Reproduced on Dubbo Java 3.2.16 (Spring Boot 3.2.4, OpenJDK 17.0.10, macOS 14, hessian2 default serialization).

I also inspected the same code on upstream/3.3 (HEAD 3dbba260ca) and upstream/3.4: the relevant lines in PojoUtils.realize1 and DefaultSerializeClassChecker.loadClass are identical, so the gap exists there as well — though I have only run the live reproduction on 3.2.16.

Steps to reproduce this issue

Define a service whose parameter type is a concrete POJO class that does not implement Serializable:

// API
public interface GreetingService {
    String processRiskDto(RiskDto arg);
}

// DTO — intentionally NOT Serializable
public class RiskDto {
    private String label;
    private Integer sequence;
    // standard getters / setters / no-arg constructor
}

Provider implementation just echoes the arg.

On the consumer side, invoke it generically through GenericService. We compare two payloads:

@DubboReference(interfaceClass = GreetingService.class, generic = true)
private GenericService genericGreetingService;

// Case A: Map without "class" key
Map<String, Object> a = new LinkedHashMap<>();
a.put("label", "no-class-key");
a.put("sequence", 100);
genericGreetingService.$invoke(
        "processRiskDto",
        new String[]{"com.example.api.dto.RiskDto"},
        new Object[]{a});

// Case B: Map with "class" key
Map<String, Object> b = new LinkedHashMap<>();
b.put("class", "com.example.api.dto.RiskDto");
b.put("label", "with-class-key");
b.put("sequence", 200);
genericGreetingService.$invoke(
        "processRiskDto",
        new String[]{"com.example.api.dto.RiskDto"},
        new Object[]{b});

Default protocol/serialization (dubbo + hessian2), default dubbo.application.checkSerializable (true).

Observed behavior

  • Case A (no class key) — succeeds. Provider's processRiskDto is invoked with a fully populated RiskDto instance, even though RiskDto does not implement Serializable.
  • Case B (with class key) — fails with the expected:
Caused by: java.lang.IllegalArgumentException: [Serialization Security] Serialized class
    com.example.api.dto.RiskDto has not implement Serializable interface.
    Current mode is strict check, will disallow to deserialize it by default.
    at org.apache.dubbo.common.utils.DefaultSerializeClassChecker.loadClass(DefaultSerializeClassChecker.java:114)
    at org.apache.dubbo.common.utils.PojoUtils.realize1(PojoUtils.java:460)
    at org.apache.dubbo.common.utils.PojoUtils.realize0(PojoUtils.java:348)
    at org.apache.dubbo.common.utils.PojoUtils.realize(PojoUtils.java:250)
    at org.apache.dubbo.common.utils.PojoUtils.realize(PojoUtils.java:130)
    at org.apache.dubbo.rpc.filter.GenericFilter.invoke(GenericFilter.java:120)

I also verified this on the provider side with Arthas: when Case A is invoked, DefaultSerializeClassChecker.loadClass is never called for RiskDto, while GreetingServiceImpl.processRiskDto is invoked and receives a real RiskDto instance.

What you expected to happen

For a strong-typed call (or for a generic call with a class key), Dubbo enforces the Serializable contract on user POJOs (per DefaultSerializeClassChecker.loadClass and Hessian2SerializerFactory.checkSerializable). The same DTO type behaves inconsistently depending on whether the caller happens to include a class entry in the generic Map:

Call style Serializable enforced?
Strong-typed RPC Yes (Hessian2 path)
Generic call, Map with class key Yes (loadClass path)
Generic call, Map without class key No

The third row looks surprising: the same non-Serializable DTO is rejected in two paths and silently accepted in the third. I would expect the Serializable contract to be a property of the type, not of whether the caller chose to include a class field.

The main question I would like the maintainers to clarify is: is this difference intentional, or is it an oversight in the check coverage?

Anything else

Code analysis

The check is wired only into the "load class by name" code path. In PojoUtils.realize1:

// dubbo-common/.../PojoUtils.java
if (pojo instanceof Map<?, ?> && type != null) {
    Object className = ((Map<Object, Object>) pojo).get("class");
    if (className instanceof String) {                          // ← gate
        if (!CLASS_NOT_FOUND_CACHE.containsKey(className)) {
            try {
                type = DefaultSerializeClassChecker.getInstance()
                        .loadClass(ClassUtils.getClassLoader(), (String) className);  // ← only check site
            } catch (ClassNotFoundException e) {
                CLASS_NOT_FOUND_CACHE.put((String) className, NOT_FOUND_VALUE);
            }
        }
    }
    // ...
    // Eventually reaches:
    dest = newInstance(type);   // line 557 / 560 — plain reflection, no check
}

DefaultSerializeClassChecker.loadClass (line 105) is the only place the Serializable check happens on this path. When the Map has no class entry, the if at line 456 short-circuits, the loader is never invoked, and newInstance(type) reaches cls.getDeclaredConstructor().newInstance() directly.

The other Serializable check, Hessian2SerializerFactory.checkSerializable (line 74), is also bypassed in this case because in pure generic invocation the wire only carries HashMap + primitives — Hessian2 never sees the user POJO class on the wire and therefore never registers a serializer for it.

Why this might be unintentional

A few observations that suggest this is a coverage gap rather than a deliberate exemption:

  1. The first version of the check (PR [2.7] Add serializable check for pojo #11430, Feb 2023) placed validateClass inside the if (className instanceof String) block. Subsequent refactors in PR Enhance Check #11419 kept the same gating, suggesting the author was thinking "validate when loading by string name" rather than "validate every type that gets instantiated".
  2. DefaultSerializeClassChecker's class JavaDoc references Fastjson2's ContextAutoTypeBeforeHandler — which is an autotype-string security filter. The Serializable check appears to be a secondary concern bolted onto that filter.
  3. The comment in Hessian2SerializerFactory.checkSerializable describes a "two-checker" model where Hessian2 and DefaultSerializeClassChecker jointly enforce the contract. The generic-no-class-key path slips between both checkers.
  4. There is no unit test that pins the current behavior. In PojoUtilsTest, every test DTO implements Serializable, so the no-class-key + non-Serializable combination is not covered either way.

Question for the maintainers

Could you clarify whether the current behavior is intentional? Either answer is useful:

  • If intentional (e.g. types resolved from method signatures are considered trusted and exempt from this check), it would be helpful to document that, so users do not unknowingly rely on the inconsistency.
  • If unintentional, I am happy to follow up with a fix and tests.

Are you willing to submit a pull request to fix on your own?

  • Yes I am willing to submit a pull request on my own!

Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status

    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions