diff --git a/CHANGES.md b/CHANGES.md index 7c73709d..a407ca4b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -94,13 +94,24 @@ To be released. are removed; UnoCSS emits a single _src/public/uno.css_ whose URL is cache-busted by file mtime. + - Fixed a bug where incoming ActivityPub posts with timestamps more than + 12 hours in the future were accepted and stuck to the top of the + federated timeline, enabling timeline manipulation via forged timestamps. + Such posts are now silently ignored. [[#67], [#466]] + + - Fixed a crash when persisting ActivityPub posts with a `published` date + before the Unix epoch (January 1, 1970), which caused `uuidv7()` to + receive a negative timestamp. [[#67], [#466]] + - Upgraded Fedify to 2.2.0. [FEP-044f]: https://w3id.org/fep/044f +[#67]: https://github.com/fedify-dev/hollo/issues/67 [#457]: https://github.com/fedify-dev/hollo/pull/457 [#458]: https://github.com/fedify-dev/hollo/pull/458 [#459]: https://github.com/fedify-dev/hollo/pull/459 [#460]: https://github.com/fedify-dev/hollo/pull/460 +[#466]: https://github.com/fedify-dev/hollo/pull/466 Version 0.8.1 diff --git a/src/federation/inbox.ts b/src/federation/inbox.ts index b91c87c2..16ada79d 100644 --- a/src/federation/inbox.ts +++ b/src/federation/inbox.ts @@ -974,8 +974,14 @@ export async function onPostUpdated( }) : null; - // Persist the updated post - await persistPost(db, object, ctx.origin, getPersistOptions(ctx)); + // Persist the updated post; null means the post was rejected (e.g. future timestamp) + const updatedPost = await persistPost( + db, + object, + ctx.origin, + getPersistOptions(ctx), + ); + if (updatedPost == null) return; // Create quoted_update notifications for users who quoted this post if (existingPost != null) { diff --git a/src/federation/post.test.ts b/src/federation/post.test.ts index 7ec9720e..9b00fa90 100644 --- a/src/federation/post.test.ts +++ b/src/federation/post.test.ts @@ -17,6 +17,7 @@ import { createAccount } from "../../tests/helpers/oauth"; import db from "../db"; import { accounts, follows, instances, posts, timelinePosts } from "../schema"; import type { Uuid } from "../uuid"; +import { toTemporalInstant } from "./date"; import { onPostShared } from "./inbox"; import { persistPost, persistSharingPost, toObject } from "./post"; @@ -381,6 +382,102 @@ describe("persistPost", () => { expect(post?.repliesCount).toBe(3); expect(jobs.map((job) => job.repliesIri)).toEqual([repliesIri]); }); + + it("ignores posts with a published date more than 12 hours in the future", async () => { + expect.assertions(3); + const author = await seedRemoteAccount("author"); + const futureDate = new Date(Date.now() + 13 * 60 * 60 * 1000); + const iri = "https://remote.test/@author/posts/future"; + + const result = await persistPost( + db, + new Note({ + id: new URL(iri), + attribution: createPerson(author), + content: "
From the future
", + to: PUBLIC_COLLECTION, + published: toTemporalInstant(futureDate), + }), + "https://hollo.test", + { account: author }, + ); + const row = await db.query.posts.findFirst({ where: eq(posts.iri, iri) }); + const timelineRows = await db.query.timelinePosts.findMany(); + + expect(result).toBeNull(); + expect(row).toBeUndefined(); + expect(timelineRows).toHaveLength(0); + }); + + it("ignores posts with an updated date more than 12 hours in the future", async () => { + expect.assertions(3); + const author = await seedRemoteAccount("author"); + const futureDate = new Date(Date.now() + 13 * 60 * 60 * 1000); + const iri = "https://remote.test/@author/posts/future-updated"; + + const result = await persistPost( + db, + new Note({ + id: new URL(iri), + attribution: createPerson(author), + content: "Updated in the future
", + to: PUBLIC_COLLECTION, + updated: toTemporalInstant(futureDate), + }), + "https://hollo.test", + { account: author }, + ); + const row = await db.query.posts.findFirst({ where: eq(posts.iri, iri) }); + const timelineRows = await db.query.timelinePosts.findMany(); + + expect(result).toBeNull(); + expect(row).toBeUndefined(); + expect(timelineRows).toHaveLength(0); + }); + + it("accepts posts with a published date slightly in the future (within 12 hours)", async () => { + expect.assertions(1); + const author = await seedRemoteAccount("author"); + const slightlyFutureDate = new Date(Date.now() + 11 * 60 * 60 * 1000); + + const result = await persistPost( + db, + new Note({ + id: new URL("https://remote.test/@author/posts/near-future"), + attribution: createPerson(author), + content: "Slightly future
", + to: PUBLIC_COLLECTION, + published: toTemporalInstant(slightlyFutureDate), + }), + "https://hollo.test", + { account: author }, + ); + + expect(result).not.toBeNull(); + }); + + it("accepts posts with a pre-epoch timestamp without crashing", async () => { + expect.assertions(2); + const author = await seedRemoteAccount("author"); + // 1963-11-22, before Unix epoch (1970-01-01) + const preEpochDate = new Date("1963-11-22T12:30:00Z"); + + const result = await persistPost( + db, + new Note({ + id: new URL("https://remote.test/@author/posts/old-post"), + attribution: createPerson(author), + content: "A very old post
", + to: PUBLIC_COLLECTION, + published: toTemporalInstant(preEpochDate), + }), + "https://hollo.test", + { account: author }, + ); + + expect(result).not.toBeNull(); + expect(result?.published).toEqual(preEpochDate); + }); }); describe("toObject", () => { diff --git a/src/federation/post.ts b/src/federation/post.ts index eb25e04b..837c7b0e 100644 --- a/src/federation/post.ts +++ b/src/federation/post.ts @@ -187,6 +187,21 @@ export async function persistPost( if (existingPost != null && existingPost.account.owner != null) { return existingPost; } + const publishedRaw = toDate(object.published); + const updatedRaw = toDate(object.updated); + const now = Date.now(); + const twelveHoursMs = 12 * 60 * 60 * 1000; + if ( + (publishedRaw != null && +publishedRaw > now + twelveHoursMs) || + (updatedRaw != null && +updatedRaw > now + twelveHoursMs) + ) { + logger.debug( + "Ignoring post {iri} with a timestamp too far in the future: " + + "published={published}, updated={updated}", + { iri: object.id.href, published: publishedRaw, updated: updatedRaw }, + ); + return null; + } const actor = await object.getAttribution(options); logger.debug("Fetched actor: {actor}", { actor }); if (!isActor(actor)) return null; @@ -325,8 +340,8 @@ export async function persistPost( const preservedQuoteAuthorizationIri = quoteAuthorizationIri ?? (preserveAcceptedQuote ? existingPost.quoteAuthorizationIri : null); - const published = toDate(object.published); - const updated = toDate(object.updated) ?? published ?? new Date(); + const published = publishedRaw; + const updated = updatedRaw ?? published ?? new Date(); const values = { type: object instanceof Question @@ -380,7 +395,7 @@ export async function persistPost( .values({ ...values, repliesCount: existingPost?.repliesCount ?? 0, - id: uuidv7(+(published ?? updated)), + id: uuidv7(Math.max(0, +(published ?? updated))), iri: object.id.href, }) .onConflictDoUpdate({