Der Manager hoert zusaetzlich auf einem lokalen Socket (0600 bzw. Pipe nur fuer den eigenen Benutzer). Dort gibt es nur /api/shell/*, ueber TCP gibt es diese Pfade nicht. Ein Einmal-Code (256 Bit, einmal, hoechstens 60 s) wird in der WebUI aus dem Fragment gegen das Token getauscht und sofort aus der Adresse entfernt. Tests: zweimal, zu spaet, ueber TCP - alle scheitern. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
88 lines
4.3 KiB
C#
88 lines
4.3 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using Microsoft.AspNetCore.Http;
|
|
|
|
namespace ClawdDotNet.Manager.Web;
|
|
|
|
/// <summary>
|
|
/// Der Torwächter der Weboberfläche: an die Daten kommt nur, wer das Token hat.
|
|
///
|
|
/// <para><b>Warum der Manager einen eigenen hat und nicht den der Instanz.</b> 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 <c>/api</c> und <c>/events</c> kommt über TCP herein, und alles davon
|
|
/// braucht das Token. Eine Regel weniger ist hier eine Regel, die nicht falsch angewandt
|
|
/// werden kann.</para>
|
|
///
|
|
/// <para><b>Was ohne Token ausgeliefert wird, ist die leere Hülle</b> — 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.</para>
|
|
///
|
|
/// <para><b>Warum der Vergleich in fester Zeit läuft:</b> 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.</para>
|
|
/// </summary>
|
|
public sealed class AccessToken(byte[] expected)
|
|
{
|
|
/// <summary>Kürzer als das hier darf ein Token nicht sein.</summary>
|
|
public const int MinimumLength = 20;
|
|
|
|
public static AccessToken For(string token)
|
|
=> new(Encoding.UTF8.GetBytes(token));
|
|
|
|
/// <summary>Ob dieser Pfad ein Token braucht: alles unter <c>/api</c> und <c>/events</c>.</summary>
|
|
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);
|
|
|
|
/// <summary>#764: Where the WebUI trades a one-time code from the shell for the access token.</summary>
|
|
public const string LoginExchangePath = "/api/login/exchange";
|
|
|
|
/// <summary>
|
|
/// Holt das Token aus der Anfrage.
|
|
///
|
|
/// <para><c>Authorization: Bearer …</c> 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.</para>
|
|
///
|
|
/// <para>Die Ausnahme ist der Ereignisstrom: <c>EventSource</c> 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.</para>
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|