Uh oh!
There was an error while loading. Please reload this page.
Harden the java serialization fallback bridge with a JEP-290 serial filter - #9075
Conversation
…ilter Add topology.fall.back.on.java.serialization.filter, a JEP-290 filter pattern for the java serialization fallback bridge. DefaultKryoFactory parses the pattern once at kryo construction, so an invalid pattern fails worker setup with the config key in the error, and SerializableSerializer installs it via setObjectInputFilter whenever it deserializes. The filter is topology-scoped and also covers programmatic construction such as local mode, which a JVM-wide jdk.serialFilter in worker.childopts does not reach. conf/defaults.yaml sets a default pattern: a deny-list of well-known gadget namespaces (commons-collections 3/4 functors and comparators, beanutils, xalan external and JDK-internal, rowset, c3p0, groovy closures) plus maxbytes=10485760. An empty or unset value leaves the bridge unfiltered, as before. The pattern uses JEP-290 wildcards: pkg.* covers direct package members and pkg.** also covers subpackages; tests exercise both depths against loadable classes in denied packages, end to end through KryoValuesSerializer and KryoValuesDeserializer.
rzo1
left a comment
There was a problem hiding this comment.
Thanks for this — and for the thorough writeup and the issue thread beforehand. I built the branch and ran the new tests locally on JDK 25: 8/8 pass, and CI is green.
The mechanism is right, and I want to say that up front: topology-scoped rather than JVM-wide, parsed once at kryo construction so a bad pattern fails fast, installed per ObjectInputStream, null/empty preserving the old behaviour, and the no-arg SerializableSerializer constructor kept for compatibility. That is the shape I would have asked for.
My objections are about the default, not the code.
1. Please don't ship the deny-list as the conf/defaults.yaml default
This is a behavioural break in a patch release for every cluster running topology.fall.back.on.java.serialization: true — new class rejections and a 10 MB cap on a path that has no cap today. You flagged this yourself in the description; I'd like to take you up on the offer and keep option (2) pure.
Ship the mechanism with an empty default, and put the recommended pattern in docs/SECURITY.md as a copy-paste block operators can adopt deliberately. Point 2 is the real argument for this.
2. As a security default, the deny-list is materially incomplete
Missing entry points that are on the JDK or the Storm worker classpath, so reachable on a stock cluster:
javax.management.BadAttributeValueExpException— the trigger for a large share of published chainssun.reflect.annotation.AnnotationInvocationHandlercom.sun.jndi.**,java.rmi.**clojure.**— Storm ships Clojure in the worker classpathorg.apache.commons.fileupload.**,bsh.**,org.python.**,org.jboss.**
A twelve-entry list that stops the names we happened to think of, shipped as a default, buys false confidence: an operator reads "hardened" and stops looking. JEP-290's own guidance is allow-list first. That is a fine thing to document as a starting point for someone who has consciously enabled the fallback and knows their payload classes; it is not a good thing to enable silently on their behalf. If a deny-list does ship as the default it needs a much wider set plus an explicit "not exhaustive" note.
3. The filter only exists in DefaultKryoFactory, but defaults.yaml and the docs present it as unconditional
SerializationFactory.getKryo() (SerializationFactory.java:56) instantiates whatever topology.kryo.factory names. A custom IKryoFactory — a documented extension point — silently gets no filter even with the key set.
Either install it in SerializationFactory.getKryo after the kryoFactory.getKryo(conf) call, or state the DefaultKryoFactory-only scope in the Config javadoc and both doc pages.
4. Validate at submit time, not at worker start
@IsString only checks the type, so a bad pattern currently fails every worker at kryo construction and turns into a supervisor restart loop, with nothing surfaced at submission. A ConfigValidation validator that calls ObjectInputFilter.Config.createFilter would let nimbus reject it on submit — much better than discovering it per-worker.
Also RuntimeException → IllegalArgumentException in DefaultKryoFactory.getJavaSerializationFilter; the message itself is good.
5. Doc scoping is too broad
topology.fall.back.on.java.serialization: true also loosens DefaultStateSerializer — see docs/State-checkpointing.md:230, where it is documented as the escape hatch for state that predates kryo registration. That path builds its own new Kryo(...) rather than going through DefaultKryoFactory, so the filter never reaches it. (Lower risk, since unregistered classes there go through FieldSerializer and no readObject runs — but the new prose reads as if the whole config switch is now filtered.) Please scope the wording in Serialization.md and SECURITY.md to the tuple fallback bridge.
Smaller things
- Test fixtures squat third-party package names (
org.apache.commons.collections.functors,...comparators,com.mchange.v2.c3p0.impl) understorm-client/test/jvm. I checked and none of those artifacts is on storm-client's test classpath today, so there's no split package yet — but it's a trap for whoever adds one. Suggestion: keep one end-to-end round-trip on a Storm-owned class with a topology-scoped pattern (which the!java.util.PriorityQueuetests already do nicely), and cover the shipped defaults by implementingObjectInputFilter.FilterInfoin the test and callingcheckInputdirectly. Same coverage, no fake packages. KryoSerializableDefault.setJavaSerializationFilteris a public setter for a security control. A constructor parameter would be harder to get wrong.testDefaultFilterEnforcesMaxBytesLimitallocates ~11 MB per run. It's fine — the whole class runs in 0.26s — just noting it in case it ever moves somewhere hotter.
Merge order
Worth landing this after #9076. On master today this PR alone turns a gadget payload into a worker kill: the filter's InvalidClassException propagates out of SerializableSerializer.read and up through DeserializingConnectionCallback.recv() into Utils.handleUncaughtException. With #9076 in first, InvalidClassException extends IOException, so a filtered payload is dropped and counted instead — which is the behaviour this PR's documentation implies but doesn't yet get on its own.
L1nq0
commented
Sep 4, 2026
Thanks for the thorough review, and for saying up front that the mechanism is the shape you'd have asked for. Taking the points in order:
Smaller things, all accepted: the test fixtures stop squatting third-party package names; coverage keeps the end-to-end round-trips on Storm-owned classes with topology-scoped patterns, and checks the shipped pattern by implementing ObjectInputFilter.FilterInfo in the test and calling checkInput directly. setJavaSerializationFilter becomes a constructor parameter instead of a public setter. The maxbytes allocation stays as is, noted. Merge order: agreed, and thanks for laying out the interaction. Once #9076 lands I'll rebase this on it, so what reviewers see is the composed behaviour: a filtered payload dropped and counted rather than killing the worker. |
reiabreu
commented
Sep 5, 2026
Hey folks, I'll try to provide some feedback over the weekend. Thank you |
hey! I did a sweep with the help of an LLM and this is what is outstanding:
|
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Remove the default value of topology.fall.back.on.java.serialization.filter from conf/defaults.yaml. A shipped deny-list is not a security boundary, and defaulting it on silently replaced the operator's JVM-wide jdk.serialFilter on the bridge streams. docs/SECURITY.md now carries a sample pattern instead: the previous gadget deny-list plus well-known additions (JMX reflection gadget, JNDI, RMI, Clojure, FileUpload, BeanShell, Jython, JBoss). maxbytes alone would not bound a single large primitive array, so the pattern also carries maxdepth/maxrefs/maxarray. SerializableSerializer now reads the stream's existing filter (a JVM-wide jdk.serialFilter, if set) and merges the two via ObjectInputFilter.merge, so a JVM-wide allow-list still applies on this path. The bridge also validates the declared length before allocating: a negative length is always refused, and on buffered input (the tuple paths) a declared length larger than the bytes remaining in the frame is refused before the byte[] allocation. Stream-backed programmatic use keeps the previous behavior, since the stream may still deliver the declared bytes. KryoSerializableDefault takes the filter as a constructor argument and holds it in a final field; the mutable setter is removed so the filter cannot be swapped after construction. ConfigValidation gains SerialFilterPatternValidator, wired to the TOPOLOGY_FALL_BACK_ON_JAVA_SERIALIZATION_FILTER field, so a malformed pattern fails topology submission with the key named; the parse error in DefaultKryoFactory is now an IllegalArgumentException. The filter applies to the default factory's fallback bridge; a custom topology.kryo.factory or the pre-kryo state serializer is not covered. Tests now use JDK classes only and cover wildcard depth, the sample pattern, the size limits, the length guard, and the merge helper.
L1nq0
commented
Sep 7, 2026
@reiabreu Thanks for the sweep; all four points are in the revision, pushed as 54e4926.
Beyond your list, the same revision also took GGraziadei's inline points (constructor-injected filter instead of a public setter, and a length guard in SerializableSerializer.read() so the attacker-sized buffer is no longer allocated from an unvalidated length field on the tuple path), and rzo1's earlier points (empty default, submit-time validation of the pattern, Storm-owned test classes). |
Uh oh!
There was an error while loading. Please reload this page.
rzo1
commented
Sep 8, 2026
Thanks for the contribution @L1nq0 - thanks for the reviews! |
Closes#9073
Upgrade note: no default pattern ships with this change. The key is unset in conf/defaults.yaml and the fallback bridge behaves exactly as on master unless a topology sets it. The recommended pattern lives in docs/SECURITY.md as a block the operator opts into. For operators who also run a JVM-wide -Djdk.serialFilter: when the topology key is set, the per-stream filter is merged with the process-wide one (ObjectInputFilter.merge), so a reject from either side still rejects and the operator's own filter keeps applying on this path.
What this adds
A new config key, topology.fall.back.on.java.serialization.filter, holding a JEP-290 filter pattern for the java serialization fallback bridge. The pattern is parsed once at kryo construction, and an invalid pattern is rejected at topology submission: a ConfigValidation validator runs it through ObjectInputFilter.Config.createFilter, so a typo fails on nimbus with an IllegalArgumentException naming the key instead of producing a supervisor restart loop. SerializableSerializer installs the filter on every ObjectInputStream it opens, merged with any JVM-wide serial filter as above. The filter is topology-scoped and also covers programmatic construction such as local mode, which a JVM-wide filter in worker.childopts cannot reach.
The filter covers the default factory's fallback bridge. A custom topology.kryo.factory is on its own, since SerializationFactory only ever gets back a finished Kryo with no way to reach a third-party factory's fallback path; DefaultStateSerializer, the pre-kryo state escape hatch, is not covered either.
The recommended pattern
docs/SECURITY.md carries the block: the gadget-namespace deny entries (commons-collections 3/4 functors and comparators, beanutils, xalan external and JDK-internal, rowset, c3p0, groovy closures, plus BadAttributeValueExpException, AnnotationInvocationHandler, com.sun.jndi, java.rmi, clojure, commons-fileupload, bsh, org.python, org.jboss) followed by maxdepth=64;maxrefs=2097152;maxarray=1048576;maxbytes=10485760. It carries an explicit not-exhaustive note and is worded per JEP-290's allow-list-first guidance: a starting point for an operator who has turned the fallback on and knows which classes cross it, not a security boundary. Payloads that legitimately need bigger arrays or sizes should raise maxarray/maxbytes accordingly.
On the size limits: maxbytes counts per object, not per tuple, because a new ObjectInputStream is opened for each value. It can also be overshot by a single large array, since the filter sees the array before reading its contents; that is the reason the sample also carries maxarray.
Hardening in the bridge itself
SerializableSerializer.read() no longer allocates from an unvalidated length field. A negative declared length is rejected on any input, and on buffered input (which is what the tuple path uses) a declared length that exceeds the bytes remaining in the input is rejected before the allocation. Stream-backed programmatic use of the deserializer is exempt from the upper bound, because a stream may still deliver the declared bytes after the buffer runs out (examples/storm-loadgen feeds it an Input over a request InputStream). The filter itself is passed to KryoSerializableDefault at construction and held in a private final field; there is no post-construction mutator.
Tests
15 cases in SerializableSerializerFilterTest, most running end to end through KryoValuesSerializer/KryoValuesDeserializer. They cover reject and allow round-trips on both wildcard depths (pkg.* matches direct members, pkg.** also matches subpackages) on JDK classes that really go through the bridge, the unset-key no-op, invalid patterns failing fast, the sample pattern checked against deny entries whose classes exist on the classpath, the maxbytes limit across many small arrays, and the maxarray limit. The declared-length guard gets three cases: oversized and negative lengths on buffered input, and the stream-backed exemption. A separate test asserts the sample pattern appears verbatim in docs/SECURITY.md, and the merge helper gets unit tests for the null case, a denial from either side, and the tighter limit winning. TestConfigValidate additionally covers submit-time rejection of invalid patterns with the key named.
Merge order: this is best merged after #9076, so what lands on master is the composed behaviour: a payload the filter rejects is dropped and counted as a deserializationFailure rather than killing the receiving worker. I will rebase onto #9076 once it merges.