Skip to content

TINKERPOP-3278 Disable Java serialization on Gryo IO reads - #3578

Open
GumpacG wants to merge 6 commits into
apache:3.7-devfrom
GumpacG:gryo-bug
Open

TINKERPOP-3278 Disable Java serialization on Gryo IO reads#3578
GumpacG wants to merge 6 commits into
apache:3.7-devfrom
GumpacG:gryo-bug

Conversation

@GumpacG

@GumpacG GumpacG commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Several Gryo type registrations, mostly TraversalStrategy implementations and TraversalExplanation,
were bound to Kryo's JavaSerializer, which deserializes via java.io.ObjectInputStream.readObject().
Reading a Gryo document from an untrusted source could therefore reconstruct and run an arbitrary
Serializable object graph during decoding, before the graph layer accepts or rejects anything.

This change stops the graph-document IO paths from reaching that sink, while leaving full fidelity
available for trusted, in-process use.

What changed

New API

  • GryoMapper.Builder.javaSerializationAllowed(boolean), default true. When false, every registration
    whose serializer resolves to a JavaSerializer is dropped, including registrations contributed through an
    IoRegistry or addCustom(...), whether the serializer is supplied directly, as a Function, or via a
    class default serializer.

Paths hardened (build their mappers with javaSerializationAllowed(false))

  • The io() step (read and write branches)
  • GryoReader and GryoWriter default mappers
  • GryoIo (covers graph.io(IoCore.gryo()) and gryo graph persistence)
  • The Hadoop Gryo input/output formats (GryoRecordReader, GryoRecordWriter)

Reads and writes are hardened together so these paths cannot produce a document they will not read back.

Unchanged (full fidelity retained for trusted bytes)

  • A directly built GryoMapper.build().create()
  • GryoPool, and the spark-gremlin and Hadoop object pools (which additionally run unlocked by design)
  • GraphSON and GraphBinary (never used native Java serialization)

Behavior change

A stream that presents one of the affected type ids now fails with an unregistered class id instead of
being deserialized:

org.apache.tinkerpop.shaded.kryo.KryoException: Encountered unregistered class ID: 187

Affected types: PartitionStrategy, SubgraphStrategy, SeedStrategy, VertexProgramStrategy,
ProductiveByStrategy, OptionsStrategy and TraversalExplanation in both Gryo 1.0 and 3.0, plus
GroupStep.GroupBiOperator and OrderGlobalStep.OrderBiOperator in Gryo 1.0.

Breaking changes and capability impact

Breaking: reading a Gryo document that embeds one of the affected types now fails on the default IO paths
(io(), GryoReader, GryoWriter, GryoIo, and the Hadoop formats) instead of deserializing it. Restore the
old behavior where the bytes are trusted with javaSerializationAllowed(true) (see below). Additive API only,
no signatures changed; GryoMapper.build().create() and graph-structure documents are unaffected.

A graph document produced by writeGraph/writeVertices contains only vertices, edges, properties and
their values, never strategies, so in practice nothing is lost on the graph-document paths. Nothing in the
repository round-trips these types through Gryo: OLAP ships strategies to workers via JDK serialization in
the job configuration, not through the Gryo registrations.

The capability is removed from the default, not from the product. A directly built mapper keeps full
fidelity, and javaSerializationAllowed(true) restores it on any builder. GryoIo exposes it through the
onMapper consumer where the bytes are trusted:

GryoIo.build().graph(graph).onMapper(m -> ((GryoMapper.Builder) m).javaSerializationAllowed(true)).create();

Migration: a caller holding an older .kryo document that embeds one of the affected types can read it by
supplying a mapper with javaSerializationAllowed(true) to GryoReader, or via the onMapper hook above.

Testing

  • GryoMapperTest (parameterized over Gryo 1.0 and 3.0): a canary proving readObject() does not run on a
    hardened read, a positive control proving the crafted bytes do reach the sink on a full-fidelity mapper
    (so the negative assertions are meaningful), graph-structure round-trips, and registration checks against
    the constructed Kryo for default, direct-custom, Function-custom and class-default-serializer forms.
  • GryoRecordReaderWriterTest: asserts the Hadoop reader and writer build hardened mappers.

Docs

CHANGELOG entry, an upgrade note in docs/src/upgrade/release-3.7.x.asciidoc, and scoped updates to
THREAT_MODEL.md (sections 8 and 9) describing which mappers are hardened.

Assisted-by: Kiro: Claude Opus 4.8

Comment thread THREAT_MODEL.md Outdated
this.graphFilter = VertexProgramHelper.deserialize(ConfUtil.makeApacheConfiguration(configuration), Constants.GREMLIN_HADOOP_GRAPH_FILTER);
this.gryoReader = GryoReader.build().mapper(
GryoMapper.build().addRegistries(IoRegistryHelper.createRegistries(ConfUtil.makeApacheConfiguration(configuration))).create()).create();
GryoMapper.build().javaSerializationAllowed(false).addRegistries(IoRegistryHelper.createRegistries(ConfUtil.makeApacheConfiguration(configuration))).create()).create();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

for our documentation sake, could you please add a comment as to why we disable java serialization here - something that say why it isn't necessary basically. my take is that as a record reader (and you need a similar comment in GryoRecordWriter) it is meant to serialize a graph Element which would not serialize things like OptionsStrategy and classes that use JavaSerializer.

this is in contrast to the MapReduce shuffle path (which we didn't change): VertexWritable.write()/readFields() go through KryoShimServiceLoader, which uses the GryoPool from HadoopPools. That pool is built with initializeMapper(m -> m.registrationRequired(false)) — i.e. deliberately unlocked, and not hardened. That's consistent with the threat model, which explicitly places "the Hadoop object pools that additionally run unlocked" out-of-model (they only carry intra-job graph elements between mapper and reducer, not attacker-controlled bytes). I think you should probably add a clarifying comment there about why we didn't disable the JavaSerializer there.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added comments, thanks!

Comment thread docs/src/upgrade/release-3.7.x.asciidoc
@codecov-commenter

codecov-commenter commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 75.53%. Comparing base (4722890) to head (643343c).
⚠️ Report is 48 commits behind head on 3.7-dev.

Additional details and impacted files
@@              Coverage Diff              @@
##             3.7-dev    #3578      +/-   ##
=============================================
+ Coverage      75.49%   75.53%   +0.03%     
- Complexity     13161    13184      +23     
=============================================
  Files           1092     1093       +1     
  Lines          67208    67269      +61     
  Branches        7391     7401      +10     
=============================================
+ Hits           50742    50810      +68     
+ Misses         13837    13836       -1     
+ Partials        2629     2623       -6     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@GumpacG GumpacG changed the title Disable Java serialization on Gryo IO reads TINKERPOP-3278 Disable Java serialization on Gryo IO reads Jul 28, 2026
this.typeRegistrations = builder.typeRegistrations;
this.javaSerializationAllowed = builder.javaSerializationAllowed;
this.typeRegistrations = builder.typeRegistrations.stream().
filter(tr -> javaSerializationAllowed || !(tr.getShadedSerializer() instanceof JavaSerializer)).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is there a reason we are doing the filtering of direct JavaSerializer here in the constructor, instead of inside createMapper() via the resolvesToJavaSerializer helper with the rest?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It was a miss. I've moved it to the helper function. Thanks!

@spmallette

Copy link
Copy Markdown
Contributor

VOTE +1 - recommend squashing commits in this case. not sure there's much value or history to preserve in this little change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants