Repository navigation
[fix][broker] Replace Java serialization with JSON in package metadata - #25570
Merged
Merged
Conversation
PackageMetadataUtil.fromBytes used Apache Commons Lang3
SerializationUtils.deserialize, a thin wrapper over
ObjectInputStream.readObject, which instantiates arbitrary classes
before the instanceof PackageMetadata check runs. This was the only
production Java-deserialization surface in Pulsar.
Switch writes to JSON via ObjectMapperFactory. Reads auto-detect
format per entry (leading '{' -> JSON; 0xAC 0xED -> legacy). The
legacy read path is guarded by a strict JEP 290 ObjectInputFilter
allowlist (PackageMetadata, HashMap/LinkedHashMap/Map/Map.Entry/
AbstractMap, String/Number/Long/Boolean, Object) plus resource limits
(maxdepth=5, maxrefs=100, maxbytes=1MB, maxarray=1024).
Two new broker config flags, both default true for upgrade
compatibility:
packagesManagementJsonSerializationEnabled - write format
packagesManagementAllowLegacyJavaSerialization - read acceptance
Defaults ship the fix in active form; operators can flip either for
rollback or post-migration tightening. The legacy-accept default is
scheduled to flip to false in a future release.
nodece
approved these changes
Apr 23, 2026
nodece
left a comment
Member
There was a problem hiding this comment.
Makes sense from a security perspective.
dao-jun
approved these changes
Apr 23, 2026
poorbarcode
pushed a commit
to poorbarcode/pulsar
that referenced
this pull request
May 6, 2026
11 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
PackageMetadataUtil.fromBytesusedorg.apache.commons.lang3.SerializationUtils.deserialize, a thin wrapper overObjectInputStream.readObject.readObjectinstantiates arbitrary classes from the stream before the post-readinstanceof PackageMetadatacheck runs, so any gadget chain on the classpath would fire before validation. This was the only production Java-deserialization surface in Pulsar (SerializationUtils.deserializehad exactly one hit; the remainingObjectInputStream/readObjectreferences areBitSetRecyclable's in-memory recycler and the BouncyCastle PEM reader, neither a Java-serialization surface).Severity — not externally reachable
The deserialization path is not reachable from external attackers. The REST entry points (
pulsar-broker/.../admin/v3/Packages.java) accept JSON via Jackson from authenticated admins; Pulsar then re-serializes with Java serialization to store in BookKeeper / filesystem. The Java-serialized blob only exists at the storage layer, written by Pulsar itself from already-validated JSON. Exploiting the gadget chain requires either write access to the backing package storage, or a separate future bug that feeds raw bytes intofromBytes.This PR is best understood as defense in depth plus eliminating an unnecessary dangerous primitive, not a live RCE fix.
Modifications
Write path — JSON via the existing
ObjectMapperFactory(same factory used across the codebase for admin/REST serialization).Read path — auto-detects format per entry:
{→ JSON (always safe).0xAC 0xED→ legacy deserialization, gated by config and wrapped in a strict JEP 290ObjectInputFilter. The filter allowlist is narrow:PackageMetadata,HashMap/LinkedHashMap/Map/Map.Entry/AbstractMap,String/Number/Long/Boolean,Object, plus resource limits (maxdepth=5,maxrefs=100,maxbytes=1MB,maxarray=1024) and!*denying everything else. Class descriptors are filtered before instantiation, so gadget chains fail immediately.MetadataFormatExceptionwith a message naming the config flag to flip.Two new broker config flags (both default
true, underCATEGORY_PACKAGES_MANAGEMENT):packagesManagementJsonSerializationEnabled— write format. Rollback switch; legacy format will be removed in a future release.packagesManagementAllowLegacyJavaSerialization— read acceptance. The filter is always applied when the legacy path runs. This default is scheduled to flip tofalsein a future release.The two flags allow a phased rollout: defaults ship the fix active; operators can flip either independently (rollback writes, or tighten reads after migration).
On broker startup the new ctor in
PackagesManagementImplemitsWARNs describing the active combination (migration window, rollback mode, or the misconfigured "new writes use Java but reads reject it" state).Verifying this change
This change added tests and can be verified as follows:
./gradlew :pulsar-package-management:pulsar-package-core:test --tests 'PackageMetadataSerdeTest' --tests 'PackagesManagementImplTest'— all pass../gradlew :pulsar-broker-common:compileJava :pulsar-broker:compileJava— passes.rg 'SerializationUtils\.(de)?serialize|ObjectInputStream' pulsar-package-management/ --glob '*.java'→ production hits are confined toPackageMetadataUtil.java(both paths gated by flags); remaining hits are the filter-rejection test fixture.New tests in
PackageMetadataSerdeTest:testJsonRoundTrip— confirms bytes start with{.testLegacyRoundTrip— confirms bytes start with0xAC 0xED.testJsonReadableWhenLegacyDisabled— JSON always works.testLegacyRejectedWhenLegacyDisabled— rejection names the config flag.testFilterRejectsUnsafeClass— crafts a valid Java stream with a disallowed class (ArrayList); filter rejects before instantiation, not via the post-readinstanceof.testUnknownFormatRejected,testEmptyRejected.Does this pull request potentially affect one of the following parts:
PackageMetadataUtil.fromBytes(byte[])andtoBytes(PackageMetadata)remain as@Deprecatedoverloads delegating to the new two-arg forms; source-compatible for external callers.true; one scheduled to flip tofalsein a future release as documented in its@FieldContextdoc).Documentation
doc-not-needed(The config docs in
ServiceConfiguration.javacarry the operator-facing migration notes; a separate release-notes entry will cover the scheduled default flip.)