using System.Security.Cryptography; using System.Text; using Microsoft.AspNetCore.Http; namespace ClawdDotNet.Manager.Web; /// /// Der Torwächter der Weboberfläche: an die Daten kommt nur, wer das Token hat. /// /// Warum der Manager einen eigenen hat und nicht den der Instanz. Die Instanz /// hört seit Stufe 2 nur noch auf ihrem Socket, und dort ist die Grenze das Dateisystem — /// ihr Torwächter kennt deshalb eine Ausnahme für lokale Verbindungen. Hier gibt es die /// nicht: Alles unter /api und /events kommt über TCP herein, und alles davon /// braucht das Token. Eine Regel weniger ist hier eine Regel, die nicht falsch angewandt /// werden kann. /// /// Was ohne Token ausgeliefert wird, ist die leere Hülle — HTML, CSS und /// JavaScript. Das ist kein Zugeständnis, sondern die einzige Bauform, die funktioniert: /// Ein Browser, der eine Adresse anspringt, kann keine Kopfzeile mitschicken; wäre die /// Seite selbst gesperrt, gäbe es keinen Ort, an dem man das Token je eingeben könnte. /// In den Dateien steht nichts, was jemand nicht wissen dürfte. /// /// Warum der Vergleich in fester Zeit läuft: Ein gewöhnlicher /// Zeichenkettenvergleich bricht beim ersten Unterschied ab. Wer die Antwortzeit misst, /// rät das Token Zeichen für Zeichen statt in einem Rutsch — bei einem Dienst, der /// Agenten mit Dateizugriff steuert, ist das kein theoretischer Einwand. /// public sealed class AccessToken(byte[] expected) { /// Kürzer als das hier darf ein Token nicht sein. public const int MinimumLength = 20; public static AccessToken For(string token) => new(Encoding.UTF8.GetBytes(token)); /// Ob dieser Pfad ein Token braucht: alles unter /api und /events. public static bool NeedsToken(PathString path) => (path.StartsWithSegments("/api") || path.StartsWithSegments("/events")) // #764: Exchanging a one-time code is the login itself - like the page that asks // for the token, it cannot require one. The code is the proof (ShellLogin). && !path.Equals(LoginExchangePath, StringComparison.OrdinalIgnoreCase); /// #764: Where the WebUI trades a one-time code from the shell for the access token. public const string LoginExchangePath = "/api/login/exchange"; /// /// Holt das Token aus der Anfrage. /// /// Authorization: Bearer … ist der Weg. Ein Cookie wäre bequemer und /// genau deshalb falsch: Der Browser schickt es bei jeder Anfrage automatisch mit, /// auch bei einer, die eine fremde Seite ausgelöst hat. Eine Kopfzeile, die /// JavaScript setzen muss, wird nicht von selbst mitgeschickt — damit ist CSRF hier /// keine eigene Baustelle. /// /// Die Ausnahme ist der Ereignisstrom: EventSource im Browser kann keine /// Kopfzeilen setzen. Dafür — und nur dafür — wird das Token auch aus der /// Abfragezeichenkette gelesen. Eine bewusst in Kauf genommene Schwäche: Adressen mit /// Abfragezeichenkette landen in Protokollen. Vertretbar, weil die Weboberfläche in /// der Vorgabe nur auf Loopback hört; wer sie ins Netz stellt, hat einen Reverse Proxy /// davor und entscheidet dort, was protokolliert wird. /// public static string? Read(HttpRequest request) { var header = request.Headers.Authorization.ToString(); if (header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) return header["Bearer ".Length..].Trim(); if (request.Path.StartsWithSegments("/events") && request.Query.TryGetValue("token", out var fromQuery)) return fromQuery.ToString(); return null; } public bool Matches(string? candidate) { if (string.IsNullOrEmpty(candidate)) return false; // FixedTimeEquals gibt bei ungleicher Laenge false zurueck, ohne zu werfen. Dass // dabei die Laenge durchscheint, ist unbedenklich: Sie ist kein Geheimnis, // sondern eine feste Zahl. Geheim ist der Inhalt, und der wird in fester Zeit // verglichen. return CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(candidate), expected); } }