Skip to content

fix/signal property sync type check - #25438

Open
totally-not-ai[bot] wants to merge 8 commits into
mainfrom
fix/signal-property-sync-type-check
Open

fix/signal property sync type check#25438
totally-not-ai[bot] wants to merge 8 commits into
mainfrom
fix/signal-property-sync-type-check

Conversation

@totally-not-ai

@totally-not-ai totally-not-ai Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

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 ClassCastException or — 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.set only asserted the value type, so nothing stopped it in production.

How

  • SignalBindingFeature.updateSignalByWriteCallback now wraps the write callback and treats a ClassCastException or an InvalidSignalValueTypeException crossing 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:
    • the compiler generates the cast into the callback when the value type is known → ClassCastException;
    • generic code has erased that cast → the shared signal itself rejects the value.
  • SharedValueSignal.set / replace now verify the value type instead of only asserting it, throwing a new InvalidSignalValueTypeException (an IllegalArgumentException). This is what closes the poisoning hole in production builds, where assertions are disabled.
  • InvalidSignalValueTypeException is a new public exception in com.vaadin.flow.signals so 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.rawValueTypeOf and a SharedValueSignal.valueType accessor plus ClassCastException message forensics — all of those are gone again, and the net diff to public API is just the new exception class.

Behaviour notes

  • An unrelated ClassCastException thrown 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.
  • An erased write to a local signal still stores the client value as-is. A local signal declares no value type and is confined to one session, so it cannot poison a tree that other sessions read.

API Changes

com.vaadin.flow.signals.InvalidSignalValueTypeException

// Added
public class InvalidSignalValueTypeException extends IllegalArgumentException // thrown when a value is written to a signal that cannot hold that type
public InvalidSignalValueTypeException(String message)

com.vaadin.flow.signals.shared.SharedValueSignal

// Changed
- public SignalOperation<T> set(T value) // wrong value type only asserted
+ public SignalOperation<T> set(T value) // now throws InvalidSignalValueTypeException for a wrong-typed value
- public SignalOperation<Void> replace(T expectedValue, T newValue) // value type not checked
+ public SignalOperation<Void> replace(T expectedValue, T newValue) // now throws InvalidSignalValueTypeException for a wrong-typed new value

Test summary

# Status What the test verifies Why it matters
1 A client-sent JSON object for a SharedValueSignal<String> bound with a typed callback leaves the signal and the property at "foo", and fires no property change event The core fix: a crafted client value must not become a server error nor reach application listeners
2 The same client value through an erased write callback into a SharedValueSignal<String> leaves the signal readable at "foo" This is the poisoning path — a committed JSON object breaks every read of the signal in every session on the tree
3 Binding a ValueSignal whose erased callback writes into a SharedValueSignal is still rejected and reverted Only the shared signal can detect the mismatch here; covers the binding shape where neither cast nor bound-signal type helps
4 An unrelated ClassCastException thrown inside the write callback is reverted, not propagated Pins the deliberate widening of the catch — it must not surface as a server error
5 An erased write to a local ValueSignal stores the client value as-is and fires the change event Guards against over-rejecting: local signals declare no value type and are session-confined
6 SharedValueSignal.set with a wrong-typed value throws IllegalArgumentException and leaves the previous value in place Without a real check (not an assert) the tree can be poisoned in production
7 SharedValueSignal.replace validates the new value before submitting anything, leaving the signal untouched replace was the remaining unchecked write path into the tree
  • ElementBindPropertyTest.bindProperty_clientSendsObjectForStringSignal_updateIgnored → 1
  • ElementBindPropertyTest.bindProperty_clientSendsObjectForErasedStringSignal_signalNotPoisoned → 2
  • ElementBindPropertyTest.bindProperty_clientSendsObjectForSignalWritingToSharedSignal_updateIgnored → 3
  • ElementBindPropertyTest.bindProperty_writeCallbackThrowsUnrelatedClassCastException_updateIgnored → 4
  • ElementBindPropertyTest.bindProperty_clientSendsObjectForErasedLocalSignal_valueAccepted → 5
  • SharedValueSignalTest.constructor_type_noValueAndTypeIsUsed (changed) → 6
  • SharedValueSignalTest.constructor_initialValue_valueUsedAndTypeIsInferred (changed) → 6
  • SharedValueSignalTest.replace_valueOfWrongType_throws → 7

Rows 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 IllegalArgumentException supertype rather than InvalidSignalValueTypeException, 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.

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.
@github-actions github-actions Bot added the +0.1.0 label Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Test Results

 1 434 files  ±0   1 518 suites  ±0   1h 29m 35s ⏱️ - 2m 23s
11 888 tests +6  11 820 ✅ +6  68 💤 ±0  0 ❌ ±0 
12 206 runs  +6  12 138 ✅ +6  68 💤 ±0  0 ❌ ±0 

Results for commit da4ae4a. ± Comparison against base commit bb9132f.

♻️ This comment has been updated with latest results.

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.
@Artur-
Artur- requested a review from Legioth September 6, 2026 09:00
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.
@sonarqubecloud

sonarqubecloud Bot commented Sep 6, 2026

Copy link
Copy Markdown

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants