Skip to content

[fix][broker] Replace Java serialization with JSON in package metadata - #25570

Merged
nodece merged 1 commit into
apache:masterfrom
lhotari:lh-fix-pkg-meta-java-serial
Apr 23, 2026
Merged

nodece merged 1 commit into
apache:masterfrom
lhotari:lh-fix-pkg-meta-java-serial

Conversation

@lhotari

@lhotari lhotari commented Apr 22, 2026

Copy link
Copy Markdown
Member

Motivation

PackageMetadataUtil.fromBytes used org.apache.commons.lang3.SerializationUtils.deserialize, a thin wrapper over ObjectInputStream.readObject. readObject instantiates arbitrary classes from the stream before the post-read instanceof PackageMetadata check runs, so any gadget chain on the classpath would fire before validation. This was the only production Java-deserialization surface in Pulsar (SerializationUtils.deserialize had exactly one hit; the remaining ObjectInputStream/readObject references are BitSetRecyclable'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 into fromBytes.

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:

  • Leading { → JSON (always safe).
  • Java stream magic 0xAC 0xED → legacy deserialization, gated by config and wrapped in a strict JEP 290 ObjectInputFilter. 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.
  • Anything else → MetadataFormatException with a message naming the config flag to flip.

Two new broker config flags (both default true, under CATEGORY_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 to false in 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 PackagesManagementImpl emits WARNs 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.
  • Static audit: rg 'SerializationUtils\.(de)?serialize|ObjectInputStream' pulsar-package-management/ --glob '*.java' → production hits are confined to PackageMetadataUtil.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 with 0xAC 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-read instanceof.
  • testUnknownFormatRejected, testEmptyRejected.

Does this pull request potentially affect one of the following parts:

  • The public API — PackageMetadataUtil.fromBytes(byte[]) and toBytes(PackageMetadata) remain as @Deprecated overloads delegating to the new two-arg forms; source-compatible for external callers.
  • The default values of configurations — two new configs added (both default true; one scheduled to flip to false in a future release as documented in its @FieldContext doc).

Documentation

  • doc-not-needed

(The config docs in ServiceConfiguration.java carry the operator-facing migration notes; a separate release-notes entry will cover the scheduled default flip.)

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 nodece left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense from a security perspective.

@nodece
nodece merged commit 81a7753 into apache:master Apr 23, 2026
80 of 83 checks passed
poorbarcode pushed a commit to poorbarcode/pulsar that referenced this pull request May 6, 2026
@lhotari lhotari added this to the 5.0.0-M1 milestone Jun 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants