From c7bd0ba6d82a41b99cc396e3d69e9561ebfb5e50 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 5 May 2026 18:40:52 +0900 Subject: [PATCH] Ignore posts with out-of-range timestamps Incoming ActivityPub posts whose published or updated timestamp is more than 12 hours in the future are now silently rejected before any remote dereferencing takes place. This prevents timeline manipulation via forged future timestamps. Posts with a published date before the Unix epoch (e.g. 1963) no longer crash the server: the UUIDv7 generator was receiving a negative millisecond value, causing an exception. The timestamp is now clamped to zero (Unix epoch) when generating the row ID. Also fixed onPostUpdated() in the federation inbox: it previously ignored the return value of persistPost(), so a rejected Update activity could still dispatch quoted_update notifications for the stale pre-update row. Fixes https://github.com/fedify-dev/hollo/issues/67 Assisted-by: Claude Code:claude-sonnet-4-6 Assisted-by: Codex:gpt-5.5 --- CHANGES.md | 11 +++++ src/federation/inbox.ts | 10 +++- src/federation/post.test.ts | 97 +++++++++++++++++++++++++++++++++++++ src/federation/post.ts | 21 ++++++-- 4 files changed, 134 insertions(+), 5 deletions(-) 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({