mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-04 11:27:21 +00:00
Improved the security of the connection between the app window and AI Studio (#1146)
This commit is contained in:
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);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Reflection;
|
||||
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the files below wwwroot, which every build embeds into the app.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The app reads files such as its changelogs directly instead of requesting them over HTTP from
|
||||
/// its own server, which answers the app window only. The release build serves its static files
|
||||
/// from the same provider.
|
||||
/// </remarks>
|
||||
public sealed class WebRootFiles
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the files below wwwroot.
|
||||
/// </summary>
|
||||
public IFileProvider FileProvider { get; } = new ManifestEmbeddedFileProvider(Assembly.GetAssembly(type: typeof(Program))!, "wwwroot");
|
||||
|
||||
/// <summary>
|
||||
/// Reads a text file below wwwroot.
|
||||
/// </summary>
|
||||
/// <param name="path">The path below wwwroot, such as <c>changelog/v26.10.1.md</c>.</param>
|
||||
/// <returns>The text of the file, or an empty string when there is no such file.</returns>
|
||||
public async Task<string> ReadTextAsync(string path)
|
||||
{
|
||||
var file = this.FileProvider.GetFileInfo(path);
|
||||
if (!file.Exists)
|
||||
return string.Empty;
|
||||
|
||||
await using var fileStream = file.CreateReadStream();
|
||||
using var reader = new StreamReader(fileStream);
|
||||
return await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies a file below wwwroot to the given place, such as one the user chose to save it to.
|
||||
/// </summary>
|
||||
/// <param name="path">The path below wwwroot, such as <c>specs/eri/v1.json</c>.</param>
|
||||
/// <param name="destinationPath">Where to write the copy. An existing file there gets replaced.</param>
|
||||
/// <returns>True when the copy was written, false when there is no such file below wwwroot.</returns>
|
||||
public async Task<bool> CopyToAsync(string path, string destinationPath)
|
||||
{
|
||||
var file = this.FileProvider.GetFileInfo(path);
|
||||
if (!file.Exists)
|
||||
return false;
|
||||
|
||||
await using var source = file.CreateReadStream();
|
||||
await using var destination = File.Create(destinationPath);
|
||||
await source.CopyToAsync(destination);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user