From befe550f370f537ab0618e4ad3b17ee112db3e81 Mon Sep 17 00:00:00 2001 From: Laura Hausmann Date: Fri, 28 Feb 2025 20:50:28 +0100 Subject: [PATCH] [backend/middleware] Authenticate requests with signature headers when authorized fetch is disabled This fixes a bug that prevented fetching follower-only posts from remote instances when the local instance has authorized fetch disabled. --- .../Middleware/AuthorizedFetchMiddleware.cs | 85 +++++++++++-------- 1 file changed, 51 insertions(+), 34 deletions(-) diff --git a/Iceshrimp.Backend/Core/Middleware/AuthorizedFetchMiddleware.cs b/Iceshrimp.Backend/Core/Middleware/AuthorizedFetchMiddleware.cs index 7c9a462b..e33d736b 100644 --- a/Iceshrimp.Backend/Core/Middleware/AuthorizedFetchMiddleware.cs +++ b/Iceshrimp.Backend/Core/Middleware/AuthorizedFetchMiddleware.cs @@ -7,8 +7,10 @@ using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Federation.Cryptography; using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion; using Iceshrimp.Backend.Core.Services; +using Iceshrimp.Utils.Common; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; using static Iceshrimp.Backend.Core.Federation.ActivityPub.UserResolver; namespace Iceshrimp.Backend.Core.Middleware; @@ -37,25 +39,46 @@ public class AuthorizedFetchMiddleware( mfmConverter.SupportsHtmlFormatting.Value = true; mfmConverter.SupportsInlineMedia.Value = true; - if (!config.Value.AuthorizedFetch) - { - await next(ctx); - return; - } - - ctx.Response.Headers.CacheControl = "private, no-store"; - - var request = ctx.Request; - var ct = appLifetime.ApplicationStopping; - + // Short-circuit instance & relay actor fetches _instanceActorUri ??= $"/users/{(await systemUserSvc.GetInstanceActorAsync()).Id}"; _relayActorUri ??= $"/users/{(await systemUserSvc.GetRelayActorAsync()).Id}"; - if (request.Path.Value == _instanceActorUri || request.Path.Value == _relayActorUri) + if (ctx.Request.Path.Value == _instanceActorUri || ctx.Request.Path.Value == _relayActorUri) { await next(ctx); return; } + // Prevent authenticated responses from ending up in caches, and prevent unauthenticated responses + // from being returned for authenticated requests + if (ctx.Request.Headers.ContainsKey("Signature")) + ctx.Response.Headers.CacheControl = "private, no-store"; + else + ctx.Response.Headers.Append(HeaderNames.Vary, "Signature"); + + var res = await ValidateSignatureAsync(ctx); + + // We want to reject blocked instances even if authorized fetch is disabled + if (res.TryGetError(out var error) && error is InstanceBlockedException) + throw error; + + if (res.TryGetResult(out var user) && user.Value != null) + { + ctx.SetActor(user.Value); + } + else if (config.Value.AuthorizedFetch) + { + if (error != null) throw error; + throw new GracefulException(HttpStatusCode.Forbidden, "Request signature validation failed"); + } + + await next(ctx); + } + + private async Task>> ValidateSignatureAsync(HttpContext ctx) + { + var request = ctx.Request; + var ct = appLifetime.ApplicationStopping; + UserPublickey? key = null; var verified = false; @@ -64,17 +87,15 @@ public class AuthorizedFetchMiddleware( try { if (!request.Headers.TryGetValue("signature", out var sigHeader)) - throw new GracefulException(HttpStatusCode.Unauthorized, "Request is missing the signature header"); + return 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); + return new InstanceBlockedException(); // First, we check if we already have the key - key = await db.UserPublickeys.Include(p => p.User) - .FirstOrDefaultAsync(p => p.KeyId == sig.KeyId, ct); + 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) @@ -82,31 +103,26 @@ public class AuthorizedFetchMiddleware( 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); + 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 (e is GracefulException) return e; + return 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.IsSuspended) - throw GracefulException.Forbidden("User is suspended"); + return GracefulException.Forbidden("User is suspended"); if (key.User.IsLocalUser) - throw new Exception("Remote user must have a host"); + return 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); + return new InstanceBlockedException(); List headers = ["(request-target)", "host"]; @@ -128,18 +144,19 @@ public class AuthorizedFetchMiddleware( } catch (Exception e) { - if (e is AuthFetchException afe) throw GracefulException.Accepted(afe.Message); - if (e is GracefulException { SuppressLog: true }) throw; + if (e is AuthFetchException afe) return GracefulException.Accepted(afe.Message); + if (e is GracefulException { SuppressLog: true }) return e; logger.LogDebug("Error validating HTTP signature: {error}", e.Message); } if (!verified || key == null) - throw new GracefulException(HttpStatusCode.Forbidden, "Request signature validation failed"); + return new Optional(null); - ctx.SetActor(key.User); - - await next(ctx); + return new Optional(key.User); } + + private class InstanceBlockedException() : GracefulException(HttpStatusCode.Forbidden, "Forbidden", + "Instance is blocked", suppressLog: true); } public class AuthorizedFetchAttribute : Attribute;