Changed the app to require the session cookie for every request

This commit is contained in:
Thorsten Sommer committed 2026-10-31 19:16:20 +01:00
1 parent 97f3f97679
commit 42ed1db4a4
7 files changed
+175 -24

No files matched your search

+9 -2
View File
@@ -92,7 +92,14 @@ internal sealed class Program
}
API_TOKEN = apiToken;
var uiToken = Environment.GetEnvironmentVariable(AppAccessPolicy.UI_TOKEN_ENVIRONMENT_VARIABLE);
if(string.IsNullOrWhiteSpace(uiToken))
{
Console.WriteLine($"Error: The {AppAccessPolicy.UI_TOKEN_ENVIRONMENT_VARIABLE} environment variable is not set.");
return;
}
using var rust = new RustService(rustApiPort, certificateFingerprint);
var appPort = await rust.GetAppPort();
if(appPort == 0)
@@ -290,7 +297,7 @@ internal sealed class Program
//
builder.WebHost.UseUrls($"http://127.0.0.1:{appPort}");
builder.Services.Configure<HostFilteringOptions>(AppAccessPolicy.ConfigureHostFiltering);
builder.Services.AddSingleton(new AppAccessPolicy(appPort));
builder.Services.AddSingleton(new AppAccessPolicy(appPort, uiToken));
#if DEBUG
builder.WebHost.UseWebRoot("wwwroot");
@@ -7,10 +7,13 @@ namespace AIStudio.Tools.Security;
/// </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 request which carries an Origin header
/// must 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.
/// with all of its transports, and every endpoint alike. There are no exceptions: a request without
/// the session cookie gets refused, whatever it asks for.
///
/// 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>
@@ -23,6 +26,12 @@ public sealed class AppAccessMiddleware(RequestDelegate next, AppAccessPolicy po
/// </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;
@@ -32,17 +41,25 @@ public sealed class AppAccessMiddleware(RequestDelegate next, AppAccessPolicy po
var origin = context.Request.Headers.Origin;
if (!StringValues.IsNullOrEmpty(origin) && (origin.Count != 1 || !policy.IsAppOrigin(origin.ToString())))
{
this.Refuse(context, "another origin");
await this.RefuseAsync(context, "another origin");
return;
}
if (!policy.HasSessionCookie(context.Request))
{
await this.RefuseAsync(context, "a client without the session cookie");
return;
}
await next(context);
}
private void Refuse(HttpContext context, string reason)
private async Task RefuseAsync(HttpContext context, string reason)
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
this.LogRefusal(context, reason);
context.Response.StatusCode = StatusCodes.Status403Forbidden;
context.Response.ContentType = "text/plain; charset=utf-8";
await context.Response.WriteAsync(REFUSAL_TEXT);
}
/// <summary>
@@ -1,3 +1,6 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.HostFiltering;
namespace AIStudio.Tools.Security;
@@ -5,9 +8,25 @@ 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>
public sealed class AppAccessPolicy(int appPort)
/// <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.
@@ -16,6 +35,8 @@ public sealed class AppAccessPolicy(int appPort)
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>
@@ -33,4 +54,21 @@ public sealed class AppAccessPolicy(int appPort)
/// <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)
{
if (!request.Cookies.TryGetValue(SESSION_COOKIE_NAME, out var value) || string.IsNullOrEmpty(value))
return false;
return CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(value), this.uiTokenBytes);
}
}
+2 -1
View File
@@ -9,7 +9,8 @@ namespace AIStudio.Tools;
/// </summary>
/// <remarks>
/// The app reads files such as its changelogs directly instead of requesting them over HTTP from
/// its own server. The release build serves its static files from the same provider.
/// 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
{
@@ -2,6 +2,7 @@
- Added a setting for the size of the interface, which you find in the app settings. You can choose a size for each of your screens, and AI Studio switches to it when you move its window to another screen. Thanks, Paul Koudelka (`PaulKoudelka`), for this contribution.
- Improved the documentation for IT departments, which now lives in our [wiki](https://github.com/MindWorkAI/AI-Studio/wiki/Enterprise-IT). It is sorted by task, explains the setup on Windows, Linux, and macOS, and offers complete configurations for typical needs, such as using only local models. The link in the app settings leads there, and the wiki also holds the installation guide in English and German.
- Improved the German translation throughout: we proofread all texts and corrected wrong labels as well as spelling and grammar mistakes. The texts now always address you formally and use the same terms everywhere.
- Improved the security of the connection between the app window and AI Studio.
- Changed the size of AI Studio on laptop screens: it now appears in the same size on every laptop, whatever the scaling of your operating system. If you prefer the previous size, choose "System scaling" for the interface size in the app settings. Contributed by Paul Koudelka.
- Fixed AI Studio becoming unusable when an update failed after its download. The app now restarts with the version you have installed, so you can simply try the update again.
- Fixed configuration plugins you installed yourself being able to change the tool settings of your organization, such as the minimum provider confidence of a tool. These settings now follow the same rules as all other settings, which also gives you back your own values once your organization removes its configuration.
@@ -9,24 +9,46 @@ namespace AIStudio.Tests.Tools.AppAccess;
/// Checks which requests the app server lets through to the app.
/// </summary>
/// <remarks>
/// A request which a script sends carries the origin of its page. Only the pages of the app may send
/// requests to the app server: a page from anywhere else, and a sandboxed iframe with its origin
/// "null", get refused.
/// Only the app window has the session cookie, so every request without it gets refused, whatever
/// it asks for: pages, static files, the Blazor hub, the local files, and the preview of a visual
/// briefing alike. A request which a script sends also carries the origin of its page. Only the
/// pages of the app may send such requests: a page from anywhere else, and a sandboxed iframe with
/// its origin "null", get refused even with the cookie.
/// </remarks>
[TestFixture]
public sealed class AppAccessMiddlewareTests
{
private const int APP_PORT = 61153;
private const string UI_TOKEN = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
/// <summary>
/// The status a request gets when it passes the middleware.
/// </summary>
private const int PASSED = StatusCodes.Status204NoContent;
private static async Task<int> SendAsync(params string[] origins)
private static readonly string[] PATHS =
[
"/",
"/chat",
"/_blazor/negotiate",
"/_blazor",
"/_framework/blazor.web.js",
"/local/file",
"/visual-briefing/preview/0b6c1c43-6d2e-4f0b-9a35-6f6f3c1f2a10/6a4e7d6e-2a8c-4bb0-8c55-7a2b7b3c9e01",
"/system/MudBlazor/MudBlazor.min.css",
"/app.css",
"/changelog/v26.10.1.md",
"/does-not-exist"
];
private static async Task<int> SendAsync(string path, string? sessionCookie, params string[] origins)
{
var middleware = new AppAccessMiddleware(Pass, new AppAccessPolicy(APP_PORT), NullLogger<AppAccessMiddleware>.Instance);
var context = new DefaultHttpContext { Request = { Method = HttpMethods.Post, Path = "/_blazor/negotiate" } };
var middleware = new AppAccessMiddleware(Pass, new AppAccessPolicy(APP_PORT, UI_TOKEN), NullLogger<AppAccessMiddleware>.Instance);
var context = new DefaultHttpContext { Request = { Method = HttpMethods.Get, Path = path } };
if (sessionCookie is not null)
context.Request.Headers.Cookie = $"{AppAccessPolicy.SESSION_COOKIE_NAME}={sessionCookie}";
if (origins.Length > 0)
context.Request.Headers.Origin = origins;
@@ -40,10 +62,35 @@ public sealed class AppAccessMiddlewareTests
return Task.CompletedTask;
}
[Test]
public async Task ARequestWithoutAnOriginPasses()
[TestCaseSource(nameof(PATHS))]
public async Task ARequestWithTheSessionCookiePasses(string path)
{
Assert.That(await SendAsync(), Is.EqualTo(PASSED));
Assert.That(await SendAsync(path, UI_TOKEN), Is.EqualTo(PASSED));
}
[TestCaseSource(nameof(PATHS))]
public async Task ARequestWithoutTheSessionCookieIsRefused(string path)
{
Assert.That(await SendAsync(path, null), Is.EqualTo(StatusCodes.Status403Forbidden));
}
[TestCase("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdee")]
[TestCase("0123456789abcdef")]
[TestCase("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0")]
[TestCase("")]
public async Task ARequestWithAWrongSessionCookieIsRefused(string sessionCookie)
{
Assert.That(await SendAsync("/", sessionCookie), Is.EqualTo(StatusCodes.Status403Forbidden));
}
[Test]
public async Task TheTokenUnderAnotherCookieNameIsRefused()
{
var middleware = new AppAccessMiddleware(Pass, new AppAccessPolicy(APP_PORT, UI_TOKEN), NullLogger<AppAccessMiddleware>.Instance);
var context = new DefaultHttpContext { Request = { Method = HttpMethods.Get, Path = "/", Headers = { Cookie = $"session={UI_TOKEN}" } } };
await middleware.InvokeAsync(context);
Assert.That(context.Response.StatusCode, Is.EqualTo(StatusCodes.Status403Forbidden));
}
[TestCase("http://127.0.0.1:61153")]
@@ -51,7 +98,7 @@ public sealed class AppAccessMiddlewareTests
[TestCase("http://[::1]:61153")]
public async Task ARequestFromTheAppPasses(string origin)
{
Assert.That(await SendAsync(origin), Is.EqualTo(PASSED));
Assert.That(await SendAsync("/_blazor/negotiate", UI_TOKEN, origin), Is.EqualTo(PASSED));
}
[TestCase("http://rebind.example:61153")]
@@ -59,14 +106,14 @@ public sealed class AppAccessMiddlewareTests
[TestCase("http://127.0.0.1:61154")]
[TestCase("https://127.0.0.1:61153")]
[TestCase("http://127.0.0.1")]
public async Task ARequestFromAnotherOriginIsRefused(string origin)
public async Task ARequestFromAnotherOriginIsRefusedEvenWithTheSessionCookie(string origin)
{
Assert.That(await SendAsync(origin), Is.EqualTo(StatusCodes.Status403Forbidden));
Assert.That(await SendAsync("/_blazor/negotiate", UI_TOKEN, origin), Is.EqualTo(StatusCodes.Status403Forbidden));
}
[Test]
public async Task ARequestWithSeveralOriginsIsRefused()
{
Assert.That(await SendAsync("http://127.0.0.1:61153", "http://rebind.example:61153"), Is.EqualTo(StatusCodes.Status403Forbidden));
Assert.That(await SendAsync("/_blazor/negotiate", UI_TOKEN, "http://127.0.0.1:61153", "http://rebind.example:61153"), Is.EqualTo(StatusCodes.Status403Forbidden));
}
}
@@ -0,0 +1,40 @@
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using AIStudio.Tools.Security;
namespace AIStudio.Tests.Tools.AppAccess;
/// <summary>
/// Checks that the app server and the Rust runtime agree on the names around the session cookie.
/// </summary>
/// <remarks>
/// The runtime sets the cookie and hands the token over in an environment variable; the server
/// reads both under its own constants. Should the names drift apart, the server would refuse the
/// app window, and nothing but a start of the app would tell. The test reads the constants of the
/// runtime from its source.
/// </remarks>
[TestFixture]
public sealed class AppAccessRuntimeAgreementTests
{
private static string ReadRuntimeSource([CallerFilePath] string sourceFilePath = "") => File.ReadAllText(Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "..", "..", "..", "..", "runtime", "src", "app_access.rs"));
private static string ReadRustConstant(string name)
{
var match = Regex.Match(ReadRuntimeSource(), $"""pub const {name}: &str = "([^"]+)";""");
Assert.That(match.Success, Is.True, $"The runtime defines no constant {name}.");
return match.Groups[1].Value;
}
[Test]
public void BothUseTheSameCookieName()
{
Assert.That(ReadRustConstant("SESSION_COOKIE_NAME"), Is.EqualTo(AppAccessPolicy.SESSION_COOKIE_NAME));
}
[Test]
public void BothUseTheSameEnvironmentVariable()
{
Assert.That(ReadRustConstant("DOTNET_ENV_UI_TOKEN"), Is.EqualTo(AppAccessPolicy.UI_TOKEN_ENVIRONMENT_VARIABLE));
}
}