Skip to content

Commit dd4ff02

Browse files
committed
reject non-finite deflection in setControl
The CTRL channel routes straight into setControl, and that setter was the one of the three without the guard setAlpha and setSpeed have carried since UDP landed: math.Max/math.Min propagate a NaN instead of clamping it. A stray "CTRL nan" from anywhere on the network left controlDeg permanently NaN, and in a scene whose control surface is rotated by it, that surface leaves the rasterized mask entirely. Reproduced before fixing and pinned at both levels: the setter, and the wire form through the real parse-and-drain path. Also checks the rebind Close in the multicast supervisor, which the linter flagged; the socket is being replaced either way, so the error has nowhere to go but the blank identifier.
1 parent e2df90c commit dd4ff02

3 files changed

Lines changed: 72 additions & 1 deletion

File tree

control_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package main
2+
3+
import (
4+
"math"
5+
"testing"
6+
)
7+
8+
// TestSetControlRejectsNonFinite guards the third of the three setters every
9+
// input path shares. math.Max/math.Min propagate a NaN rather than clamping
10+
// it, and a NaN deflection rotates the control surface out of the rasterized
11+
// mask, so an unchecked "CTRL nan" over UDP would delete part of the body with
12+
// no way back. setAlpha and setSpeed have carried this guard since the UDP
13+
// channel landed; this one arrived with CTRL.
14+
func TestSetControlRejectsNonFinite(t *testing.T) {
15+
g := simGame()
16+
g.setScene(nekoScene(), "neko")
17+
want := g.controlDeg
18+
19+
for _, bad := range []float64{math.NaN(), math.Inf(1), math.Inf(-1)} {
20+
g.setControl(bad)
21+
if g.controlDeg != want {
22+
t.Fatalf("setControl(%v): controlDeg = %v, want unchanged %v", bad, g.controlDeg, want)
23+
}
24+
}
25+
26+
// The guard must not be over-broad: a valid deflection still applies, and
27+
// an out-of-range one still clamps rather than being rejected.
28+
g.setControl(12)
29+
if g.controlDeg != 12 {
30+
t.Errorf("setControl(12): controlDeg = %v, want 12", g.controlDeg)
31+
}
32+
g.setControl(9999)
33+
if g.controlDeg != controlLimit {
34+
t.Errorf("setControl(9999): controlDeg = %v, want clamped to %v", g.controlDeg, controlLimit)
35+
}
36+
}
37+
38+
// TestUDPControlChannelIsGuarded replays the wire form of the same attack, so
39+
// the guarantee is pinned at the protocol level and not just at the setter.
40+
func TestUDPControlChannelIsGuarded(t *testing.T) {
41+
g := simGame()
42+
g.setScene(nekoScene(), "neko")
43+
44+
for _, wire := range []string{"CTRL nan", "CTRL inf", "CTRL -inf", "CTRL 1e400"} {
45+
g.applyControlMessage(wire)
46+
g.drainPending()
47+
if math.IsNaN(g.controlDeg) || math.IsInf(g.controlDeg, 0) {
48+
t.Fatalf("%q: controlDeg went non-finite (%v)", wire, g.controlDeg)
49+
}
50+
}
51+
52+
solid := 0
53+
for _, v := range g.sceneMask(0) {
54+
if v {
55+
solid++
56+
}
57+
}
58+
if solid == 0 {
59+
t.Error("the body vanished from the mask after hostile CTRL input")
60+
}
61+
}

game.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,14 @@ func (g *Game) controlObject() *scene.Object {
541541
// setControl changes the live control-surface deflection in place (no reset),
542542
// re-applying immediately so it moves even while the timeline is paused.
543543
func (g *Game) setControl(deg float64) {
544+
// Reject non-finite input here, the way setAlpha and setSpeed already do:
545+
// math.Max/math.Min propagate a NaN instead of clamping it, and a NaN
546+
// deflection rotates the control surface out of the rasterized mask, so a
547+
// stray "CTRL nan" over the network would delete part of the body with no
548+
// UI path back. This is the choke point every caller shares.
549+
if math.IsNaN(deg) || math.IsInf(deg, 0) {
550+
return
551+
}
544552
g.controlDeg = math.Max(-controlLimit, math.Min(controlLimit, deg))
545553
g.noteUserInput()
546554
if g.scn == nil {

udpcontrol.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@ func (g *Game) udpControlSupervisor(addr string, conn net.PacketConn) {
6868
// The read loop only returns on a real socket error; rebinding
6969
// below is also the recovery path for that, not just the timer.
7070
case <-time.After(udpRebindInterval):
71-
conn.Close()
71+
// Closing is how the read loop is unblocked, so a close error has
72+
// nowhere useful to go: the socket is being replaced regardless.
73+
_ = conn.Close()
7274
<-done
7375
}
7476

0 commit comments

Comments
 (0)