Files
swyx/src/SwyxTray/Web/WebSocketHandshake.cs
T
Sven 78b17a1350 SwyxTray und Firefox-Plugin in das Repository aufnehmen
Windows-Tray-Anwendung (CLMgr-Anbindung an SwyxIt!) samt WebSocket-Zugang,
Firefox-Erweiterung und Browser-Beispielclient.
2026-08-21 11:33:52 +02:00

262 lines
9.4 KiB
C#

using System.Security.Cryptography;
using System.Text;
namespace SwyxTray.Web;
/// <summary>
/// Der HTTP-Teil des WebSocket-Aufbaus (RFC 6455, Abschnitt 4.2). Das ist der
/// einzige Teil des Protokolls, der von Hand entsteht — Rahmen, Maskierung,
/// Ping/Pong und der Schliessvorgang kommen danach von
/// <see cref="System.Net.WebSockets.WebSocket.CreateFromStream"/>.
/// </summary>
internal static class WebSocketHandshake
{
/// <summary>Feste GUID aus RFC 6455 zur Bildung des Accept-Werts.</summary>
private const string AcceptGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
private const int MaxHeaderBytes = 16 * 1024;
public const string SubProtocol = "swyxtray.v1";
/// <summary>Erlaubte Werte des Host-Headers — Schutz vor DNS-Rebinding.</summary>
private static readonly string[] LoopbackHosts = ["127.0.0.1", "localhost", "[::1]", "::1"];
internal sealed record Request(
string Method,
string Target,
IReadOnlyDictionary<string, string> Headers,
string? Token,
string Origin,
string? SubProtocol);
/// <summary>
/// Liest die Handshake-Anfrage. Bewusst byteweise bis zur Leerzeile: alles
/// dahinter gehoert bereits dem WebSocket, und ein zu grosszuegiges Lesen
/// wuerde die ersten Frames verschlucken.
/// </summary>
public static async Task<Request?> ReadAsync(Stream stream, CancellationToken cancellationToken)
{
var buffer = new byte[1];
var raw = new List<byte>(1024);
while (raw.Count < MaxHeaderBytes)
{
var read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
if (read == 0)
{
return null;
}
raw.Add(buffer[0]);
if (raw.Count >= 4 &&
raw[^4] == (byte)'\r' && raw[^3] == (byte)'\n' &&
raw[^2] == (byte)'\r' && raw[^1] == (byte)'\n')
{
return Parse(Encoding.ASCII.GetString(raw.ToArray()));
}
}
return null;
}
private static Request? Parse(string text)
{
var lines = text.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
if (lines.Length == 0)
{
return null;
}
var requestLine = lines[0].Split(' ');
if (requestLine.Length < 3)
{
return null;
}
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var line in lines.Skip(1))
{
var separator = line.IndexOf(':');
if (separator <= 0)
{
continue;
}
var name = line[..separator].Trim();
var value = line[(separator + 1)..].Trim();
// Mehrfach gesendete Header zusammenfassen, wie in HTTP vorgesehen.
headers[name] = headers.TryGetValue(name, out var existing)
? $"{existing}, {value}"
: value;
}
var offered = headers.GetValueOrDefault("Sec-WebSocket-Protocol", string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return new Request(
Method: requestLine[0],
Target: requestLine[1],
Headers: headers,
Token: TokenFrom(requestLine[1], offered),
Origin: headers.GetValueOrDefault("Origin", string.Empty),
SubProtocol: offered.Contains(SubProtocol) ? SubProtocol : null);
}
/// <summary>
/// Das Token darf in der Abfragezeichenfolge (<c>?token=…</c>) oder als
/// Unterprotokoll <c>token.…</c> kommen. Die zweite Form ist fuer Seiten
/// nuetzlich, die das Geheimnis nicht in einer URL stehen haben wollen —
/// die WebSocket-API des Browsers kennt keine eigenen Header.
/// </summary>
private static string? TokenFrom(string target, IEnumerable<string> offeredProtocols)
{
var fromProtocol = offeredProtocols
.FirstOrDefault(p => p.StartsWith("token.", StringComparison.Ordinal))?["token.".Length..];
if (!string.IsNullOrEmpty(fromProtocol))
{
return fromProtocol;
}
var query = target.IndexOf('?');
if (query < 0)
{
return null;
}
foreach (var pair in target[(query + 1)..].Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var equals = pair.IndexOf('=');
if (equals > 0 && pair[..equals].Equals("token", StringComparison.OrdinalIgnoreCase))
{
return Uri.UnescapeDataString(pair[(equals + 1)..]);
}
}
return null;
}
/// <summary>
/// Prueft die Anfrage. Liefert <c>null</c>, wenn alles passt, sonst
/// HTTP-Status und Begruendung fuer die Absage.
/// </summary>
public static (int Status, string Reason)? Validate(Request request, WebSocketConfig config)
{
if (!request.Method.Equals("GET", StringComparison.Ordinal))
{
return (405, "Nur GET.");
}
if (!Contains(request.Headers.GetValueOrDefault("Upgrade"), "websocket") ||
!Contains(request.Headers.GetValueOrDefault("Connection"), "upgrade"))
{
return (400, "Kein WebSocket-Upgrade.");
}
if (request.Headers.GetValueOrDefault("Sec-WebSocket-Version") != "13")
{
return (426, "Es wird nur Sec-WebSocket-Version 13 unterstuetzt.");
}
if (string.IsNullOrEmpty(request.Headers.GetValueOrDefault("Sec-WebSocket-Key")))
{
return (400, "Sec-WebSocket-Key fehlt.");
}
// Bis hierher ging es nur um ein wohlgeformtes Upgrade; alles Weitere
// sind die Zugriffsschranken — und die sind zur Zeit abgeschaltet,
// siehe WebSocketConfig.SecurityDisabled.
if (WebSocketConfig.SecurityDisabled)
{
return null;
}
// Ohne diese Pruefung koennte eine Seite einen eigenen Namen auf
// 127.0.0.1 zeigen lassen und so aus ihrem Ursprung heraus verbinden.
// Bei Zugriff aus dem Netz entfaellt sie: welchen Namen oder welche
// Adresse ein entfernter Rechner verwendet, kann die App nicht wissen.
if (!config.AllowRemoteAccess)
{
var raw = request.Headers.GetValueOrDefault("Host", string.Empty);
var host = raw.StartsWith('[') ? "[::1]" : raw.Split(':')[0];
if (!LoopbackHosts.Contains(host, StringComparer.OrdinalIgnoreCase))
{
return (400, "Unerwarteter Host — erlaubt ist nur 127.0.0.1 bzw. localhost.");
}
}
if (!config.IsTokenValid(request.Token))
{
return (403, "Token fehlt oder ist falsch.");
}
// Fehlt der Origin, ist der Aufrufer kein Browser. Das ist zulaessig,
// weil das Token bereits nachgewiesen wurde; eine Webseite kann den
// Header dagegen nicht weglassen, ihr Ursprung wird also stets geprueft.
if (request.Origin.Length > 0 && !config.IsOriginAllowed(request.Origin))
{
return (403, $"Origin '{request.Origin}' ist nicht freigegeben.");
}
return null;
}
public static Task AcceptAsync(Stream stream, Request request, CancellationToken cancellationToken)
{
var key = request.Headers["Sec-WebSocket-Key"];
var accept = Convert.ToBase64String(SHA1.HashData(Encoding.ASCII.GetBytes(key + AcceptGuid)));
var response = new StringBuilder()
.Append("HTTP/1.1 101 Switching Protocols\r\n")
.Append("Upgrade: websocket\r\n")
.Append("Connection: Upgrade\r\n")
.Append($"Sec-WebSocket-Accept: {accept}\r\n");
if (request.SubProtocol is not null)
{
response.Append($"Sec-WebSocket-Protocol: {request.SubProtocol}\r\n");
}
return WriteAsync(stream, response.Append("\r\n").ToString(), cancellationToken);
}
/// <summary>
/// Absage mit Klartext im Rumpf. Der Browser zeigt davon zwar nur den
/// Status, aber im Protokoll der App und in einem Netzwerkmitschnitt steht
/// damit der Grund — sonst bleibt nur ein wortloses Scheitern.
/// </summary>
public static Task RejectAsync(Stream stream, int status, string reason, CancellationToken cancellationToken)
{
var body = Encoding.UTF8.GetBytes(reason);
var head =
$"HTTP/1.1 {status} {StatusText(status)}\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n" +
$"Content-Length: {body.Length}\r\n" +
"Connection: close\r\n\r\n";
return WriteAsync(stream, head + reason, cancellationToken);
}
private static async Task WriteAsync(Stream stream, string text, CancellationToken cancellationToken)
{
await stream.WriteAsync(Encoding.UTF8.GetBytes(text), cancellationToken).ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
private static bool Contains(string? headerValue, string token)
=> headerValue is not null && headerValue.Contains(token, StringComparison.OrdinalIgnoreCase);
private static string StatusText(int status) => status switch
{
400 => "Bad Request",
403 => "Forbidden",
405 => "Method Not Allowed",
426 => "Upgrade Required",
503 => "Service Unavailable",
_ => "Error"
};
}