mirror of
https://github.com/esiur/esiur-dotnet.git
synced 2026-07-30 17:30:40 +00:00
542 lines
19 KiB
C#
542 lines
19 KiB
C#
using System.Net;
|
|
using System.Reflection;
|
|
using System.Text;
|
|
using Esiur.Core;
|
|
using Esiur.Data;
|
|
using Esiur.Net;
|
|
using Esiur.Net.Http;
|
|
using Esiur.Net.Packets.Http;
|
|
using Esiur.Net.Packets.WebSocket;
|
|
using Esiur.Net.Sockets;
|
|
using Esiur.Resource;
|
|
|
|
namespace Esiur.Tests.Unit;
|
|
|
|
public class HttpWebSocketConnectionTests
|
|
{
|
|
[Fact]
|
|
public void Handshake_RequiresGetHttp11TokensVersionAndCanonical16ByteKey()
|
|
{
|
|
Assert.True(HttpConnection.IsWebsocketRequest(ValidHandshake()));
|
|
|
|
var request = ValidHandshake();
|
|
request.Method = Esiur.Net.Packets.Http.HttpMethod.POST;
|
|
Assert.False(HttpConnection.IsWebsocketRequest(request));
|
|
|
|
request = ValidHandshake();
|
|
request.Version = "HTTP/1.0";
|
|
Assert.False(HttpConnection.IsWebsocketRequest(request));
|
|
|
|
request = ValidHandshake();
|
|
request.RawMethod = "get";
|
|
Assert.False(HttpConnection.IsWebsocketRequest(request));
|
|
|
|
request = ValidHandshake();
|
|
request.Headers["Connection"] = "keep-alive, not-an-upgrade";
|
|
Assert.False(HttpConnection.IsWebsocketRequest(request));
|
|
|
|
request = ValidHandshake();
|
|
request.Headers["Upgrade"] = "notwebsocket";
|
|
Assert.False(HttpConnection.IsWebsocketRequest(request));
|
|
|
|
request = ValidHandshake();
|
|
request.Headers["Sec-WebSocket-Version"] = "12";
|
|
Assert.False(HttpConnection.IsWebsocketRequest(request));
|
|
|
|
request = ValidHandshake();
|
|
request.Headers["Sec-WebSocket-Key"] = "not-base64";
|
|
Assert.False(HttpConnection.IsWebsocketRequest(request));
|
|
|
|
request = ValidHandshake();
|
|
request.Headers["Sec-WebSocket-Key"] = Convert.ToBase64String(new byte[15]);
|
|
Assert.False(HttpConnection.IsWebsocketRequest(request));
|
|
}
|
|
|
|
[Fact]
|
|
public void Upgrade_SelectsOnlyAnExplicitlySupportedSubprotocol()
|
|
{
|
|
var request = ValidHandshake();
|
|
request.Headers["Sec-WebSocket-Protocol"] = "chat, superchat";
|
|
var response = new HttpResponsePacket();
|
|
response.Headers["Sec-WebSocket-Protocol"] = "untrusted";
|
|
|
|
Assert.True(HttpConnection.Upgrade(request, response));
|
|
Assert.Null(response.Headers["Sec-WebSocket-Protocol"]);
|
|
Assert.Equal("websocket", response.Headers["Upgrade"]);
|
|
Assert.Equal("Upgrade", response.Headers["Connection"]);
|
|
|
|
response = new HttpResponsePacket();
|
|
Assert.True(HttpConnection.Upgrade(
|
|
request,
|
|
response,
|
|
new[] { "superchat", "chat" },
|
|
out var selected));
|
|
Assert.Equal("superchat", selected);
|
|
Assert.Equal("superchat", response.Headers["Sec-WebSocket-Protocol"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void MalformedAdvertisedUpgrade_IsRejectedBeforeFiltersOrRoutesRun()
|
|
{
|
|
var server = new HttpServer();
|
|
var (connection, socket, filter) = CreateHttpConnection(server);
|
|
var request = Encoding.ASCII.GetBytes(
|
|
"GET / HTTP/1.1\r\n" +
|
|
"Connection: Upgrade\r\n" +
|
|
"Upgrade: websocket\r\n" +
|
|
"Sec-WebSocket-Version: 13\r\n" +
|
|
"Sec-WebSocket-Key: invalid\r\n\r\n");
|
|
|
|
Receive(connection, socket, request);
|
|
|
|
Assert.Equal(SocketState.Closed, socket.State);
|
|
Assert.False(connection.WSMode);
|
|
Assert.Equal(0, filter.ExecutionCount);
|
|
Assert.StartsWith("HTTP/1.1 400 Bad Request", Encoding.ASCII.GetString(Assert.Single(socket.Sent)));
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidUpgrade_UsesServerSubprotocolConfiguration()
|
|
{
|
|
var server = new HttpServer
|
|
{
|
|
WebSocketSubprotocols = new[] { "superchat" }
|
|
};
|
|
var (connection, socket, filter) = CreateHttpConnection(server);
|
|
var request = Encoding.ASCII.GetBytes(
|
|
"GET / HTTP/1.1\r\n" +
|
|
"Connection: keep-alive, Upgrade\r\n" +
|
|
"Upgrade: websocket\r\n" +
|
|
"Sec-WebSocket-Version: 13\r\n" +
|
|
$"Sec-WebSocket-Key: {Convert.ToBase64String(new byte[16])}\r\n" +
|
|
"Sec-WebSocket-Protocol: chat, superchat\r\n\r\n");
|
|
|
|
Receive(connection, socket, request);
|
|
|
|
Assert.Equal(SocketState.Established, socket.State);
|
|
Assert.True(connection.WSMode);
|
|
Assert.Equal("superchat", connection.WebSocketSubprotocol);
|
|
Assert.Equal(1, filter.ExecutionCount);
|
|
Assert.Contains(
|
|
"sec-websocket-protocol: superchat\r\n",
|
|
Encoding.ASCII.GetString(Assert.Single(socket.Sent)),
|
|
StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidUpgrade_DrainsFramesCoalescedWithTheHttpHandshake()
|
|
{
|
|
var (connection, socket, filter) = CreateHttpConnection();
|
|
var handshake = Encoding.ASCII.GetBytes(
|
|
"GET / HTTP/1.1\r\n" +
|
|
"Connection: Upgrade\r\n" +
|
|
"Upgrade: websocket\r\n" +
|
|
"Sec-WebSocket-Version: 13\r\n" +
|
|
$"Sec-WebSocket-Key: {Convert.ToBase64String(new byte[16])}\r\n\r\n");
|
|
var input = Concat(
|
|
handshake,
|
|
Frame(WebsocketPacket.WSOpcode.BinaryFrame, true, true, new byte[] { 1 }),
|
|
Frame(WebsocketPacket.WSOpcode.BinaryFrame, true, true, new byte[] { 2 }));
|
|
|
|
Receive(connection, socket, input);
|
|
|
|
Assert.True(connection.WSMode);
|
|
Assert.Equal(3, filter.ExecutionCount);
|
|
Assert.Collection(
|
|
filter.Messages,
|
|
message => Assert.Equal(new byte[] { 1 }, message.Payload),
|
|
message => Assert.Equal(new byte[] { 2 }, message.Payload));
|
|
}
|
|
|
|
[Fact]
|
|
public void BuiltInWebSocket_SendAlwaysRecomposesServerFramesAsUnmasked()
|
|
{
|
|
var (connection, socket, _) = CreateWebSocketConnection();
|
|
var packet = new WebsocketPacket
|
|
{
|
|
FIN = true,
|
|
Opcode = WebsocketPacket.WSOpcode.BinaryFrame,
|
|
Mask = true,
|
|
MaskKey = new byte[] { 1, 2, 3, 4 },
|
|
Message = new byte[] { 7, 8, 9 }
|
|
};
|
|
packet.Compose();
|
|
|
|
connection.Send(packet);
|
|
|
|
var sent = ParseServerFrame(Assert.Single(socket.Sent));
|
|
Assert.False(sent.Mask);
|
|
Assert.Equal(new byte[] { 7, 8, 9 }, sent.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuiltInWebSocket_ReassemblesMessagesHandlesPingAndDrainsCoalescedFrames()
|
|
{
|
|
var (connection, socket, filter) = CreateWebSocketConnection();
|
|
var frames = Concat(
|
|
Frame(WebsocketPacket.WSOpcode.TextFrame, false, true, Encoding.UTF8.GetBytes("hel")),
|
|
Frame(WebsocketPacket.WSOpcode.Ping, true, true, Encoding.ASCII.GetBytes("p")),
|
|
Frame(WebsocketPacket.WSOpcode.ContinuationFrame, true, true, Encoding.UTF8.GetBytes("lo")),
|
|
Frame(WebsocketPacket.WSOpcode.BinaryFrame, true, true, new byte[] { 1, 2 }));
|
|
|
|
Receive(connection, socket, frames);
|
|
|
|
Assert.Equal(SocketState.Established, socket.State);
|
|
Assert.Collection(
|
|
filter.Messages,
|
|
message =>
|
|
{
|
|
Assert.Equal(WebsocketPacket.WSOpcode.TextFrame, message.Opcode);
|
|
Assert.Equal("hello", Encoding.UTF8.GetString(message.Payload));
|
|
},
|
|
message =>
|
|
{
|
|
Assert.Equal(WebsocketPacket.WSOpcode.BinaryFrame, message.Opcode);
|
|
Assert.Equal(new byte[] { 1, 2 }, message.Payload);
|
|
});
|
|
|
|
var pong = Assert.Single(socket.Sent);
|
|
var parsedPong = ParseServerFrame(pong);
|
|
Assert.Equal(WebsocketPacket.WSOpcode.Pong, parsedPong.Opcode);
|
|
Assert.Equal("p", Encoding.ASCII.GetString(parsedPong.Message));
|
|
Assert.Equal(WebsocketPacket.WSOpcode.BinaryFrame, connection.WSRequest.Opcode);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuiltInWebSocket_RetainsAnIncompleteFrameWithoutLosingTheNextFrame()
|
|
{
|
|
var (connection, socket, filter) = CreateWebSocketConnection();
|
|
var first = Frame(
|
|
WebsocketPacket.WSOpcode.BinaryFrame,
|
|
true,
|
|
true,
|
|
new byte[] { 1, 2, 3, 4 });
|
|
var second = Frame(
|
|
WebsocketPacket.WSOpcode.BinaryFrame,
|
|
true,
|
|
true,
|
|
new byte[] { 5, 6 });
|
|
var buffer = new NetworkBuffer();
|
|
var split = first.Length - 2;
|
|
|
|
buffer.Write(first, 0, (uint)split);
|
|
connection.NetworkReceive(socket, buffer);
|
|
Assert.True(buffer.Protected);
|
|
Assert.Empty(filter.Messages);
|
|
|
|
buffer.Write(Concat(first.Skip(split).ToArray(), second));
|
|
connection.NetworkReceive(socket, buffer);
|
|
|
|
Assert.False(buffer.Protected);
|
|
Assert.Collection(
|
|
filter.Messages,
|
|
message => Assert.Equal(new byte[] { 1, 2, 3, 4 }, message.Payload),
|
|
message => Assert.Equal(new byte[] { 5, 6 }, message.Payload));
|
|
}
|
|
|
|
[Fact]
|
|
public void BuiltInWebSocket_RejectsUnmaskedClientFramesWithProtocolClose()
|
|
{
|
|
var (connection, socket, filter) = CreateWebSocketConnection();
|
|
|
|
Receive(connection, socket, Frame(
|
|
WebsocketPacket.WSOpcode.BinaryFrame,
|
|
true,
|
|
false,
|
|
new byte[] { 1 }));
|
|
|
|
Assert.Equal(SocketState.Closed, socket.State);
|
|
Assert.Empty(filter.Messages);
|
|
AssertCloseCode(socket, 1002);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuiltInWebSocket_RejectsInvalidUtf8AcrossFragments()
|
|
{
|
|
var (connection, socket, filter) = CreateWebSocketConnection();
|
|
|
|
Receive(connection, socket, Concat(
|
|
Frame(WebsocketPacket.WSOpcode.TextFrame, false, true, new byte[] { 0xC3 }),
|
|
Frame(WebsocketPacket.WSOpcode.ContinuationFrame, true, true, new byte[] { 0x28 })));
|
|
|
|
Assert.Equal(SocketState.Closed, socket.State);
|
|
Assert.Empty(filter.Messages);
|
|
AssertCloseCode(socket, 1007);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuiltInWebSocket_EnforcesAggregateFragmentLimit()
|
|
{
|
|
var server = new HttpServer { MaximumWebSocketMessageLength = 4 };
|
|
var (connection, socket, filter) = CreateWebSocketConnection(server);
|
|
|
|
Receive(connection, socket, Concat(
|
|
Frame(WebsocketPacket.WSOpcode.BinaryFrame, false, true, new byte[] { 1, 2, 3 }),
|
|
Frame(WebsocketPacket.WSOpcode.ContinuationFrame, true, true, new byte[] { 4, 5 })));
|
|
|
|
Assert.Equal(SocketState.Closed, socket.State);
|
|
Assert.Empty(filter.Messages);
|
|
AssertCloseCode(socket, 1009);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuiltInWebSocket_EchoesAValidClosePayloadThenCloses()
|
|
{
|
|
var (connection, socket, _) = CreateWebSocketConnection();
|
|
var payload = new byte[] { 0x03, 0xE8 };
|
|
|
|
Receive(connection, socket, Frame(
|
|
WebsocketPacket.WSOpcode.ConnectionClose,
|
|
true,
|
|
true,
|
|
payload));
|
|
|
|
Assert.Equal(SocketState.Closed, socket.State);
|
|
var close = ParseServerFrame(Assert.Single(socket.Sent));
|
|
Assert.Equal(WebsocketPacket.WSOpcode.ConnectionClose, close.Opcode);
|
|
Assert.Equal(payload, close.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuiltInWebSocket_WaitsForCloseFrameSendBeforeClosingTransport()
|
|
{
|
|
var pendingSend = new AsyncReply<bool>();
|
|
var server = new HttpServer();
|
|
var filter = new CaptureFilter();
|
|
SetFilters(server, filter);
|
|
var socket = new TestSocket(pendingSend);
|
|
var connection = new HttpConnection { Server = server, WSMode = true };
|
|
connection.Assign(socket);
|
|
|
|
Receive(connection, socket, Frame(
|
|
WebsocketPacket.WSOpcode.ConnectionClose,
|
|
true,
|
|
true,
|
|
new byte[] { 0x03, 0xE8 }));
|
|
|
|
Assert.Equal(SocketState.Established, socket.State);
|
|
Assert.Single(socket.Sent);
|
|
|
|
pendingSend.Trigger(true);
|
|
Assert.Equal(SocketState.Closed, socket.State);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExceptionDetails_AreGenericByDefaultAndEncodedWhenOptedIn()
|
|
{
|
|
var server = new HttpServer();
|
|
var connection = new HttpConnection { Server = server };
|
|
var exception = new InvalidOperationException("<script>secret</script>");
|
|
|
|
var genericPage = connection.FormatError500Page(exception);
|
|
Assert.Contains("An internal server error occurred.", genericPage);
|
|
Assert.DoesNotContain("secret", genericPage, StringComparison.Ordinal);
|
|
|
|
server.ExposeExceptionDetails = true;
|
|
var detailedPage = connection.FormatError500Page(exception);
|
|
Assert.Contains("<script>secret</script>", detailedPage);
|
|
Assert.DoesNotContain("<script>", detailedPage, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void SessionLookup_RestoresRefreshesAndStopsReturningDestroyedSessions()
|
|
{
|
|
var server = new HttpServer();
|
|
var session = server.CreateSession("known-session", 0);
|
|
var initialAction = session.LastAction;
|
|
var request = new HttpRequestPacket { Cookies = new StringKeyList() };
|
|
request.Cookies["SID"] = session.Id;
|
|
var connection = new HttpConnection
|
|
{
|
|
Server = server,
|
|
Request = request
|
|
};
|
|
|
|
Assert.True(server.TryGetSession(session.Id, out var found));
|
|
Assert.Same(session, found);
|
|
|
|
connection.RestoreSessionFromRequest();
|
|
Assert.Same(session, connection.Session);
|
|
Assert.True(session.LastAction >= initialAction);
|
|
|
|
session.Destroy();
|
|
Assert.False(server.TryGetSession(session.Id, out _));
|
|
connection.RestoreSessionFromRequest();
|
|
Assert.Null(connection.Session);
|
|
}
|
|
|
|
private static HttpRequestPacket ValidHandshake()
|
|
{
|
|
var headers = new StringKeyList();
|
|
headers["Connection"] = "keep-alive, Upgrade";
|
|
headers["Upgrade"] = "websocket";
|
|
headers["Sec-WebSocket-Version"] = "13";
|
|
headers["Sec-WebSocket-Key"] = Convert.ToBase64String(new byte[16]);
|
|
return new HttpRequestPacket
|
|
{
|
|
Method = Esiur.Net.Packets.Http.HttpMethod.GET,
|
|
Version = "HTTP/1.1",
|
|
Headers = headers,
|
|
URL = "/"
|
|
};
|
|
}
|
|
|
|
private static (HttpConnection Connection, TestSocket Socket, CaptureFilter Filter)
|
|
CreateWebSocketConnection(HttpServer? server = null)
|
|
{
|
|
var result = CreateHttpConnection(server);
|
|
result.Connection.WSMode = true;
|
|
return result;
|
|
}
|
|
|
|
private static (HttpConnection Connection, TestSocket Socket, CaptureFilter Filter)
|
|
CreateHttpConnection(HttpServer? server = null)
|
|
{
|
|
server ??= new HttpServer();
|
|
var filter = new CaptureFilter();
|
|
SetFilters(server, filter);
|
|
|
|
var socket = new TestSocket();
|
|
var connection = new HttpConnection
|
|
{
|
|
Server = server
|
|
};
|
|
connection.Assign(socket);
|
|
return (connection, socket, filter);
|
|
}
|
|
|
|
private static void SetFilters(HttpServer server, params HttpFilter[] filters)
|
|
=> typeof(HttpServer)
|
|
.GetField("filters", BindingFlags.Instance | BindingFlags.NonPublic)!
|
|
.SetValue(server, filters);
|
|
|
|
private static void Receive(
|
|
HttpConnection connection,
|
|
TestSocket socket,
|
|
byte[] message)
|
|
{
|
|
var buffer = new NetworkBuffer();
|
|
buffer.Write(message);
|
|
connection.NetworkReceive(socket, buffer);
|
|
}
|
|
|
|
private static byte[] Frame(
|
|
WebsocketPacket.WSOpcode opcode,
|
|
bool final,
|
|
bool masked,
|
|
byte[] payload)
|
|
{
|
|
var packet = new WebsocketPacket
|
|
{
|
|
FIN = final,
|
|
Opcode = opcode,
|
|
Mask = masked,
|
|
MaskKey = new byte[] { 1, 2, 3, 4 },
|
|
Message = payload
|
|
};
|
|
packet.Compose();
|
|
return packet.Data;
|
|
}
|
|
|
|
private static byte[] Concat(params byte[][] messages)
|
|
{
|
|
var length = messages.Sum(message => message.Length);
|
|
var result = new byte[length];
|
|
var offset = 0;
|
|
foreach (var message in messages)
|
|
{
|
|
Buffer.BlockCopy(message, 0, result, offset, message.Length);
|
|
offset += message.Length;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static WebsocketPacket ParseServerFrame(byte[] data)
|
|
{
|
|
var packet = new WebsocketPacket { ExpectedMask = false };
|
|
Assert.Equal(data.Length, packet.Parse(data, 0, (uint)data.Length));
|
|
return packet;
|
|
}
|
|
|
|
private static void AssertCloseCode(TestSocket socket, int expected)
|
|
{
|
|
var close = ParseServerFrame(Assert.Single(socket.Sent));
|
|
Assert.Equal(WebsocketPacket.WSOpcode.ConnectionClose, close.Opcode);
|
|
Assert.Equal(expected, close.Message[0] << 8 | close.Message[1]);
|
|
}
|
|
|
|
private sealed class CaptureFilter : HttpFilter
|
|
{
|
|
public List<(WebsocketPacket.WSOpcode Opcode, byte[] Payload)> Messages { get; } = new();
|
|
public int ExecutionCount { get; private set; }
|
|
|
|
public override AsyncReply<bool> Execute(HttpConnection sender)
|
|
{
|
|
ExecutionCount++;
|
|
var packet = sender.WSRequest;
|
|
if (packet != null)
|
|
Messages.Add((packet.Opcode, packet.Message.ToArray()));
|
|
return new AsyncReply<bool>(true);
|
|
}
|
|
|
|
public override AsyncReply<bool> Handle(
|
|
ResourceOperation operation,
|
|
IResourceContext? context = null) => new(true);
|
|
}
|
|
|
|
private sealed class TestSocket : ISocket
|
|
{
|
|
private readonly AsyncReply<bool>? sendReply;
|
|
|
|
public TestSocket(AsyncReply<bool>? sendReply = null)
|
|
=> this.sendReply = sendReply;
|
|
|
|
public event DestroyedEvent? OnDestroy;
|
|
public SocketState State { get; private set; } = SocketState.Established;
|
|
public INetworkReceiver<ISocket> Receiver { get; set; } = null!;
|
|
public IPEndPoint RemoteEndPoint { get; } = new(IPAddress.Loopback, 12345);
|
|
public IPEndPoint LocalEndPoint { get; } = new(IPAddress.Loopback, 54321);
|
|
public List<byte[]> Sent { get; } = new();
|
|
|
|
public void Send(byte[] message) => Send(message, 0, message.Length);
|
|
|
|
public void Send(byte[] message, int offset, int length)
|
|
{
|
|
var copy = new byte[length];
|
|
Buffer.BlockCopy(message, offset, copy, 0, length);
|
|
Sent.Add(copy);
|
|
}
|
|
|
|
public AsyncReply<bool> SendAsync(byte[] message, int offset, int length)
|
|
{
|
|
Send(message, offset, length);
|
|
return sendReply ?? new AsyncReply<bool>(true);
|
|
}
|
|
|
|
public void Close()
|
|
{
|
|
if (State == SocketState.Closed)
|
|
return;
|
|
|
|
State = SocketState.Closed;
|
|
Receiver?.NetworkClose(this);
|
|
}
|
|
|
|
public void Destroy()
|
|
{
|
|
Close();
|
|
OnDestroy?.Invoke(this);
|
|
OnDestroy = null;
|
|
}
|
|
|
|
public AsyncReply<bool> Connect(string hostname, ushort port) => new(false);
|
|
public bool Begin() => true;
|
|
public AsyncReply<bool> BeginAsync() => new(true);
|
|
public AsyncReply<ISocket> AcceptAsync() => new((ISocket)null!);
|
|
public ISocket Accept() => null!;
|
|
public void Hold() { }
|
|
public void Unhold() { }
|
|
}
|
|
}
|