diff --git a/app/MindWork AI Studio/Program.cs b/app/MindWork AI Studio/Program.cs index 53ad77567..1c91db5b5 100644 --- a/app/MindWork AI Studio/Program.cs +++ b/app/MindWork AI Studio/Program.cs @@ -25,6 +25,7 @@ using AIStudio.Tools.Web; using Microsoft.AspNetCore.Components.Server.Circuits; using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.HostFiltering; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.Logging.Console; @@ -288,6 +289,8 @@ internal sealed class Program // localhost could end up with another process holding that port there. // builder.WebHost.UseUrls($"http://127.0.0.1:{appPort}"); + builder.Services.Configure(AppAccessPolicy.ConfigureHostFiltering); + builder.Services.AddSingleton(new AppAccessPolicy(appPort)); #if DEBUG builder.WebHost.UseWebRoot("wwwroot"); @@ -342,6 +345,13 @@ internal sealed class Program DATABASE_CLIENT_PROVIDER = app.Services.GetRequiredService(); + // + // These two come first, so that no request reaches anything else without passing them. We add + // the host filtering ourselves instead of relying on the defaults of ASP.NET Core to do so: + // + app.UseHostFiltering(); + app.UseMiddleware(); + programLogger.LogInformation("Initialize internal file system."); app.Use(Redirect.HandlerContentAsync); app.Use(FileHandler.HandlerAsync); diff --git a/app/MindWork AI Studio/Tools/Security/AppAccessMiddleware.cs b/app/MindWork AI Studio/Tools/Security/AppAccessMiddleware.cs new file mode 100644 index 000000000..647393b57 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Security/AppAccessMiddleware.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.Primitives; + +namespace AIStudio.Tools.Security; + +/// +/// Refuses every request to the app server which does not come from the app window. +/// +/// +/// 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. +/// +/// The next middleware. +/// Decides which requests may pass. +/// The logger. +public sealed class AppAccessMiddleware(RequestDelegate next, AppAccessPolicy policy, ILogger logger) +{ + /// + /// 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. + /// + private static readonly TimeSpan REFUSAL_LOG_INTERVAL = TimeSpan.FromMinutes(1); + + 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()))) + { + this.Refuse(context, "another origin"); + return; + } + + await next(context); + } + + private void Refuse(HttpContext context, string reason) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + this.LogRefusal(context, reason); + } + + /// + /// 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. + /// + 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); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Security/AppAccessPolicy.cs b/app/MindWork AI Studio/Tools/Security/AppAccessPolicy.cs new file mode 100644 index 000000000..665879202 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Security/AppAccessPolicy.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.HostFiltering; + +namespace AIStudio.Tools.Security; + +/// +/// Decides which requests may reach the app server. +/// +/// The port the app server listens on. +public sealed class AppAccessPolicy(int appPort) +{ + /// + /// 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. + /// + private static readonly string[] ALLOWED_HOSTS = ["127.0.0.1", "localhost", "[::1]"]; + + private readonly HashSet allowedOrigins = ALLOWED_HOSTS.Select(host => $"http://{host}:{appPort}").ToHashSet(StringComparer.OrdinalIgnoreCase); + + /// + /// Lets the host filtering of ASP.NET Core accept only the host names of the app server. + /// + /// The options of the host filtering. + public static void ConfigureHostFiltering(HostFilteringOptions options) + { + options.AllowedHosts = [..ALLOWED_HOSTS]; + options.AllowEmptyHosts = false; + options.IncludeFailureMessage = false; + } + + /// + /// Checks whether the given origin is the app itself. + /// + /// The value of the Origin header of a request. + /// True when a page of the app made the request. + public bool IsAppOrigin(string origin) => this.allowedOrigins.Contains(origin); +} \ No newline at end of file diff --git a/app/Tests/Tools/AppAccess/AppAccessMiddlewareTests.cs b/app/Tests/Tools/AppAccess/AppAccessMiddlewareTests.cs new file mode 100644 index 000000000..c0e25bba2 --- /dev/null +++ b/app/Tests/Tools/AppAccess/AppAccessMiddlewareTests.cs @@ -0,0 +1,72 @@ +using AIStudio.Tools.Security; + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; + +namespace AIStudio.Tests.Tools.AppAccess; + +/// +/// Checks which requests the app server lets through to the app. +/// +/// +/// 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. +/// +[TestFixture] +public sealed class AppAccessMiddlewareTests +{ + private const int APP_PORT = 61153; + + /// + /// The status a request gets when it passes the middleware. + /// + private const int PASSED = StatusCodes.Status204NoContent; + + private static async Task SendAsync(params string[] origins) + { + var middleware = new AppAccessMiddleware(Pass, new AppAccessPolicy(APP_PORT), NullLogger.Instance); + var context = new DefaultHttpContext { Request = { Method = HttpMethods.Post, Path = "/_blazor/negotiate" } }; + 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; + } + + [Test] + public async Task ARequestWithoutAnOriginPasses() + { + Assert.That(await SendAsync(), Is.EqualTo(PASSED)); + } + + [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(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 ARequestFromAnotherOriginIsRefused(string origin) + { + Assert.That(await SendAsync(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)); + } +} \ No newline at end of file diff --git a/app/Tests/Tools/AppAccess/AppAccessPipelineTests.cs b/app/Tests/Tools/AppAccess/AppAccessPipelineTests.cs new file mode 100644 index 000000000..b33b18a89 --- /dev/null +++ b/app/Tests/Tools/AppAccess/AppAccessPipelineTests.cs @@ -0,0 +1,29 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; + +namespace AIStudio.Tests.Tools.AppAccess; + +/// +/// Checks that the access checks run before anything else in the request pipeline of the app. +/// +/// +/// 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. +/// +[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(" })); + } +} \ No newline at end of file diff --git a/app/Tests/Tools/AppAccess/HostFilteringTests.cs b/app/Tests/Tools/AppAccess/HostFilteringTests.cs new file mode 100644 index 000000000..4bf69ba45 --- /dev/null +++ b/app/Tests/Tools/AppAccess/HostFilteringTests.cs @@ -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; + +/// +/// Checks which host names the app server answers to. +/// +/// +/// 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. +/// +[TestFixture] +public sealed class HostFilteringTests +{ + /// + /// The status a request gets when it passes the filter. + /// + private const int PASSED = StatusCodes.Status204NoContent; + + private static async Task SendAsync(string host) + { + await using var services = new ServiceCollection() + .AddLogging() + .Configure(AppAccessPolicy.ConfigureHostFiltering) + .BuildServiceProvider(); + + var middleware = ActivatorUtilities.CreateInstance(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)); + } +} \ No newline at end of file