mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-04 13:07: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,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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user