P5 done: a key we cannot fetch for now gets 503, and follow/like/block ids stay private on purpose
- When a sender's key cannot be fetched because its server timed out or answered 5xx, the inbox answers 503 with Retry-After: 300 instead of 401, so Mastodon 4.7 retries rather than switching to RFC 9421 signatures we do not verify yet. The fetcher's failure cache now remembers whether a failure was temporary. - Follow, Like, Block, Accept, Reject and Undo ids are deliberately not dereferenceable: serving them would publish who follows, likes and blocks whom. They are always sent with their object embedded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
b691c6766d
commit
81de470f64
7 files changed
+51
-9
No files matched your search
@@ -132,6 +132,11 @@ Posts with a location (shown to nearby users of this server) never leave the ser
|
|||||||
- **Fetching.** All fetches are signed by the instance actor. They go only to public addresses, follow at most three
|
- **Fetching.** All fetches are signed by the instance actor. They go only to public addresses, follow at most three
|
||||||
redirects and read at most 1 MB.
|
redirects and read at most 1 MB.
|
||||||
- **HTML.** Received HTML is sanitised to Mastodon's allowlist.
|
- **HTML.** Received HTML is sanitised to Mastodon's allowlist.
|
||||||
|
- **Keys we cannot fetch for now.** When a sender's key cannot be fetched because its server timed out or answered
|
||||||
|
5xx, the inbox answers 503 with `Retry-After: 300` rather than 401.
|
||||||
|
- **Activity ids.** `Create` and `Announce` ids dereference. `Follow`, `Like`, `Block` and the `Accept`, `Reject` and
|
||||||
|
`Undo` that answer them do not, because serving them would reveal who follows, likes and blocks whom; they are always
|
||||||
|
sent with their object embedded.
|
||||||
- **Delivery.** Failed deliveries are retried with Mastodon's backoff (16 attempts). A host that keeps failing is paused,
|
- **Delivery.** Failed deliveries are retried with Mastodon's backoff (16 attempts). A host that keeps failing is paused,
|
||||||
starting at an hour and growing to a week.
|
starting at an hour and growing to a week.
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,21 @@ namespace PrivaPub.Tests.Federation
|
|||||||
Assert.NotNull(message.ConversationId);
|
Assert.NotNull(message.ConversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task An_unreachable_key_asks_the_sender_to_retry_instead_of_refusing()
|
||||||
|
{
|
||||||
|
var (_, alice) = await _harness.Persona("alice");
|
||||||
|
var offline = new RemoteActor(_harness.Peer, "offline", origin: "http://127.0.0.1:9");
|
||||||
|
|
||||||
|
var result = await _harness.Deliver(offline, "/human-centipede", new JsonObject
|
||||||
|
{
|
||||||
|
["id"] = NewId(offline, "follows"), ["type"] = "Follow", ["actor"] = offline.Id, ["object"] = alice.Uri
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(503, result.StatusCode);
|
||||||
|
Assert.Equal(300, result.RetryAfterSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task A_join_of_a_local_post_is_answered_with_ignore()
|
public async Task A_join_of_a_local_post_is_answered_with_ignore()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ namespace PrivaPub.Federation.Actors
|
|||||||
Task<FetchedJson> FetchObject(string uri, CancellationToken token);
|
Task<FetchedJson> FetchObject(string uri, CancellationToken token);
|
||||||
Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token);
|
Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token);
|
||||||
Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token);
|
Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token);
|
||||||
|
bool KeyTemporarilyUnavailable(string keyId) => false;
|
||||||
Task<string> ResolveHandle(string handle, CancellationToken token);
|
Task<string> ResolveHandle(string handle, CancellationToken token);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +121,8 @@ namespace PrivaPub.Federation.Actors
|
|||||||
return await Upsert(actor, key, token);
|
return await Upsert(actor, key, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool KeyTemporarilyUnavailable(string keyId) => Origin.Of(keyId) != default && _http.FailedTemporarily(StripFragment(keyId));
|
||||||
|
|
||||||
public async Task<string> ResolveHandle(string handle, CancellationToken token)
|
public async Task<string> ResolveHandle(string handle, CancellationToken token)
|
||||||
{
|
{
|
||||||
var parts = handle?.TrimStart('@').Split('@');
|
var parts = handle?.TrimStart('@').Split('@');
|
||||||
|
|||||||
@@ -333,6 +333,8 @@ namespace PrivaPub.Federation.Controllers
|
|||||||
{
|
{
|
||||||
if (result.Error != default)
|
if (result.Error != default)
|
||||||
_logger.LogInformation("Inbox refused with {Status}: {Error}", result.StatusCode, result.Error);
|
_logger.LogInformation("Inbox refused with {Status}: {Error}", result.StatusCode, result.Error);
|
||||||
|
if (result.RetryAfterSeconds is { } seconds)
|
||||||
|
Response.Headers.RetryAfter = seconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
return result.Error == default ? StatusCode(result.StatusCode) : StatusCode(result.StatusCode, result.Error);
|
return result.Error == default ? StatusCode(result.StatusCode) : StatusCode(result.StatusCode, result.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ using static PrivaPub.Federation.Objects.ActivityJson;
|
|||||||
|
|
||||||
namespace PrivaPub.Federation.Inbox
|
namespace PrivaPub.Federation.Inbox
|
||||||
{
|
{
|
||||||
public sealed record InboxResult(int StatusCode, string Error = default);
|
public sealed record InboxResult(int StatusCode, string Error = default, int? RetryAfterSeconds = default);
|
||||||
|
|
||||||
public sealed record InboxPayload(string ActorURI, string Activity, string Inbox = default, string KeyId = default, string Algorithm = default,
|
public sealed record InboxPayload(string ActorURI, string Activity, string Inbox = default, string KeyId = default, string Algorithm = default,
|
||||||
string[] SignedHeaders = default, DateTime? ReceivedAt = default);
|
string[] SignedHeaders = default, DateTime? ReceivedAt = default);
|
||||||
@@ -25,6 +25,8 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
|
|
||||||
public class InboxReceiver : IInboxReceiver
|
public class InboxReceiver : IInboxReceiver
|
||||||
{
|
{
|
||||||
|
const int KeyRetrySeconds = 300;
|
||||||
|
|
||||||
const int MaxBodyBytes = 1024 * 1024;
|
const int MaxBodyBytes = 1024 * 1024;
|
||||||
|
|
||||||
readonly ILocalActorService _localActors;
|
readonly ILocalActorService _localActors;
|
||||||
@@ -88,6 +90,8 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature))
|
if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature))
|
||||||
{
|
{
|
||||||
keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: true, token);
|
keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: true, token);
|
||||||
|
if (keyOwner == default && _remoteActors.KeyTemporarilyUnavailable(parameters.KeyId))
|
||||||
|
return new(StatusCodes.Status503ServiceUnavailable, "the signing key could not be fetched; try again later", KeyRetrySeconds);
|
||||||
if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature))
|
if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature))
|
||||||
return new(StatusCodes.Status401Unauthorized, "the signature does not verify");
|
return new(StatusCodes.Status401Unauthorized, "the signature does not verify");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ namespace PrivaPub.Infrastructure.Http
|
|||||||
{
|
{
|
||||||
bool IsAllowed(Uri target);
|
bool IsAllowed(Uri target);
|
||||||
Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token);
|
Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token);
|
||||||
|
bool FailedTemporarily(string url);
|
||||||
Task<HttpResponseMessage> Send(HttpRequestMessage request, CancellationToken token);
|
Task<HttpResponseMessage> Send(HttpRequestMessage request, CancellationToken token);
|
||||||
Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, CancellationToken token);
|
Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, CancellationToken token);
|
||||||
}
|
}
|
||||||
@@ -107,7 +108,8 @@ namespace PrivaPub.Infrastructure.Http
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
return Refuse(negativeKey, url, $"status {(int)response.StatusCode}");
|
return Refuse(negativeKey, url, $"status {(int)response.StatusCode}",
|
||||||
|
transient: (int)response.StatusCode is >= 500 or 429 or 408);
|
||||||
|
|
||||||
var mediaType = response.Content.Headers.ContentType?.MediaType;
|
var mediaType = response.Content.Headers.ContentType?.MediaType;
|
||||||
if (mediaType == default || !JsonMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase))
|
if (mediaType == default || !JsonMediaTypes.Contains(mediaType, StringComparer.OrdinalIgnoreCase))
|
||||||
@@ -125,14 +127,21 @@ namespace PrivaPub.Infrastructure.Http
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (!token.IsCancellationRequested)
|
catch (OperationCanceledException) when (!token.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
return Refuse(negativeKey, url, "a timeout");
|
return Refuse(negativeKey, url, "a timeout", transient: true);
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is HttpRequestException or JsonException or BlockedDestinationException)
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
return Refuse(negativeKey, url, ex.Message, transient: true);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is JsonException or BlockedDestinationException)
|
||||||
{
|
{
|
||||||
return Refuse(negativeKey, url, ex.Message);
|
return Refuse(negativeKey, url, ex.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool FailedTemporarily(string url) =>
|
||||||
|
Uri.TryCreate(url, UriKind.Absolute, out var target) && _cache.TryGetValue(NegativeKey(target), out bool transient) && transient;
|
||||||
|
|
||||||
public async Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, CancellationToken token)
|
public async Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, CancellationToken token)
|
||||||
{
|
{
|
||||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target))
|
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target))
|
||||||
@@ -200,9 +209,9 @@ namespace PrivaPub.Infrastructure.Http
|
|||||||
|
|
||||||
static string NegativeKey(Uri target) => "federation-http:refused:" + target.AbsoluteUri;
|
static string NegativeKey(Uri target) => "federation-http:refused:" + target.AbsoluteUri;
|
||||||
|
|
||||||
FetchedJson Refuse(string negativeKey, string url, string reason)
|
FetchedJson Refuse(string negativeKey, string url, string reason, bool transient = false)
|
||||||
{
|
{
|
||||||
_cache.Set(negativeKey, true, NegativeCacheLifetime);
|
_cache.Set(negativeKey, transient, NegativeCacheLifetime);
|
||||||
_logger.LogInformation("GET {Url} refused: {Reason}", url, reason);
|
_logger.LogInformation("GET {Url} refused: {Reason}", url, reason);
|
||||||
return default;
|
return default;
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-3
@@ -10,7 +10,7 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati
|
|||||||
- [x] P2 Mastodon client API: v1.4.0, deployed 2026-10-01; OAuth and the anonymous API verified on production, the signed-in API verified locally (no real-client login on production yet)
|
- [x] P2 Mastodon client API: v1.4.0, deployed 2026-10-01; OAuth and the anonymous API verified on production, the signed-in API verified locally (no real-client login on production yet)
|
||||||
- [x] P3 Social features: v1.5.0, deployed 2026-10-01; media, proxy, blocks, mutes, bookmarks, pins and reports verified by tests and locally (no upload on production yet)
|
- [x] P3 Social features: v1.5.0, deployed 2026-10-01; media, proxy, blocks, mutes, bookmarks, pins and reports verified by tests and locally (no upload on production yet)
|
||||||
- [x] P4 Groups and privacy features: v1.6.0, deployed 2026-10-01; communities, circles and local-only located posts verified by tests. v1.6.1 adds the pasture (`tools/pasture/`): live interop with GoToSocial 0.22.1 passes all 25 checks, three runs in a row. Lemmy and a live Mastodon circle member are not run yet; the pasture has GoToSocial only
|
- [x] P4 Groups and privacy features: v1.6.0, deployed 2026-10-01; communities, circles and local-only located posts verified by tests. v1.6.1 adds the pasture (`tools/pasture/`): live interop with GoToSocial 0.22.1 passes all 25 checks, three runs in a row. Lemmy and a live Mastodon circle member are not run yet; the pasture has GoToSocial only
|
||||||
- [ ] P5 Lose nothing: wire tolerance, full objects, raw capture (see `docs/INTEROP.md`)
|
- [x] P5 Lose nothing: v1.7.0 to v1.9.1, deployed 2026-10-01; parsing, typed details, provenance, downvotes, tombstones, federated blocks, all checked live against GoToSocial. Book reviews and forum threads keep their raw form only (typed in P7 and P8)
|
||||||
- [ ] P6 Emoji, polls, quotes, reactions, cards, players
|
- [ ] P6 Emoji, polls, quotes, reactions, cards, players
|
||||||
- [ ] P7 Threads, communities, moderation, the social graph
|
- [ ] P7 Threads, communities, moderation, the social graph
|
||||||
- [ ] P8 Signatures, discovery, the long tail
|
- [ ] P8 Signatures, discovery, the long tail
|
||||||
@@ -414,7 +414,7 @@ it, raw where it doesn't.
|
|||||||
- Persona usernames match Mastodon's and Misskey's pattern (groups already did).
|
- Persona usernames match Mastodon's and Misskey's pattern (groups already did).
|
||||||
- Every term we emit is defined in our JSON-LD context.
|
- Every term we emit is defined in our JSON-LD context.
|
||||||
- `Vary: Accept`.
|
- `Vary: Accept`.
|
||||||
- Still open: every activity id dereferences.
|
- Not done on purpose: see "Deliberately not done" below.
|
||||||
- **Owner decisions that were small** (done in v1.7.0): blocks federate (`Block`, `Undo{Block}`); a persona's and a
|
- **Owner decisions that were small** (done in v1.7.0): blocks federate (`Block`, `Undo{Block}`); a persona's and a
|
||||||
group's `published` is a random day up to two weeks before its creation (migration `_007`).
|
group's `published` is a random day up to two weeks before its creation (migration `_007`).
|
||||||
- **Parsing every shape** (done in v1.8.0, `Federation/Objects/ObjectShapes.cs`):
|
- **Parsing every shape** (done in v1.8.0, `Federation/Objects/ObjectShapes.cs`):
|
||||||
@@ -441,7 +441,11 @@ it, raw where it doesn't.
|
|||||||
- A deleted object's id is kept for 90 days (W9), and its `ObjectRecord` is removed with the post.
|
- A deleted object's id is kept for 90 days (W9), and its `ObjectRecord` is removed with the post.
|
||||||
- **Typed details reach clients** (done in v1.9.0) as `Status.privapub`.
|
- **Typed details reach clients** (done in v1.9.0) as `Status.privapub`.
|
||||||
- **Delivery:** a 503 with `Retry-After` is waited out like a 429 and no longer counts against the host (done in v1.9.0).
|
- **Delivery:** a 503 with `Retry-After` is waited out like a 429 and no longer counts against the host (done in v1.9.0).
|
||||||
Still open: answering 503 ourselves when a key fetch fails temporarily, and making every activity id dereference.
|
We answer 503 with `Retry-After` ourselves when a sender's key cannot be fetched for a temporary reason (v1.9.1),
|
||||||
|
so Mastodon 4.7 retries instead of switching to RFC 9421.
|
||||||
|
- **Deliberately not done: dereferenceable `Follow`, `Like`, `Block`, `Accept`, `Reject` and `Undo` ids.** Serving them
|
||||||
|
would publish who follows, likes and blocks whom, which our collections deliberately hide. Every one of them is sent
|
||||||
|
with its object embedded, which is all Misskey needs. `Create` and `Announce` ids do dereference.
|
||||||
|
|
||||||
#### P6 What people see: emoji, polls, quotes, reactions, cards, players
|
#### P6 What people see: emoji, polls, quotes, reactions, cards, players
|
||||||
- **Custom emoji** on posts, names, fields and poll options, proxied.
|
- **Custom emoji** on posts, names, fields and poll options, proxied.
|
||||||
|
|||||||
Reference in new issue
Block a user