fix(battle-node): harden SIO parse + narrow Matched OppoId/Seed to int

#3: SocketIoFrame.Parse now range-checks the packet type char (was
unchecked cast — any char outside 0-6 produced an undefined enum
value) and uses int.TryParse for ack-id (was int.Parse — a >10-digit
ack-id threw OverflowException, tearing down the WS mid-game). Both
now throw ArgumentException consistently. The read loop in
RealParticipant wraps both EIO and SIO parse calls with try-catch so
a malformed frame is logged and skipped instead of killing the battle.

#4: MatchedSelfInfo/MatchedOppoInfo OppoId and Seed narrowed from
long to int. The client reads both with Convert.ToInt32 inside a
swallowing try/catch — any value > int.MaxValue silently dropped the
Matched event, preventing the battle from starting. Seed was already
int-range (BattleSeeds.Stable returns int); OppoId (viewer ID) is
~847M in captures, well under int.MaxValue. The narrowing cast now
happens explicitly in ServerBattleFrames.BuildMatched at the wire
boundary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-04 21:57:29 -04:00
parent e9af7af1b8
commit 99129c786c
9 changed files with 65 additions and 33 deletions

View File

@@ -23,7 +23,7 @@ public static class ServerBattleFrames
public static MsgEnvelope BuildMatched( public static MsgEnvelope BuildMatched(
MatchContext selfCtx, MatchContext oppoCtx, MatchContext selfCtx, MatchContext oppoCtx,
long selfViewerId, long oppoViewerId, long selfViewerId, long oppoViewerId,
string battleId, long seed, IReadOnlyList<long> selfDeckOrder) => string battleId, int seed, IReadOnlyList<long> selfDeckOrder) =>
EnvelopeForPush(NetworkBattleUri.Matched, EnvelopeForPush(NetworkBattleUri.Matched,
new MatchedBody( new MatchedBody(
SelfInfo: new MatchedSelfInfo( SelfInfo: new MatchedSelfInfo(
@@ -34,7 +34,7 @@ public static class ServerBattleFrames
DegreeId: selfCtx.DegreeId, DegreeId: selfCtx.DegreeId,
FieldId: selfCtx.FieldId, FieldId: selfCtx.FieldId,
IsOfficial: selfCtx.IsOfficial != 0, IsOfficial: selfCtx.IsOfficial != 0,
OppoId: oppoViewerId, OppoId: (int)oppoViewerId,
Seed: seed), Seed: seed),
OppoInfo: new MatchedOppoInfo( OppoInfo: new MatchedOppoInfo(
CountryCode: oppoCtx.CountryCode, CountryCode: oppoCtx.CountryCode,
@@ -44,7 +44,7 @@ public static class ServerBattleFrames
DegreeId: oppoCtx.DegreeId, DegreeId: oppoCtx.DegreeId,
FieldId: oppoCtx.FieldId, FieldId: oppoCtx.FieldId,
IsOfficial: oppoCtx.IsOfficial != 0, IsOfficial: oppoCtx.IsOfficial != 0,
OppoId: selfViewerId, OppoId: (int)selfViewerId,
Seed: seed, Seed: seed,
OppoDeckCount: oppoCtx.SelfDeckCardIds.Count), OppoDeckCount: oppoCtx.SelfDeckCardIds.Count),
SelfDeck: BuildPlayerDeck(selfDeckOrder)), SelfDeck: BuildPlayerDeck(selfDeckOrder)),

View File

@@ -19,8 +19,8 @@ public sealed record MatchedSelfInfo(
[property: JsonPropertyName("fieldId")] int FieldId, [property: JsonPropertyName("fieldId")] int FieldId,
[property: JsonPropertyName("isOfficial")] [property: JsonPropertyName("isOfficial")]
[property: JsonConverter(typeof(NumericBoolJsonConverter))] bool IsOfficial, [property: JsonConverter(typeof(NumericBoolJsonConverter))] bool IsOfficial,
[property: JsonPropertyName("oppoId")] long OppoId, [property: JsonPropertyName("oppoId")] int OppoId,
[property: JsonPropertyName("seed")] long Seed); [property: JsonPropertyName("seed")] int Seed);
public sealed record MatchedOppoInfo( public sealed record MatchedOppoInfo(
[property: JsonPropertyName("country_code")] string CountryCode, [property: JsonPropertyName("country_code")] string CountryCode,
@@ -31,8 +31,8 @@ public sealed record MatchedOppoInfo(
[property: JsonPropertyName("fieldId")] int FieldId, [property: JsonPropertyName("fieldId")] int FieldId,
[property: JsonPropertyName("isOfficial")] [property: JsonPropertyName("isOfficial")]
[property: JsonConverter(typeof(NumericBoolJsonConverter))] bool IsOfficial, [property: JsonConverter(typeof(NumericBoolJsonConverter))] bool IsOfficial,
[property: JsonPropertyName("oppoId")] long OppoId, [property: JsonPropertyName("oppoId")] int OppoId,
[property: JsonPropertyName("seed")] long Seed, [property: JsonPropertyName("seed")] int Seed,
[property: JsonPropertyName("oppoDeckCount")] int OppoDeckCount); [property: JsonPropertyName("oppoDeckCount")] int OppoDeckCount);
public sealed record DeckCardRef( public sealed record DeckCardRef(

View File

@@ -134,7 +134,15 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
{ {
var text = Encoding.UTF8.GetString(msg.Value.Bytes); var text = Encoding.UTF8.GetString(msg.Value.Bytes);
if (text.Length == 0) continue; if (text.Length == 0) continue;
var eio = EngineIoFrame.Parse(text);
EngineIoFrame eio;
try { eio = EngineIoFrame.Parse(text); }
catch (ArgumentException ex)
{
_log.LogWarning(ex, "Dropping unparseable EIO frame from viewer {Vid}", ViewerId);
continue;
}
if (_diagnosticLogging) if (_diagnosticLogging)
{ {
_log.LogInformation( _log.LogInformation(
@@ -149,7 +157,13 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
} }
if (eio.Type != EngineIoPacketType.Message) continue; if (eio.Type != EngineIoPacketType.Message) continue;
var sio = SocketIoFrame.Parse(eio.Payload); SocketIoFrame sio;
try { sio = SocketIoFrame.Parse(eio.Payload); }
catch (ArgumentException ex)
{
_log.LogWarning(ex, "Dropping unparseable SIO frame from viewer {Vid}", ViewerId);
continue;
}
if (sio.AttachmentCount > 0) if (sio.AttachmentCount > 0)
{ {
pendingFrame = sio; pendingFrame = sio;

View File

@@ -52,7 +52,10 @@ public sealed class SocketIoFrame
if (string.IsNullOrEmpty(raw)) if (string.IsNullOrEmpty(raw))
throw new ArgumentException("Empty SIO payload", nameof(raw)); throw new ArgumentException("Empty SIO payload", nameof(raw));
var type = (SocketIoPacketType)(raw[0] - '0'); var typeChar = raw[0];
if (typeChar < '0' || typeChar > '6')
throw new ArgumentException($"Invalid SIO type char '{typeChar}'", nameof(raw));
var type = (SocketIoPacketType)(typeChar - '0');
var cursor = 1; var cursor = 1;
var attachmentCount = 0; var attachmentCount = 0;
@@ -84,7 +87,9 @@ public sealed class SocketIoFrame
{ {
var start = cursor; var start = cursor;
while (cursor < raw.Length && char.IsDigit(raw[cursor])) cursor++; while (cursor < raw.Length && char.IsDigit(raw[cursor])) cursor++;
ackId = int.Parse(raw.AsSpan(start, cursor - start)); if (!int.TryParse(raw.AsSpan(start, cursor - start), out var parsedAckId))
throw new ArgumentException("SIO ack-id overflows int32", nameof(raw));
ackId = parsedAckId;
} }
var argsJson = cursor < raw.Length ? raw.Substring(cursor) : string.Empty; var argsJson = cursor < raw.Length ? raw.Substring(cursor) : string.Empty;

View File

@@ -14,19 +14,19 @@ public class ServerBattleFramesTests
{ {
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(), var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(),
selfViewerId: 906243102, oppoViewerId: 847666884, selfViewerId: 906243102, oppoViewerId: 847666884,
battleId: "b", seed: 17_548_138L, selfDeckOrder: FixtureCtx().SelfDeckCardIds); battleId: "b", seed: 17_548_138, selfDeckOrder: FixtureCtx().SelfDeckCardIds);
Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.Matched)); Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.Matched));
var body = (MatchedBody)env.Body; var body = (MatchedBody)env.Body;
Assert.That(body.SelfInfo.OppoId, Is.EqualTo(847666884L)); Assert.That(body.SelfInfo.OppoId, Is.EqualTo(847666884));
Assert.That(body.OppoInfo.OppoId, Is.EqualTo(906243102L)); Assert.That(body.OppoInfo.OppoId, Is.EqualTo(906243102));
Assert.That(env.Bid, Is.EqualTo("b")); Assert.That(env.Bid, Is.EqualTo("b"));
} }
[Test] [Test]
public void BuildMatched_ContainsThirtyCardSelfDeck() public void BuildMatched_ContainsThirtyCardSelfDeck()
{ {
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(), 1, 2, "b", 17_548_138L, FixtureCtx().SelfDeckCardIds); var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(), 1, 2, "b", 17_548_138, FixtureCtx().SelfDeckCardIds);
var body = (MatchedBody)env.Body; var body = (MatchedBody)env.Body;
Assert.That(body.SelfDeck.Count, Is.EqualTo(30)); Assert.That(body.SelfDeck.Count, Is.EqualTo(30));
} }
@@ -35,7 +35,7 @@ public class ServerBattleFramesTests
public void BuildMatched_deck_idxs_pair_1to30_with_context_card_ids() public void BuildMatched_deck_idxs_pair_1to30_with_context_card_ids()
{ {
var draftedDeck = Enumerable.Range(1, 30).Select(i => 200_000_000L + i).ToList(); var draftedDeck = Enumerable.Range(1, 30).Select(i => 200_000_000L + i).ToList();
var env = ServerBattleFrames.BuildMatched(FixtureCtx(draftedDeck), FakeOpponentCtx(), 1, 2, "b", 17_548_138L, draftedDeck); var env = ServerBattleFrames.BuildMatched(FixtureCtx(draftedDeck), FakeOpponentCtx(), 1, 2, "b", 17_548_138, draftedDeck);
var body = (MatchedBody)env.Body; var body = (MatchedBody)env.Body;
for (int i = 0; i < 30; i++) for (int i = 0; i < 30; i++)
@@ -56,7 +56,7 @@ public class ServerBattleFramesTests
EmblemId = "888", DegreeId = "777", FieldId = 42, IsOfficial = 1, EmblemId = "888", DegreeId = "777", FieldId = 42, IsOfficial = 1,
}; };
var env = ServerBattleFrames.BuildMatched(ctx, FakeOpponentCtx(), 1, 2, "b", 17_548_138L, ctx.SelfDeckCardIds); var env = ServerBattleFrames.BuildMatched(ctx, FakeOpponentCtx(), 1, 2, "b", 17_548_138, ctx.SelfDeckCardIds);
var body = (MatchedBody)env.Body; var body = (MatchedBody)env.Body;
Assert.That(body.SelfInfo.CountryCode, Is.EqualTo("JPN")); Assert.That(body.SelfInfo.CountryCode, Is.EqualTo("JPN"));

View File

@@ -28,7 +28,7 @@ public class TypedBodyWireShapeTests
// with "Value cannot be null. Parameter name: source". The prod wire format // with "Value cannot be null. Parameter name: source". The prod wire format
// emits envelope keys (uri first) before body keys; we must too. // emits envelope keys (uri first) before body keys; we must too.
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(), var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(),
selfViewerId: 1, oppoViewerId: 2, battleId: "b", seed: 17_548_138L, selfViewerId: 1, oppoViewerId: 2, battleId: "b", seed: 17_548_138,
selfDeckOrder: FixtureCtx().SelfDeckCardIds); selfDeckOrder: FixtureCtx().SelfDeckCardIds);
var json = MsgEnvelope.ToJson(env); var json = MsgEnvelope.ToJson(env);
@@ -48,7 +48,7 @@ public class TypedBodyWireShapeTests
{ {
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(), var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(),
selfViewerId: 906243102, oppoViewerId: 847666884, battleId: "597830888107", selfViewerId: 906243102, oppoViewerId: 847666884, battleId: "597830888107",
seed: 17_548_138L, selfDeckOrder: FixtureCtx().SelfDeckCardIds); seed: 17_548_138, selfDeckOrder: FixtureCtx().SelfDeckCardIds);
var json = MsgEnvelope.ToJson(env); var json = MsgEnvelope.ToJson(env);
var node = JsonNode.Parse(json)!.AsObject(); var node = JsonNode.Parse(json)!.AsObject();

View File

@@ -15,11 +15,11 @@ public class MatchedBodyTests
SelfInfo: new MatchedSelfInfo( SelfInfo: new MatchedSelfInfo(
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011", CountryCode: "KOR", UserName: "Player", SleeveId: "3000011",
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, EmblemId: "701441011", DegreeId: "300003", FieldId: 43,
IsOfficial: false, OppoId: 847666884L, Seed: 17_548_138L), IsOfficial: false, OppoId: 847666884, Seed: 17_548_138),
OppoInfo: new MatchedOppoInfo( OppoInfo: new MatchedOppoInfo(
CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010", CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010",
EmblemId: "400001100", DegreeId: "120027", FieldId: 5, EmblemId: "400001100", DegreeId: "120027", FieldId: 5,
IsOfficial: false, OppoId: 906243102L, Seed: 17_548_138L, OppoDeckCount: 30), IsOfficial: false, OppoId: 906243102, Seed: 17_548_138, OppoDeckCount: 30),
SelfDeck: new[] { new DeckCardRef(Idx: 1, CardId: 100011010L) }); SelfDeck: new[] { new DeckCardRef(Idx: 1, CardId: 100011010L) });
var node = (JsonObject)JsonSerializer.SerializeToNode(body)!; var node = (JsonObject)JsonSerializer.SerializeToNode(body)!;
@@ -32,16 +32,16 @@ public class MatchedBodyTests
Assert.That(selfInfo["degreeId"]!.GetValue<string>(), Is.EqualTo("300003")); Assert.That(selfInfo["degreeId"]!.GetValue<string>(), Is.EqualTo("300003"));
Assert.That(selfInfo["fieldId"]!.GetValue<int>(), Is.EqualTo(43)); Assert.That(selfInfo["fieldId"]!.GetValue<int>(), Is.EqualTo(43));
Assert.That(selfInfo["isOfficial"]!.GetValue<int>(), Is.EqualTo(0)); Assert.That(selfInfo["isOfficial"]!.GetValue<int>(), Is.EqualTo(0));
Assert.That(selfInfo["oppoId"]!.GetValue<long>(), Is.EqualTo(847666884L)); Assert.That(selfInfo["oppoId"]!.GetValue<int>(), Is.EqualTo(847666884));
Assert.That(selfInfo["seed"]!.GetValue<long>(), Is.EqualTo(17_548_138L)); Assert.That(selfInfo["seed"]!.GetValue<int>(), Is.EqualTo(17_548_138));
} }
[Test] [Test]
public void OppoInfo_HasOppoDeckCount_OnTheWire() public void OppoInfo_HasOppoDeckCount_OnTheWire()
{ {
var body = new MatchedBody( var body = new MatchedBody(
SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,false,1L,1L), SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,false,1,1),
OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,false,1L,1L, OppoDeckCount: 30), OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,false,1,1, OppoDeckCount: 30),
SelfDeck: System.Array.Empty<DeckCardRef>()); SelfDeck: System.Array.Empty<DeckCardRef>());
var node = (JsonObject)JsonSerializer.SerializeToNode(body)!; var node = (JsonObject)JsonSerializer.SerializeToNode(body)!;
@@ -54,8 +54,8 @@ public class MatchedBodyTests
public void SelfInfo_DoesNotHaveOppoDeckCount_OnTheWire() public void SelfInfo_DoesNotHaveOppoDeckCount_OnTheWire()
{ {
var body = new MatchedBody( var body = new MatchedBody(
SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,false,1L,1L), SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,false,1,1),
OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,false,1L,1L,30), OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,false,1,1,30),
SelfDeck: System.Array.Empty<DeckCardRef>()); SelfDeck: System.Array.Empty<DeckCardRef>());
var node = (JsonObject)JsonSerializer.SerializeToNode(body)!; var node = (JsonObject)JsonSerializer.SerializeToNode(body)!;
@@ -68,8 +68,8 @@ public class MatchedBodyTests
public void ResultCode_DefaultsToOne_OnConstruction() public void ResultCode_DefaultsToOne_OnConstruction()
{ {
var body = new MatchedBody( var body = new MatchedBody(
SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,false,1L,1L), SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,false,1,1),
OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,false,1L,1L,30), OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,false,1,1,30),
SelfDeck: System.Array.Empty<DeckCardRef>()); SelfDeck: System.Array.Empty<DeckCardRef>());
Assert.That(body.ResultCode, Is.EqualTo(1)); Assert.That(body.ResultCode, Is.EqualTo(1));
@@ -81,8 +81,8 @@ public class MatchedBodyTests
public void SelfDeck_SerializesAsArray_WithIdxAndCardIdKeys() public void SelfDeck_SerializesAsArray_WithIdxAndCardIdKeys()
{ {
var body = new MatchedBody( var body = new MatchedBody(
SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,false,1L,1L), SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,false,1,1),
OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,false,1L,1L,30), OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,false,1,1,30),
SelfDeck: new[] SelfDeck: new[]
{ {
new DeckCardRef(Idx: 1, CardId: 100011010L), new DeckCardRef(Idx: 1, CardId: 100011010L),

View File

@@ -99,8 +99,8 @@ public class BattleSessionDispatchTests
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle)); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
var body = (MatchedBody)routes[0].Frame.Body; var body = (MatchedBody)routes[0].Frame.Body;
Assert.That(body.SelfInfo.Seed, Is.EqualTo((long)BattleSeeds.Stable(s.MasterSeed))); Assert.That(body.SelfInfo.Seed, Is.EqualTo(BattleSeeds.Stable(s.MasterSeed)));
Assert.That(body.OppoInfo.Seed, Is.EqualTo((long)BattleSeeds.Stable(s.MasterSeed))); Assert.That(body.OppoInfo.Seed, Is.EqualTo(BattleSeeds.Stable(s.MasterSeed)));
} }
[Test] [Test]

View File

@@ -125,4 +125,17 @@ public class SocketIoFrameTests
// The event name must be JSON-escaped: each " becomes \", and the literal \ becomes \\. // The event name must be JSON-escaped: each " becomes \", and the literal \ becomes \\.
Assert.That(text, Does.Contain("\"weird \\\"name\\\" with \\\\ backslash\"")); Assert.That(text, Does.Contain("\"weird \\\"name\\\" with \\\\ backslash\""));
} }
[Test]
public void Parse_InvalidTypeChar_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => SocketIoFrame.Parse("9[\"msg\"]"));
Assert.That(ex!.Message, Does.Contain("Invalid SIO type char"));
}
[Test]
public void Parse_OverflowingAckId_Throws()
{
Assert.Throws<ArgumentException>(() => SocketIoFrame.Parse("2999999999999[\"msg\"]"));
}
} }