mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-03 19:47:21 +00:00
86 lines
3.5 KiB
C#
86 lines
3.5 KiB
C#
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. There are no exceptions: a request without
|
|
/// the session cookie gets refused, whatever it asks for.
|
|
///
|
|
/// A request which carries an Origin header must, in addition, 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);
|
|
|
|
/// <summary>
|
|
/// What a refused request gets to read. The app window only shows it when it lost its cookie,
|
|
/// and a new start gives it a new one.
|
|
/// </summary>
|
|
private const string REFUSAL_TEXT = "This window cannot access AI Studio. Please restart AI Studio.";
|
|
|
|
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())))
|
|
{
|
|
await this.RefuseAsync(context, "another origin");
|
|
return;
|
|
}
|
|
|
|
if (!policy.HasSessionCookie(context.Request))
|
|
{
|
|
await this.RefuseAsync(context, "a client without the session cookie");
|
|
return;
|
|
}
|
|
|
|
await next(context);
|
|
}
|
|
|
|
private async Task RefuseAsync(HttpContext context, string reason)
|
|
{
|
|
this.LogRefusal(context, reason);
|
|
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
|
context.Response.ContentType = "text/plain; charset=utf-8";
|
|
await context.Response.WriteAsync(REFUSAL_TEXT);
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
} |