Files
DodoSSH/src/DodoSSH.Client.Terminal/TerminalDataPlane.cs
T
laurensandClaude Opus 5 1908189100
ci / build and test (push) Successful in 5m43s
ci / android head (push) Successful in 4m4s
ci / desktop nightly (push) Successful in 1m28s
ci / api image (push) Successful in 26s
Make the terminal's right button copy and paste, and never open a menu
Selecting output and right-clicking it opened the WebView's own context menu with
Copy on it, which is correct behaviour for a web page and wrong for a terminal.
What a terminal's right button has meant since the Windows console got QuickEdit,
and since PuTTY shipped, is this: with something selected it copies and drops the
selection, with nothing selected it pastes. No menu, no second click.

The gesture can only live in the page. The selection is drawn into a canvas the
host never sees, and once a terminal has focus the host's window receives no input
events at all — the same reason the Ctrl+plus chords are handled there.

◆ THE CLIPBOARD IS THE HOST'S IN BOTH DIRECTIONS, AND THE READING IS WHY.

navigator.clipboard.readText() is gated behind the clipboard-read permission in
every Chromium, and an embedded WebView with no PermissionRequested handler
answers a permission request by putting a prompt on the screen. Trading a context
menu for a permission dialog is not this change. So the page asks and the host
answers, exactly as requestFontSizeStep does.

Writing does not go the other way either, although navigator.clipboard.writeText
would have worked from the page on Windows: a clipboard that is the host's in one
direction and the page's in the other is two mechanisms to keep working across
WebView2, WebKitGTK and Android's WebView. The host has owned a clipboard delegate
since the keychain grew its COPY buttons, so each App.axaml.cs grows a
ClipboardReader beside its ClipboardWriter and MainWindowViewModel takes it.

Three opcodes: client ClipboardCopy and ClipboardPaste, server Clipboard.

◆ Clipboard IS NOT Paste, AND THE DIFFERENCE IS THE BRACKETING.

Paste carries a snippet off the sidebar, and the page deliberately *types* a
single-line one rather than bracketing it, because bash marks a bracketed paste as
an active region and a snippet should read as though it had been typed. A clipboard
paste is the case those markers are for — readline uses them to decide it has been
pasted into — so Clipboard is always bracketed and never followed by an Enter. One
opcode for both would have needed a flag meaning "which of the two kinds of paste
is this", on a payload whose existing flag byte means something else.

Both client frames are dispatched before the session lookup, which is what keeps
the gesture working in a pane whose shell has just ended: squinting at the message
a remote left behind is exactly when somebody copies the line. Dispatch was past
MA0051's length once it grew a second such branch, so the frames no pump needs to
see moved into Announce() and the font-size chord went with them.

◆ CAPTURED AND STOPPED, RATHER THAN MERELY PREVENTED.

preventDefault alone suppresses the menu and still lets xterm's own contextmenu
handler run, which moves its helper textarea under the pointer at z-index 1000 and
leaves the selection sitting in it, selected. Both of those exist to serve the
native menu. Taking the event in the capture phase on the pane and stopping it
there is what keeps the gesture wholly the page's.

rightClickSelectsWord is now false, which on macOS is a change of default rather
than a restatement of it: xterm sets it true there alone, so a right-click would
have selected the word under the pointer and copied that instead of pasting —
wrong on one platform only, which is the worst way for it to be wrong.

The renderer harness grows the pane it has been inviting since
RendererReconnectionTests: a fake Terminal, the two addons, and a real UTF-8 codec,
because both directions run through the page's own TextEncoder and TextDecoder and
a Latin-1 stub would pass a test with an em dash in the selection. Its warning
about declaring names terminal.js declares now covers the half that does not
SyntaxError — a harness frame() was silently replaced by the page's frame() and
failed six tests on a type error a long way from the cause.

Manual checks 1.11 and 11.13 are what is left for a person: that a real contextmenu
event reaches a capture listener, that no native menu is drawn once it is
prevented, and that xterm's real paste() brackets. 11.13 also records what is
unverified — whether Android's WebView delivers a pointer's secondary click as a
contextmenu event at all. ClipboardReader is wired on that head regardless,
because the alternative is a head where the paste half silently does nothing and
nobody finds out; that head is not compiled here either, this machine having no
android workload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:46:36 +02:00

689 lines
28 KiB
C#

using System.Buffers.Text;
using System.Globalization;
using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text;
namespace DodoSSH.Client.Terminal;
/// <summary>
/// Serves the renderer page and carries terminal frames, over one loopback socket.
/// </summary>
/// <remarks>
/// <para>
/// The data plane is a socket rather than the WebView's JavaScript bridge. The bridge is UI-thread
/// bound string evaluation with no backpressure signal, which at terminal throughput means thousands
/// of script evaluations per second on the thread that also has to paint. A socket gives ordering,
/// binary payloads and flow control for free.
/// </para>
/// <para>
/// The page is served from the same listener as the socket, which is what makes the <c>Origin</c>
/// header predictable — it is always <c>http://127.0.0.1:{port}</c>. Loading the page through a
/// WebView virtual-host mapping instead would produce a different origin on each backend and give
/// nothing to validate against.
/// </para>
/// <para>
/// <b>What the token and origin check actually defend against.</b> Not a hostile process running as
/// the same user: that process can already read this one's memory, so nothing here is a boundary
/// against it. They defend against a web page in the user's browser, which can and does attempt
/// WebSocket connections to loopback ports, and against a second copy of the application
/// accidentally attaching to the wrong terminal. Both are real; neither is stopped by the socket
/// being loopback alone.
/// </para>
/// </remarks>
public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
{
/// <summary>Subprotocol the renderer must request.</summary>
public const string SubProtocol = "dodossh.terminal.v1";
/// <summary>Path the renderer page is served from.</summary>
public const string PagePath = "/terminal";
/// <summary>Path the WebSocket upgrade is accepted on.</summary>
public const string SocketPath = "/socket";
/// <summary>Placeholder in the page that is replaced with the connection token.</summary>
public const string TokenPlaceholder = "__DODOSSH_TOKEN__";
/// <summary>Placeholder in the page that is replaced with the socket URL.</summary>
public const string SocketUrlPlaceholder = "__DODOSSH_SOCKET__";
/// <summary>RFC 6455 §1.3: the fixed GUID mixed into the handshake response.</summary>
private const string HandshakeGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
private const int MaximumRequestBytes = 16 * 1024;
private const int ReceiveBufferBytes = 64 * 1024;
private readonly TcpListener listener;
private readonly ITerminalAssetProvider assets;
private readonly Dictionary<uint, TerminalSessionPump> pumps = [];
private readonly Lock pumpGate = new();
private readonly SemaphoreSlim sendGate = new(1, 1);
private readonly CancellationTokenSource lifetime = new();
private readonly TaskCompletionSource rendererAttached =
new(TaskCreationOptions.RunContinuationsAsynchronously);
private WebSocket? socket;
private int disposed;
/// <param name="assets">Where the renderer's files come from.</param>
public TerminalDataPlane(ITerminalAssetProvider assets)
{
ArgumentNullException.ThrowIfNull(assets);
this.assets = assets;
Token = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32));
listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
Port = ((IPEndPoint)listener.LocalEndpoint).Port;
}
/// <summary>The port the OS assigned.</summary>
public int Port { get; }
/// <summary>The single-use connection token embedded in the served page.</summary>
public string Token { get; }
/// <summary>Where the WebView should navigate.</summary>
public Uri PageUrl => new(
string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{Port}{PagePath}"),
UriKind.Absolute);
/// <summary>Completes once the renderer has attached its socket.</summary>
public Task RendererAttached => rendererAttached.Task;
/// <summary>Raised when the page asks for a different font size.</summary>
/// <remarks>
/// Raised on the socket's receive loop rather than any UI thread, so a handler that touches view models
/// has to marshal. <see cref="TerminalWorkspace"/> forwards it as it arrives and leaves that to the
/// shell, which is where the thread affinity is known.
/// </remarks>
public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;
/// <summary>Raised when the page has text for the system clipboard.</summary>
/// <remarks>
/// A right-click over a selection. Raised on the socket's receive loop, like
/// <see cref="FontSizeStepRequested"/> and with the same obligation on a handler that touches a view
/// model — and the clipboard itself is a UI-thread object on more than one platform, so this one has to
/// be marshalled even where nothing is bound to.
/// </remarks>
public event EventHandler<TerminalClipboardCopyEventArgs>? ClipboardCopyRequested;
/// <summary>Raised when the page wants the system clipboard's text pasted into one of its panes.</summary>
/// <remarks>
/// A right-click with nothing selected. Raised on the socket's receive loop; see
/// <see cref="ClipboardCopyRequested"/>. The answer goes back as
/// <see cref="TerminalServerOpcode.Clipboard"/>, which is <see cref="TerminalWorkspace"/>'s to send.
/// </remarks>
public event EventHandler<TerminalClipboardPasteEventArgs>? ClipboardPasteRequested;
/// <summary>
/// Raised after a socket attaches — the first one, and every later takeover.
/// </summary>
/// <remarks>
/// <para>
/// Raised after <see cref="socket"/> has been swapped in but before <see cref="ReceiveLoopAsync"/> starts
/// consuming it, on the socket-accept thread — the same thread that is in the middle of
/// <see cref="UpgradeAsync"/> for this connection. <see cref="TerminalWorkspace"/> is this event's one
/// subscriber, and it uses the ordering to replay session state before anything the fresh page sends
/// (a resize, an early acknowledgement) can be dispatched; see its remark for why the two racing is
/// harmless regardless.
/// </para>
/// <para>
/// Unlike <see cref="RendererAttached"/>, which resolves once and answers "has a renderer ever attached"
/// for <see cref="TerminalWorkspace.WaitForRendererAsync"/>, this fires every time — because a takeover
/// is exactly the case <see cref="RendererAttached"/> was never meant to describe again.
/// </para>
/// </remarks>
public event EventHandler? SocketAttached;
/// <summary>Registers a session so inbound frames can be routed to it.</summary>
public void Register(uint sessionId, TerminalSessionPump pump)
{
ArgumentNullException.ThrowIfNull(pump);
lock (pumpGate)
{
pumps[sessionId] = pump;
}
}
/// <summary>Forgets a session that has ended.</summary>
public void Unregister(uint sessionId)
{
lock (pumpGate)
{
pumps.Remove(sessionId);
}
}
/// <summary>Accepts connections until disposed.</summary>
public async Task RunAsync(CancellationToken cancellationToken)
{
using var linked = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken,
lifetime.Token);
while (!linked.Token.IsCancellationRequested)
{
TcpClient client;
try
{
client = await listener.AcceptTcpClientAsync(linked.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
// Each connection on its own task, and deliberately not awaited. An upgraded WebSocket
// lives for the whole session, so handling connections in sequence would leave the accept
// loop parked inside the receive loop and every later request unanswered — the page's
// script and stylesheet among them. Two upgrades racing each other need no coordination
// here either, because the takeover in UpgradeAsync swaps the shared socket field with an
// interlocked exchange rather than assuming it is the only writer.
_ = HandleConnectionAsync(client, linked.Token);
}
}
private async Task HandleConnectionAsync(TcpClient client, CancellationToken cancellationToken)
{
try
{
await HandleAsync(client, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception)
when (exception is IOException or SocketException or WebSocketException
or OperationCanceledException or ObjectDisposedException)
{
// A renderer that went away mid-handshake, or a shutdown in progress. Ordinary.
}
finally
{
client.Dispose();
}
}
/// <inheritdoc />
public async ValueTask SendAsync(ReadOnlyMemory<byte> frame, CancellationToken cancellationToken)
{
var current = socket;
if (current is null || current.State != WebSocketState.Open)
{
// Dropped rather than queued. A terminal whose renderer has gone has nothing to catch up
// on, and buffering for one that may never return is the unbounded growth the credit
// window exists to prevent.
return;
}
// WebSocket forbids concurrent sends, and every session shares this one socket.
await sendGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await current
.SendAsync(frame, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// Not a cancellation despite the type: .NET's ManagedWebSocket wraps a send that fails because
// the underlying connection is already gone — which is exactly what a killed renderer's socket
// looks like — in an OperationCanceledException of its own manufacture, regardless of whether
// anyone actually cancelled anything. The filter is what tells the two apart: if the caller's
// own token were the cause, IsCancellationRequested would be true here and this catch does not
// apply, so a real cancellation still propagates. Everything below about why this must not
// fault the caller applies here exactly as it does to the exception types in the next catch.
}
catch (Exception exception)
when (exception is WebSocketException or ObjectDisposedException
or InvalidOperationException or IOException)
{
// The state check above is not atomic with the send, and a WebView renderer process killed by
// Android leaves its socket reporting Open long after nobody is reading from the other end. This
// has to read as "nobody listening" — the same as the no-socket case above — and never as a
// fault: SendAsync is called from TerminalSessionPump.SendOutputAsync inside the flush loop, and
// letting this exception escape would fault that loop. A faulted flush loop stops draining the
// credit window, the reader blocks once it fills, and the SSH session behind it freezes for good
// while LiveSessionCount still counts it as running. A dropped frame is recoverable — a frozen
// session is not.
}
finally
{
sendGate.Release();
}
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref disposed, 1) == 1)
{
return;
}
await lifetime.CancelAsync().ConfigureAwait(false);
rendererAttached.TrySetCanceled();
listener.Dispose();
socket?.Dispose();
sendGate.Dispose();
lifetime.Dispose();
}
private async Task HandleAsync(TcpClient client, CancellationToken cancellationToken)
{
var stream = client.GetStream();
await using var streamScope = stream.ConfigureAwait(false);
var request = await ReadRequestAsync(stream, cancellationToken).ConfigureAwait(false);
if (request is null)
{
return;
}
if (string.Equals(request.Path, SocketPath, StringComparison.Ordinal))
{
await UpgradeAsync(stream, request, cancellationToken).ConfigureAwait(false);
return;
}
await ServeAssetAsync(stream, request.Path, cancellationToken).ConfigureAwait(false);
}
private async Task ServeAssetAsync(Stream stream, string path, CancellationToken cancellationToken)
{
var asset = assets.Find(path);
if (asset is null)
{
await WriteResponseAsync(stream, "404 Not Found", "text/plain", "Not found"u8.ToArray(), cancellationToken)
.ConfigureAwait(false);
return;
}
var content = asset.Content;
if (string.Equals(path, PagePath, StringComparison.Ordinal))
{
// The token and socket URL are substituted at serve time rather than being written into
// the file, so the token never touches disk and never appears in a URL that could reach a
// log or a browser history.
var text = Encoding.UTF8.GetString(content)
.Replace(TokenPlaceholder, Token, StringComparison.Ordinal)
.Replace(
SocketUrlPlaceholder,
string.Create(CultureInfo.InvariantCulture, $"ws://127.0.0.1:{Port}{SocketPath}"),
StringComparison.Ordinal);
content = Encoding.UTF8.GetBytes(text);
}
await WriteResponseAsync(stream, "200 OK", asset.ContentType, content, cancellationToken)
.ConfigureAwait(false);
}
/// <remarks>
/// <para>
/// <b>Takeover, not rejection.</b> A valid upgrade always wins the socket, even when one is already
/// attached — the old socket is aborted and the newcomer takes its place. Refusing a second attach used
/// to be the rule, on the theory that one renderer lives for the whole process. That is WebView2's
/// truth and not Android's: the platform kills the WebView's renderer process under memory pressure or
/// simply for being backgrounded, the page reloads, and the reload's socket is a second valid upgrade —
/// refusing it left the terminal permanently unreachable with no way back short of restarting the app.
/// </para>
/// <para>
/// Refusing protects nothing here anyway: only our own page knows the token (see the type-level remark
/// on what the token defends against), so a second valid upgrade <em>is</em> our page, reattaching.
/// Waiting for the old socket to notice it is dead and close on its own is not a safer alternative
/// either — a killed renderer process sends no TCP FIN, so the old receive loop can sit unaware for the
/// whole 30-second keepalive interval, and every reload landing in that window would still find the
/// door held shut by a socket nobody is on the other end of. Taking over immediately is what makes a
/// reload actually reattach.
/// </para>
/// </remarks>
private async Task UpgradeAsync(
Stream stream,
HttpRequestLine request,
CancellationToken cancellationToken)
{
if (!IsAcceptableUpgrade(request))
{
await WriteResponseAsync(
stream, "403 Forbidden", "text/plain", "Rejected"u8.ToArray(), cancellationToken)
.ConfigureAwait(false);
return;
}
var key = request.Headers.GetValueOrDefault("sec-websocket-key")!;
var accept = ComputeHandshakeAccept(key);
var handshake =
"HTTP/1.1 101 Switching Protocols\r\n"
+ "Upgrade: websocket\r\n"
+ "Connection: Upgrade\r\n"
+ "Sec-WebSocket-Accept: " + accept + "\r\n"
+ "Sec-WebSocket-Protocol: " + SubProtocol + "\r\n\r\n";
await stream.WriteAsync(Encoding.ASCII.GetBytes(handshake), cancellationToken).ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
// The handshake is ours; the framing is the BCL's. Hand-rolling masking, fragmentation and
// control frames would be a great deal of code for no gain.
using var webSocket = WebSocket.CreateFromStream(
stream,
new WebSocketCreationOptions
{
IsServer = true,
SubProtocol = SubProtocol,
KeepAliveInterval = TimeSpan.FromSeconds(30),
});
// Whatever was attached before is displaced, not merely overwritten: Exchange hands back the old
// reference so it can be aborted rather than left to linger as a socket nothing reads from again.
// Abort rather than a graceful close — a close frame would wait on a peer that, per the remark
// above, may never notice it should reply, and the newcomer already proved it is our page.
var previous = Interlocked.Exchange(ref socket, webSocket);
previous?.Abort();
rendererAttached.TrySetResult();
SocketAttached?.Invoke(this, EventArgs.Empty);
try
{
await ReceiveLoopAsync(webSocket, cancellationToken).ConfigureAwait(false);
}
finally
{
// Cleared only if the field still holds this connection's own socket. A takeover has already
// swapped in a newer one by the time an aborted receive loop unwinds to here, and clearing the
// field regardless would race the newcomer: whichever of the two finished last would win, and
// it must always be the newcomer, never this one going away.
Interlocked.CompareExchange(ref socket, null, webSocket);
}
}
/// <remarks>
/// The origin is checked because a page in the user's browser can attempt a WebSocket connection to
/// a loopback port, and the token because that page could otherwise simply guess the path. Neither
/// defends against a process running as this user; that one can read our memory regardless.
/// </remarks>
private bool IsAcceptableUpgrade(HttpRequestLine request)
{
var origin = request.Headers.GetValueOrDefault("origin");
var expectedOrigin = string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{Port}");
if (!string.Equals(origin, expectedOrigin, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (request.Headers.GetValueOrDefault("sec-websocket-key") is null)
{
return false;
}
var protocols = request.Headers.GetValueOrDefault("sec-websocket-protocol") ?? string.Empty;
var offered = protocols
.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (!offered.Contains(SubProtocol, StringComparer.Ordinal))
{
return false;
}
// Carried as a subprotocol rather than a query parameter, which keeps it out of anything that
// logs URLs.
var presented = offered.FirstOrDefault(p => p.StartsWith("token.", StringComparison.Ordinal));
return presented is not null
&& CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(presented["token.".Length..]),
Encoding.ASCII.GetBytes(Token));
}
private async Task ReceiveLoopAsync(WebSocket webSocket, CancellationToken cancellationToken)
{
var buffer = new byte[ReceiveBufferBytes];
while (webSocket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
{
var result = await webSocket
.ReceiveAsync(buffer.AsMemory(), cancellationToken)
.ConfigureAwait(false);
if (result.MessageType == WebSocketMessageType.Close)
{
return;
}
if (!result.EndOfMessage)
{
// A frame larger than the receive buffer. Terminal input and acknowledgements are tiny,
// so this is either a bug or something hostile; dropping is the safe response.
continue;
}
Dispatch(buffer.AsSpan(0, result.Count));
}
}
private void Dispatch(ReadOnlySpan<byte> frame)
{
if (!TerminalFrame.TryRead(frame, out var opcode, out var sessionId, out var payload))
{
return;
}
if (Announce(opcode, sessionId, payload))
{
return;
}
TerminalSessionPump? pump;
lock (pumpGate)
{
pump = pumps.GetValueOrDefault(sessionId);
}
if (pump is null)
{
// A frame for a session that has already ended. Ordinary during teardown.
return;
}
switch ((TerminalClientOpcode)opcode)
{
case TerminalClientOpcode.Input:
// Fire and forget: a blocked write must not stall acknowledgements for other sessions
// sharing this socket.
_ = pump.WriteInputAsync(payload.ToArray(), lifetime.Token).AsTask();
break;
case TerminalClientOpcode.Acknowledge:
if (TerminalFrame.TryReadAcknowledgement(payload, out var rendered))
{
pump.Acknowledge(rendered);
}
break;
case TerminalClientOpcode.Resize:
if (TerminalFrame.TryReadResize(payload, out var size))
{
pump.Resize(size);
}
break;
default:
// Unknown opcode from a newer page than this host. Ignored rather than fatal.
break;
}
}
/// <summary>
/// Raises the frames no pump needs to see, and says whether this was one of them.
/// </summary>
/// <remarks>
/// <para>
/// Answered before the session lookup, because none of these three touches a pump. A text-size chord is
/// about the person reading rather than about one terminal, and the page sends whichever id it had to
/// hand. A copy is about the machine's clipboard. A paste request is answered by sending a frame back
/// rather than by writing to a pty, and <see cref="TerminalWorkspace.SendClipboardAsync"/> does its own
/// liveness check — which is the one that can honestly refuse.
/// </para>
/// <para>
/// Which is also what keeps all three working in a pane whose shell has just ended, and that is when
/// they are most wanted: squinting at the message a remote left behind is exactly when somebody makes
/// the text bigger or copies the line. Dropping the frame at the pump lookup would have made a dead tab
/// the one place the gesture silently did nothing.
/// </para>
/// </remarks>
private bool Announce(byte opcode, uint sessionId, ReadOnlySpan<byte> payload)
{
switch ((TerminalClientOpcode)opcode)
{
case TerminalClientOpcode.FontSizeStep:
if (TerminalFrame.TryReadFontSizeStep(payload, out var step))
{
FontSizeStepRequested?.Invoke(this, new TerminalFontSizeStepEventArgs(step));
}
return true;
case TerminalClientOpcode.ClipboardCopy:
// An empty copy is dropped rather than forwarded. The clipboard is shared with everything
// else on the machine, so "copy nothing" would be a gesture that quietly destroys whatever
// was on it — and the page does not send one, which makes this a guard against a frame
// arriving from something other than the page we shipped.
if (payload.Length > 0)
{
ClipboardCopyRequested?.Invoke(
this,
new TerminalClipboardCopyEventArgs(Encoding.UTF8.GetString(payload)));
}
return true;
case TerminalClientOpcode.ClipboardPaste:
ClipboardPasteRequested?.Invoke(this, new TerminalClipboardPasteEventArgs(sessionId));
return true;
default:
return false;
}
}
/// <summary>
/// Computes the <c>Sec-WebSocket-Accept</c> value RFC 6455 §4.2.2 requires.
/// </summary>
/// <remarks>
/// <para>
/// SHA-1 is mandated by the specification and is not doing security work here. The value proves
/// only that the server understood the WebSocket handshake rather than being a plain HTTP server
/// that echoed the request; it authenticates nothing and protects no data. Substituting SHA-256
/// would simply make the handshake fail with every client in existence.
/// </para>
/// <para>
/// The alternative that avoids SHA-1 in our own code is
/// <c>HttpListener.AcceptWebSocketAsync</c>, which throws <c>PlatformNotSupportedException</c>
/// off Windows — so it would trade a documented suppression for a platform restriction.
/// </para>
/// </remarks>
[System.Diagnostics.CodeAnalysis.SuppressMessage(
"Security",
"CA5350:Do Not Use Weak Cryptographic Algorithms",
Justification = "RFC 6455 mandates SHA-1 for the handshake; it carries no security property.")]
[System.Diagnostics.CodeAnalysis.SuppressMessage(
"ApiDesign",
"RS0030:Do not use banned APIs",
Justification = "RFC 6455 mandates SHA-1 for the handshake; it carries no security property.")]
private static string ComputeHandshakeAccept(string key) =>
Convert.ToBase64String(SHA1.HashData(Encoding.ASCII.GetBytes(key + HandshakeGuid)));
private static async Task WriteResponseAsync(
Stream stream,
string status,
string contentType,
byte[] body,
CancellationToken cancellationToken)
{
var header =
"HTTP/1.1 " + status + "\r\n"
+ "Content-Type: " + contentType + "\r\n"
+ "Content-Length: " + body.Length.ToString(CultureInfo.InvariantCulture) + "\r\n"
// The page carries a connection token, so it must never be cached anywhere.
+ "Cache-Control: no-store\r\n"
+ "Connection: close\r\n\r\n";
await stream.WriteAsync(Encoding.ASCII.GetBytes(header), cancellationToken).ConfigureAwait(false);
await stream.WriteAsync(body, cancellationToken).ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
private static async Task<HttpRequestLine?> ReadRequestAsync(
Stream stream,
CancellationToken cancellationToken)
{
var buffer = new byte[MaximumRequestBytes];
var count = 0;
while (count < buffer.Length)
{
var read = await stream
.ReadAsync(buffer.AsMemory(count), cancellationToken)
.ConfigureAwait(false);
if (read == 0)
{
break;
}
count += read;
if (Encoding.ASCII.GetString(buffer, 0, count).Contains("\r\n\r\n", StringComparison.Ordinal))
{
break;
}
}
var lines = Encoding.ASCII.GetString(buffer, 0, count).Split("\r\n");
var parts = lines[0].Split(' ');
if (parts.Length < 2 || !string.Equals(parts[0], "GET", StringComparison.Ordinal))
{
return null;
}
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var line in lines.Skip(1))
{
if (line.Length == 0)
{
break;
}
var separator = line.IndexOf(':', StringComparison.Ordinal);
if (separator > 0)
{
headers[line[..separator].Trim()] = line[(separator + 1)..].Trim();
}
}
var path = parts[1];
var query = path.IndexOf('?', StringComparison.Ordinal);
return new HttpRequestLine(query < 0 ? path : path[..query], headers);
}
private sealed record HttpRequestLine(string Path, IReadOnlyDictionary<string, string> Headers);
}