[backend/asp] Refactor middleware stack

This commit splits the request pipeline conditionally instead of invoking every middleware in the stack.

It also simplifies middleware instantiation by using runtime discovery, allowing for Plugins to add Middleware.
This commit is contained in:
Laura Hausmann 2024-11-17 01:09:15 +01:00
parent 70c692e1cb
commit 705e061f74
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
15 changed files with 467 additions and 411 deletions

View file

@ -1,6 +1,8 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using System.Xml.Linq; using System.Xml.Linq;
using Iceshrimp.AssemblyUtils;
using Iceshrimp.Backend.Components.PublicPreview.Attributes; using Iceshrimp.Backend.Components.PublicPreview.Attributes;
using Iceshrimp.Backend.Components.PublicPreview.Renderers; using Iceshrimp.Backend.Components.PublicPreview.Renderers;
using Iceshrimp.Backend.Controllers.Federation; using Iceshrimp.Backend.Controllers.Federation;
@ -9,6 +11,7 @@ using Iceshrimp.Backend.Controllers.Web.Renderers;
using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Federation.WebFinger; using Iceshrimp.Backend.Core.Federation.WebFinger;
using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion; using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion;
using Iceshrimp.Backend.Core.Middleware; using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services; using Iceshrimp.Backend.Core.Services;
@ -30,8 +33,6 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
using AuthenticationMiddleware = Iceshrimp.Backend.Core.Middleware.AuthenticationMiddleware;
using AuthorizationMiddleware = Iceshrimp.Backend.Core.Middleware.AuthorizationMiddleware;
using NoteRenderer = Iceshrimp.Backend.Controllers.Web.Renderers.NoteRenderer; using NoteRenderer = Iceshrimp.Backend.Controllers.Web.Renderers.NoteRenderer;
using NotificationRenderer = Iceshrimp.Backend.Controllers.Web.Renderers.NotificationRenderer; using NotificationRenderer = Iceshrimp.Backend.Controllers.Web.Renderers.NotificationRenderer;
using UserRenderer = Iceshrimp.Backend.Controllers.Web.Renderers.UserRenderer; using UserRenderer = Iceshrimp.Backend.Controllers.Web.Renderers.UserRenderer;
@ -68,10 +69,6 @@ public static class ServiceExtensions
.AddScoped<BiteService>() .AddScoped<BiteService>()
.AddScoped<ImportExportService>() .AddScoped<ImportExportService>()
.AddScoped<UserProfileMentionsResolver>() .AddScoped<UserProfileMentionsResolver>()
.AddScoped<AuthorizedFetchMiddleware>()
.AddScoped<InboxValidationMiddleware>()
.AddScoped<AuthenticationMiddleware>()
.AddScoped<ErrorHandlerMiddleware>()
.AddScoped<Controllers.Mastodon.Renderers.UserRenderer>() .AddScoped<Controllers.Mastodon.Renderers.UserRenderer>()
.AddScoped<Controllers.Mastodon.Renderers.NoteRenderer>() .AddScoped<Controllers.Mastodon.Renderers.NoteRenderer>()
.AddScoped<Controllers.Mastodon.Renderers.NotificationRenderer>() .AddScoped<Controllers.Mastodon.Renderers.NotificationRenderer>()
@ -100,16 +97,10 @@ public static class ServiceExtensions
.AddSingleton<QueueService>() .AddSingleton<QueueService>()
.AddSingleton<ObjectStorageService>() .AddSingleton<ObjectStorageService>()
.AddSingleton<EventService>() .AddSingleton<EventService>()
.AddSingleton<RequestBufferingMiddleware>()
.AddSingleton<AuthorizationMiddleware>()
.AddSingleton<RequestVerificationMiddleware>()
.AddSingleton<RequestDurationMiddleware>()
.AddSingleton<FederationSemaphoreMiddleware>()
.AddSingleton<PushService>() .AddSingleton<PushService>()
.AddSingleton<StreamingService>() .AddSingleton<StreamingService>()
.AddSingleton<ImageProcessor>() .AddSingleton<ImageProcessor>()
.AddSingleton<RazorViewRenderService>() .AddSingleton<RazorViewRenderService>()
.AddSingleton<BlazorSsrHandoffMiddleware>()
.AddSingleton<MfmRenderer>() .AddSingleton<MfmRenderer>()
.AddSingleton<MatcherPolicy, PublicPreviewRouteMatcher>() .AddSingleton<MatcherPolicy, PublicPreviewRouteMatcher>()
.AddSingleton<PolicyService>(); .AddSingleton<PolicyService>();
@ -139,6 +130,21 @@ public static class ServiceExtensions
services.AddHostedService<PushService>(provider => provider.GetRequiredService<PushService>()); services.AddHostedService<PushService>(provider => provider.GetRequiredService<PushService>());
} }
public static void AddMiddleware(this IServiceCollection services)
{
var types = PluginLoader
.Assemblies.Prepend(Assembly.GetExecutingAssembly())
.SelectMany(p => AssemblyLoader.GetImplementationsOfInterface(p, typeof(IMiddlewareService)));
foreach (var type in types)
{
if (type.GetProperty(nameof(IMiddlewareService.Lifetime))?.GetValue(null) is not ServiceLifetime lifetime)
continue;
services.Add(new ServiceDescriptor(type, type, lifetime));
}
}
public static void ConfigureServices(this IServiceCollection services, IConfiguration configuration) public static void ConfigureServices(this IServiceCollection services, IConfiguration configuration)
{ {
// @formatter:off // @formatter:off

View file

@ -1,3 +1,5 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
@ -32,6 +34,10 @@ public static class WebApplicationExtensions
.UseMiddleware<BlazorSsrHandoffMiddleware>(); .UseMiddleware<BlazorSsrHandoffMiddleware>();
} }
// Prevents conditional middleware from being invoked on non-matching requests
private static IApplicationBuilder UseMiddleware<T>(this IApplicationBuilder app) where T : IConditionalMiddleware
=> app.UseWhen(T.Predicate, builder => UseMiddlewareExtensions.UseMiddleware<T>(builder));
public static IApplicationBuilder UseOpenApiWithOptions(this WebApplication app) public static IApplicationBuilder UseOpenApiWithOptions(this WebApplication app)
{ {
app.MapSwagger("/openapi/{documentName}.{extension:regex(^(json|ya?ml)$)}") app.MapSwagger("/openapi/{documentName}.{extension:regex(^(json|ya?ml)$)}")
@ -305,4 +311,32 @@ public static class WebApplicationExtensions
[DllImport("libc")] [DllImport("libc")]
static extern int chmod(string pathname, int mode); static extern int chmod(string pathname, int mode);
} }
}
public interface IConditionalMiddleware
{
public static abstract bool Predicate(HttpContext ctx);
}
public interface IMiddlewareService : IMiddleware
{
public static abstract ServiceLifetime Lifetime { get; }
}
public class ConditionalMiddleware<T> : IConditionalMiddleware where T : Attribute
{
[SuppressMessage("ReSharper", "StaticMemberInGenericType", Justification = "Intended behavior")]
private static readonly ConcurrentDictionary<Endpoint, bool> Cache = [];
public static bool Predicate(HttpContext ctx)
=> ctx.GetEndpoint() is { } endpoint && Cache.GetOrAdd(endpoint, e => GetAttribute(e) != null);
private static T? GetAttribute(Endpoint? endpoint)
=> endpoint?.Metadata.GetMetadata<T>();
private static T? GetAttribute(HttpContext ctx)
=> GetAttribute(ctx.GetEndpoint());
protected static T GetAttributeOrFail(HttpContext ctx)
=> GetAttribute(ctx) ?? throw new Exception("Failed to get middleware filter attribute");
} }

View file

@ -1,6 +1,7 @@
using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers; using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion; using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion;
using Iceshrimp.Backend.Core.Services; using Iceshrimp.Backend.Core.Services;
@ -9,101 +10,104 @@ using Microsoft.EntityFrameworkCore;
namespace Iceshrimp.Backend.Core.Middleware; namespace Iceshrimp.Backend.Core.Middleware;
public class AuthenticationMiddleware(DatabaseContext db, UserService userSvc, MfmConverter mfmConverter) : IMiddleware public class AuthenticationMiddleware(
DatabaseContext db,
UserService userSvc,
MfmConverter mfmConverter
) : ConditionalMiddleware<AuthenticateAttribute>, IMiddlewareService
{ {
public static ServiceLifetime Lifetime => ServiceLifetime.Scoped;
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
{ {
var endpoint = ctx.GetEndpoint(); var endpoint = ctx.GetEndpoint();
var attribute = endpoint?.Metadata.GetMetadata<AuthenticateAttribute>(); var attribute = GetAttributeOrFail(ctx);
if (attribute != null) var isBlazorSsr = endpoint?.Metadata.GetMetadata<RootComponentMetadata>() != null;
if (isBlazorSsr)
{ {
var isBlazorSsr = endpoint?.Metadata.GetMetadata<RootComponentMetadata>() != null; await AuthenticateBlazorSsr(ctx, attribute);
if (isBlazorSsr) await next(ctx);
{ return;
await AuthenticateBlazorSsr(ctx, attribute); }
await next(ctx);
return;
}
ctx.Response.Headers.CacheControl = "private, no-store"; ctx.Response.Headers.CacheControl = "private, no-store";
var request = ctx.Request; var request = ctx.Request;
var header = request.Headers.Authorization.ToString(); var header = request.Headers.Authorization.ToString();
if (!header.ToLowerInvariant().StartsWith("bearer ")) if (!header.ToLowerInvariant().StartsWith("bearer "))
{
await next(ctx);
return;
}
var token = header[7..];
var isMastodon = endpoint?.Metadata.GetMetadata<MastodonApiControllerAttribute>() != null;
if (isMastodon)
{
var oauthToken = await db.OauthTokens
.Include(p => p.User.UserProfile)
.Include(p => p.User.UserSettings)
.Include(p => p.App)
.FirstOrDefaultAsync(p => p.Token == token && p.Active);
if (oauthToken?.User.IsSuspended == true)
throw GracefulException
.Unauthorized("Your access has been suspended by the instance administrator.");
if (oauthToken == null)
{ {
await next(ctx); await next(ctx);
return; return;
} }
var token = header[7..]; if ((attribute.AdminRole && !oauthToken.User.IsAdmin) ||
(attribute.ModeratorRole &&
var isMastodon = endpoint?.Metadata.GetMetadata<MastodonApiControllerAttribute>() != null; oauthToken.User is { IsAdmin: false, IsModerator: false }))
if (isMastodon)
{ {
var oauthToken = await db.OauthTokens await next(ctx);
.Include(p => p.User.UserProfile) return;
.Include(p => p.User.UserSettings)
.Include(p => p.App)
.FirstOrDefaultAsync(p => p.Token == token && p.Active);
if (oauthToken?.User.IsSuspended == true)
throw GracefulException
.Unauthorized("Your access has been suspended by the instance administrator.");
if (oauthToken == null)
{
await next(ctx);
return;
}
if ((attribute.AdminRole && !oauthToken.User.IsAdmin) ||
(attribute.ModeratorRole &&
oauthToken.User is { IsAdmin: false, IsModerator: false }))
{
await next(ctx);
return;
}
if (attribute.Scopes.Length > 0 &&
attribute.Scopes.Except(MastodonOauthHelpers.ExpandScopes(oauthToken.Scopes)).Any())
{
await next(ctx);
return;
}
userSvc.UpdateOauthTokenMetadata(oauthToken);
ctx.SetOauthToken(oauthToken);
mfmConverter.SupportsHtmlFormatting = oauthToken.SupportsHtmlFormatting;
} }
else
if (attribute.Scopes.Length > 0 &&
attribute.Scopes.Except(MastodonOauthHelpers.ExpandScopes(oauthToken.Scopes)).Any())
{ {
var session = await db.Sessions await next(ctx);
.Include(p => p.User.UserProfile) return;
.Include(p => p.User.UserSettings)
.FirstOrDefaultAsync(p => p.Token == token && p.Active);
if (session?.User.IsSuspended == true)
throw GracefulException
.Unauthorized("Your access has been suspended by the instance administrator.");
if (session == null)
{
await next(ctx);
return;
}
if ((attribute.AdminRole && !session.User.IsAdmin) ||
(attribute.ModeratorRole &&
session.User is { IsAdmin: false, IsModerator: false }))
{
await next(ctx);
return;
}
userSvc.UpdateSessionMetadata(session);
ctx.SetSession(session);
} }
userSvc.UpdateOauthTokenMetadata(oauthToken);
ctx.SetOauthToken(oauthToken);
mfmConverter.SupportsHtmlFormatting = oauthToken.SupportsHtmlFormatting;
}
else
{
var session = await db.Sessions
.Include(p => p.User.UserProfile)
.Include(p => p.User.UserSettings)
.FirstOrDefaultAsync(p => p.Token == token && p.Active);
if (session?.User.IsSuspended == true)
throw GracefulException
.Unauthorized("Your access has been suspended by the instance administrator.");
if (session == null)
{
await next(ctx);
return;
}
if ((attribute.AdminRole && !session.User.IsAdmin) ||
(attribute.ModeratorRole &&
session.User is { IsAdmin: false, IsModerator: false }))
{
await next(ctx);
return;
}
userSvc.UpdateSessionMetadata(session);
ctx.SetSession(session);
} }
await next(ctx); await next(ctx);

View file

@ -1,43 +1,41 @@
using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers; using Iceshrimp.Backend.Core.Helpers;
namespace Iceshrimp.Backend.Core.Middleware; namespace Iceshrimp.Backend.Core.Middleware;
public class AuthorizationMiddleware : IMiddleware public class AuthorizationMiddleware(RequestDelegate next) : ConditionalMiddleware<AuthorizeAttribute>
{ {
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) public async Task InvokeAsync(HttpContext ctx)
{ {
var endpoint = ctx.GetEndpoint(); var endpoint = ctx.GetEndpoint();
var attribute = endpoint?.Metadata.GetMetadata<AuthorizeAttribute>(); var attribute = GetAttributeOrFail(ctx);
if (attribute != null) ctx.Response.Headers.CacheControl = "private, no-store";
var isMastodon = endpoint?.Metadata.GetMetadata<MastodonApiControllerAttribute>() != null;
if (isMastodon)
{ {
ctx.Response.Headers.CacheControl = "private, no-store"; var token = ctx.GetOauthToken();
var isMastodon = endpoint?.Metadata.GetMetadata<MastodonApiControllerAttribute>() != null; if (token is not { Active: true })
throw GracefulException.Unauthorized("This method requires an authenticated user");
if (isMastodon) if (attribute.Scopes.Length > 0 &&
{ attribute.Scopes.Except(MastodonOauthHelpers.ExpandScopes(token.Scopes)).Any())
var token = ctx.GetOauthToken(); throw GracefulException.Forbidden("This action is outside the authorized scopes");
if (token is not { Active: true }) if (attribute.AdminRole && !token.User.IsAdmin)
throw GracefulException.Unauthorized("This method requires an authenticated user"); throw GracefulException.Forbidden("This action is outside the authorized scopes");
if (attribute.Scopes.Length > 0 && if (attribute.ModeratorRole && token.User is { IsAdmin: false, IsModerator: false })
attribute.Scopes.Except(MastodonOauthHelpers.ExpandScopes(token.Scopes)).Any()) throw GracefulException.Forbidden("This action is outside the authorized scopes");
throw GracefulException.Forbidden("This action is outside the authorized scopes"); }
if (attribute.AdminRole && !token.User.IsAdmin) else
throw GracefulException.Forbidden("This action is outside the authorized scopes"); {
if (attribute.ModeratorRole && token.User is { IsAdmin: false, IsModerator: false }) var session = ctx.GetSession();
throw GracefulException.Forbidden("This action is outside the authorized scopes"); if (session is not { Active: true })
} throw GracefulException.Unauthorized("This method requires an authenticated user");
else if (attribute.AdminRole && !session.User.IsAdmin)
{ throw GracefulException.Forbidden("This action is outside the authorized scopes");
var session = ctx.GetSession(); if (attribute.ModeratorRole && session.User is { IsAdmin: false, IsModerator: false })
if (session is not { Active: true }) throw GracefulException.Forbidden("This action is outside the authorized scopes");
throw GracefulException.Unauthorized("This method requires an authenticated user");
if (attribute.AdminRole && !session.User.IsAdmin)
throw GracefulException.Forbidden("This action is outside the authorized scopes");
if (attribute.ModeratorRole && session.User is { IsAdmin: false, IsModerator: false })
throw GracefulException.Forbidden("This action is outside the authorized scopes");
}
} }
await next(ctx); await next(ctx);

View file

@ -3,6 +3,7 @@ using System.Net;
using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Federation.Cryptography; using Iceshrimp.Backend.Core.Federation.Cryptography;
using Iceshrimp.Backend.Core.Services; using Iceshrimp.Backend.Core.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -21,107 +22,110 @@ public class AuthorizedFetchMiddleware(
ActivityPub.FederationControlService fedCtrlSvc, ActivityPub.FederationControlService fedCtrlSvc,
ILogger<AuthorizedFetchMiddleware> logger, ILogger<AuthorizedFetchMiddleware> logger,
IHostApplicationLifetime appLifetime IHostApplicationLifetime appLifetime
) : IMiddleware ) : ConditionalMiddleware<AuthorizedFetchAttribute>, IMiddlewareService
{ {
public static ServiceLifetime Lifetime => ServiceLifetime.Scoped;
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
{ {
var attribute = ctx.GetEndpoint()?.Metadata.GetMetadata<AuthorizedFetchAttribute>(); if (!config.Value.AuthorizedFetch)
if (attribute != null && config.Value.AuthorizedFetch)
{ {
ctx.Response.Headers.CacheControl = "private, no-store"; await next(ctx);
return;
}
var request = ctx.Request; ctx.Response.Headers.CacheControl = "private, no-store";
var ct = appLifetime.ApplicationStopping;
//TODO: cache this somewhere var request = ctx.Request;
var instanceActorUri = $"/users/{(await systemUserSvc.GetInstanceActorAsync()).Id}"; var ct = appLifetime.ApplicationStopping;
if (request.Path.Value == instanceActorUri)
//TODO: cache this somewhere
var instanceActorUri = $"/users/{(await systemUserSvc.GetInstanceActorAsync()).Id}";
if (request.Path.Value == instanceActorUri)
{
await next(ctx);
return;
}
UserPublickey? key = null;
var verified = false;
logger.LogTrace("Processing authorized fetch request for {path}", request.Path);
try
{
if (!request.Headers.TryGetValue("signature", out var sigHeader))
throw new GracefulException(HttpStatusCode.Unauthorized, "Request is missing the signature header");
var sig = HttpSignature.Parse(sigHeader.ToString());
if (await fedCtrlSvc.ShouldBlockAsync(sig.KeyId))
throw new GracefulException(HttpStatusCode.Forbidden, "Forbidden", "Instance is blocked",
suppressLog: true);
// First, we check if we already have the key
key = await db.UserPublickeys.Include(p => p.User)
.FirstOrDefaultAsync(p => p.KeyId == sig.KeyId, ct);
// If we don't, we need to try to fetch it
if (key == null)
{ {
await next(ctx); try
return; {
var user = await userResolver.ResolveAsync(sig.KeyId, ResolveFlags.Uri).WaitAsync(ct);
key = await db.UserPublickeys.Include(p => p.User)
.FirstOrDefaultAsync(p => p.User == user, ct);
// If the key is still null here, we have a data consistency issue and need to update the key manually
key ??= await userSvc.UpdateUserPublicKeyAsync(user).WaitAsync(ct);
}
catch (Exception e)
{
if (e is GracefulException) throw;
throw new Exception($"Failed to fetch key of signature user ({sig.KeyId}) - {e.Message}");
}
} }
UserPublickey? key = null; // If we still don't have the key, something went wrong and we need to throw an exception
var verified = false; if (key == null) throw new Exception($"Failed to fetch key of signature user ({sig.KeyId})");
logger.LogTrace("Processing authorized fetch request for {path}", request.Path); if (key.User.IsLocalUser)
throw new Exception("Remote user must have a host");
try // We want to check both the user host & the keyId host (as account & web domain might be different)
if (await fedCtrlSvc.ShouldBlockAsync(key.User.Host, key.KeyId))
throw new GracefulException(HttpStatusCode.Forbidden, "Forbidden", "Instance is blocked",
suppressLog: true);
List<string> headers = ["(request-target)", "host"];
if (sig.Created != null && !sig.Headers.Contains("date"))
headers.Add("(created)");
else
headers.Add("date");
verified = await HttpSignature.VerifyAsync(request, sig, headers, key.KeyPem);
logger.LogDebug("HttpSignature.Verify returned {result} for key {keyId}", verified, sig.KeyId);
if (!verified)
{ {
if (!request.Headers.TryGetValue("signature", out var sigHeader)) logger.LogDebug("Refetching user key...");
throw new GracefulException(HttpStatusCode.Unauthorized, "Request is missing the signature header"); key = await userSvc.UpdateUserPublicKeyAsync(key);
var sig = HttpSignature.Parse(sigHeader.ToString());
if (await fedCtrlSvc.ShouldBlockAsync(sig.KeyId))
throw new GracefulException(HttpStatusCode.Forbidden, "Forbidden", "Instance is blocked",
suppressLog: true);
// First, we check if we already have the key
key = await db.UserPublickeys.Include(p => p.User)
.FirstOrDefaultAsync(p => p.KeyId == sig.KeyId, ct);
// If we don't, we need to try to fetch it
if (key == null)
{
try
{
var user = await userResolver.ResolveAsync(sig.KeyId, ResolveFlags.Uri).WaitAsync(ct);
key = await db.UserPublickeys.Include(p => p.User)
.FirstOrDefaultAsync(p => p.User == user, ct);
// If the key is still null here, we have a data consistency issue and need to update the key manually
key ??= await userSvc.UpdateUserPublicKeyAsync(user).WaitAsync(ct);
}
catch (Exception e)
{
if (e is GracefulException) throw;
throw new Exception($"Failed to fetch key of signature user ({sig.KeyId}) - {e.Message}");
}
}
// If we still don't have the key, something went wrong and we need to throw an exception
if (key == null) throw new Exception($"Failed to fetch key of signature user ({sig.KeyId})");
if (key.User.IsLocalUser)
throw new Exception("Remote user must have a host");
// We want to check both the user host & the keyId host (as account & web domain might be different)
if (await fedCtrlSvc.ShouldBlockAsync(key.User.Host, key.KeyId))
throw new GracefulException(HttpStatusCode.Forbidden, "Forbidden", "Instance is blocked",
suppressLog: true);
List<string> headers = ["(request-target)", "host"];
if (sig.Created != null && !sig.Headers.Contains("date"))
headers.Add("(created)");
else
headers.Add("date");
verified = await HttpSignature.VerifyAsync(request, sig, headers, key.KeyPem); verified = await HttpSignature.VerifyAsync(request, sig, headers, key.KeyPem);
logger.LogDebug("HttpSignature.Verify returned {result} for key {keyId}", verified, sig.KeyId); logger.LogDebug("HttpSignature.Verify returned {result} for key {keyId}", verified, sig.KeyId);
if (!verified)
{
logger.LogDebug("Refetching user key...");
key = await userSvc.UpdateUserPublicKeyAsync(key);
verified = await HttpSignature.VerifyAsync(request, sig, headers, key.KeyPem);
logger.LogDebug("HttpSignature.Verify returned {result} for key {keyId}", verified, sig.KeyId);
}
} }
catch (Exception e)
{
if (e is AuthFetchException afe) throw GracefulException.Accepted(afe.Message);
if (e is GracefulException { SuppressLog: true }) throw;
logger.LogDebug("Error validating HTTP signature: {error}", e.Message);
}
if (!verified || key == null)
throw new GracefulException(HttpStatusCode.Forbidden, "Request signature validation failed");
ctx.SetActor(key.User);
} }
catch (Exception e)
{
if (e is AuthFetchException afe) throw GracefulException.Accepted(afe.Message);
if (e is GracefulException { SuppressLog: true }) throw;
logger.LogDebug("Error validating HTTP signature: {error}", e.Message);
}
if (!verified || key == null)
throw new GracefulException(HttpStatusCode.Forbidden, "Request signature validation failed");
ctx.SetActor(key.User);
await next(ctx); await next(ctx);
} }

View file

@ -1,15 +1,16 @@
using System.Reflection; using System.Reflection;
using Iceshrimp.Backend.Core.Extensions;
using Microsoft.AspNetCore.Components.Endpoints; using Microsoft.AspNetCore.Components.Endpoints;
namespace Iceshrimp.Backend.Core.Middleware; namespace Iceshrimp.Backend.Core.Middleware;
public class BlazorSsrHandoffMiddleware : IMiddleware public class BlazorSsrHandoffMiddleware(RequestDelegate next) : ConditionalMiddleware<BlazorSsrAttribute>
{ {
public async Task InvokeAsync(HttpContext context, RequestDelegate next) public async Task InvokeAsync(HttpContext context)
{ {
var attribute = context.GetEndpoint() var attribute = context.GetEndpoint()
?.Metadata.GetMetadata<RootComponentMetadata>() ?.Metadata.GetMetadata<RootComponentMetadata>()
?.Type.GetCustomAttributes<RazorSsrAttribute>() ?.Type.GetCustomAttributes<BlazorSsrAttribute>()
.FirstOrDefault(); .FirstOrDefault();
if (attribute != null) if (attribute != null)
@ -34,4 +35,4 @@ public class BlazorSsrHandoffMiddleware : IMiddleware
} }
} }
public class RazorSsrAttribute : Attribute; public class BlazorSsrAttribute : Attribute;

View file

@ -15,11 +15,13 @@ namespace Iceshrimp.Backend.Core.Middleware;
public class ErrorHandlerMiddleware( public class ErrorHandlerMiddleware(
[SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor")] [SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor")]
IOptionsSnapshot<Config.SecuritySection> options, IOptionsMonitor<Config.SecuritySection> options,
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
RazorViewRenderService razor RazorViewRenderService razor
) : IMiddleware ) : IMiddlewareService
{ {
public static ServiceLifetime Lifetime => ServiceLifetime.Singleton;
private static readonly XmlSerializer XmlSerializer = new(typeof(ErrorResponse)); private static readonly XmlSerializer XmlSerializer = new(typeof(ErrorResponse));
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
@ -37,7 +39,7 @@ public class ErrorHandlerMiddleware(
type = type[..(type.IndexOf('>') + 1)]; type = type[..(type.IndexOf('>') + 1)];
var logger = loggerFactory.CreateLogger(type); var logger = loggerFactory.CreateLogger(type);
var verbosity = options.Value.ExceptionVerbosity; var verbosity = options.CurrentValue.ExceptionVerbosity;
if (ctx.Response.HasStarted) if (ctx.Response.HasStarted)
{ {

View file

@ -1,5 +1,6 @@
using System.Net; using System.Net;
using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers; using Iceshrimp.Backend.Core.Helpers;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@ -8,8 +9,10 @@ namespace Iceshrimp.Backend.Core.Middleware;
public class FederationSemaphoreMiddleware( public class FederationSemaphoreMiddleware(
IOptions<Config.PerformanceSection> config, IOptions<Config.PerformanceSection> config,
IHostApplicationLifetime appLifetime IHostApplicationLifetime appLifetime
) : IMiddleware ) : ConditionalMiddleware<FederationSemaphoreAttribute>, IMiddlewareService
{ {
public static ServiceLifetime Lifetime => ServiceLifetime.Singleton;
private readonly SemaphorePlus _semaphore = new(Math.Max(config.Value.FederationRequestHandlerConcurrency, 1)); private readonly SemaphorePlus _semaphore = new(Math.Max(config.Value.FederationRequestHandlerConcurrency, 1));
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
@ -20,13 +23,6 @@ public class FederationSemaphoreMiddleware(
return; return;
} }
var attribute = ctx.GetEndpoint()?.Metadata.GetMetadata<FederationSemaphoreAttribute>();
if (attribute == null)
{
await next(ctx);
return;
}
try try
{ {
var cts = CancellationTokenSource var cts = CancellationTokenSource

View file

@ -4,6 +4,7 @@ using System.Net.Http.Headers;
using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Federation.ActivityStreams; using Iceshrimp.Backend.Core.Federation.ActivityStreams;
using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types; using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
using Iceshrimp.Backend.Core.Federation.Cryptography; using Iceshrimp.Backend.Core.Federation.Cryptography;
@ -25,233 +26,230 @@ public class InboxValidationMiddleware(
ActivityPub.FederationControlService fedCtrlSvc, ActivityPub.FederationControlService fedCtrlSvc,
ILogger<InboxValidationMiddleware> logger, ILogger<InboxValidationMiddleware> logger,
IHostApplicationLifetime appLifetime IHostApplicationLifetime appLifetime
) : IMiddleware ) : ConditionalMiddleware<InboxValidationAttribute>, IMiddlewareService
{ {
public static ServiceLifetime Lifetime => ServiceLifetime.Scoped;
private static readonly JsonSerializerSettings JsonSerializerSettings = private static readonly JsonSerializerSettings JsonSerializerSettings =
new() { DateParseHandling = DateParseHandling.None }; new() { DateParseHandling = DateParseHandling.None };
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
{ {
var attribute = ctx.GetEndpoint()?.Metadata.GetMetadata<InboxValidationAttribute>(); var request = ctx.Request;
var ct = appLifetime.ApplicationStopping;
if (attribute != null) if (request is not { ContentType: not null, ContentLength: > 0 })
throw GracefulException.UnprocessableEntity("Inbox request must have a body");
HttpSignature.HttpSignatureHeader? sig = null;
if (request.Headers.TryGetValue("signature", out var sigHeader))
{ {
var request = ctx.Request; try
var ct = appLifetime.ApplicationStopping; {
sig = HttpSignature.Parse(sigHeader.ToString());
if (await fedCtrlSvc.ShouldBlockAsync(sig.KeyId))
throw new GracefulException(HttpStatusCode.Forbidden, "Forbidden", "Instance is blocked",
suppressLog: true);
}
catch (Exception e)
{
if (e is GracefulException { SuppressLog: true }) throw;
}
}
if (request is not { ContentType: not null, ContentLength: > 0 }) var body = await new StreamReader(request.Body).ReadToEndAsync(ct);
throw GracefulException.UnprocessableEntity("Inbox request must have a body"); request.Body.Seek(0, SeekOrigin.Begin);
HttpSignature.HttpSignatureHeader? sig = null; JToken parsed;
try
{
parsed = JToken.Parse(body);
}
catch (Exception e)
{
logger.LogDebug("Failed to parse ASObject ({error}), skipping", e.Message);
return;
}
if (request.Headers.TryGetValue("signature", out var sigHeader)) JArray? expanded;
try
{
expanded = LdHelpers.Expand(parsed);
if (expanded == null) throw new Exception("Failed to expand ASObject");
}
catch (Exception e)
{
logger.LogDebug("Failed to expand ASObject ({error}), skipping", e.Message);
return;
}
ASObject? obj;
try
{
obj = ASObject.Deserialize(expanded);
if (obj == null) throw new Exception("Failed to deserialize ASObject");
}
catch (Exception e)
{
throw GracefulException
.UnprocessableEntity($"Failed to deserialize request body as ASObject: {e.Message}");
}
if (obj is not ASActivity activity)
throw new GracefulException(HttpStatusCode.UnprocessableEntity,
"Request body is not an ASActivity", $"Type: {obj.Type}");
UserPublickey? key = null;
var verified = false;
try
{
if (sig == null)
throw new GracefulException(HttpStatusCode.Unauthorized, "Request is missing the signature header");
// First, we check if we already have the key
key = await db.UserPublickeys.Include(p => p.User)
.FirstOrDefaultAsync(p => p.KeyId == sig.KeyId, ct);
// If we don't, we need to try to fetch it
if (key == null)
{ {
try try
{ {
sig = HttpSignature.Parse(sigHeader.ToString()); var flags = activity is ASDelete
if (await fedCtrlSvc.ShouldBlockAsync(sig.KeyId)) ? ResolveFlags.Uri | ResolveFlags.OnlyExisting
throw new GracefulException(HttpStatusCode.Forbidden, "Forbidden", "Instance is blocked", : ResolveFlags.Uri;
suppressLog: true);
var user = await userResolver.ResolveOrNullAsync(sig.KeyId, flags).WaitAsync(ct);
if (user == null) throw AuthFetchException.NotFound("Delete activity actor is unknown");
key = await db.UserPublickeys.Include(p => p.User)
.FirstOrDefaultAsync(p => p.User == user, ct);
// If the key is still null here, we have a data consistency issue and need to update the key manually
key ??= await userSvc.UpdateUserPublicKeyAsync(user).WaitAsync(ct);
} }
catch (Exception e) catch (Exception e)
{ {
if (e is GracefulException { SuppressLog: true }) throw; if (e is GracefulException) throw;
throw new
GracefulException($"Failed to fetch key of signature user ({sig.KeyId}) - {e.Message}");
} }
} }
var body = await new StreamReader(request.Body).ReadToEndAsync(ct); // If we still don't have the key, something went wrong and we need to throw an exception
request.Body.Seek(0, SeekOrigin.Begin); if (key == null) throw new GracefulException($"Failed to fetch key of signature user ({sig.KeyId})");
JToken parsed; if (key.User.IsLocalUser)
throw new Exception("Remote user must have a host");
// We want to check both the user host & the keyId host (as account & web domain might be different)
if (await fedCtrlSvc.ShouldBlockAsync(key.User.Host, key.KeyId))
throw new GracefulException(HttpStatusCode.Forbidden, "Forbidden", "Instance is blocked",
suppressLog: true);
List<string> headers = ["(request-target)", "digest", "host"];
if (sig.Created != null && !sig.Headers.Contains("date"))
headers.Add("(created)");
else
headers.Add("date");
verified = await HttpSignature.VerifyAsync(request, sig, headers, key.KeyPem);
logger.LogDebug("HttpSignature.Verify returned {result} for key {keyId}", verified, sig.KeyId);
if (!verified)
{
logger.LogDebug("Refetching user key...");
key = await userSvc.UpdateUserPublicKeyAsync(key);
verified = await HttpSignature.VerifyAsync(request, sig, headers, key.KeyPem);
logger.LogDebug("HttpSignature.Verify returned {result} for key {keyId}", verified, sig.KeyId);
}
}
catch (Exception e)
{
if (e is AuthFetchException afe) throw GracefulException.Accepted(afe.Message);
if (e is GracefulException { SuppressLog: true }) throw;
logger.LogDebug("Error validating HTTP signature: {error}", e.Message);
}
if (
(!verified || (key?.User.Uri != null && activity.Actor?.Id != key.User.Uri)) &&
(activity is ASDelete || config.Value.AcceptLdSignatures)
)
{
if (activity is ASDelete)
logger.LogDebug("Activity is ASDelete & actor uri is not matching, trying LD signature next...");
else
logger.LogDebug("Trying LD signature next...");
try try
{ {
parsed = JToken.Parse(body); var contentType = new MediaTypeHeaderValue(request.ContentType);
} if (!ActivityPub.ActivityFetcherService.IsValidActivityContentType(contentType))
catch (Exception e) throw new Exception("Request body is not an activity");
{
logger.LogDebug("Failed to parse ASObject ({error}), skipping", e.Message);
return;
}
JArray? expanded; if (activity.Actor == null)
try throw new Exception("Activity has no actor");
{ if (await fedCtrlSvc.ShouldBlockAsync(new Uri(activity.Actor.Id).Host))
expanded = LdHelpers.Expand(parsed); throw new GracefulException(HttpStatusCode.Forbidden, "Forbidden", "Instance is blocked",
if (expanded == null) throw new Exception("Failed to expand ASObject"); suppressLog: true);
} key = null;
catch (Exception e) key = await db.UserPublickeys
{ .Include(p => p.User)
logger.LogDebug("Failed to expand ASObject ({error}), skipping", e.Message); .FirstOrDefaultAsync(p => p.User.Uri == activity.Actor.Id, ct);
return;
}
ASObject? obj;
try
{
obj = ASObject.Deserialize(expanded);
if (obj == null) throw new Exception("Failed to deserialize ASObject");
}
catch (Exception e)
{
throw GracefulException
.UnprocessableEntity($"Failed to deserialize request body as ASObject: {e.Message}");
}
if (obj is not ASActivity activity)
throw new GracefulException(HttpStatusCode.UnprocessableEntity,
"Request body is not an ASActivity", $"Type: {obj.Type}");
UserPublickey? key = null;
var verified = false;
try
{
if (sig == null)
throw new GracefulException(HttpStatusCode.Unauthorized, "Request is missing the signature header");
// First, we check if we already have the key
key = await db.UserPublickeys.Include(p => p.User)
.FirstOrDefaultAsync(p => p.KeyId == sig.KeyId, ct);
// If we don't, we need to try to fetch it
if (key == null) if (key == null)
{ {
try var flags = activity is ASDelete
{ ? ResolveFlags.Uri | ResolveFlags.OnlyExisting
var flags = activity is ASDelete : ResolveFlags.Uri;
? ResolveFlags.Uri | ResolveFlags.OnlyExisting
: ResolveFlags.Uri;
var user = await userResolver.ResolveOrNullAsync(sig.KeyId, flags).WaitAsync(ct); var user = await userResolver
if (user == null) throw AuthFetchException.NotFound("Delete activity actor is unknown"); .ResolveOrNullAsync(activity.Actor.Id, flags)
key = await db.UserPublickeys.Include(p => p.User) .WaitAsync(ct);
.FirstOrDefaultAsync(p => p.User == user, ct); if (user == null) throw AuthFetchException.NotFound("Delete activity actor is unknown");
key = await db.UserPublickeys
.Include(p => p.User)
.FirstOrDefaultAsync(p => p.User == user, ct);
// If the key is still null here, we have a data consistency issue and need to update the key manually if (key == null)
key ??= await userSvc.UpdateUserPublicKeyAsync(user).WaitAsync(ct); throw new Exception($"Failed to fetch public key for user {activity.Actor.Id}");
}
catch (Exception e)
{
if (e is GracefulException) throw;
throw new
GracefulException($"Failed to fetch key of signature user ({sig.KeyId}) - {e.Message}");
}
} }
// If we still don't have the key, something went wrong and we need to throw an exception if (await fedCtrlSvc.ShouldBlockAsync(key.User.Host, new Uri(key.KeyId).Host))
if (key == null) throw new GracefulException($"Failed to fetch key of signature user ({sig.KeyId})");
if (key.User.IsLocalUser)
throw new Exception("Remote user must have a host");
// We want to check both the user host & the keyId host (as account & web domain might be different)
if (await fedCtrlSvc.ShouldBlockAsync(key.User.Host, key.KeyId))
throw new GracefulException(HttpStatusCode.Forbidden, "Forbidden", "Instance is blocked", throw new GracefulException(HttpStatusCode.Forbidden, "Forbidden", "Instance is blocked",
suppressLog: true); suppressLog: true);
List<string> headers = ["(request-target)", "digest", "host"]; // We need to re-run deserialize & expand with date time handling disabled for JSON-LD canonicalization to work correctly
var rawDeserialized = JsonConvert.DeserializeObject<JObject?>(body, JsonSerializerSettings);
if (sig.Created != null && !sig.Headers.Contains("date")) var rawExpanded = LdHelpers.Expand(rawDeserialized);
headers.Add("(created)"); if (rawExpanded == null)
else throw new Exception("Failed to expand activity for LD signature processing");
headers.Add("date"); verified = await LdSignature.VerifyAsync(expanded, rawExpanded, key.KeyPem, key.KeyId);
logger.LogDebug("LdSignature.VerifyAsync returned {result} for actor {id}",
verified = await HttpSignature.VerifyAsync(request, sig, headers, key.KeyPem); verified, activity.Actor.Id);
logger.LogDebug("HttpSignature.Verify returned {result} for key {keyId}", verified, sig.KeyId);
if (!verified) if (!verified)
{ {
logger.LogDebug("Refetching user key..."); logger.LogDebug("Refetching user key...");
key = await userSvc.UpdateUserPublicKeyAsync(key); key = await userSvc.UpdateUserPublicKeyAsync(key);
verified = await HttpSignature.VerifyAsync(request, sig, headers, key.KeyPem); verified = await LdSignature.VerifyAsync(expanded, rawExpanded, key.KeyPem, key.KeyId);
logger.LogDebug("HttpSignature.Verify returned {result} for key {keyId}", verified, sig.KeyId); logger.LogDebug("LdSignature.VerifyAsync returned {result} for actor {id}",
verified, activity.Actor.Id);
} }
} }
catch (Exception e) catch (Exception e)
{ {
if (e is AuthFetchException afe) throw GracefulException.Accepted(afe.Message); if (e is AuthFetchException afe) throw GracefulException.Accepted(afe.Message);
if (e is GracefulException { SuppressLog: true }) throw; if (e is GracefulException { SuppressLog: true }) throw;
logger.LogDebug("Error validating HTTP signature: {error}", e.Message); logger.LogError("Error validating JSON-LD signature: {error}", e.Message);
} }
if (
(!verified || (key?.User.Uri != null && activity.Actor?.Id != key.User.Uri)) &&
(activity is ASDelete || config.Value.AcceptLdSignatures)
)
{
if (activity is ASDelete)
logger.LogDebug("Activity is ASDelete & actor uri is not matching, trying LD signature next...");
else
logger.LogDebug("Trying LD signature next...");
try
{
var contentType = new MediaTypeHeaderValue(request.ContentType);
if (!ActivityPub.ActivityFetcherService.IsValidActivityContentType(contentType))
throw new Exception("Request body is not an activity");
if (activity.Actor == null)
throw new Exception("Activity has no actor");
if (await fedCtrlSvc.ShouldBlockAsync(new Uri(activity.Actor.Id).Host))
throw new GracefulException(HttpStatusCode.Forbidden, "Forbidden", "Instance is blocked",
suppressLog: true);
key = null;
key = await db.UserPublickeys
.Include(p => p.User)
.FirstOrDefaultAsync(p => p.User.Uri == activity.Actor.Id, ct);
if (key == null)
{
var flags = activity is ASDelete
? ResolveFlags.Uri | ResolveFlags.OnlyExisting
: ResolveFlags.Uri;
var user = await userResolver
.ResolveOrNullAsync(activity.Actor.Id, flags)
.WaitAsync(ct);
if (user == null) throw AuthFetchException.NotFound("Delete activity actor is unknown");
key = await db.UserPublickeys
.Include(p => p.User)
.FirstOrDefaultAsync(p => p.User == user, ct);
if (key == null)
throw new Exception($"Failed to fetch public key for user {activity.Actor.Id}");
}
if (await fedCtrlSvc.ShouldBlockAsync(key.User.Host, new Uri(key.KeyId).Host))
throw new GracefulException(HttpStatusCode.Forbidden, "Forbidden", "Instance is blocked",
suppressLog: true);
// We need to re-run deserialize & expand with date time handling disabled for JSON-LD canonicalization to work correctly
var rawDeserialized = JsonConvert.DeserializeObject<JObject?>(body, JsonSerializerSettings);
var rawExpanded = LdHelpers.Expand(rawDeserialized);
if (rawExpanded == null)
throw new Exception("Failed to expand activity for LD signature processing");
verified = await LdSignature.VerifyAsync(expanded, rawExpanded, key.KeyPem, key.KeyId);
logger.LogDebug("LdSignature.VerifyAsync returned {result} for actor {id}",
verified, activity.Actor.Id);
if (!verified)
{
logger.LogDebug("Refetching user key...");
key = await userSvc.UpdateUserPublicKeyAsync(key);
verified = await LdSignature.VerifyAsync(expanded, rawExpanded, key.KeyPem, key.KeyId);
logger.LogDebug("LdSignature.VerifyAsync returned {result} for actor {id}",
verified, activity.Actor.Id);
}
}
catch (Exception e)
{
if (e is AuthFetchException afe) throw GracefulException.Accepted(afe.Message);
if (e is GracefulException { SuppressLog: true }) throw;
logger.LogError("Error validating JSON-LD signature: {error}", e.Message);
}
}
if (!verified || key == null)
throw new GracefulException(HttpStatusCode.Forbidden, "Request signature validation failed");
ctx.SetActor(key.User);
} }
if (!verified || key == null)
throw new GracefulException(HttpStatusCode.Forbidden, "Request signature validation failed");
ctx.SetActor(key.User);
await next(ctx); await next(ctx);
} }
} }

View file

@ -1,16 +1,22 @@
using Iceshrimp.Backend.Core.Extensions;
using JetBrains.Annotations;
namespace Iceshrimp.Backend.Core.Middleware; namespace Iceshrimp.Backend.Core.Middleware;
public class RequestBufferingMiddleware : IMiddleware [UsedImplicitly]
public class RequestBufferingMiddleware(RequestDelegate next) : ConditionalMiddleware<EnableRequestBufferingAttribute>
{ {
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) [UsedImplicitly]
public async Task InvokeAsync(HttpContext ctx)
{ {
var attribute = ctx.GetEndpoint()?.Metadata.GetMetadata<EnableRequestBufferingAttribute>(); var attr = GetAttributeOrFail(ctx);
if (attribute != null) ctx.Request.EnableBuffering(attribute.MaxLength); ctx.Request.EnableBuffering(attr.MaxLength);
await next(ctx); await next(ctx);
} }
} }
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class EnableRequestBufferingAttribute(long maxLength) : Attribute public class EnableRequestBufferingAttribute(long maxLength) : Attribute
{ {
internal long MaxLength = maxLength; internal readonly long MaxLength = maxLength;
} }

View file

@ -1,11 +1,14 @@
using System.Diagnostics; using System.Diagnostics;
using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Extensions;
using JetBrains.Annotations;
namespace Iceshrimp.Backend.Core.Middleware; namespace Iceshrimp.Backend.Core.Middleware;
public class RequestDurationMiddleware : IMiddleware [UsedImplicitly]
public class RequestDurationMiddleware(RequestDelegate next)
{ {
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) [UsedImplicitly]
public async Task InvokeAsync(HttpContext ctx)
{ {
if (ctx.GetEndpoint()?.Metadata.GetMetadata<HideRequestDuration>() == null) if (ctx.GetEndpoint()?.Metadata.GetMetadata<HideRequestDuration>() == null)
{ {

View file

@ -1,4 +1,5 @@
using Iceshrimp.Backend.Core.Configuration; using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Extensions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Core.Middleware; namespace Iceshrimp.Backend.Core.Middleware;
@ -7,8 +8,10 @@ public class RequestVerificationMiddleware(
IOptions<Config.InstanceSection> config, IOptions<Config.InstanceSection> config,
IHostEnvironment environment, IHostEnvironment environment,
ILogger<RequestVerificationMiddleware> logger ILogger<RequestVerificationMiddleware> logger
) : IMiddleware ) : IMiddlewareService
{ {
public static ServiceLifetime Lifetime => ServiceLifetime.Singleton;
private readonly bool _isDevelopment = environment.IsDevelopment(); private readonly bool _isDevelopment = environment.IsDevelopment();
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)

View file

@ -47,7 +47,7 @@
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" /> <PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
<PackageReference Include="System.Text.Json" Version="9.0.0" /> <PackageReference Include="System.Text.Json" Version="9.0.0" />
<PackageReference Include="Ulid" Version="1.3.4" /> <PackageReference Include="Ulid" Version="1.3.4" />
<PackageReference Include="Iceshrimp.AssemblyUtils" Version="1.0.1" /> <PackageReference Include="Iceshrimp.AssemblyUtils" Version="1.0.2" />
<PackageReference Include="Iceshrimp.MimeTypes" Version="1.0.1" /> <PackageReference Include="Iceshrimp.MimeTypes" Version="1.0.1" />
<PackageReference Include="Iceshrimp.WebPush" Version="2.1.0" /> <PackageReference Include="Iceshrimp.WebPush" Version="2.1.0" />
<PackageReference Include="NetVips" Version="3.0.0" /> <PackageReference Include="NetVips" Version="3.0.0" />

View file

@ -7,7 +7,7 @@
@using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Routing
@inject IOptions<Config.InstanceSection> Instance @inject IOptions<Config.InstanceSection> Instance
@preservewhitespace true @preservewhitespace true
@attribute [RazorSsr] @attribute [BlazorSsr]
@inherits AsyncComponentBase @inherits AsyncComponentBase
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">

View file

@ -37,6 +37,7 @@ builder.Services.AddResponseCompression();
builder.Services.AddRazorPages(); builder.Services.AddRazorPages();
builder.Services.AddRazorComponents(); builder.Services.AddRazorComponents();
builder.Services.AddAntiforgery(o => o.Cookie.Name = "CSRF-Token"); builder.Services.AddAntiforgery(o => o.Cookie.Name = "CSRF-Token");
builder.Services.AddMiddleware();
builder.Services.AddServices(builder.Configuration); builder.Services.AddServices(builder.Configuration);
builder.Services.ConfigureServices(builder.Configuration); builder.Services.ConfigureServices(builder.Configuration);