mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-01 22:07:20 +00:00
Added host and origin checks for requests to the app
This commit is contained in:
6 files changed
+282
No files matched your search
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user