Skip to content

Commit 1415317

Browse files
mikhail-dclclaude
andcommitted
FieldValidator: reject NaN/Infinity in client-supplied floats
Covers Position, Velocity, RotationY, Movement/Slide blends, and optional HeadYaw/HeadPitch across input/emote/teleport messages. Guards the snapshot ring from poisoned values that break observer interpolation and inflate IsSameState diffs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d13a3f4 commit 1415317

3 files changed

Lines changed: 152 additions & 4 deletions

File tree

docs/hardening.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -280,9 +280,18 @@ that fall outside the encoder's grid produce garbage global positions downstream
280280
### Defense
281281

282282
`src/DCLPulse/Messaging/Hardening/FieldValidator.cs` — one class, three per-message methods
283-
(`ValidatePlayerStateInput`, `ValidateEmoteStart`, `ValidateTeleport`). Parcel bounds come
284-
from the existing `ParcelEncoderOptions` (`ParcelEncoder.IsValidIndex`). On any violation the
285-
peer is disconnected with a message-type-specific `DisconnectReason`.
283+
(`ValidatePlayerStateInput`, `ValidateEmoteStart`, `ValidateTeleport`). Checks performed:
284+
285+
- Parcel-index bounds (delegated to `ParcelEncoder.IsValidIndex`).
286+
- String length caps (`EmoteId`, `Realm`).
287+
- `EmoteStart.DurationMs` upper bound.
288+
- **Finiteness** (`float.IsFinite`) on every client-supplied float: `Position`, `Velocity`,
289+
`RotationY`, `MovementBlend`, `SlideBlend`, optional `HeadYaw`/`HeadPitch`,
290+
`TeleportRequest.Position`. Rejects NaN and ±Infinity. Optional fields (head yaw/pitch)
291+
are checked only when the proto's `Has*` flag is set.
292+
- Null-guard on `Position`/`Velocity` proto sub-messages to prevent NRE on malformed input.
293+
294+
On any violation the peer is disconnected with a message-type-specific `DisconnectReason`.
286295

287296
### Config
288297

src/DCLPulse/Messaging/Hardening/FieldValidator.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using Pulse.Metrics;
55
using Pulse.Peers;
66
using Pulse.Transport;
7+
using Vector3Proto = Decentraland.Common.Vector3;
78

89
namespace Pulse.Messaging.Hardening;
910

@@ -30,6 +31,9 @@ public bool ValidatePlayerStateInput(PeerIndex from, PeerState state, PlayerStat
3031
if (!IsValidParcel(input.State.ParcelIndex))
3132
return Reject(from, state, DisconnectReason.INVALID_INPUT_FIELD);
3233

34+
if (!IsValidPlayerStateFloats(input.State))
35+
return Reject(from, state, DisconnectReason.INVALID_INPUT_FIELD);
36+
3337
return true;
3438
}
3539

@@ -44,6 +48,9 @@ public bool ValidateEmoteStart(PeerIndex from, PeerState state, EmoteStart emote
4448
if (!IsValidParcel(emote.PlayerState.ParcelIndex))
4549
return Reject(from, state, DisconnectReason.INVALID_EMOTE_FIELD);
4650

51+
if (!IsValidPlayerStateFloats(emote.PlayerState))
52+
return Reject(from, state, DisconnectReason.INVALID_EMOTE_FIELD);
53+
4754
return true;
4855
}
4956

@@ -58,8 +65,33 @@ public bool ValidateTeleport(PeerIndex from, PeerState state, TeleportRequest re
5865
if (!IsValidParcel(request.ParcelIndex))
5966
return Reject(from, state, DisconnectReason.INVALID_TELEPORT_FIELD);
6067

68+
if (!IsFinite(request.Position))
69+
return Reject(from, state, DisconnectReason.INVALID_TELEPORT_FIELD);
70+
6171
return true;
6272
}
6373

6474
private bool IsValidParcel(int index) => parcelEncoder.IsValidIndex(index);
75+
76+
/// <summary>
77+
/// Rejects NaN/±Infinity on every client-supplied float in <see cref="PlayerState" />.
78+
/// These values would propagate into the snapshot ring and fan out to observers, where
79+
/// NaN breaks interpolation and inflates the <c>IsSameState</c> diff check (NaN != NaN
80+
/// under IEEE). Also rejects malformed proto with unset Position/Velocity, which would
81+
/// otherwise NRE in the handler. Optional fields (head yaw/pitch) are checked only
82+
/// when present.
83+
/// </summary>
84+
private static bool IsValidPlayerStateFloats(PlayerState s) =>
85+
s.Position is not null
86+
&& s.Velocity is not null
87+
&& IsFinite(s.Position)
88+
&& IsFinite(s.Velocity)
89+
&& float.IsFinite(s.RotationY)
90+
&& float.IsFinite(s.MovementBlend)
91+
&& float.IsFinite(s.SlideBlend)
92+
&& (!s.HasHeadYaw || float.IsFinite(s.HeadYaw))
93+
&& (!s.HasHeadPitch || float.IsFinite(s.HeadPitch));
94+
95+
private static bool IsFinite(Vector3Proto? v) =>
96+
v is not null && float.IsFinite(v.X) && float.IsFinite(v.Y) && float.IsFinite(v.Z);
6597
}

src/DCLPulseTests/Hardening/FieldValidatorTests.cs

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,13 @@ private FieldValidator Create(int maxEmoteIdLength = 64, int maxRealmLength = 12
3434

3535
private static PeerState NewState() => new (PeerConnectionState.AUTHENTICATED);
3636

37-
private static PlayerState ValidPlayerState() => new () { ParcelIndex = 100 };
37+
private static PlayerState ValidPlayerState() =>
38+
new ()
39+
{
40+
ParcelIndex = 100,
41+
Position = new Vector3(),
42+
Velocity = new Vector3(),
43+
};
3844

3945
private static EmoteStart ValidEmoteStart(string emoteId = "wave", uint? durationMs = 3000) =>
4046
new ()
@@ -183,6 +189,107 @@ public void OversizedRealm_Rejects()
183189
transport.Received(1).Disconnect(PEER, DisconnectReason.INVALID_TELEPORT_FIELD);
184190
}
185191

192+
// ── Numeric finiteness (NaN/Inf) ─────────────────────────────────
193+
194+
private static PlayerStateInput InputWith(Action<PlayerState> mutate)
195+
{
196+
PlayerState s = ValidPlayerState();
197+
mutate(s);
198+
return new PlayerStateInput { State = s };
199+
}
200+
201+
[Test]
202+
public void NaNPosition_InInput_Rejects()
203+
{
204+
FieldValidator v = Create();
205+
PlayerStateInput msg = InputWith(s => s.Position = new Vector3 { X = float.NaN, Y = 0, Z = 0 });
206+
207+
Assert.That(v.ValidatePlayerStateInput(PEER, NewState(), msg), Is.False);
208+
transport.Received(1).Disconnect(PEER, DisconnectReason.INVALID_INPUT_FIELD);
209+
}
210+
211+
[Test]
212+
public void InfinityVelocity_InInput_Rejects()
213+
{
214+
FieldValidator v = Create();
215+
PlayerStateInput msg = InputWith(s => s.Velocity = new Vector3 { X = 0, Y = float.PositiveInfinity, Z = 0 });
216+
217+
Assert.That(v.ValidatePlayerStateInput(PEER, NewState(), msg), Is.False);
218+
transport.Received(1).Disconnect(PEER, DisconnectReason.INVALID_INPUT_FIELD);
219+
}
220+
221+
[Test]
222+
public void NaNRotationY_InInput_Rejects()
223+
{
224+
FieldValidator v = Create();
225+
PlayerStateInput msg = InputWith(s => s.RotationY = float.NaN);
226+
227+
Assert.That(v.ValidatePlayerStateInput(PEER, NewState(), msg), Is.False);
228+
}
229+
230+
[Test]
231+
public void NaNMovementBlend_InInput_Rejects()
232+
{
233+
FieldValidator v = Create();
234+
PlayerStateInput msg = InputWith(s => s.MovementBlend = float.NaN);
235+
236+
Assert.That(v.ValidatePlayerStateInput(PEER, NewState(), msg), Is.False);
237+
}
238+
239+
[Test]
240+
public void NaNHeadYaw_InInput_Rejects()
241+
{
242+
FieldValidator v = Create();
243+
PlayerStateInput msg = InputWith(s => s.HeadYaw = float.NaN);
244+
245+
Assert.That(v.ValidatePlayerStateInput(PEER, NewState(), msg), Is.False);
246+
}
247+
248+
[Test]
249+
public void UnsetHeadYaw_IsIgnored()
250+
{
251+
// HasHeadYaw is false by default; finiteness check should skip it regardless of value.
252+
FieldValidator v = Create();
253+
var msg = new PlayerStateInput { State = ValidPlayerState() };
254+
255+
Assert.That(v.ValidatePlayerStateInput(PEER, NewState(), msg), Is.True);
256+
}
257+
258+
[Test]
259+
public void NullPosition_InInput_Rejects()
260+
{
261+
// Malformed proto with unset Position would NRE in the handler; validator must reject.
262+
FieldValidator v = Create();
263+
var msg = new PlayerStateInput
264+
{
265+
State = new PlayerState { ParcelIndex = 100, Velocity = new Vector3() },
266+
};
267+
268+
Assert.That(v.ValidatePlayerStateInput(PEER, NewState(), msg), Is.False);
269+
}
270+
271+
[Test]
272+
public void NaNPosition_InEmote_RejectsWithEmoteField()
273+
{
274+
FieldValidator v = Create();
275+
EmoteStart msg = ValidEmoteStart();
276+
msg.PlayerState.Position = new Vector3 { X = float.NaN, Y = 0, Z = 0 };
277+
278+
Assert.That(v.ValidateEmoteStart(PEER, NewState(), msg), Is.False);
279+
transport.Received(1).Disconnect(PEER, DisconnectReason.INVALID_EMOTE_FIELD);
280+
}
281+
282+
[Test]
283+
public void NaNPosition_InTeleport_RejectsWithTeleportField()
284+
{
285+
FieldValidator v = Create();
286+
TeleportRequest msg = ValidTeleport();
287+
msg.Position = new Vector3 { X = float.NaN, Y = 0, Z = 0 };
288+
289+
Assert.That(v.ValidateTeleport(PEER, NewState(), msg), Is.False);
290+
transport.Received(1).Disconnect(PEER, DisconnectReason.INVALID_TELEPORT_FIELD);
291+
}
292+
186293
[Test]
187294
public void OutOfRangeParcel_InTeleport_Rejects()
188295
{

0 commit comments

Comments
 (0)