Skip to content

Commit 3183bef

Browse files
committed
Validate quote request responses
Accept and Reject responses for quote requests now require the responder to match the quoted post author before mutating quote state. Responses that use the stored QuoteRequest IRI form are also resolved from the local pending quote, so peers do not have to embed the QuoteRequest object. Fixes #457 (comment) Fixes #457 (comment) Assisted-by: Codex:gpt-5.5
1 parent a447a69 commit 3183bef

3 files changed

Lines changed: 147 additions & 36 deletions

File tree

src/federation/inbox.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,62 @@ describe("quote request lifecycle", () => {
251251
expect(quoted?.quotesCount).toBe(1);
252252
});
253253

254+
it("marks a pending quote accepted from Accept<QuoteRequest IRI>", async () => {
255+
expect.assertions(3);
256+
257+
const seeded = await seedPendingQuote();
258+
const authorizationIri = `${seeded.quotedPostIri}/quote_authorizations/${seeded.quotePostId}`;
259+
const accept = new Accept({
260+
actor: new URL("https://hollo.test/@quote-author"),
261+
object: new URL(`${seeded.quotePostIri}#quote-request`),
262+
result: new URL(authorizationIri),
263+
});
264+
265+
await onQuoteRequestAccepted(ctx, accept);
266+
267+
const quote = await db.query.posts.findFirst({
268+
where: eq(posts.id, seeded.quotePostId),
269+
});
270+
const quoted = await db.query.posts.findFirst({
271+
where: eq(posts.id, seeded.quotedPostId),
272+
});
273+
expect(quote?.quoteState).toBe("accepted");
274+
expect(quote?.quoteAuthorizationIri).toBe(authorizationIri);
275+
expect(quoted?.quotesCount).toBe(1);
276+
});
277+
278+
it("ignores quote request responses from another actor", async () => {
279+
expect.assertions(3);
280+
281+
const seeded = await seedPendingQuote();
282+
const authorizationIri = `${seeded.quotedPostIri}/quote_authorizations/${seeded.quotePostId}`;
283+
const accept = new Accept({
284+
actor: new URL("https://hollo.test/@quote-quoter"),
285+
object: new QuoteRequest({
286+
object: new URL(seeded.quotedPostIri),
287+
instrument: new URL(seeded.quotePostIri),
288+
}),
289+
result: new URL(authorizationIri),
290+
});
291+
const reject = new Reject({
292+
actor: new URL("https://hollo.test/@quote-quoter"),
293+
object: new URL(`${seeded.quotePostIri}#quote-request`),
294+
});
295+
296+
await onQuoteRequestAccepted(ctx, accept);
297+
await onQuoteRequestRejected(ctx, reject);
298+
299+
const quote = await db.query.posts.findFirst({
300+
where: eq(posts.id, seeded.quotePostId),
301+
});
302+
const quoted = await db.query.posts.findFirst({
303+
where: eq(posts.id, seeded.quotedPostId),
304+
});
305+
expect(quote?.quoteState).toBe("pending");
306+
expect(quote?.quoteAuthorizationIri).toBeNull();
307+
expect(quoted?.quotesCount).toBe(0);
308+
});
309+
254310
it("marks a pending quote rejected from Reject<QuoteRequest>", async () => {
255311
expect.assertions(2);
256312

@@ -275,6 +331,27 @@ describe("quote request lifecycle", () => {
275331
expect(quoted?.quotesCount).toBe(0);
276332
});
277333

334+
it("marks a pending quote rejected from Reject<QuoteRequest IRI>", async () => {
335+
expect.assertions(2);
336+
337+
const seeded = await seedPendingQuote();
338+
const reject = new Reject({
339+
actor: new URL("https://hollo.test/@quote-author"),
340+
object: new URL(`${seeded.quotePostIri}#quote-request`),
341+
});
342+
343+
await onQuoteRequestRejected(ctx, reject);
344+
345+
const quote = await db.query.posts.findFirst({
346+
where: eq(posts.id, seeded.quotePostId),
347+
});
348+
const quoted = await db.query.posts.findFirst({
349+
where: eq(posts.id, seeded.quotedPostId),
350+
});
351+
expect(quote?.quoteState).toBe("rejected");
352+
expect(quoted?.quotesCount).toBe(0);
353+
});
354+
278355
it("marks an accepted quote revoked when its authorization is deleted", async () => {
279356
expect.assertions(2);
280357

src/federation/inbox.ts

Lines changed: 66 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -380,39 +380,74 @@ export async function onFollowRejected(
380380
}
381381
}
382382

383-
async function getQuoteRequestFromActivity(
383+
type QuoteRequestReference = {
384+
quoteIri: string;
385+
targetIri: string | null;
386+
};
387+
388+
function getQuoteIriFromQuoteRequestId(requestId: URL): string {
389+
const quoteIri = new URL(requestId.href);
390+
if (quoteIri.hash === "#quote-request") quoteIri.hash = "";
391+
return quoteIri.href;
392+
}
393+
394+
async function getQuoteRequestReferenceFromActivity(
384395
activity: Accept | Reject,
385-
): Promise<QuoteRequest | null> {
386-
const object = await activity.getObject({ crossOrigin: "trust" });
387-
return object instanceof QuoteRequest ? object : null;
396+
): Promise<QuoteRequestReference | null> {
397+
const object = await activity.getObject({
398+
crossOrigin: "trust",
399+
suppressError: true,
400+
});
401+
if (object instanceof QuoteRequest) {
402+
const quoteIri = object.instrumentId?.href;
403+
if (quoteIri == null) return null;
404+
return {
405+
quoteIri,
406+
targetIri: object.objectId?.href ?? null,
407+
};
408+
}
409+
if (activity.objectId == null) return null;
410+
return {
411+
quoteIri: getQuoteIriFromQuoteRequestId(activity.objectId),
412+
targetIri: null,
413+
};
388414
}
389415

390416
async function updateQuoteRequestState(
391-
request: QuoteRequest,
417+
request: QuoteRequestReference,
418+
responderIri: string | null,
392419
state: "accepted" | "rejected",
393420
quoteAuthorizationIri: string | null,
394-
): Promise<void> {
395-
const quoteIri = request.instrumentId?.href;
396-
if (quoteIri == null) return;
421+
): Promise<boolean> {
397422
const quote = await db.query.posts.findFirst({
398-
where: eq(posts.iri, quoteIri),
423+
where: eq(posts.iri, request.quoteIri),
424+
with: { quoteTarget: { with: { account: true } } },
399425
});
400-
if (quote == null) return;
426+
if (quote == null) return false;
427+
if (quote.quoteState !== "pending") return false;
401428
const target =
402-
request.objectId == null
403-
? null
429+
request.targetIri == null
430+
? quote.quoteTarget
404431
: await db.query.posts.findFirst({
405-
where: eq(posts.iri, request.objectId.href),
432+
where: eq(posts.iri, request.targetIri),
433+
with: { account: true },
406434
});
435+
if (target == null) return false;
436+
if (responderIri == null || responderIri !== target.account.iri) {
437+
return false;
438+
}
439+
if (quote.quoteTargetIri == null) return false;
440+
if (request.targetIri != null && quote.quoteTargetIri !== request.targetIri) {
441+
return false;
442+
}
407443
await db.transaction(async (tx) => {
408444
await tx
409445
.update(posts)
410446
.set({
411447
quoteState: state,
412448
quoteAuthorizationIri,
413-
quoteTargetId: target?.id ?? quote.quoteTargetId,
414-
quoteTargetIri:
415-
request.objectId?.href ?? quote.quoteTargetIri ?? target?.iri ?? null,
449+
quoteTargetId: target.id,
450+
quoteTargetIri: request.targetIri ?? quote.quoteTargetIri ?? target.iri,
416451
updated: new Date(),
417452
})
418453
.where(eq(posts.id, quote.id));
@@ -427,16 +462,18 @@ async function updateQuoteRequestState(
427462
.where(eq(posts.id, target.id));
428463
}
429464
});
465+
return true;
430466
}
431467

432468
export async function onQuoteRequestAccepted(
433469
_ctx: InboxContext<void>,
434470
accept: Accept,
435-
): Promise<void> {
436-
const request = await getQuoteRequestFromActivity(accept);
437-
if (request == null) return;
438-
await updateQuoteRequestState(
471+
): Promise<boolean> {
472+
const request = await getQuoteRequestReferenceFromActivity(accept);
473+
if (request == null) return false;
474+
return await updateQuoteRequestState(
439475
request,
476+
accept.actorId?.href ?? null,
440477
"accepted",
441478
accept.resultId?.href ?? null,
442479
);
@@ -445,10 +482,15 @@ export async function onQuoteRequestAccepted(
445482
export async function onQuoteRequestRejected(
446483
_ctx: InboxContext<void>,
447484
reject: Reject,
448-
): Promise<void> {
449-
const request = await getQuoteRequestFromActivity(reject);
450-
if (request == null) return;
451-
await updateQuoteRequestState(request, "rejected", null);
485+
): Promise<boolean> {
486+
const request = await getQuoteRequestReferenceFromActivity(reject);
487+
if (request == null) return false;
488+
return await updateQuoteRequestState(
489+
request,
490+
reject.actorId?.href ?? null,
491+
"rejected",
492+
null,
493+
);
452494
}
453495

454496
export async function onQuoteAuthorizationDeleted(

src/federation/index.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -87,20 +87,12 @@ federation
8787
})
8888
.on(Follow, onFollowed)
8989
.on(Accept, async (ctx, accept) => {
90-
const object = await accept.getObject({ crossOrigin: "trust" });
91-
if (object instanceof QuoteRequest) {
92-
await onQuoteRequestAccepted(ctx, accept);
93-
} else {
94-
await onFollowAccepted(ctx, accept);
95-
}
90+
if (await onQuoteRequestAccepted(ctx, accept)) return;
91+
await onFollowAccepted(ctx, accept);
9692
})
9793
.on(Reject, async (ctx, reject) => {
98-
const object = await reject.getObject({ crossOrigin: "trust" });
99-
if (object instanceof QuoteRequest) {
100-
await onQuoteRequestRejected(ctx, reject);
101-
} else {
102-
await onFollowRejected(ctx, reject);
103-
}
94+
if (await onQuoteRequestRejected(ctx, reject)) return;
95+
await onFollowRejected(ctx, reject);
10496
})
10597
.on(QuoteRequest, onQuoteRequested)
10698
.on(Create, async (ctx, create) => {

0 commit comments

Comments
 (0)