Iceshrimp.NET/Iceshrimp.Backend/Core/Middleware/InboxValidationMiddleware.cs
2024-05-03 02:39:48 +02:00

216 lines
No EOL
8.5 KiB
C#

using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Net.Http.Headers;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Federation.ActivityStreams;
using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
using Iceshrimp.Backend.Core.Federation.Cryptography;
using Iceshrimp.Backend.Core.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Iceshrimp.Backend.Core.Middleware;
public class InboxValidationMiddleware(
[SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor")]
IOptionsSnapshot<Config.SecuritySection> config,
DatabaseContext db,
ActivityPub.UserResolver userResolver,
UserService userSvc,
ActivityPub.FederationControlService fedCtrlSvc,
ILogger<InboxValidationMiddleware> logger,
IHostApplicationLifetime appLifetime
) : IMiddleware
{
private static readonly JsonSerializerSettings JsonSerializerSettings =
new() { DateParseHandling = DateParseHandling.None };
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
{
var attribute = ctx.GetEndpoint()?.Metadata.GetMetadata<InboxValidationAttribute>();
if (attribute != null)
{
var request = ctx.Request;
var ct = appLifetime.ApplicationStopping;
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))
{
try
{
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;
}
}
var body = await new StreamReader(request.Body).ReadToEndAsync(ct);
request.Body.Seek(0, SeekOrigin.Begin);
var expanded = LdHelpers.Expand(JToken.Parse(body));
if (expanded == null)
throw new Exception("Failed to expand ASObject");
var obj = ASObject.Deserialize(expanded);
if (obj == null)
throw new Exception("Failed to deserialize ASObject");
if (obj is not ASActivity activity)
throw new GracefulException("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
{
var user = await userResolver.ResolveAsync(sig.KeyId, activity is ASDelete).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)
{
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 (key == null) throw new GracefulException($"Failed to fetch key of signature user ({sig.KeyId})");
if (key.User.Host == null)
throw new GracefulException("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
{
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 user = await userResolver.ResolveAsync(activity.Actor.Id, activity is ASDelete)
.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);
}
await next(ctx);
}
}
public class InboxValidationAttribute : Attribute;