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,176 @@
using AIStudio.Tools.Security;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
namespace AIStudio.Tests.Tools.AppAccess;
/// <summary>
/// Checks which requests the app server lets through to the app.
/// </summary>
/// <remarks>
/// 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 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, 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;
await middleware.InvokeAsync(context);
return context.Response.StatusCode;
}
private static Task Pass(HttpContext context)
{
context.Response.StatusCode = PASSED;
return Task.CompletedTask;
}
[TestCaseSource(nameof(PATHS))]
public async Task ARequestWithTheSessionCookiePasses(string path)
{
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")]
[TestCase("http://localhost:61153")]
[TestCase("http://[::1]:61153")]
public async Task ARequestFromTheAppPasses(string origin)
{
Assert.That(await SendAsync("/_blazor/negotiate", UI_TOKEN, origin), Is.EqualTo(PASSED));
}
[TestCase("http://rebind.example:61153")]
[TestCase("null")]
[TestCase("http://127.0.0.1:61154")]
[TestCase("https://127.0.0.1:61153")]
[TestCase("http://127.0.0.1")]
public async Task ARequestFromAnotherOriginIsRefusedEvenWithTheSessionCookie(string origin)
{
Assert.That(await SendAsync("/_blazor/negotiate", UI_TOKEN, origin), Is.EqualTo(StatusCodes.Status403Forbidden));
}
[Test]
public async Task ARequestWithSeveralOriginsIsRefused()
{
Assert.That(await SendAsync("/_blazor/negotiate", UI_TOKEN, "http://127.0.0.1:61153", "http://rebind.example:61153"), Is.EqualTo(StatusCodes.Status403Forbidden));
}
#if DEBUG
private static async Task<HttpResponse> BootAsync(string method, string? token)
{
var middleware = new AppAccessMiddleware(Pass, new AppAccessPolicy(APP_PORT, UI_TOKEN), NullLogger<AppAccessMiddleware>.Instance);
var context = new DefaultHttpContext { Request = { Method = method, Path = "/dev/boot" } };
if (token is not null)
context.Request.QueryString = QueryString.Create("token", token);
await middleware.InvokeAsync(context);
return context.Response;
}
[Test]
public async Task TheBootLinkOfADevelopmentBuildHandsOutTheSessionCookie()
{
var response = await BootAsync(HttpMethods.Get, UI_TOKEN);
var cookie = response.Headers.SetCookie.ToString().ToLowerInvariant();
Assert.Multiple(() =>
{
Assert.That(response.StatusCode, Is.EqualTo(StatusCodes.Status302Found));
Assert.That(response.Headers.Location.ToString(), Is.EqualTo("/"));
Assert.That(cookie, Does.StartWith($"{AppAccessPolicy.SESSION_COOKIE_NAME}={UI_TOKEN}"));
Assert.That(cookie, Does.Contain("httponly"));
Assert.That(cookie, Does.Contain("samesite=lax"));
Assert.That(cookie, Does.Contain("path=/"));
Assert.That(cookie, Does.Not.Contain("expires"));
});
}
[TestCase("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdee")]
[TestCase("")]
[TestCase(null)]
public async Task TheBootLinkWithAWrongTokenIsRefused(string? token)
{
var response = await BootAsync(HttpMethods.Get, token);
Assert.Multiple(() =>
{
Assert.That(response.StatusCode, Is.EqualTo(StatusCodes.Status403Forbidden));
Assert.That(response.Headers.SetCookie.ToString(), Is.Empty);
});
}
[Test]
public async Task TheBootLinkAnswersOnlyAGet()
{
var response = await BootAsync(HttpMethods.Post, UI_TOKEN);
Assert.Multiple(() =>
{
Assert.That(response.StatusCode, Is.EqualTo(StatusCodes.Status403Forbidden));
Assert.That(response.Headers.SetCookie.ToString(), Is.Empty);
});
}
#endif
}
@@ -0,0 +1,29 @@
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
namespace AIStudio.Tests.Tools.AppAccess;
/// <summary>
/// Checks that the access checks run before anything else in the request pipeline of the app.
/// </summary>
/// <remarks>
/// A middleware or an endpoint which comes first would answer requests the checks never saw. The
/// test reads the calls from the source of Program.cs, so that a new one is checked without anybody
/// having to add it to a test.
/// </remarks>
[TestFixture]
public sealed class AppAccessPipelineTests
{
private static string ReadProgramSource([CallerFilePath] string sourceFilePath = "") => File.ReadAllText(Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "..", "..", "..", "MindWork AI Studio", "Program.cs"));
[Test]
public void TheAccessChecksComeBeforeEveryOtherMiddlewareAndEndpoint()
{
var calls = Regex.Matches(ReadProgramSource(), @"\bapp\.(Use|Map)\w*(<\w+>)?\(")
.Select(match => match.Value)
.ToList();
Assert.That(calls, Has.Count.GreaterThan(2), "The test found no middleware or endpoint after the access checks.");
Assert.That(calls.Take(2), Is.EqualTo(new[] { "app.UseHostFiltering(", "app.UseMiddleware<AppAccessMiddleware>(" }));
}
}
@@ -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));
}
}
@@ -0,0 +1,66 @@
using AIStudio.Tools.Security;
using Microsoft.AspNetCore.HostFiltering;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
namespace AIStudio.Tests.Tools.AppAccess;
/// <summary>
/// Checks which host names the app server answers to.
/// </summary>
/// <remarks>
/// A website may point a name of its own at the loopback address. The browser then sends that name
/// as the host, and the app server must refuse the request. The test runs the host filtering of
/// ASP.NET Core with the options the app configures.
/// </remarks>
[TestFixture]
public sealed class HostFilteringTests
{
/// <summary>
/// The status a request gets when it passes the filter.
/// </summary>
private const int PASSED = StatusCodes.Status204NoContent;
private static async Task<int> SendAsync(string host)
{
await using var services = new ServiceCollection()
.AddLogging()
.Configure<HostFilteringOptions>(AppAccessPolicy.ConfigureHostFiltering)
.BuildServiceProvider();
var middleware = ActivatorUtilities.CreateInstance<HostFilteringMiddleware>(services, (RequestDelegate)Pass);
var context = new DefaultHttpContext { Request = { Host = new HostString(host) } };
await middleware.Invoke(context);
return context.Response.StatusCode;
}
private static Task Pass(HttpContext context)
{
context.Response.StatusCode = PASSED;
return Task.CompletedTask;
}
[TestCase("127.0.0.1:61153")]
[TestCase("localhost:61153")]
[TestCase("[::1]:61153")]
public async Task TheLoopbackNamesPass(string host)
{
Assert.That(await SendAsync(host), Is.EqualTo(PASSED));
}
[TestCase("rebind.example:61153")]
[TestCase("127.0.0.1.rebind.example:61153")]
[TestCase("localhost.rebind.example")]
public async Task AnyOtherNameIsRefused(string host)
{
Assert.That(await SendAsync(host), Is.EqualTo(StatusCodes.Status400BadRequest));
}
[Test]
public async Task ARequestWithoutAHostIsRefused()
{
Assert.That(await SendAsync(string.Empty), Is.EqualTo(StatusCodes.Status400BadRequest));
}
}
@@ -0,0 +1,73 @@
using AIStudio.Assistants.ERI;
using AIStudio.Tools;
using Changelog = AIStudio.Components.Changelog;
namespace AIStudio.Tests.Tools.AppAccess;
/// <summary>
/// Checks that the app reads the files below wwwroot which it shows itself.
/// </summary>
/// <remarks>
/// The changelogs and the ERI specifications come from the files embedded into the app, not from a
/// request to its own server. A file which is missing there would leave the changelog or the ERI
/// assistant empty without any error.
/// </remarks>
[TestFixture]
public sealed class WebRootFilesTests
{
private static IEnumerable<string> ChangelogFiles() => Changelog.LOGS.Select(log => log.Filename);
private static IEnumerable<ERIVersion> SpecificationVersions() => Enum.GetValues<ERIVersion>().Where(version => version.WasSpecificationSelected());
[TestCaseSource(nameof(ChangelogFiles))]
public async Task EveryChangelogCanBeRead(string filename)
{
var text = await new WebRootFiles().ReadTextAsync($"changelog/{filename}");
Assert.That(text, Is.Not.Empty);
}
[TestCaseSource(nameof(SpecificationVersions))]
public async Task EveryERISpecificationCanBeRead(ERIVersion version)
{
var text = await version.ReadSpecification(new WebRootFiles());
Assert.That(text, Is.Not.Empty);
}
[Test]
public async Task AMissingFileReadsAsEmptyText()
{
var text = await new WebRootFiles().ReadTextAsync("changelog/does-not-exist.md");
Assert.That(text, Is.Empty);
}
[TestCaseSource(nameof(SpecificationVersions))]
public async Task EveryERISpecificationCanBeSaved(ERIVersion version)
{
var webRootFiles = new WebRootFiles();
var destinationPath = Path.Combine(Path.GetTempPath(), $"eri-specification-{Guid.NewGuid():N}.json");
try
{
Assert.That(await webRootFiles.CopyToAsync(version.SpecificationURL(), destinationPath), Is.True);
Assert.That(await File.ReadAllTextAsync(destinationPath), Is.EqualTo(await version.ReadSpecification(webRootFiles)));
}
finally
{
File.Delete(destinationPath);
}
}
[Test]
public async Task AMissingFileIsNotCopied()
{
var destinationPath = Path.Combine(Path.GetTempPath(), $"eri-specification-{Guid.NewGuid():N}.json");
var copied = await new WebRootFiles().CopyToAsync("specs/eri/does-not-exist.json", destinationPath);
Assert.Multiple(() =>
{
Assert.That(copied, Is.False);
Assert.That(File.Exists(destinationPath), Is.False);
});
}
}