Added host and origin checks for requests to the app

This commit is contained in:
Thorsten Sommer committed 2026-10-31 19:09:17 +01:00
1 parent a72dc90f31
commit 2a5da9fce1
6 files changed
+282

No files matched your search

+10
View File
@@ -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<HostFilteringOptions>(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<DatabaseClientProvider>();
//
// 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<AppAccessMiddleware>();
programLogger.LogInformation("Initialize internal file system.");
app.Use(Redirect.HandlerContentAsync);
app.Use(FileHandler.HandlerAsync);
@@ -0,0 +1,69 @@
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 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.
/// </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);
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);
}
/// <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,36 @@
using Microsoft.AspNetCore.HostFiltering;
namespace AIStudio.Tools.Security;
/// <summary>
/// Decides which requests may reach the app server.
/// </summary>
/// <param name="appPort">The port the app server listens on.</param>
public sealed class AppAccessPolicy(int appPort)
{
/// <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);
/// <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);
}
@@ -0,0 +1,72 @@
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>
/// 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.
/// </remarks>
[TestFixture]
public sealed class AppAccessMiddlewareTests
{
private const int APP_PORT = 61153;
/// <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)
{
var middleware = new AppAccessMiddleware(Pass, new AppAccessPolicy(APP_PORT), NullLogger<AppAccessMiddleware>.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));
}
}
@@ -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,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));
}
}