fix/signal property sync type check - #25438
Open
totally-not-ai[bot] wants to merge 8 commits into
Open
Conversation
A property sync RPC hands the raw client value to the write callback of a two-way property binding through an unchecked cast. When the client sends a JSON object for a signal of another type, the value either escapes as a ClassCastException from the RPC handler or, when generic code has erased the callback type, is committed to the shared tree - after which every read of the signal fails in every session, since SharedValueSignal.set only asserts the value type.
A property sync RPC passed the raw client value to the write callback of a two-way property binding through an unchecked cast. A value of the wrong type either escaped the RPC handler as a ClassCastException or, when generic code had erased the type of the callback, ended up committed to the signal tree, after which every read of the signal failed in every session using the tree. Such a value is now rejected before the callback is invoked when the bound signal declares a value type, and a ClassCastException from the callback is handled the same way for signals that don't. Either way the property is reverted to the current signal value, like when a write callback declines a value. SharedValueSignal also verifies the value type of set and replace instead of only asserting it, so that a wrong-typed value can no longer be committed to the tree in production.
The architecture test that forbids references to shared signal classes from code outside the signals packages rejected reading the value type directly through SignalUtils, so the lookup goes through a helper in the signals impl package instead.
Catching every ClassCastException from a write callback also swallowed unrelated cast failures in application code, and a callback that writes into a signal declaring its value type threw past the guard entirely, turning a crafted client value into a server error instead of an ignored update. The type check of a shared value signal now throws a dedicated InvalidSignalValueTypeException that the binding recognizes, and a ClassCastException is only treated as a refused value when the exception is about the type of the value that was passed in. Anything else propagates like every other write callback failure.
Contributor
The write callback exists only to pass the value on to the bound signal, so a ClassCastException or an InvalidSignalValueTypeException crossing it means the value doesn't fit the signal. Catching both covers every binding shape: the compiler generates the cast into the callback when the value type is known, and the shared signal itself rejects the value when generic code has erased that cast. This makes the up-front value type lookup redundant, so SignalTypeUtils, SignalUtils.rawValueTypeOf and the SharedValueSignal.valueType accessor are all dropped again, along with the ClassCastException message forensics that were needed to tell an erased cast apart from an unrelated one. An unrelated ClassCastException from a write callback is now logged and reverted like any other rejected value instead of propagating.
The new cases registered an empty property change listener, which left the most important part of a rejected value unasserted: that it never reaches application listeners. They now use a listener that fails the test, matching the neighbouring cases. Also covers the two behaviours that the widened catch introduced but that no case stated. A ClassCastException thrown by the callback itself is reverted rather than propagated, since it is indistinguishable from the cast that the compiler generates for a typed callback. An erased write to a local signal still stores the client value as-is, since a local signal declares no value type and cannot poison a tree that other sessions read.
Sonar reads updateSignalByWriteCallback through the @NullMarked class and concludes that the new value can never be null, which makes the null check in the warning message dead code. The logged cause already names both the offending type and the type the signal expects, so the message doesn't need to repeat it. Also hoist the cast and the value out of the assertThrows lambda in replace_valueOfWrongType_throws so that only the call under test can throw.
|
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.



What
A property sync RPC passed the raw client value to the write callback of a two-way property binding through an unchecked cast. A value of the wrong type then either escaped the RPC handler as a
ClassCastExceptionor — when generic code had erased the type of the callback — was committed to the shared signal tree. In the latter case the tree ends up holding JSON that cannot be deserialized back into the declared value type, so every subsequent read of that signal fails in every session sharing the tree.SharedValueSignal.setonlyasserted the value type, so nothing stopped it in production.How
SignalBindingFeature.updateSignalByWriteCallbacknow wraps the write callback and treats aClassCastExceptionor anInvalidSignalValueTypeExceptioncrossing it as "the bound signal cannot hold this value": the failure is logged as a warning and the property is reverted to the current signal value, exactly like when a write callback declines a value. Since the callback's only job is to pass the value on to the signal, catching at that boundary covers every binding shape:ClassCastException;SharedValueSignal.set/replacenow verify the value type instead of only asserting it, throwing a newInvalidSignalValueTypeException(anIllegalArgumentException). This is what closes the poisoning hole in production builds, where assertions are disabled.InvalidSignalValueTypeExceptionis a new public exception incom.vaadin.flow.signalsso that the binding layer can recognise a refused value without inspecting exception messages.Because detection happens at the callback boundary, no up-front value type lookup is needed: earlier iterations of this branch added
SignalTypeUtils,SignalUtils.rawValueTypeOfand aSharedValueSignal.valueTypeaccessor plusClassCastExceptionmessage forensics — all of those are gone again, and the net diff to public API is just the new exception class.Behaviour notes
ClassCastExceptionthrown inside a write callback is now logged and reverted rather than propagated. It is indistinguishable from the cast the compiler generates for a typed callback, so it is handled the same way.API Changes
com.vaadin.flow.signals.InvalidSignalValueTypeException
com.vaadin.flow.signals.shared.SharedValueSignal
Test summary
SharedValueSignal<String>bound with a typed callback leaves the signal and the property at"foo", and fires no property change eventSharedValueSignal<String>leaves the signal readable at"foo"ValueSignalwhose erased callback writes into aSharedValueSignalis still rejected and revertedClassCastExceptionthrown inside the write callback is reverted, not propagatedValueSignalstores the client value as-is and fires the change eventSharedValueSignal.setwith a wrong-typed value throwsIllegalArgumentExceptionand leaves the previous value in placeassert) the tree can be poisoned in productionSharedValueSignal.replacevalidates the new value before submitting anything, leaving the signal untouchedreplacewas the remaining unchecked write path into the treeElementBindPropertyTest.bindProperty_clientSendsObjectForStringSignal_updateIgnored→ 1ElementBindPropertyTest.bindProperty_clientSendsObjectForErasedStringSignal_signalNotPoisoned→ 2ElementBindPropertyTest.bindProperty_clientSendsObjectForSignalWritingToSharedSignal_updateIgnored→ 3ElementBindPropertyTest.bindProperty_writeCallbackThrowsUnrelatedClassCastException_updateIgnored→ 4ElementBindPropertyTest.bindProperty_clientSendsObjectForErasedLocalSignal_valueAccepted→ 5SharedValueSignalTest.constructor_type_noValueAndTypeIsUsed(changed) → 6SharedValueSignalTest.constructor_initialValue_valueUsedAndTypeIsInferred(changed) → 6SharedValueSignalTest.replace_valueOfWrongType_throws→ 7Rows 1–5 assert "no property change event" through a shared listener that fails the test, so the "never reaches application listeners" part of each row is pinned without a separate row. Deliberately untested: the exact exception subtype — rows 6 and 7 assert the
IllegalArgumentExceptionsupertype rather thanInvalidSignalValueTypeException, which is instead exercised end-to-end by rows 2 and 3 (the binding only reverts if it recognises that specific type). The warning log message itself is not asserted.