fix(battle-node): read WS credentials from headers; skip Steam auth on WS upgrades

Two issues caught in the real-client smoke:

1) BestHTTP's SocketOptions.AdditionalQueryParams puts BattleId and
   viewerId on HTTP request HEADERS for WebSocket-only transport
   (NOT on the URL query string as the in-battle/transport.md spec
   says). Real clients therefore send them as headers; our handler
   was reading from query and rejecting every connect with "Unknown
   battle/viewer pair: <bid>/<garbage>". Fix: header-first, query-
   fallback (so the integration test still works against TestServer).

2) The Steam auth handler was running on every WS upgrade and
   throwing NotSupportedException on Request.Body.Seek (Kestrel's
   HttpRequestStream doesn't support Seek, and a WS upgrade is GET
   with Content-Length: 0 anyway). It flooded logs and added no
   value — the battle node has its own per-connection credentials.
   Skip auth when IsWebSocketRequest is true.

Spec correction for in-battle/transport.md to follow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-01 01:12:21 -04:00
parent 5525dbee24
commit 1252f7bd35
2 changed files with 24 additions and 3 deletions

View File

@@ -27,10 +27,15 @@ public sealed class BattleNodeWebSocketHandler
return;
}
var battleId = ctx.Request.Query["BattleId"].ToString();
var encryptedViewerId = ctx.Request.Query["viewerId"].ToString();
// BestHTTP's SocketOptions.AdditionalQueryParams puts these on HTTP request HEADERS
// for the WebSocket-only transport (not on the URL query string). Real clients
// therefore send BattleId/viewerId as headers; the integration test sends them as
// query params for convenience. Check headers first, fall back to query.
var battleId = ReadCredential(ctx, "BattleId");
var encryptedViewerId = ReadCredential(ctx, "viewerId");
if (string.IsNullOrEmpty(battleId) || string.IsNullOrEmpty(encryptedViewerId))
{
_log.LogWarning("WS upgrade missing BattleId or viewerId (header or query).");
ctx.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
@@ -43,7 +48,7 @@ public sealed class BattleNodeWebSocketHandler
}
catch (Exception ex)
{
_log.LogWarning(ex, "viewerId query param failed to decrypt");
_log.LogWarning(ex, "viewerId failed to decrypt (encryptedLen={Len})", encryptedViewerId.Length);
ctx.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
@@ -61,4 +66,11 @@ public sealed class BattleNodeWebSocketHandler
var session = new BattleSession(ws, battleId, viewerId, _loggerFactory.CreateLogger<BattleSession>());
await session.RunAsync(ctx.RequestAborted);
}
private static string ReadCredential(HttpContext ctx, string name)
{
var header = ctx.Request.Headers[name].ToString();
if (!string.IsNullOrEmpty(header)) return header;
return ctx.Request.Query[name].ToString();
}
}

View File

@@ -35,6 +35,15 @@ public class SteamSessionAuthenticationHandler : AuthenticationHandler<SteamAuth
protected async override Task<AuthenticateResult> HandleAuthenticateAsync()
{
string path = Request.Path;
// WebSocket upgrades carry no body — Request.Body.Seek throws NotSupportedException
// on Kestrel's HttpRequestStream. The battle node has its own per-connection auth
// (encrypted viewerId query/header validated against the matched battle id), so the
// Steam handler has nothing to do here. Returning NoResult lets the request proceed
// unauthenticated to the WS endpoint.
if (Context.WebSockets.IsWebSocketRequest)
{
return AuthenticateResult.NoResult();
}
byte[] requestBytes;
try
{