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

View File

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

View File

@@ -134,7 +134,15 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
{
var text = Encoding.UTF8.GetString(msg.Value.Bytes);
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)
{
_log.LogInformation(
@@ -149,7 +157,13 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
}
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)
{
pendingFrame = sio;

View File

@@ -52,7 +52,10 @@ public sealed class SocketIoFrame
if (string.IsNullOrEmpty(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 attachmentCount = 0;
@@ -84,7 +87,9 @@ public sealed class SocketIoFrame
{
var start = 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;