Improved the security of the connection between the app window and AI Studio (#1146)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-01 12:29:37 +01:00
1 parent 64dc621a7d
commit faae2951f7
26 files changed
+1056 -88

No files matched your search

@@ -0,0 +1,127 @@
using Microsoft.Extensions.Primitives;
namespace AIStudio.Tools.Security;
/// <summary>
/// Refuses every request to the app server which does not come from the app window.
/// </summary>
/// <remarks>
/// The middleware runs before every other one, so it covers pages, static files, the Blazor hub
/// with all of its transports, and every endpoint alike. A release build makes no exception: a
/// request without the session cookie gets refused, whatever it asks for. Only a development build
/// also takes its boot link, with which another browser gets the cookie.
///
/// A request which carries an Origin header must, in addition, come from a page of the app.
/// Browsers send that header with requests made by a script, the WebSocket of the Blazor hub
/// included, but not when they load a page. A sandboxed iframe sends "null", which gets refused as
/// well.
/// </remarks>
/// <param name="next">The next middleware.</param>
/// <param name="policy">Decides which requests may pass.</param>
/// <param name="logger">The logger.</param>
public sealed class AppAccessMiddleware(RequestDelegate next, AppAccessPolicy policy, ILogger<AppAccessMiddleware> logger)
{
/// <summary>
/// The shortest interval between two log entries about refused requests. Any process on this
/// machine can send requests, and an entry for each of them would flood the log.
/// </summary>
private static readonly TimeSpan REFUSAL_LOG_INTERVAL = TimeSpan.FromMinutes(1);
/// <summary>
/// What a refused request gets to read. The app window only shows it when it lost its cookie,
/// and a new start gives it a new one.
/// </summary>
private const string REFUSAL_TEXT = "This window cannot access AI Studio. Please restart AI Studio.";
private readonly Lock refusalLock = new();
private DateTimeOffset lastRefusalLog = DateTimeOffset.MinValue;
private int refusalsSinceLastLog;
public async Task InvokeAsync(HttpContext context)
{
var origin = context.Request.Headers.Origin;
if (!StringValues.IsNullOrEmpty(origin) && (origin.Count != 1 || !policy.IsAppOrigin(origin.ToString())))
{
await this.RefuseAsync(context, "another origin");
return;
}
#if DEBUG
if (HttpMethods.IsGet(context.Request.Method) && context.Request.Path.Equals(DEV_BOOT_PATH, StringComparison.Ordinal))
{
await this.BootAsync(context);
return;
}
#endif
if (!policy.HasSessionCookie(context.Request))
{
await this.RefuseAsync(context, "a client without the session cookie");
return;
}
await next(context);
}
#if DEBUG
/// <summary>
/// The path of the boot link which development builds write for other browsers, such as the
/// one of an agent. A release build knows neither the link nor this path.
/// </summary>
private const string DEV_BOOT_PATH = "/dev/boot";
/// <summary>
/// Trades the token of a boot link for the session cookie and sends the browser on to the app.
/// The browser opens the link by itself, so it sends the cookie along after the redirect.
/// </summary>
private async Task BootAsync(HttpContext context)
{
var token = context.Request.Query["token"].ToString();
if (!policy.IsUIToken(token))
{
await this.RefuseAsync(context, "a boot link with a wrong token");
return;
}
context.Response.Cookies.Append(AppAccessPolicy.SESSION_COOKIE_NAME, token, new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Lax,
Path = "/",
IsEssential = true
});
context.Response.Redirect("/");
}
#endif
private async Task RefuseAsync(HttpContext context, string reason)
{
this.LogRefusal(context, reason);
context.Response.StatusCode = StatusCodes.Status403Forbidden;
context.Response.ContentType = "text/plain; charset=utf-8";
await context.Response.WriteAsync(REFUSAL_TEXT);
}
/// <summary>
/// Logs a refused request, at most once per interval, together with the number of requests
/// refused since the last entry. The entry names the path but never the query or any header.
/// </summary>
private void LogRefusal(HttpContext context, string reason)
{
var now = DateTimeOffset.UtcNow;
int refusals;
lock (this.refusalLock)
{
this.refusalsSinceLastLog++;
if (now - this.lastRefusalLog < REFUSAL_LOG_INTERVAL)
return;
refusals = this.refusalsSinceLastLog;
this.refusalsSinceLastLog = 0;
this.lastRefusalLog = now;
}
logger.LogWarning("Refused {Refusals} request(s) to the app server since the last report. The latest one, '{Method} {Path}', came from {Reason}.", refusals, context.Request.Method, context.Request.Path.Value, reason);
}
}
@@ -0,0 +1,76 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.HostFiltering;
namespace AIStudio.Tools.Security;
/// <summary>
/// Decides which requests may reach the app server.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="appPort">The port the app server listens on.</param>
/// <param name="uiToken">The token of this start, which the session cookie carries.</param>
public sealed class AppAccessPolicy(int appPort, string uiToken)
{
/// <summary>
/// The name of the session cookie. The Rust runtime sets the cookie under the same name.
/// </summary>
public const string SESSION_COOKIE_NAME = "ai_studio_session";
/// <summary>
/// The environment variable which carries the token from the Rust runtime to this server.
/// </summary>
public const string UI_TOKEN_ENVIRONMENT_VARIABLE = "AI_STUDIO_UI_TOKEN";
/// <summary>
/// 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.
/// </summary>
private static readonly string[] ALLOWED_HOSTS = ["127.0.0.1", "localhost", "[::1]"];
private readonly HashSet<string> allowedOrigins = ALLOWED_HOSTS.Select(host => $"http://{host}:{appPort}").ToHashSet(StringComparer.OrdinalIgnoreCase);
private readonly byte[] uiTokenBytes = Encoding.UTF8.GetBytes(uiToken);
/// <summary>
/// Lets the host filtering of ASP.NET Core accept only the host names of the app server.
/// </summary>
/// <param name="options">The options of the host filtering.</param>
public static void ConfigureHostFiltering(HostFilteringOptions options)
{
options.AllowedHosts = [..ALLOWED_HOSTS];
options.AllowEmptyHosts = false;
options.IncludeFailureMessage = false;
}
/// <summary>
/// Checks whether the given origin is the app itself.
/// </summary>
/// <param name="origin">The value of the Origin header of a request.</param>
/// <returns>True when a page of the app made the request.</returns>
public bool IsAppOrigin(string origin) => this.allowedOrigins.Contains(origin);
/// <summary>
/// Checks whether the given request carries the session cookie of this start.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="request">The request.</param>
/// <returns>True when the request comes from the app window.</returns>
public bool HasSessionCookie(HttpRequest request) => request.Cookies.TryGetValue(SESSION_COOKIE_NAME, out var value) && this.IsUIToken(value);
/// <summary>
/// Checks whether the given value is the token of this start, in a time which does not depend
/// on where a wrong value differs.
/// </summary>
/// <param name="value">The value to check.</param>
/// <returns>True when the value is the token.</returns>
public bool IsUIToken(string? value) => !string.IsNullOrEmpty(value) && CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(value), this.uiTokenBytes);
}