You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@dubbo.apache.org by "uuuyuqi (via GitHub)" <gi...@apache.org> on 2026/05/14 07:14:10 UTC

[I] [Bug] Generic invocation without `class` key bypasses Serializable check on `PojoUtils.realize` [dubbo]

uuuyuqi opened a new issue, #16270:
URL: https://github.com/apache/dubbo/issues/16270

   
   ## 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<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`:
   
   ```java
   // 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 #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)
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@dubbo.apache.org.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@dubbo.apache.org
For additional commands, e-mail: notifications-help@dubbo.apache.org


Re: [I] [Bug] Generic invocation without `class` key bypasses Serializable check on `PojoUtils.realize` [dubbo]

Posted by "uuuyuqi (via GitHub)" <gi...@apache.org>.
uuuyuqi commented on issue #16270:
URL: https://github.com/apache/dubbo/issues/16270#issuecomment-4448612993

   **Real-world impact: an upstream gateway change unintentionally exposed this gap**
   
   A bit of background on why this came up — I want to share it because it shows the inconsistency is not just theoretical, and small upstream changes can flip many providers from "working" to "failing" overnight.
   
   In the deployment we operate, many internal services are exposed externally through an HTTP-to-Dubbo gateway. The gateway accepts JSON, builds the corresponding `Map<String, Object>` payload, and forwards the call to the backend Dubbo provider via `GenericService.$invoke`. Historically the gateway did **not** include the `class` entry in the forwarded Map.
   
   Recently the gateway rolled out a change to always include the `class` entry in the forwarded payload (a reasonable behavior for type fidelity in nested / polymorphic fields). Functionally for the typed-call path it would have been a no-op, but **on the Dubbo provider side it flipped many in-flight calls from the "no class key" branch (Serializable check skipped) to the "with class key" branch (Serializable check enforced via `loadClass`)**. The result was a wave of `IllegalArgumentException: ... has not implement Serializable interface` failures on providers whose DTOs had not yet been migrated to implement `Serializable`.
   
   The migration plan for those non-`Serializable` DTOs was already in flight on the security side (collect via runtime scan, drive remediation through tickets), so the long-term direction was understood. The surprise was that an upstream gateway change — a single line of "always add `class` for better type fidelity" — was enough to short-circuit that gradual migration and turn the gap into a production incident.
   
   This is why I think the inconsistency is worth fixing (or at least documenting clearly): the same DTO type behaving differently depending on whether a caller / gateway / proxy chose to include a `class` entry is **a hidden compatibility variable** that any change in middleware behavior can flip. If the framework itself enforced the contract consistently, this kind of upstream change would be a no-op rather than an outage trigger.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: notifications-unsubscribe@dubbo.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@dubbo.apache.org
For additional commands, e-mail: notifications-help@dubbo.apache.org