[Bug] Generic invocation without `class` key bypasses Serializable check on `PojoUtils.realize`
- Dominant language
- Java
- Stars
- 41.6k
- Forks
- 26.4k
- Avg merge
- 15h 13m
- Merged PRs (30d)
- 4
Description
## Pre-check
- [x] I am sure that all the content I provide is in English.
## Search before asking
- [x] I had searched in the [issues](https://github.com/apache/dubbo/issues?q=is%3Aissue) 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`:
```java
// 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:
```java
@DubboReference(interfaceClass = GreetingService.class, generic = true)
private GenericService genericGreetingService;
// Case A: Map without "class" key
Map 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 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`:
```java
// dubbo-common/.../PojoUtils.java
if (pojo instanceof Map && type != null) {
Object className = ((Map) 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 #11430, Feb 2023) placed `validateClass` **inside** the `if (className instanceof String)` block. Subsequent refactors in PR #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?
- [x] Yes I am willing to submit a pull request on my own!
## Code of Conduct
- [x] I agree to follow this project's [Code of Conduct](https://www.apache.org/foundation/policies/conduct)
Contributor guide
Research direction
Start with dubbo-common/.../PojoUtils.java, especially realize1, then read DefaultSerializeClassChecker and Hessian2SerializerFactory to compare the two Serializable-check paths. Reproduce both generic invocation cases and inspect PojoUtilsTest, which currently lacks the non-Serializable DTO combination. Done means the intended behavior is decided and covered by tests, with any required implementation change validated.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- java
- Domain
- distributed-systems, security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 42/100