using System.Security.Cryptography; using System.Text; using Microsoft.AspNetCore.HostFiltering; namespace AIStudio.Tools.Security; /// /// Decides which requests may reach the app server. /// /// /// The Rust runtime makes a new token for every start. It gives the token to this server through /// an environment variable, and to the app window as the value of the session cookie. Nothing else /// on this machine has the cookie, so a request with it comes from the app window. /// /// The port the app server listens on. /// The token of this start, which the session cookie carries. public sealed class AppAccessPolicy(int appPort, string uiToken) { /// /// The name of the session cookie. The Rust runtime sets the cookie under the same name. /// public const string SESSION_COOKIE_NAME = "ai_studio_session"; /// /// The environment variable which carries the token from the Rust runtime to this server. /// public const string UI_TOKEN_ENVIRONMENT_VARIABLE = "AI_STUDIO_UI_TOKEN"; /// /// The host names under which the app server answers. A request for any other name, such as one /// which a website points at the loopback address, gets refused. /// private static readonly string[] ALLOWED_HOSTS = ["127.0.0.1", "localhost", "[::1]"]; private readonly HashSet allowedOrigins = ALLOWED_HOSTS.Select(host => $"http://{host}:{appPort}").ToHashSet(StringComparer.OrdinalIgnoreCase); private readonly byte[] uiTokenBytes = Encoding.UTF8.GetBytes(uiToken); /// /// Lets the host filtering of ASP.NET Core accept only the host names of the app server. /// /// The options of the host filtering. public static void ConfigureHostFiltering(HostFilteringOptions options) { options.AllowedHosts = [..ALLOWED_HOSTS]; options.AllowEmptyHosts = false; options.IncludeFailureMessage = false; } /// /// Checks whether the given origin is the app itself. /// /// The value of the Origin header of a request. /// True when a page of the app made the request. public bool IsAppOrigin(string origin) => this.allowedOrigins.Contains(origin); /// /// Checks whether the given request carries the session cookie of this start. /// /// /// The comparison takes the same time no matter where a wrong value differs, so the time of a /// refusal tells nothing about how much of a guess was right. /// /// The request. /// True when the request comes from the app window. public bool HasSessionCookie(HttpRequest request) => request.Cookies.TryGetValue(SESSION_COOKIE_NAME, out var value) && this.IsUIToken(value); /// /// Checks whether the given value is the token of this start, in a time which does not depend /// on where a wrong value differs. /// /// The value to check. /// True when the value is the token. public bool IsUIToken(string? value) => !string.IsNullOrEmpty(value) && CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(value), this.uiTokenBytes); }