improve voice chat security by implementing join validation
All checks were successful
Server Build / publish (push) Successful in 29s
Voice Build / publish (push) Successful in 24s

This commit is contained in:
olcxja 2026-08-24 15:01:32 +02:00
commit 1915198d82
7 changed files with 392 additions and 9 deletions

View file

@ -18,7 +18,6 @@ public class Requests
string roomId = context.Request.Query["room"].ToString();
string userIdStr = context.Request.Query["userId"].ToString();
string secret = context.Request.Query["secret"].ToString();
if (string.IsNullOrEmpty(roomId) || !ulong.TryParse(userIdStr, out ulong clientId))
{
@ -27,6 +26,56 @@ public class Requests
}
using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
// --- Ticket handshake: first frame must be "<publicId>.<iv>.<ct>".
// Unauthenticated connections never touch the room roster; the blob is
// validated by the main server, which consumes it atomically.
// Tiny buffer: unauthenticated clients cannot pin large allocations. ---
var authBuffer = ArrayPool<byte>.Shared.Rent(2048);
string ticket;
using (var authCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)))
{
WebSocketReceiveResult firstFrame;
try
{
firstFrame = await webSocket.ReceiveAsync(new ArraySegment<byte>(authBuffer), authCts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine($"Voice ws: ticket timeout, room={roomId}");
await SafeClose(webSocket, "Ticket timeout");
return;
}
catch (WebSocketException)
{
return;
}
if (firstFrame.MessageType != WebSocketMessageType.Text || !firstFrame.EndOfMessage || firstFrame.Count == 0)
{
await SafeClose(webSocket, "Invalid ticket");
return;
}
ticket = System.Text.Encoding.UTF8.GetString(authBuffer, 0, firstFrame.Count);
}
ArrayPool<byte>.Shared.Return(authBuffer);
string[] ticketParts = ticket.Split('.');
bool frameShapeOk = ticketParts.Length == 3
&& ticketParts[0].Length > 0 && ticketParts[0].Length <= 64
&& ticketParts[1].Length == 16
&& ticketParts[2].Length > 0 && ticketParts[2].Length <= 128;
Console.WriteLine($"Voice ws: validating {ticketParts[0][..Math.Min(8, ticketParts[0].Length)]} room={roomId}");
if (!frameShapeOk ||
!await Utils.AuthorizeConnection(ticketParts[0], ticketParts[1], ticketParts[2], roomId, clientId))
{
Console.WriteLine($"Voice ws: invalid ticket, room={roomId}");
await SafeClose(webSocket, "Invalid ticket");
return;
}
Session currentSession = null;
var roomClients = Rooms.GetOrAdd(roomId, _ => new ConcurrentDictionary<ulong, Session>());
if (roomClients.TryGetValue(clientId, out var oldSession) && oldSession.Socket.State == WebSocketState.Open)
@ -40,7 +89,7 @@ public class Requests
}
}
var currentSession = new Session(webSocket);
currentSession = new Session(webSocket);
roomClients[clientId] = currentSession;
_ = Task.Run(async () =>
@ -53,7 +102,7 @@ public class Requests
{
await currentSession.Socket.SendAsync(new ArraySegment<byte>(packet.Buffer, 0, packet.Length),
WebSocketMessageType.Binary, true, CancellationToken.None);
Interlocked.Add(ref TotalBytesSent, (ulong)packet.Length);
}
}
@ -67,6 +116,7 @@ public class Requests
}
});
// Big buffer only for authenticated media traffic.
var receiveBuffer = ArrayPool<byte>.Shared.Rent(1024 * 1024);
try
{
@ -80,10 +130,10 @@ public class Requests
result = await webSocket.ReceiveAsync(
new ArraySegment<byte>(receiveBuffer, totalBytes, receiveBuffer.Length - totalBytes),
CancellationToken.None);
totalBytes += result.Count;
Interlocked.Add(ref TotalBytesReceived, (ulong)result.Count);
} while (!result.EndOfMessage);
if (result.MessageType == WebSocketMessageType.Close) break;
@ -121,4 +171,16 @@ public class Requests
if (roomClients.IsEmpty) Rooms.TryRemove(roomId, out _);
}
}
private static async Task SafeClose(WebSocket socket, string reason)
{
try
{
if (socket.State == WebSocketState.Open)
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, reason, CancellationToken.None);
}
catch
{
}
}
}