using System.Security.Cryptography; using System.Text; namespace SwyxTray.Web; /// /// 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 /// . /// internal static class WebSocketHandshake { /// Feste GUID aus RFC 6455 zur Bildung des Accept-Werts. private const string AcceptGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; private const int MaxHeaderBytes = 16 * 1024; public const string SubProtocol = "swyxtray.v1"; /// Erlaubte Werte des Host-Headers — Schutz vor DNS-Rebinding. private static readonly string[] LoopbackHosts = ["127.0.0.1", "localhost", "[::1]", "::1"]; internal sealed record Request( string Method, string Target, IReadOnlyDictionary Headers, string? Token, string Origin, string? SubProtocol); /// /// 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. /// public static async Task ReadAsync(Stream stream, CancellationToken cancellationToken) { var buffer = new byte[1]; var raw = new List(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(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); } /// /// Das Token darf in der Abfragezeichenfolge (?token=…) oder als /// Unterprotokoll token.… 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. /// private static string? TokenFrom(string target, IEnumerable 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; } /// /// Prueft die Anfrage. Liefert null, wenn alles passt, sonst /// HTTP-Status und Begruendung fuer die Absage. /// 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); } /// /// 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. /// 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" }; }