A remote actor is believed only from its own origin

S1 and S2 of the roadmap. RemoteActorService:
- FetchObject accepts a document only when its id is the address it was
  served from; a same-origin document naming another address is asked for
  at that address once (how GoToSocial serves its key URIs), anything else
  is dropped;
- GetActorByKeyId accepts a key only when the actor lists it, its owner is
  the actor and it lives on the actor's origin, whether the keyId points at
  the actor or at a key document;
- a refetch for a key or an actor happens at most once per five minutes,
  so a bad signature cannot make us hammer a host;
- the cache row is written by one atomic upsert on ActorURI;
- every fetch is signed by the instance actor, never by the persona that
  happened to receive the activity.

The inbox refuses an activity whose id is not on its actor's origin, and
an Undo of someone else's activity; a Create's object, an Update and a
Delete must be on the actor's origin too, and a cross-origin object is
refetched from its own origin before it is trusted.

Tests: a fake peer on two origins serves forged actors, foreign-owned keys,
cross-origin key documents, aliases and a GoToSocial-style key address
(integration, PRIVAPUB_TEST_MONGOD=1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-01 10:49:58 +02:00
1 parent ccc3597699
commit a060204dd6
9 files changed
+593 -81

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers;
using MongoDB.Driver;
using MongoDB.Entities;
[assembly: AssemblyFixture(typeof(PrivaPub.Tests.Support.MongoFixture))]
namespace PrivaPub.Tests.Support
{
public sealed class MongoFixture : IAsyncLifetime
{
public const string Skip = "set PRIVAPUB_TEST_MONGOD=1 (and optionally PRIVAPUB_TEST_MONGO) to run the tests that need a mongod";
public static bool Enabled => Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGOD") == "1";
public string Database { get; } = $"PrivaPubTests_{Guid.NewGuid():N}";
public async ValueTask InitializeAsync()
{
if (!Enabled)
return;
try
{
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
}
catch (BsonSerializationException)
{
}
var connection = Environment.GetEnvironmentVariable("PRIVAPUB_TEST_MONGO") ?? "mongodb://127.0.0.1:27017";
await DB.InitAsync(Database, MongoClientSettings.FromConnectionString(connection));
}
public async ValueTask DisposeAsync()
{
if (Enabled)
await DB.Default.Database().Client.DropDatabaseAsync(Database);
}
}
}
+79
View File
@@ -0,0 +1,79 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using PrivaPub.Infrastructure.Http;
using System.Collections.Concurrent;
namespace PrivaPub.Tests.Support
{
public sealed class Peer : IAsyncDisposable
{
readonly WebApplication _app;
readonly ConcurrentDictionary<string, string> _documents = new();
public int Port { get; }
public string A => $"http://127.0.0.1:{Port}";
public string B => $"http://localhost:{Port}";
public ConcurrentQueue<HttpRequestRecord> Requests { get; } = new();
Peer(WebApplication app, int port)
{
_app = app;
Port = port;
}
public static async Task<Peer> Start()
{
var builder = WebApplication.CreateSlimBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
var app = builder.Build();
Peer peer = default;
app.Run(async context =>
{
peer.Requests.Enqueue(new(context.Request.Method, context.Request.Path, context.Request.Headers["Signature"].ToString()));
var key = context.Request.Path.Value;
if (!peer._documents.TryGetValue(key, out var document))
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
context.Response.ContentType = "application/activity+json";
await context.Response.WriteAsync(document.Replace("{A}", peer.A).Replace("{B}", peer.B));
});
await app.StartAsync();
peer = new Peer(app, new Uri(app.Urls.First()).Port);
return peer;
}
public void Serve(string path, string json) => _documents[path] = json;
public static FederationHttp Http(IMemoryCache cache = default)
{
var options = new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true };
var services = new ServiceCollection();
services.AddHttpClient(FederationHttp.ClientName)
.ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options));
return new FederationHttp(services.BuildServiceProvider().GetRequiredService<IHttpClientFactory>(),
cache ?? new MemoryCache(new MemoryCacheOptions()), new StaticOptions<FederationOptions>(options),
NullLogger<FederationHttp>.Instance);
}
public async ValueTask DisposeAsync() => await _app.DisposeAsync();
}
public sealed record HttpRequestRecord(string Method, string Path, string Signature);
public sealed class StaticOptions<T> : IOptionsMonitor<T>
{
public StaticOptions(T value) => CurrentValue = value;
public T CurrentValue { get; }
public T Get(string name) => CurrentValue;
public IDisposable OnChange(Action<T, string> listener) => default;
}
}