Public Access
The throughput harness the plan requires before any UI, plus the SSH plumbing under it. 94 new tests, no WebView involved. Credit-based flow control is what makes `yes` survivable. A terminal renders at 60 Hz at best while a remote produces output as fast as the network allows, and the difference has to accumulate somewhere or be refused. Credit is reserved *before* reading, never after: because the pump cannot read more than the renderer has room for, the coalescing buffer is bounded by the window rather than by how fast the remote can talk. When credit runs out the pump stops reading, SSH's own receive window closes, and the remote sshd blocks -- backpressure to the source with no custom protocol. Verified by falsification, not just by passing: with the credit gate removed three tests fail, including the throughput harness's bounded-memory assertion. Acknowledgements are clamped because they cross into JavaScript, where a buggy or hostile page could otherwise claim to have rendered a gigabyte and talk the host into an unbounded read. Host key trust is enforced by *failing* the connection rather than prompting inside the handshake. SSH.NET raises verification synchronously, so consulting the user there would block the handshake on a UI round trip and deadlock the first time the prompt needed the UI thread. Unknown host and changed key become distinct exceptions the caller resolves asynchronously. A mismatch has no retry path at all: a dialog offering to continue is how users are trained to click through the one warning that actually indicates interception. A legitimately rebuilt server is handled by removing the pin in settings, away from the moment of connecting. The data plane serves the renderer page from the same loopback listener as the socket, which makes Origin predictable -- always http://127.0.0.1:{port} -- where a WebView virtual-host mapping would give a different origin per backend and nothing to validate. The token is substituted at serve time, so it never touches disk and never appears in a URL. Being clear about what that buys: not protection from a process running as this user, which can read our memory anyway, but from a page in the user's browser attempting WebSocket connections to loopback ports, which is a real and routine thing. Two bugs the tests caught. The accept loop handled connections serially, so an upgraded WebSocket parked it inside the receive loop and every later request went unanswered -- the page's own script among them. The suite hung rather than failed, which is how I found it. And SHA-1 is unavoidable here: RFC 6455 mandates it for Sec-WebSocket-Accept, where it authenticates nothing. Suppressed narrowly with that reasoning; the alternative, HttpListener.AcceptWebSocketAsync, throws PlatformNotSupportedException off Windows.
390 lines
12 KiB
C#
390 lines
12 KiB
C#
using System.Globalization;
|
|
using System.Net;
|
|
using System.Net.WebSockets;
|
|
using System.Text;
|
|
using DodoSSH.Client.Ssh;
|
|
|
|
namespace DodoSSH.Client.Terminal.Tests;
|
|
|
|
/// <summary>
|
|
/// The loopback transport end to end, with a real WebSocket client.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Uses <see cref="ClientWebSocket"/> against the real listener rather than a stubbed transport,
|
|
/// because the hand-rolled HTTP upgrade is the part that could be subtly wrong — and a handshake that
|
|
/// no real client accepts would pass any test that skipped it.
|
|
/// </remarks>
|
|
public sealed class TerminalDataPlaneTests : IAsyncDisposable
|
|
{
|
|
private const uint SessionId = 3;
|
|
|
|
private static readonly byte[] PageTemplate = Encoding.UTF8.GetBytes(
|
|
$"<html><body data-token=\"{TerminalDataPlane.TokenPlaceholder}\" "
|
|
+ $"data-socket=\"{TerminalDataPlane.SocketUrlPlaceholder}\"></body></html>");
|
|
|
|
private readonly TerminalDataPlane plane = new(new InMemoryTerminalAssetProvider(
|
|
new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)
|
|
{
|
|
[TerminalDataPlane.PagePath] = new("text/html; charset=utf-8", PageTemplate),
|
|
["/xterm.js"] = new("text/javascript; charset=utf-8", "console.log(1)"u8.ToArray()),
|
|
}));
|
|
|
|
private readonly CancellationTokenSource lifetime = new();
|
|
private Task? server;
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await lifetime.CancelAsync();
|
|
await plane.DisposeAsync();
|
|
|
|
if (server is not null)
|
|
{
|
|
try
|
|
{
|
|
await server;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Expected: the accept loop is stopped by cancelling it.
|
|
}
|
|
}
|
|
|
|
lifetime.Dispose();
|
|
}
|
|
|
|
// ---- Serving the page ----
|
|
|
|
[Fact]
|
|
public async Task ThePage_IsServedWithTheTokenAndSocketUrlSubstituted()
|
|
{
|
|
// Substituted at serve time rather than written into the file, so the token never touches disk
|
|
// and never appears in a URL that a log or a browser history could keep.
|
|
Start();
|
|
|
|
using var client = new HttpClient();
|
|
var body = await client.GetStringAsync(plane.PageUrl, TestContext.Current.CancellationToken);
|
|
|
|
body.ShouldContain(plane.Token);
|
|
body.ShouldContain(
|
|
string.Create(CultureInfo.InvariantCulture, $"ws://127.0.0.1:{plane.Port}/socket"));
|
|
|
|
body.ShouldNotContain(TerminalDataPlane.TokenPlaceholder);
|
|
body.ShouldNotContain(TerminalDataPlane.SocketUrlPlaceholder);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ThePage_IsNotCached()
|
|
{
|
|
// It carries a connection token.
|
|
Start();
|
|
|
|
using var client = new HttpClient();
|
|
using var response = await client.GetAsync(plane.PageUrl, TestContext.Current.CancellationToken);
|
|
|
|
response.Headers.CacheControl!.NoStore.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnUnknownPath_Is404()
|
|
{
|
|
Start();
|
|
|
|
using var client = new HttpClient();
|
|
using var response = await client.GetAsync(
|
|
new Uri($"http://127.0.0.1:{plane.Port}/nope"),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
|
|
}
|
|
|
|
// ---- Attaching ----
|
|
|
|
[Fact]
|
|
public async Task TheRenderer_Attaches()
|
|
{
|
|
Start();
|
|
|
|
using var socket = await ConnectAsync();
|
|
|
|
socket.State.ShouldBe(WebSocketState.Open);
|
|
socket.SubProtocol.ShouldBe(TerminalDataPlane.SubProtocol);
|
|
|
|
await plane.RendererAttached;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AConnectionWithoutTheToken_IsRejected()
|
|
{
|
|
Start();
|
|
|
|
await Should.ThrowAsync<WebSocketException>(async () =>
|
|
await ConnectAsync(token: null));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AConnectionWithTheWrongToken_IsRejected()
|
|
{
|
|
Start();
|
|
|
|
await Should.ThrowAsync<WebSocketException>(async () =>
|
|
await ConnectAsync(token: "not-the-token"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AConnectionFromAnotherOrigin_IsRejected()
|
|
{
|
|
// The threat this actually addresses: a page open in the user's browser can attempt WebSocket
|
|
// connections to loopback ports, and would otherwise reach a terminal.
|
|
Start();
|
|
|
|
await Should.ThrowAsync<WebSocketException>(async () =>
|
|
await ConnectAsync(origin: "https://evil.example"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ASecondRenderer_IsRejected()
|
|
{
|
|
Start();
|
|
|
|
using var first = await ConnectAsync();
|
|
first.State.ShouldBe(WebSocketState.Open);
|
|
|
|
await Should.ThrowAsync<WebSocketException>(async () => await ConnectAsync());
|
|
}
|
|
|
|
// ---- Frames ----
|
|
|
|
[Fact]
|
|
public async Task Output_ReachesTheRenderer()
|
|
{
|
|
Start();
|
|
|
|
using var socket = await ConnectAsync();
|
|
|
|
await using var session = new FakeShellSession(bytesToProduce: 128);
|
|
await using var pump = CreatePump(session);
|
|
plane.Register(SessionId, pump);
|
|
|
|
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
|
|
|
// SessionOpened first, then the output.
|
|
var opened = await ReceiveAsync(socket);
|
|
opened.Opcode.ShouldBe((byte)TerminalServerOpcode.SessionOpened);
|
|
opened.SessionId.ShouldBe(SessionId);
|
|
|
|
var output = await ReceiveAsync(socket);
|
|
output.Opcode.ShouldBe((byte)TerminalServerOpcode.Output);
|
|
output.Payload.Length.ShouldBe(128);
|
|
|
|
// Acknowledge it, exactly as the page does from term.write's callback.
|
|
await SendAsync(
|
|
socket,
|
|
(byte)TerminalClientOpcode.Acknowledge,
|
|
TerminalFrame.CreateAcknowledgementPayload((uint)output.Payload.Length));
|
|
|
|
await run;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Input_ReachesTheRemote()
|
|
{
|
|
Start();
|
|
|
|
using var socket = await ConnectAsync();
|
|
|
|
await using var session = new FakeShellSession();
|
|
await using var pump = CreatePump(session);
|
|
plane.Register(SessionId, pump);
|
|
|
|
await SendAsync(socket, (byte)TerminalClientOpcode.Input, "whoami\r"u8.ToArray());
|
|
|
|
await WaitUntilAsync(() => session.Written.Length > 0);
|
|
|
|
Encoding.UTF8.GetString(session.Written).ShouldBe("whoami\r");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AResize_ReachesTheRemote()
|
|
{
|
|
Start();
|
|
|
|
using var socket = await ConnectAsync();
|
|
|
|
await using var session = new FakeShellSession();
|
|
await using var pump = CreatePump(session);
|
|
plane.Register(SessionId, pump);
|
|
|
|
await SendAsync(
|
|
socket,
|
|
(byte)TerminalClientOpcode.Resize,
|
|
TerminalFrame.CreateResizePayload(new TerminalSize(132, 43, 1320, 1075)));
|
|
|
|
await WaitUntilAsync(() => session.ResizeCount > 0);
|
|
|
|
session.LastResize.ShouldBe(new TerminalSize(132, 43, 1320, 1075));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AnAcknowledgement_ReturnsCredit()
|
|
{
|
|
Start();
|
|
|
|
using var socket = await ConnectAsync();
|
|
|
|
await using var session = new FakeShellSession();
|
|
await using var pump = CreatePump(session);
|
|
plane.Register(SessionId, pump);
|
|
|
|
pump.Credits.TryReserve(1024);
|
|
pump.Credits.Outstanding.ShouldBe(1024);
|
|
|
|
await SendAsync(
|
|
socket,
|
|
(byte)TerminalClientOpcode.Acknowledge,
|
|
TerminalFrame.CreateAcknowledgementPayload(1024));
|
|
|
|
await WaitUntilAsync(() => pump.Credits.Outstanding == 0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AFrameForAnUnregisteredSession_IsIgnored()
|
|
{
|
|
// Ordinary during teardown: the page can still have frames in flight for a session that just
|
|
// ended. Dropping them must not disturb anything else.
|
|
Start();
|
|
|
|
using var socket = await ConnectAsync();
|
|
|
|
await using var session = new FakeShellSession();
|
|
await using var pump = CreatePump(session);
|
|
plane.Register(SessionId, pump);
|
|
|
|
// A session id that is never registered. Using the registered one and relying on ordering
|
|
// would be a race: frames are dispatched on the receive loop, so the orphan can land after
|
|
// registration and legitimately be delivered.
|
|
await SendAsync(socket, (byte)TerminalClientOpcode.Input, "orphan"u8.ToArray(), sessionId: 999);
|
|
|
|
await SendAsync(socket, (byte)TerminalClientOpcode.Input, "real"u8.ToArray());
|
|
await WaitUntilAsync(() => session.Written.Length > 0);
|
|
|
|
Encoding.UTF8.GetString(session.Written).ShouldBe("real");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AMalformedFrame_IsIgnored()
|
|
{
|
|
Start();
|
|
|
|
using var socket = await ConnectAsync();
|
|
|
|
// Shorter than the header.
|
|
await socket.SendAsync(
|
|
new byte[] { 1, 2 },
|
|
WebSocketMessageType.Binary,
|
|
endOfMessage: true,
|
|
TestContext.Current.CancellationToken);
|
|
|
|
await using var session = new FakeShellSession();
|
|
await using var pump = CreatePump(session);
|
|
plane.Register(SessionId, pump);
|
|
|
|
await SendAsync(socket, (byte)TerminalClientOpcode.Input, "still here"u8.ToArray());
|
|
await WaitUntilAsync(() => session.Written.Length > 0);
|
|
|
|
Encoding.UTF8.GetString(session.Written).ShouldBe("still here");
|
|
}
|
|
|
|
// ---- Helpers ----
|
|
|
|
private void Start() => server = plane.RunAsync(lifetime.Token);
|
|
|
|
private TerminalSessionPump CreatePump(FakeShellSession session) =>
|
|
new(
|
|
SessionId,
|
|
session,
|
|
plane,
|
|
TimeProvider.System,
|
|
new TerminalPumpOptions { FlushInterval = TimeSpan.FromMilliseconds(10) });
|
|
|
|
private async Task<ClientWebSocket> ConnectAsync(
|
|
string? token = "",
|
|
string? origin = null)
|
|
{
|
|
var socket = new ClientWebSocket();
|
|
|
|
socket.Options.AddSubProtocol(TerminalDataPlane.SubProtocol);
|
|
|
|
if (token is not null)
|
|
{
|
|
socket.Options.AddSubProtocol($"token.{(token.Length == 0 ? plane.Token : token)}");
|
|
}
|
|
|
|
socket.Options.SetRequestHeader(
|
|
"Origin",
|
|
origin ?? string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{plane.Port}"));
|
|
|
|
try
|
|
{
|
|
await socket.ConnectAsync(
|
|
new Uri($"ws://127.0.0.1:{plane.Port}{TerminalDataPlane.SocketPath}"),
|
|
TestContext.Current.CancellationToken);
|
|
}
|
|
catch
|
|
{
|
|
socket.Dispose();
|
|
throw;
|
|
}
|
|
|
|
return socket;
|
|
}
|
|
|
|
private static async Task SendAsync(
|
|
ClientWebSocket socket,
|
|
byte opcode,
|
|
byte[] payload,
|
|
uint sessionId = SessionId) =>
|
|
await socket.SendAsync(
|
|
TerminalFrame.Create(opcode, sessionId, payload),
|
|
WebSocketMessageType.Binary,
|
|
endOfMessage: true,
|
|
TestContext.Current.CancellationToken);
|
|
|
|
private static async Task<(byte Opcode, uint SessionId, byte[] Payload)> ReceiveAsync(
|
|
ClientWebSocket socket)
|
|
{
|
|
var buffer = new byte[64 * 1024];
|
|
|
|
var result = await socket.ReceiveAsync(
|
|
buffer.AsMemory(),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
TerminalFrame.TryRead(buffer.AsSpan(0, result.Count), out var opcode, out var sessionId, out var payload)
|
|
.ShouldBeTrue();
|
|
|
|
return (opcode, sessionId, payload.ToArray());
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Polls rather than awaiting a signal: inbound frames are dispatched on the transport's receive
|
|
/// loop, which has no completion for the caller to await. The bound keeps a genuine failure a
|
|
/// failure rather than a hang.
|
|
/// </remarks>
|
|
private static async Task WaitUntilAsync(Func<bool> condition)
|
|
{
|
|
var deadline = TimeProvider.System.GetUtcNow() + TimeSpan.FromSeconds(5);
|
|
|
|
while (TimeProvider.System.GetUtcNow() < deadline)
|
|
{
|
|
if (condition())
|
|
{
|
|
return;
|
|
}
|
|
|
|
await Task.Delay(10, TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
throw new TimeoutException("The expected state was not reached within 5 seconds.");
|
|
}
|
|
}
|