diff --git a/CHANGES.md b/CHANGES.md index b5d0cd60..b0c6eacb 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -15,6 +15,24 @@ To be released. reference through an `Update`; failed rejection updates can be retried without changing the quote state or counts. [[#602]] + - FEP-044f quote authorization now uses Fedify's + *@fedify/interaction-controls* package to build and verify quote + requests and authorizations and to evaluate local quote policies. + Verification is stricter in a few cases: [[#635], [#641]] + + - Incoming `QuoteRequest` activities must have an `id` and an `actor`, + and their quote post's `quote` and `quoteUrl` must point to the same + post. Invalid requests are ignored before the quote post is stored. + - An embedded quote post from another origin is fetched from its own + server instead of being trusted. + - A `QuoteAuthorization` from an `Accept` or from a remote quote post is + always fetched from its IRI. Embedded authorization contents are not + trusted. The authorization must be hosted on the same origin as the + quoted post's author. If the authorization can't be fetched because + of a temporary failure, the `Accept` is retried later. + - Outgoing `Accept` and `Reject` responses to quote requests now have + explicit IDs and address the requester in `to`. + - Added WebP (`image/webp`) as an accepted format for profile avatar and banner image uploads. Previously only JPEG, PNG, and GIF were accepted by both the Mastodon-compatible @@ -78,8 +96,6 @@ To be released. 14 adds post-quantum ML-DSA passkey support on runtimes that provide the algorithms. [[GHSA-2g3p-m8c9-hhwh], [GHSA-j3h4-m3m2-7p7j]] -[GHSA-97w4-f4rq-mgqm]: https://github.com/fedify-dev/fedify/security/advisories/GHSA-97w4-f4rq-mgqm -[GHSA-39gj-rchc-q5m3]: https://github.com/fedify-dev/fedify/security/advisories/GHSA-39gj-rchc-q5m3 [FEP-c0e0]: https://w3id.org/fep/c0e0 [Gukhanmun]: https://gukhanmun.org/ [RFC 6749]: https://datatracker.ietf.org/doc/html/rfc6749#section-3.3 @@ -90,6 +106,8 @@ To be released. [#549]: https://github.com/fedify-dev/hollo/issues/549 [#602]: https://github.com/fedify-dev/hollo/issues/602 [#609]: https://github.com/fedify-dev/hollo/issues/609 +[#635]: https://github.com/fedify-dev/hollo/issues/635 +[#641]: https://github.com/fedify-dev/hollo/pull/641 Version 0.9.21 @@ -157,6 +175,7 @@ Released on September 30, 2026. definition also causes the migration to fail; inspect it before replacing it. [[#624], [#626]] +[GHSA-97w4-f4rq-mgqm]: https://github.com/fedify-dev/fedify/security/advisories/GHSA-97w4-f4rq-mgqm [#624]: https://github.com/fedify-dev/hollo/issues/624 [#626]: https://github.com/fedify-dev/hollo/pull/626 diff --git a/package.json b/package.json index 1a0c26f8..f82a985c 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@fedify/debugger": "~2.4.0", "@fedify/fedify": "~2.4.0", "@fedify/hono": "~2.4.0", + "@fedify/interaction-controls": "~2.4.0", "@fedify/markdown-it-hashtag": "~0.3.0", "@fedify/markdown-it-mention": "~0.3.0", "@fedify/postgres": "~2.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd07b9d2..b5be7c0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: '@fedify/hono': specifier: ~2.4.0 version: 2.4.0(@fedify/fedify@2.4.0)(hono@4.13.5) + '@fedify/interaction-controls': + specifier: ~2.4.0 + version: 2.4.0(@fedify/fedify@2.4.0) '@fedify/markdown-it-hashtag': specifier: ~0.3.0 version: 0.3.0 @@ -1206,6 +1209,11 @@ packages: '@fedify/fedify': ^2.4.0 hono: ^4.0.0 + '@fedify/interaction-controls@2.4.0': + resolution: {integrity: sha512-lUZS1eOlFer33p1dgK7kH+cjxDFZLz1FP2JYXJr7/CRUw4h9fB4zk1/yBI0ws2xt2sPNwZh/dAFbtt4z1YPrug==} + peerDependencies: + '@fedify/fedify': ^2.4.0 + '@fedify/lint@2.4.0': resolution: {integrity: sha512-BYAWNuWDXpIX/TYrnqz+UZKuotaH+XqyF4hPCEv30u4wDDw3r3IeGgJlYZiREVWZtuHOdu4rcqex4gFfIXLQ6w==} peerDependencies: @@ -7544,6 +7552,13 @@ snapshots: '@fedify/fedify': 2.4.0 hono: 4.13.5 + '@fedify/interaction-controls@2.4.0(@fedify/fedify@2.4.0)': + dependencies: + '@fedify/fedify': 2.4.0 + '@fedify/vocab': 2.4.0 + '@fedify/vocab-runtime': 2.4.0 + temporal-polyfill: 1.0.5 + '@fedify/lint@2.4.0(@fedify/fedify@2.4.0)(@typescript/typescript6@6.0.2)(eslint@10.2.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: '@fedify/fedify': 2.4.0 diff --git a/src/api/v1/statuses.ts b/src/api/v1/statuses.ts index c3ad3fec..0366d891 100644 --- a/src/api/v1/statuses.ts +++ b/src/api/v1/statuses.ts @@ -23,10 +23,7 @@ import { import { getPostRelations, serializePost } from "../../entities/status"; import federation from "../../federation"; import { updateAccountStats } from "../../federation/account"; -import { - getQuoteAuthorizationIri, - sendQuoteUpdate, -} from "../../federation/inbox"; +import { sendQuoteUpdate } from "../../federation/inbox"; import { getRecipients, persistPost, @@ -37,6 +34,11 @@ import { toUpdate, updatePostStats, } from "../../federation/post"; +import { + createQuoteAuthorization, + createQuoteRequest, + getQuoteAuthorizationIri, +} from "../../federation/quote"; import { appendPostToTimelines } from "../../federation/timeline"; import { requestBody } from "../../helpers"; import { isLocalHost } from "../../instance-host"; @@ -632,14 +634,12 @@ app.post( sharedInbox: new URL(post.quoteTarget.account.sharedInboxUrl), }, }, - new vocab.QuoteRequest({ - id: new URL("#quote-request", post.iri), - actor: new URL(owner.account.iri), - object: new URL(post.quoteTarget.iri), - instrument: toObject(post, fedCtx, { - includeInactiveQuoteTarget: true, - }), - }), + createQuoteRequest( + post, + owner.account.iri, + post.quoteTarget.iri, + toObject(post, fedCtx, { includeInactiveQuoteTarget: true }), + ), { orderingKey, preferSharedInbox: true, @@ -1976,12 +1976,13 @@ app.post( new vocab.Delete({ id: new URL("#delete", quoteAuthorizationIri), actor: new URL(owner.account.iri), - object: new vocab.QuoteAuthorization({ - id: new URL(quoteAuthorizationIri), - attribution: new URL(owner.account.iri), - interactingObject: new URL(quotingPost.iri), - interactionTarget: new URL(targetPost.iri), - }), + // Keep the embedded authorization instead of the helper's + // IRI-only revocation so the wire format stays unchanged. + object: createQuoteAuthorization( + { iri: targetPost.iri, account: owner.account }, + quotingPost, + quoteAuthorizationIri, + ), }), { orderingKey: getPostOrderingKey(quotingPost.iri), diff --git a/src/federation/inbox.test.ts b/src/federation/inbox.test.ts index b1acfc82..498443ef 100644 --- a/src/federation/inbox.test.ts +++ b/src/federation/inbox.test.ts @@ -2,6 +2,7 @@ import type { InboxContext } from "@fedify/fedify"; import { Accept, Delete, + type DocumentLoader, Note, Person, QuoteAuthorization, @@ -33,6 +34,7 @@ import { onQuoteRequestRejected, sendQuoteUpdate, } from "./inbox"; +import federation from "./index"; type SeededFollow = { followerId: Uuid; @@ -68,10 +70,53 @@ async function seedFollow(): Promise { }; } +const fedCtx = federation.createContext( + new URL("https://hollo.test"), + undefined, +); + +// Documents served by the mock document loader, keyed by URL. Other +// *.test URLs answer 404; JSON-LD contexts come from Fedify's preloaded set. +const remoteDocuments = new Map(); + +function notFound(url: string): Error { + return Object.assign(new Error(`HTTP 404: ${url}`), { + response: new Response(null, { status: 404 }), + }); +} + +const documentLoader: DocumentLoader = async (url, options) => { + const document = remoteDocuments.get(url); + if (document != null) return { contextUrl: null, document, documentUrl: url }; + if (new URL(url).hostname.endsWith(".test")) throw notFound(url); + return await fedCtx.contextLoader(url, options); +}; + const ctx = { origin: "https://hollo.test", recipient: "follower", -} as InboxContext; + documentLoader, + contextLoader: fedCtx.contextLoader, + getFollowersUri: (identifier: string) => fedCtx.getFollowersUri(identifier), +} as unknown as InboxContext; + +async function serveQuoteAuthorization( + authorizationIri: string, + targetAuthorIri: string, + quoteIri: string, + targetIri: string, +): Promise { + const authorization = new QuoteAuthorization({ + id: new URL(authorizationIri), + attribution: new URL(targetAuthorIri), + interactingObject: new URL(quoteIri), + interactionTarget: new URL(targetIri), + }); + remoteDocuments.set( + authorizationIri, + await authorization.toJsonLd({ contextLoader: fedCtx.contextLoader }), + ); +} describe("onFollowAccepted", () => { beforeEach(async () => { @@ -200,6 +245,7 @@ describe("onFollowRejected", () => { describe("quote request lifecycle", () => { beforeEach(async () => { await cleanDatabase(); + remoteDocuments.clear(); }); async function seedRemoteAccount(username: string): Promise { @@ -270,6 +316,13 @@ describe("quote request lifecycle", () => { }, ]); + await serveQuoteAuthorization( + `${quotedPostIri}/quote_authorizations/${quotePostId}`, + "https://hollo.test/@quote-author", + quotePostIri, + quotedPostIri, + ); + return { quotedPostId, quotedPostIri, quotePostId, quotePostIri }; } @@ -927,6 +980,7 @@ describe("quote request lifecycle", () => { }); const request = new QuoteRequest({ + id: new URL(`https://remote.test/quote-requests/${crypto.randomUUID()}`), actor: new URL("https://remote.test/@quoter"), object: new URL(quotedPostIri), instrument: new Note({ @@ -999,6 +1053,7 @@ describe("quote request lifecycle", () => { ]); const request = new QuoteRequest({ + id: new URL(`https://remote.test/quote-requests/${crypto.randomUUID()}`), actor: new URL("https://remote.test/@attacker"), object: new URL(quotedPostIri), instrument: new Note({ @@ -1056,6 +1111,7 @@ describe("quote request lifecycle", () => { }); const request = new QuoteRequest({ + id: new URL(`https://remote.test/quote-requests/${crypto.randomUUID()}`), actor: new URL("https://remote.test/@quoter"), object: new URL(quotedPostIri), instrument: new Note({ @@ -1124,6 +1180,7 @@ describe("quote request lifecycle", () => { }); const request = new QuoteRequest({ + id: new URL(`https://remote.test/quote-requests/${crypto.randomUUID()}`), actor: new URL("https://remote.test/@quoter"), object: new URL(quotedPostIri), instrument: new Note({ @@ -1198,6 +1255,7 @@ describe("quote request lifecycle", () => { ]); const oldRequest = new QuoteRequest({ + id: new URL(`https://remote.test/quote-requests/${crypto.randomUUID()}`), actor: new URL("https://remote.test/@quoter"), object: new URL(oldPostIri), instrument: new Note({ @@ -1214,6 +1272,7 @@ describe("quote request lifecycle", () => { }), }); const newRequest = new QuoteRequest({ + id: new URL(`https://remote.test/quote-requests/${crypto.randomUUID()}`), actor: new URL("https://remote.test/@quoter"), object: new URL(newPostIri), instrument: new Note({ @@ -1278,6 +1337,7 @@ describe("quote request lifecycle", () => { }); const request = new QuoteRequest({ + id: new URL(`https://remote.test/quote-requests/${crypto.randomUUID()}`), actor: new URL("https://remote.test/@quoter"), object: new URL(quotedPostIri), instrument: new Note({ @@ -1361,6 +1421,7 @@ describe("quote request lifecycle", () => { }); const request = new QuoteRequest({ + id: new URL(`https://remote.test/quote-requests/${crypto.randomUUID()}`), actor: new URL(quoterIri), object: new URL(quotedPostIri), instrument: new Note({ @@ -1438,6 +1499,7 @@ describe("quote request lifecycle", () => { }); const request = new QuoteRequest({ + id: new URL(`https://remote.test/quote-requests/${crypto.randomUUID()}`), actor: new URL(blockedAccountIri), object: new URL(quotedPostIri), instrument: new Note({ @@ -1494,6 +1556,7 @@ describe("quote request lifecycle", () => { }); const request = new QuoteRequest({ + id: new URL(`https://remote.test/quote-requests/${crypto.randomUUID()}`), actor: new URL("https://remote.test/@attacker"), object: new URL(quotedPostIri), instrument: new Note({ @@ -1524,7 +1587,7 @@ describe("quote request lifecycle", () => { }); it("ignores a QuoteRequest whose quote targets another object", async () => { - expect.assertions(4); + expect.assertions(3); const author = await createAccount({ username: "quote-author" }); const quotedPostId = crypto.randomUUID() as Uuid; @@ -1564,6 +1627,7 @@ describe("quote request lifecycle", () => { ]); const request = new QuoteRequest({ + id: new URL(`https://remote.test/quote-requests/${crypto.randomUUID()}`), actor: new URL("https://remote.test/@quoter"), object: new URL(quotedPostIri), instrument: new Note({ @@ -1588,9 +1652,628 @@ describe("quote request lifecycle", () => { const quoted = await db.query.posts.findFirst({ where: { id: { eq: quotedPostId } }, }); - expect(quote?.quoteTargetIri).toBe(otherPostIri); - expect(quote?.quoteState).toBe("unauthorized"); + // The helper rejects the mismatched request before Hollo persists the + // instrument, so an invalid request leaves no side effects behind. + expect(quote).toBeUndefined(); expect(quoted?.quotesCount).toBe(0); expect(sendActivity).not.toHaveBeenCalled(); }); + + async function seedLocalQuoteTarget( + quoteApprovalPolicy: "public" | "followers" | "nobody" = "public", + ) { + const author = await createAccount({ username: "quote-author" }); + const quotedPostId = crypto.randomUUID() as Uuid; + const quotedPostIri = `https://hollo.test/@quote-author/${quotedPostId}`; + await db.insert(posts).values({ + id: quotedPostId, + iri: quotedPostIri, + type: "Note", + accountId: author.id as Uuid, + visibility: "public", + quoteApprovalPolicy, + contentHtml: "

Quoted post

", + content: "Quoted post", + published: new Date(), + }); + return { authorId: author.id as Uuid, quotedPostId, quotedPostIri }; + } + + const quoterIri = "https://remote.test/@quoter"; + + // Modeled on Mastodon 4.5's QuoteRequest and Note serializers, which emit + // the FEP-044f `quote` together with the `_misskey_quote` and `quoteUri` + // aliases. Not captured from live traffic. + function quoteRequestJson( + quotedPostIri: string, + overrides: Record = {}, + instrumentOverrides: Record = {}, + ): Record { + const quotePostIri = `${quoterIri}/statuses/1`; + const json = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + { + QuoteRequest: "https://w3id.org/fep/044f#QuoteRequest", + quote: { "@id": "https://w3id.org/fep/044f#quote", "@type": "@id" }, + quoteUri: "http://fedibird.com/ns#quoteUri", + _misskey_quote: "https://misskey-hub.net/ns#_misskey_quote", + }, + ], + id: `${quotePostIri}/quote_requests/1`, + type: "QuoteRequest", + actor: quoterIri, + object: quotedPostIri, + instrument: { + id: quotePostIri, + type: "Note", + attributedTo: { + id: quoterIri, + type: "Person", + preferredUsername: "quoter", + inbox: `${quoterIri}/inbox`, + }, + to: "https://www.w3.org/ns/activitystreams#Public", + content: "

Remote quote

", + quote: quotedPostIri, + quoteUri: quotedPostIri, + _misskey_quote: quotedPostIri, + ...instrumentOverrides, + }, + ...overrides, + }; + // Drop properties overridden with `undefined`. + return JSON.parse(JSON.stringify(json)); + } + + function createRequestCtx(loader: DocumentLoader = documentLoader) { + const sendActivity = vi.fn(async () => undefined); + const requestCtx = { + ...ctx, + documentLoader: loader, + sendActivity, + } as unknown as InboxContext; + return { requestCtx, sendActivity }; + } + + it("accepts a Mastodon-shaped QuoteRequest with a helper-built Accept", async () => { + const { quotedPostIri } = await seedLocalQuoteTarget(); + const { requestCtx, sendActivity } = createRequestCtx(); + const json = quoteRequestJson(quotedPostIri); + const request = await QuoteRequest.fromJsonLd(json); + + await onQuoteRequested(requestCtx, request); + + const quote = await db.query.posts.findFirst({ + where: { iri: { eq: `${quoterIri}/statuses/1` } }, + }); + expect(quote?.quoteState).toBe("accepted"); + expect(sendActivity).toHaveBeenCalledOnce(); + const [, , response] = sendActivity.mock.calls[0] as unknown as [ + unknown, + unknown, + Accept, + ]; + expect(response).toBeInstanceOf(Accept); + expect(response.id?.href).toMatch(/^https:\/\/hollo\.test\/#Accept\//); + expect(response.toIds.map((id) => id.href)).toEqual([quoterIri]); + const authorization = await response.getResult(); + expect(authorization).toBeInstanceOf(QuoteAuthorization); + expect((authorization as QuoteAuthorization).id?.href).toBe( + quote?.quoteAuthorizationIri, + ); + expect( + (authorization as QuoteAuthorization).interactingObjectId?.href, + ).toBe(quote?.iri); + expect( + (authorization as QuoteAuthorization).interactionTargetId?.href, + ).toBe(quotedPostIri); + // The echoed request keeps the sender's representation. + const echoed = (await response.toJsonLd()) as { + object: { id: string; instrument: { content: string } }; + }; + expect(echoed.object.id).toBe(json.id); + expect(echoed.object.instrument.content).toBe("

Remote quote

"); + }); + + it.each([ + ["without an id", { id: undefined }, {}], + ["without an actor", { actor: undefined }, {}], + [ + "whose quote and quoteUrl disagree", + {}, + { + quoteUri: "https://hollo.test/@quote-author/other", + _misskey_quote: "https://hollo.test/@quote-author/other", + }, + ], + ])("ignores a QuoteRequest %s", async (_, overrides, instrumentOverrides) => { + const { quotedPostId, quotedPostIri } = await seedLocalQuoteTarget(); + const { requestCtx, sendActivity } = createRequestCtx(); + const request = await QuoteRequest.fromJsonLd( + quoteRequestJson(quotedPostIri, overrides, instrumentOverrides), + ); + + await onQuoteRequested(requestCtx, request); + + const quote = await db.query.posts.findFirst({ + where: { iri: { eq: `${quoterIri}/statuses/1` } }, + }); + const quoted = await db.query.posts.findFirst({ + where: { id: { eq: quotedPostId } }, + }); + expect(quote).toBeUndefined(); + expect(quoted?.quotesCount).toBe(0); + expect(sendActivity).not.toHaveBeenCalled(); + }); + + it("refetches a cross-origin embedded instrument", async () => { + const { quotedPostIri } = await seedLocalQuoteTarget(); + const instrumentIri = "https://other.test/notes/1"; + remoteDocuments.set(instrumentIri, { + "@context": "https://www.w3.org/ns/activitystreams", + id: instrumentIri, + type: "Note", + attributedTo: "https://other.test/@victim", + content: "

The real note

", + }); + const loader = vi.fn(documentLoader); + const { requestCtx, sendActivity } = createRequestCtx(loader); + const request = await QuoteRequest.fromJsonLd( + // The embedded copy claims the requester wrote it. + quoteRequestJson(quotedPostIri, {}, { id: instrumentIri }), + ); + + await onQuoteRequested(requestCtx, request); + + expect(loader.mock.calls.map(([url]) => url)).toContain(instrumentIri); + const quote = await db.query.posts.findFirst({ + where: { iri: { eq: instrumentIri } }, + }); + expect(quote).toBeUndefined(); + expect(sendActivity).not.toHaveBeenCalled(); + }); + + it("echoes the sender's request after refetching a cross-origin instrument", async () => { + const { quotedPostIri } = await seedLocalQuoteTarget(); + const instrumentIri = `${quoterIri}/statuses/1`; + // The request comes from another origin than its embedded instrument. + const json = quoteRequestJson( + quotedPostIri, + { id: "https://relay.test/quote-requests/1" }, + { content: "

Embedded copy

" }, + ); + const instrument = json.instrument as Record; + remoteDocuments.set(instrumentIri, { + ...instrument, + "@context": json["@context"], + content: "

Fetched copy

", + }); + const { requestCtx, sendActivity } = createRequestCtx(); + const request = await QuoteRequest.fromJsonLd(json); + + await onQuoteRequested(requestCtx, request); + + const quote = await db.query.posts.findFirst({ + where: { iri: { eq: instrumentIri } }, + }); + expect(quote?.contentHtml).toBe("

Fetched copy

"); + expect(quote?.quoteState).toBe("accepted"); + expect(sendActivity).toHaveBeenCalledOnce(); + const [, , response] = sendActivity.mock.calls[0] as unknown as [ + unknown, + unknown, + Accept, + ]; + const echoed = (await response.toJsonLd()) as { + object: { instrument: { content: string } }; + }; + expect(echoed.object.instrument.content).toBe("

Embedded copy

"); + }); + + it("retries a QuoteRequest whose instrument fetch fails transiently", async () => { + const { quotedPostIri } = await seedLocalQuoteTarget(); + const instrumentIri = `${quoterIri}/statuses/1`; + const loader: DocumentLoader = async (url, options) => { + if (url === instrumentIri) throw new TypeError("fetch failed"); + return await documentLoader(url, options); + }; + const { requestCtx, sendActivity } = createRequestCtx(loader); + const request = await QuoteRequest.fromJsonLd( + quoteRequestJson(quotedPostIri, { instrument: instrumentIri }), + ); + + await expect(onQuoteRequested(requestCtx, request)).rejects.toThrow( + "fetch failed", + ); + expect(sendActivity).not.toHaveBeenCalled(); + }); + + it("does not retry a QuoteRequest whose instrument URL is refused", async () => { + const { quotedPostIri } = await seedLocalQuoteTarget(); + const instrumentIri = `${quoterIri}/statuses/1`; + const loader: DocumentLoader = async (url, options) => { + if (url === instrumentIri) { + // Fedify's SSRF protection rejects private addresses this way. + throw Object.assign(new Error("Invalid or private address"), { + name: "UrlError", + }); + } + return await documentLoader(url, options); + }; + const { requestCtx, sendActivity } = createRequestCtx(loader); + const request = await QuoteRequest.fromJsonLd( + quoteRequestJson(quotedPostIri, { instrument: instrumentIri }), + ); + + await expect(onQuoteRequested(requestCtx, request)).resolves.toBe( + undefined, + ); + expect(sendActivity).not.toHaveBeenCalled(); + }); + + it("retries a QuoteRequest whose instrument host fails DNS lookup", async () => { + const { quotedPostIri } = await seedLocalQuoteTarget(); + const instrumentIri = `${quoterIri}/statuses/1`; + const loader: DocumentLoader = async (url, options) => { + if (url === instrumentIri) { + throw Object.assign(new Error("DNS lookup failed"), { + name: "UrlError", + reason: "dns", + }); + } + return await documentLoader(url, options); + }; + const { requestCtx, sendActivity } = createRequestCtx(loader); + const request = await QuoteRequest.fromJsonLd( + quoteRequestJson(quotedPostIri, { instrument: instrumentIri }), + ); + + await expect(onQuoteRequested(requestCtx, request)).rejects.toThrow( + "DNS lookup failed", + ); + expect(sendActivity).not.toHaveBeenCalled(); + }); + + it("ignores a QuoteRequest whose instrument is gone", async () => { + const { quotedPostIri } = await seedLocalQuoteTarget(); + const { requestCtx, sendActivity } = createRequestCtx(); + const request = await QuoteRequest.fromJsonLd( + quoteRequestJson(quotedPostIri, { + instrument: `${quoterIri}/statuses/1`, + }), + ); + + await onQuoteRequested(requestCtx, request); + + expect(sendActivity).not.toHaveBeenCalled(); + }); + + it.each([ + ["followers", true, Accept], + ["followers", false, Reject], + ["nobody", true, Reject], + ] as const)( + "evaluates the %s policy (approved follower: %s)", + async (policy, approvedFollower, responseClass) => { + const { authorId, quotedPostIri } = await seedLocalQuoteTarget(policy); + const quoterId = await seedRemoteAccount("quoter"); + if (approvedFollower) { + await db.insert(follows).values({ + iri: `${quoterIri}#follows/1`, + followerId: quoterId, + followingId: authorId, + approved: new Date(), + }); + } + const { requestCtx, sendActivity } = createRequestCtx(); + const request = await QuoteRequest.fromJsonLd( + quoteRequestJson(quotedPostIri), + ); + + await onQuoteRequested(requestCtx, request); + + const quote = await db.query.posts.findFirst({ + where: { iri: { eq: `${quoterIri}/statuses/1` } }, + }); + expect(quote?.quoteState).toBe( + responseClass === Accept ? "accepted" : "rejected", + ); + expect(sendActivity).toHaveBeenCalledOnce(); + const [, , response] = sendActivity.mock.calls[0] as unknown as [ + unknown, + unknown, + Accept | Reject, + ]; + expect(response).toBeInstanceOf(responseClass); + expect(response.id?.href).toMatch( + new RegExp(`^https://hollo\\.test/#${responseClass.name}/`), + ); + }, + ); + + function getAuthorizationIri(seeded: { + quotedPostIri: string; + quotePostId: string; + }): string { + return `${seeded.quotedPostIri}/quote_authorizations/${seeded.quotePostId}`; + } + + function createAccept( + seeded: { quotePostIri: string; quotedPostIri: string }, + result: URL | QuoteAuthorization, + ): Accept { + return new Accept({ + actor: new URL("https://hollo.test/@quote-author"), + object: new URL(`${seeded.quotePostIri}#quote-request`), + result, + }); + } + + async function expectStillPending(seeded: { + quotePostId: Uuid; + quotedPostId: Uuid; + }): Promise { + const quote = await db.query.posts.findFirst({ + where: { id: { eq: seeded.quotePostId } }, + }); + const quoted = await db.query.posts.findFirst({ + where: { id: { eq: seeded.quotedPostId } }, + }); + expect(quote?.quoteState).toBe("pending"); + expect(quote?.quoteAuthorizationIri).toBeNull(); + expect(quoted?.quotesCount).toBe(0); + } + + it.each< + [ + string, + { + attribution?: string; + interactingObject?: string; + interactionTarget?: string; + }, + ] + >([ + [ + "a wrong attribution", + { attribution: "https://hollo.test/@quote-quoter" }, + ], + ["a wrong quote", { interactingObject: "https://hollo.test/@x/1" }], + ["a wrong target", { interactionTarget: "https://hollo.test/@x/2" }], + ])( + "does not accept a quote whose authorization has %s", + async (_, override) => { + const seeded = await seedPendingQuote(); + const authorizationIri = getAuthorizationIri(seeded); + await serveQuoteAuthorization( + authorizationIri, + override.attribution ?? "https://hollo.test/@quote-author", + override.interactingObject ?? seeded.quotePostIri, + override.interactionTarget ?? seeded.quotedPostIri, + ); + const { requestCtx, sendActivity } = createRequestCtx(); + + const accepted = await onQuoteRequestAccepted( + requestCtx, + createAccept(seeded, new URL(authorizationIri)), + ); + + expect(accepted).toBe(false); + expect(sendActivity).not.toHaveBeenCalled(); + await expectStillPending(seeded); + }, + ); + + it("does not accept an authorization hosted on another origin", async () => { + const seeded = await seedPendingQuote(); + const authorizationIri = "https://remote.test/quote_authorizations/1"; + await serveQuoteAuthorization( + authorizationIri, + "https://hollo.test/@quote-author", + seeded.quotePostIri, + seeded.quotedPostIri, + ); + const loader = vi.fn(documentLoader); + const { requestCtx } = createRequestCtx(loader); + + const accepted = await onQuoteRequestAccepted( + requestCtx, + createAccept(seeded, new URL(authorizationIri)), + ); + + expect(accepted).toBe(false); + expect(loader).not.toHaveBeenCalled(); + await expectStillPending(seeded); + }); + + it("does not accept an authorization that is gone or of the wrong type", async () => { + const seeded = await seedPendingQuote(); + const { requestCtx } = createRequestCtx(); + const goneIri = `${seeded.quotedPostIri}/quote_authorizations/gone`; + const noteIri = `${seeded.quotedPostIri}/quote_authorizations/note`; + remoteDocuments.set(noteIri, { + "@context": "https://www.w3.org/ns/activitystreams", + id: noteIri, + type: "Note", + attributedTo: "https://hollo.test/@quote-author", + }); + + for (const iri of [goneIri, noteIri]) { + expect( + await onQuoteRequestAccepted( + requestCtx, + createAccept(seeded, new URL(iri)), + ), + ).toBe(false); + } + await expectStillPending(seeded); + }); + + it("does not trust an embedded authorization contradicted by its source", async () => { + const seeded = await seedPendingQuote(); + const authorizationIri = getAuthorizationIri(seeded); + await serveQuoteAuthorization( + authorizationIri, + "https://hollo.test/@quote-author", + "https://hollo.test/@quote-quoter/another-quote", + seeded.quotedPostIri, + ); + const { requestCtx } = createRequestCtx(); + + const accepted = await onQuoteRequestAccepted( + requestCtx, + createAccept( + seeded, + new QuoteAuthorization({ + id: new URL(authorizationIri), + attribution: new URL("https://hollo.test/@quote-author"), + interactingObject: new URL(seeded.quotePostIri), + interactionTarget: new URL(seeded.quotedPostIri), + }), + ), + ); + + expect(accepted).toBe(false); + await expectStillPending(seeded); + }); + + it("retries an Accept whose authorization fetch fails transiently", async () => { + const seeded = await seedPendingQuote(); + let failures = 1; + const loader: DocumentLoader = async (url, options) => { + if (url === getAuthorizationIri(seeded) && failures-- > 0) { + throw new TypeError("fetch failed"); + } + return await documentLoader(url, options); + }; + const { requestCtx, sendActivity } = createRequestCtx(loader); + const accept = createAccept(seeded, new URL(getAuthorizationIri(seeded))); + + await expect(onQuoteRequestAccepted(requestCtx, accept)).rejects.toThrow( + "Failed to dereference the quote authorization", + ); + await expectStillPending(seeded); + expect(await onQuoteRequestAccepted(requestCtx, accept)).toBe(true); + + const quoted = await db.query.posts.findFirst({ + where: { id: { eq: seeded.quotedPostId } }, + }); + expect(quoted?.quotesCount).toBe(1); + expect(sendActivity).toHaveBeenCalledOnce(); + }); + + it("retries an Accept whose authorization host fails DNS lookup", async () => { + const seeded = await seedPendingQuote(); + const loader: DocumentLoader = async (url, options) => { + if (url === getAuthorizationIri(seeded)) { + throw Object.assign(new Error("DNS lookup failed"), { + name: "UrlError", + reason: "dns", + }); + } + return await documentLoader(url, options); + }; + const { requestCtx, sendActivity } = createRequestCtx(loader); + + await expect( + onQuoteRequestAccepted( + requestCtx, + createAccept(seeded, new URL(getAuthorizationIri(seeded))), + ), + ).rejects.toThrow("Failed to dereference the quote authorization"); + expect(sendActivity).not.toHaveBeenCalled(); + await expectStillPending(seeded); + }); + + it("retries an Accept whose JSON-LD context fails to load transiently", async () => { + const seeded = await seedPendingQuote(); + const contextUrl = "https://flaky-context.example/ns"; + const authorizationIri = getAuthorizationIri(seeded); + const document = remoteDocuments.get(authorizationIri) as { + "@context": unknown[]; + }; + remoteDocuments.set(authorizationIri, { + ...document, + "@context": [...document["@context"], contextUrl], + }); + let failures = 1; + const loader: DocumentLoader = async (url, options) => { + if (url === contextUrl) { + if (failures-- > 0) throw new TypeError("fetch failed"); + return { + contextUrl: null, + document: { "@context": {} }, + documentUrl: url, + }; + } + return await documentLoader(url, options); + }; + const { requestCtx } = createRequestCtx(loader); + const accept = createAccept(seeded, new URL(authorizationIri)); + + await expect(onQuoteRequestAccepted(requestCtx, accept)).rejects.toThrow( + "Failed to dereference the quote authorization", + ); + expect(await onQuoteRequestAccepted(requestCtx, accept)).toBe(true); + + const quoted = await db.query.posts.findFirst({ + where: { id: { eq: seeded.quotedPostId } }, + }); + expect(quoted?.quotesCount).toBe(1); + }); + + it("counts concurrent Accepts for the same quote once", async () => { + const seeded = await seedPendingQuote(); + const { requestCtx, sendActivity } = createRequestCtx(); + const accept = createAccept(seeded, new URL(getAuthorizationIri(seeded))); + + const results = await Promise.all([ + onQuoteRequestAccepted(requestCtx, accept), + onQuoteRequestAccepted(requestCtx, accept), + ]); + + expect(results.filter((accepted) => accepted)).toHaveLength(1); + const quoted = await db.query.posts.findFirst({ + where: { id: { eq: seeded.quotedPostId } }, + }); + expect(quoted?.quotesCount).toBe(1); + expect(sendActivity).toHaveBeenCalledOnce(); + }); + + it("keeps a rejection that lands while an Accept is being verified", async () => { + const seeded = await seedPendingQuote(); + const authorizationIri = getAuthorizationIri(seeded); + const { requestCtx: rejectCtx } = createRequestCtx(); + const loader: DocumentLoader = async (url, options) => { + if (url === authorizationIri) { + await onQuoteRequestRejected( + rejectCtx, + new Reject({ + actor: new URL("https://hollo.test/@quote-author"), + object: new URL(`${seeded.quotePostIri}#quote-request`), + }), + ); + } + return await documentLoader(url, options); + }; + const { requestCtx, sendActivity } = createRequestCtx(loader); + + const accepted = await onQuoteRequestAccepted( + requestCtx, + createAccept(seeded, new URL(authorizationIri)), + ); + + expect(accepted).toBe(false); + expect(sendActivity).not.toHaveBeenCalled(); + const quote = await db.query.posts.findFirst({ + where: { id: { eq: seeded.quotePostId } }, + }); + const quoted = await db.query.posts.findFirst({ + where: { id: { eq: seeded.quotedPostId } }, + }); + expect(quote?.quoteState).toBe("rejected"); + expect(quote?.quoteAuthorizationIri).toBeNull(); + expect(quoted?.quotesCount).toBe(0); + }); }); diff --git a/src/federation/inbox.ts b/src/federation/inbox.ts index 15f7d7b8..1cb4b2e7 100644 --- a/src/federation/inbox.ts +++ b/src/federation/inbox.ts @@ -1,4 +1,5 @@ import type { Context, InboxContext } from "@fedify/fedify"; +import { quoteInteraction } from "@fedify/interaction-controls"; import { Accept, type Add, @@ -12,6 +13,7 @@ import { Follow, isActor, Like, + type Object as ActivityObject, type Move, Note, Question, @@ -62,6 +64,7 @@ import { updateAccountStats, } from "./account"; import { + getCanQuoteRule, getRecipients, isPost, persistPollVote, @@ -70,6 +73,13 @@ import { toUpdate, updatePostStats, } from "./post"; +import { + createQuoteAuthorization, + createQuoteTargetSubject, + getQuoteAuthorizationIri, + isTransientLoaderError, + verifyQuoteAuthorization, +} from "./quote"; import { getEmojiReactionCustomEmoji } from "./reactions"; const inboxLogger = getLogger(["hollo", "inbox"]); @@ -447,20 +457,25 @@ async function getQuoteRequestReferenceFromActivity( }; } -async function updateQuoteRequestState( +type ResolvedQuoteRequestResponse = { + quote: Post & { quoteTargetIri: string }; + target: Post & { account: Account }; + repeatedRejection: boolean; +}; + +async function resolveQuoteRequestResponse( request: QuoteRequestReference, responderIri: string | null, state: "accepted" | "rejected", - quoteAuthorizationIri: string | null, -): Promise { +): Promise { const quote = await db.query.posts.findFirst({ where: { iri: { eq: request.quoteIri } }, with: { quoteTarget: { with: { account: true } } }, }); - if (quote == null) return false; + if (quote == null) return null; const repeatedRejection = state === "rejected" && quote.quoteState === "rejected"; - if (quote.quoteState !== "pending" && !repeatedRejection) return false; + if (quote.quoteState !== "pending" && !repeatedRejection) return null; const target = request.targetIri == null ? quote.quoteTarget @@ -468,40 +483,51 @@ async function updateQuoteRequestState( where: { iri: { eq: request.targetIri } }, with: { account: true }, }); - if (target == null) return false; + if (target == null) return null; if (responderIri == null || responderIri !== target.account.iri) { - return false; + return null; } - if (quote.quoteTargetIri == null) return false; - if (request.targetIri != null && quote.quoteTargetIri !== request.targetIri) { - return false; + const quoteTargetIri = quote.quoteTargetIri; + if (quoteTargetIri == null) return null; + if (request.targetIri != null && quoteTargetIri !== request.targetIri) { + return null; } - // Enqueue may have failed after the first rejection was committed. A - // validated retry can resend the clearing Update without changing its ID. - if (repeatedRejection) return true; - await db.transaction(async (tx) => { - await tx + return { quote: { ...quote, quoteTargetIri }, target, repeatedRejection }; +} + +async function commitQuoteRequestState( + { quote, target }: ResolvedQuoteRequestResponse, + state: "accepted" | "rejected", + quoteAuthorizationIri: string | null, +): Promise { + return await db.transaction(async (tx) => { + // Authorization verification may wait on the network, so only move the + // quote out of the pending state if no other response has done so. + const updated = await tx .update(posts) .set({ quoteState: state, quoteAuthorizationIri, quoteTargetId: target.id, - quoteTargetIri: request.targetIri ?? quote.quoteTargetIri ?? target.iri, updated: new Date(), }) - .where(eq(posts.id, quote.id)); - if ( - state === "accepted" && - target != null && - quote.quoteState !== "accepted" - ) { + .where( + and( + eq(posts.id, quote.id), + eq(posts.quoteState, "pending"), + eq(posts.quoteTargetIri, quote.quoteTargetIri), + ), + ) + .returning({ id: posts.id }); + if (updated.length < 1) return false; + if (state === "accepted") { await tx .update(posts) .set({ quotesCount: sql`coalesce(${posts.quotesCount}, 0) + 1` }) .where(eq(posts.id, target.id)); } + return true; }); - return true; } export async function sendQuoteUpdate( @@ -554,13 +580,38 @@ export async function onQuoteRequestAccepted( ): Promise { const request = await getQuoteRequestReferenceFromActivity(accept); if (request == null) return false; - const quoteAuthorizationIri = accept.resultId?.href; - if (quoteAuthorizationIri == null) return false; - const accepted = await updateQuoteRequestState( + const authorizationId = accept.resultId; + if (authorizationId == null) return false; + const resolved = await resolveQuoteRequestResponse( request, accept.actorId?.href ?? null, "accepted", - quoteAuthorizationIri, + ); + if (resolved == null) return false; + const verification = await verifyQuoteAuthorization(ctx, { + authorizationId, + quoteIri: resolved.quote.iri, + targetIri: resolved.quote.quoteTargetIri, + targetAuthorIri: resolved.target.account.iri, + }); + if (!verification.verified) { + if (verification.retryable) { + throw new Error( + `Failed to dereference the quote authorization ` + + `${authorizationId.href}; retrying later.`, + ); + } + inboxLogger.debug( + "Ignoring Accept with an unverified authorization: " + + "{failure}", + { failure: verification.failure }, + ); + return false; + } + const accepted = await commitQuoteRequestState( + resolved, + "accepted", + verification.authorizationIri, ); if (accepted) await sendQuoteUpdate(ctx, request.quoteIri); return accepted; @@ -572,12 +623,17 @@ export async function onQuoteRequestRejected( ): Promise { const request = await getQuoteRequestReferenceFromActivity(reject); if (request == null) return false; - const rejected = await updateQuoteRequestState( + const resolved = await resolveQuoteRequestResponse( request, reject.actorId?.href ?? null, "rejected", - null, ); + if (resolved == null) return false; + // Enqueue may have failed after the first rejection was committed. A + // validated retry can resend the clearing Update without changing its ID. + const rejected = + resolved.repeatedRejection || + (await commitQuoteRequestState(resolved, "rejected", null)); if (rejected) await sendQuoteUpdate(ctx, request.quoteIri); return rejected; } @@ -634,18 +690,14 @@ export async function onQuoteAuthorizationDeleted( await sendQuoteUpdate(ctx, quote.iri); } -export function getQuoteAuthorizationIri( - target: Pick, - quote: Pick, -): string { - return `${target.iri}/quote_authorizations/${quote.id}`; -} - async function canAutomaticallyAcceptQuoteRequest( - target: Post, - quote: Post, + ctx: InboxContext, + target: Post & { account: Account & { owner: AccountOwner | null } }, + quote: Post & { account: Account }, + subject: Note, ): Promise { if (target.accountId === quote.accountId) return true; + // A policy match alone must not bypass visibility and block checks. if (target.visibility === "direct" || target.visibility === "private") { return false; } @@ -665,20 +717,44 @@ async function canAutomaticallyAcceptQuoteRequest( }, }); if (block != null) return false; - const policy = target.quoteApprovalPolicy ?? "public"; - if (policy === "public") return true; - if (policy === "nobody") return false; - const follow = await db.query.follows.findFirst({ - where: { - RAW: (follows, { and, eq, isNotNull }) => - and( - eq(follows.followerId, quote.accountId), - eq(follows.followingId, target.accountId), - isNotNull(follows.approved), - )!, + const followersUri = + target.account.owner == null + ? null + : ctx.getFollowersUri(target.account.owner.handle); + const decision = await quoteInteraction.evaluatePolicy(ctx, { + subject, + requester: new URL(quote.account.iri), + async matchesApprovalCollection(collection, actor) { + if (followersUri == null || collection.href !== followersUri.href) { + return false; + } + if (actor.href !== quote.account.iri) return false; + const follow = await db.query.follows.findFirst({ + where: { + RAW: (follows, { and, eq, isNotNull }) => + and( + eq(follows.followerId, quote.accountId), + eq(follows.followingId, target.accountId), + isNotNull(follows.approved), + )!, + }, + }); + return follow != null; }, }); - return follow != null; + if ( + decision.result === "denied" && + decision.reason.type === "unverifiableCollection" + ) { + // The helper swallows errors from the follower lookup; rethrow so the + // inbox retries instead of sending a permanent Reject. + throw new Error( + `Failed to check the quote approval collection ` + + `${decision.reason.collection.href}; retrying later.`, + ); + } + // Hollo has no manual approval queue, so only automatic approval counts. + return decision.result === "automatic"; } export async function onQuoteRequested( @@ -691,23 +767,53 @@ export async function onQuoteRequested( with: { account: { with: { owner: true } } }, }); if (target?.account.owner == null) return; - const instrument = await request.getInstrument({ crossOrigin: "trust" }); - if (!isPost(instrument)) return; - const quoteActorIri = instrument.attributionId?.href; - if (quoteActorIri == null) return; - if (request.actorId != null && request.actorId.href !== quoteActorIri) { + // Resolve the instrument on an independent copy so the request echoed + // back in the response keeps the sender's representation (`clone()` + // would share the instrument storage). Unlike the helper, this lookup + // does not suppress errors, so transient failures are retried. + const resolving = await QuoteRequest.fromJsonLd( + await request.toJsonLd({ contextLoader: ctx.contextLoader }), + { documentLoader: ctx.documentLoader, contextLoader: ctx.contextLoader }, + ); + let instrument: ActivityObject | null; + try { + instrument = await resolving.getInstrument({ + documentLoader: ctx.documentLoader, + contextLoader: ctx.contextLoader, + }); + } catch (error) { + if (isTransientLoaderError(error)) throw error; + inboxLogger.debug( + "Failed to resolve the QuoteRequest instrument: {error}", + { + error, + }, + ); return; } - const existingQuote = - instrument.id == null - ? null - : await db.query.posts.findFirst({ - where: { iri: { eq: instrument.id.href } }, - }); + if (instrument == null) return; + const subject = createQuoteTargetSubject( + target, + getCanQuoteRule(target, ctx), + ); + const verification = await quoteInteraction.verifyRequest(ctx, { + request: resolving.clone({ object: subject, instrument }), + documentLoader: ctx.documentLoader, + }); + if (!verification.verified) { + inboxLogger.debug("Ignoring an unverified QuoteRequest: {failure}", { + failure: verification.failure, + }); + return; + } + const quoteActorIri = verification.requester.href; + const existingQuote = await db.query.posts.findFirst({ + where: { iri: { eq: verification.interactingObjectId.href } }, + }); if (existingQuote?.quoteState === "revoked") return; const persistedQuote = await persistPost( db, - instrument, + verification.interactingObject, ctx.origin, getPersistOptions(ctx), ); @@ -726,7 +832,12 @@ export async function onQuoteRequested( : null; const accepted = wasAccepted || - (await canAutomaticallyAcceptQuoteRequest(target, persistedQuote)); + (await canAutomaticallyAcceptQuoteRequest( + ctx, + target, + persistedQuote, + subject, + )); const authorizationIri = getQuoteAuthorizationIri(target, persistedQuote); await db.transaction(async (tx) => { await tx @@ -772,20 +883,31 @@ export async function onQuoteRequested( sharedInbox: new URL(persistedQuote.account.sharedInboxUrl), }, }; + // Same ID shape Fedify assigns to activities sent without one. + const responseType = accepted ? "Accept" : "Reject"; + const responseId = new URL( + `/#${responseType}/${crypto.randomUUID()}`, + ctx.origin, + ); const response = accepted - ? new Accept({ + ? quoteInteraction.createAccept({ + mode: "polite", + id: responseId, actor: new URL(target.account.iri), - object: request, - result: new QuoteAuthorization({ - id: new URL(authorizationIri), - attribution: new URL(target.account.iri), - interactingObject: new URL(persistedQuote.iri), - interactionTarget: new URL(target.iri), - }), + request, + authorization: createQuoteAuthorization( + target, + persistedQuote, + authorizationIri, + ), + to: recipient.id, }) - : new Reject({ + : quoteInteraction.createReject({ + mode: "polite", + id: responseId, actor: new URL(target.account.iri), - object: request, + request, + to: recipient.id, }); await ctx.sendActivity( { username: target.account.owner.handle }, diff --git a/src/federation/objects.ts b/src/federation/objects.ts index 879e2128..7bb85299 100644 --- a/src/federation/objects.ts +++ b/src/federation/objects.ts @@ -28,6 +28,7 @@ import { toTemporalInstant } from "./date"; import { toEmoji } from "./emoji"; import { federation } from "./federation"; import { toObject } from "./post"; +import { createQuoteAuthorization } from "./quote"; import { createRepliesQueryCache } from "./replies-cache"; const EMOJI_REACTIONS_COLLECTION = "emojiReactions"; @@ -448,12 +449,7 @@ federation.setObjectDispatcher( }, }); if (quotePost == null) return null; - return new QuoteAuthorization({ - id: new URL(`${targetPost.iri}/quote_authorizations/${quotePost.id}`), - attribution: new URL(targetPost.account.iri), - interactingObject: new URL(quotePost.iri), - interactionTarget: new URL(targetPost.iri), - }); + return createQuoteAuthorization(targetPost, quotePost); }, ); diff --git a/src/federation/post.test.ts b/src/federation/post.test.ts index 689e4ea6..77e0dd68 100644 --- a/src/federation/post.test.ts +++ b/src/federation/post.test.ts @@ -3,6 +3,7 @@ import { Announce, Article, Collection, + type DocumentLoader, Emoji, EmojiReact, Image, @@ -1821,4 +1822,187 @@ describe("persistPost quotes", () => { expect(persisted?.quoteApprovalPolicy).toBe("followers"); }); + it("keeps the legacy path for policies without a canQuote rule", async () => { + const author = await seedRemoteAccount("quote-author"); + + const persisted = await persistPost( + db, + new Note({ + id: new URL("https://remote.test/objects/partial-policy"), + attribution: createPerson(author), + // GoToSocial may advertise only some sub-policies. + interactionPolicy: new InteractionPolicy({ + canLike: new InteractionRule({ + automaticApproval: PUBLIC_COLLECTION, + }), + }), + to: PUBLIC_COLLECTION, + content: "

Partial interaction policy

", + }), + "https://hollo.test", + ); + + expect(persisted?.quoteApprovalPolicy).toBeNull(); + }); +}); + +describe("persistPost quote authorization verification", () => { + beforeEach(async () => { + await cleanDatabase(); + }); + + const { contextLoader } = federation.createContext( + new URL("https://hollo.test"), + undefined, + ); + + function createLoader(documents: Record): DocumentLoader { + return async (url, options) => { + const document = documents[url]; + if (document != null) { + return { contextUrl: null, document, documentUrl: url }; + } + if (new URL(url).hostname.endsWith(".test")) { + throw Object.assign(new Error(`HTTP 404: ${url}`), { + response: new Response(null, { status: 404 }), + }); + } + return await contextLoader(url, options); + }; + } + + async function seedQuote(slug: string) { + const author = await seedRemoteAccount("quote-author"); + const quoter = await seedRemoteAccount("quote-quoter"); + const quotedPostId = crypto.randomUUID() as Uuid; + const quotedPostIri = `https://remote.test/objects/quoted-${slug}`; + const quotePostIri = `https://remote.test/objects/quote-${slug}`; + await db.insert(posts).values({ + id: quotedPostId, + iri: quotedPostIri, + type: "Note", + accountId: author.id, + visibility: "public", + contentHtml: "

Quoted post

", + content: "Quoted post", + published: new Date(), + }); + return { author, quoter, quotedPostId, quotedPostIri, quotePostIri }; + } + + async function authorizationDocument(values: { + id: string; + attribution: string; + interactingObject: string; + interactionTarget: string; + }): Promise { + return await new QuoteAuthorization({ + id: new URL(values.id), + attribution: new URL(values.attribution), + interactingObject: new URL(values.interactingObject), + interactionTarget: new URL(values.interactionTarget), + }).toJsonLd({ contextLoader }); + } + + it("accepts a quote whose dereferenced authorization matches", async () => { + const { author, quoter, quotedPostId, quotedPostIri, quotePostIri } = + await seedQuote("valid-auth"); + const authorizationIri = `${quotedPostIri}/quote_authorizations/1`; + const documentLoader = vi.fn( + createLoader({ + [authorizationIri]: await authorizationDocument({ + id: authorizationIri, + attribution: author.iri, + interactingObject: quotePostIri, + interactionTarget: quotedPostIri, + }), + }), + ); + + const persisted = await persistPost( + db, + new Note({ + id: new URL(quotePostIri), + attribution: createPerson(quoter), + quote: new URL(quotedPostIri), + quoteAuthorization: new URL(authorizationIri), + to: PUBLIC_COLLECTION, + content: "

Quote post

", + }), + "https://hollo.test", + { documentLoader }, + ); + + expect(documentLoader.mock.calls.map(([url]) => url)).toContain( + authorizationIri, + ); + expect(persisted?.quoteTargetId).toBe(quotedPostId); + expect(persisted?.quoteState).toBe("accepted"); + expect(persisted?.quoteAuthorizationIri).toBe(authorizationIri); + }); + + it("does not trust an embedded authorization that cannot be dereferenced", async () => { + const { author, quoter, quotedPostIri, quotePostIri } = + await seedQuote("embedded-auth"); + const authorizationIri = `${quotedPostIri}/quote_authorizations/1`; + + const persisted = await persistPost( + db, + new Note({ + id: new URL(quotePostIri), + attribution: createPerson(quoter), + quote: new URL(quotedPostIri), + // Every field matches, but nothing proves the target author made it. + quoteAuthorization: new QuoteAuthorization({ + id: new URL(authorizationIri), + attribution: new URL(author.iri), + interactingObject: new URL(quotePostIri), + interactionTarget: new URL(quotedPostIri), + }), + to: PUBLIC_COLLECTION, + content: "

Quote post

", + }), + "https://hollo.test", + { documentLoader: createLoader({}) }, + ); + + expect(persisted?.quoteState).toBe("unauthorized"); + expect(persisted?.quoteAuthorizationIri).toBeNull(); + }); + + it("rejects an authorization hosted on another origin", async () => { + const { author, quoter, quotedPostIri, quotePostIri } = + await seedQuote("cross-origin-auth"); + const authorizationIri = "https://attacker.test/quote_authorizations/1"; + const documentLoader = vi.fn( + createLoader({ + [authorizationIri]: await authorizationDocument({ + id: authorizationIri, + attribution: author.iri, + interactingObject: quotePostIri, + interactionTarget: quotedPostIri, + }), + }), + ); + + const persisted = await persistPost( + db, + new Note({ + id: new URL(quotePostIri), + attribution: createPerson(quoter), + quote: new URL(quotedPostIri), + quoteAuthorization: new URL(authorizationIri), + to: PUBLIC_COLLECTION, + content: "

Quote post

", + }), + "https://hollo.test", + { documentLoader }, + ); + + expect(documentLoader.mock.calls.map(([url]) => url)).not.toContain( + authorizationIri, + ); + expect(persisted?.quoteState).toBe("unauthorized"); + expect(persisted?.quoteAuthorizationIri).toBeNull(); + }); }); diff --git a/src/federation/post.ts b/src/federation/post.ts index b7324010..be3bcbe1 100644 --- a/src/federation/post.ts +++ b/src/federation/post.ts @@ -19,7 +19,6 @@ import { lookupObject, Note, Question, - QuoteAuthorization, type Recipient, Source, Tombstone, @@ -67,6 +66,8 @@ import { } from "./account"; import { toDate, toTemporalInstant } from "./date"; import { toEmoji } from "./emoji"; +import { federation } from "./federation"; +import { verifyQuoteAuthorization } from "./quote"; import { persistRemoteEmojiReactions } from "./reactions"; import { enqueueRemoteReplyScrape } from "./replies"; import { appendPostToTimelines } from "./timeline"; @@ -169,6 +170,7 @@ function getQuoteApprovalPolicy( async function getVerifiedQuoteAuthorizationIri( object: ASPost, + baseUrl: URL | string, quoteTargetIri: string | null, quoteTargetAccountIri: string | null, options: PersistAccountOptions, @@ -182,17 +184,19 @@ async function getVerifiedQuoteAuthorizationIri( ) { return null; } - const authorization = await object.getQuoteAuthorization({ - ...options, - crossOrigin: "trust", - suppressError: true, - }); - if (!(authorization instanceof QuoteAuthorization)) return null; - if (authorization.id?.href !== authorizationId.href) return null; - if (authorization.attributionId?.href !== quoteTargetAccountIri) return null; - if (authorization.interactingObjectId?.href !== object.id.href) return null; - if (authorization.interactionTargetId?.href !== quoteTargetIri) return null; - return authorizationId.href; + // Only the authorization IRI is used: an embedded body is never trusted, + // so the helper dereferences it and checks its origin against the author. + const verification = await verifyQuoteAuthorization( + federation.createContext(new URL(baseUrl), undefined), + { + authorizationId, + quoteIri: object.id.href, + targetIri: quoteTargetIri, + targetAuthorIri: quoteTargetAccountIri, + documentLoader: options.documentLoader, + }, + ); + return verification.verified ? verification.authorizationIri : null; } export async function persistPost( @@ -383,6 +387,7 @@ export async function persistPost( previewLink == null ? null : await fetchPreviewCard(previewLink); const quoteAuthorizationIri = await getVerifiedQuoteAuthorizationIri( object, + baseUrl, quoteTargetIri, quoteTargetAccountIri, options, @@ -1188,7 +1193,7 @@ export function toObject( }); } -function getCanQuoteRule( +export function getCanQuoteRule( post: Post & { account: Account & { owner: AccountOwner | null } }, ctx: Context, ): InteractionRule { diff --git a/src/federation/quote.ts b/src/federation/quote.ts new file mode 100644 index 00000000..f6d4fafb --- /dev/null +++ b/src/federation/quote.ts @@ -0,0 +1,150 @@ +import type { Context } from "@fedify/fedify"; +import { + type InteractionAuthorizationVerificationFailure, + type QuotePost, + quoteInteraction, +} from "@fedify/interaction-controls"; +import { + type DocumentLoader, + type InteractionRule, + InteractionPolicy, + Note, + type QuoteAuthorization, + type QuoteRequest, +} from "@fedify/vocab"; +import { getLogger } from "@logtape/logtape"; + +import type { Account, Post } from "../schema"; + +const logger = getLogger(["hollo", "federation", "quote"]); + +export function getQuoteAuthorizationIri( + target: Pick, + quote: Pick, +): string { + return `${target.iri}/quote_authorizations/${quote.id}`; +} + +export function getQuoteRequestId(quote: Pick): URL { + return new URL("#quote-request", quote.iri); +} + +export function createQuoteAuthorization( + target: Pick & { account: Pick }, + quote: Pick, + authorizationIri: string = getQuoteAuthorizationIri(target, quote), +): QuoteAuthorization { + return quoteInteraction.createAuthorization({ + id: new URL(authorizationIri), + attributedTo: new URL(target.account.iri), + interactingObject: new URL(quote.iri), + interactionTarget: new URL(target.iri), + }); +} + +export function createQuoteRequest( + quote: Pick, + actorIri: string, + targetIri: string, + instrument: QuotePost, +): QuoteRequest { + return quoteInteraction.createRequest({ + id: getQuoteRequestId(quote), + actor: new URL(actorIri), + object: new URL(targetIri), + instrument, + }); +} + +/** + * Builds the local quote target as the helper's policy subject, so policy + * evaluation and request verification work on Hollo's stored settings + * instead of a self-fetch of the local post. + */ +export function createQuoteTargetSubject( + target: Pick & { account: Pick }, + canQuote: InteractionRule, +): Note { + return new Note({ + id: new URL(target.iri), + attribution: new URL(target.account.iri), + interactionPolicy: new InteractionPolicy({ canQuote }), + }); +} + +export type QuoteAuthorizationVerification = + | { verified: true; authorizationIri: string } + | { + verified: false; + failure: InteractionAuthorizationVerificationFailure; + retryable: boolean; + }; + +/** + * Whether a document loader error is worth retrying: network failures, + * timeouts, DNS lookup failures, and 5xx/408/429 responses are; other HTTP + * errors and disallowed URLs (e.g., private addresses blocked by SSRF + * protection) are permanent. + */ +export function isTransientLoaderError(error: unknown): boolean { + if (error instanceof Error && error.name === "UrlError") { + return "reason" in error && error.reason === "dns"; + } + if ( + error instanceof Error && + "response" in error && + error.response instanceof Response + ) { + const status = error.response.status; + return status >= 500 || status === 408 || status === 429; + } + return true; +} + +/** + * Verifies a FEP-044f `QuoteAuthorization` by dereferencing its IRI. + * Embedded authorization bodies are never trusted. + */ +export async function verifyQuoteAuthorization( + ctx: Context, + options: { + authorizationId: URL; + quoteIri: string; + targetIri: string; + targetAuthorIri: string; + documentLoader?: DocumentLoader; + }, +): Promise { + const documentLoader = options.documentLoader ?? ctx.documentLoader; + const loaderErrors: unknown[] = []; + const trackingLoader: DocumentLoader = async (url, loaderOptions) => { + try { + return await documentLoader(url, loaderOptions); + } catch (error) { + loaderErrors.push(error); + throw error; + } + }; + const result = await quoteInteraction.verifyAuthorization(ctx, { + authorization: options.authorizationId, + interactingObject: new URL(options.quoteIri), + interactionTarget: new URL(options.targetIri), + attributedTo: new URL(options.targetAuthorIri), + documentLoader: trackingLoader, + }); + if (result.verified) { + return { verified: true, authorizationIri: result.authorizationId.href }; + } + const retryable = + result.failure.category === "unverifiable" && + loaderErrors.some(isTransientLoaderError); + logger.debug( + "Failed to verify the quote authorization {authorizationId}: {failure}", + { + authorizationId: options.authorizationId.href, + failure: result.failure, + retryable, + }, + ); + return { verified: false, failure: result.failure, retryable }; +}