Skip to content

Commit 74c7047

Browse files
authored
Merge pull request #466 from dahlia/future-posts
Ignore posts with out-of-range timestamps
2 parents 5cd6f14 + c7bd0ba commit 74c7047

4 files changed

Lines changed: 134 additions & 5 deletions

File tree

CHANGES.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,24 @@ To be released.
9494
are removed; UnoCSS emits a single _src/public/uno.css_ whose
9595
URL is cache-busted by file mtime.
9696

97+
- Fixed a bug where incoming ActivityPub posts with timestamps more than
98+
12 hours in the future were accepted and stuck to the top of the
99+
federated timeline, enabling timeline manipulation via forged timestamps.
100+
Such posts are now silently ignored. [[#67], [#466]]
101+
102+
- Fixed a crash when persisting ActivityPub posts with a `published` date
103+
before the Unix epoch (January 1, 1970), which caused `uuidv7()` to
104+
receive a negative timestamp. [[#67], [#466]]
105+
97106
- Upgraded Fedify to 2.2.0.
98107

99108
[FEP-044f]: https://w3id.org/fep/044f
109+
[#67]: https://github.com/fedify-dev/hollo/issues/67
100110
[#457]: https://github.com/fedify-dev/hollo/pull/457
101111
[#458]: https://github.com/fedify-dev/hollo/pull/458
102112
[#459]: https://github.com/fedify-dev/hollo/pull/459
103113
[#460]: https://github.com/fedify-dev/hollo/pull/460
114+
[#466]: https://github.com/fedify-dev/hollo/pull/466
104115

105116

106117
Version 0.8.1

src/federation/inbox.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -974,8 +974,14 @@ export async function onPostUpdated(
974974
})
975975
: null;
976976

977-
// Persist the updated post
978-
await persistPost(db, object, ctx.origin, getPersistOptions(ctx));
977+
// Persist the updated post; null means the post was rejected (e.g. future timestamp)
978+
const updatedPost = await persistPost(
979+
db,
980+
object,
981+
ctx.origin,
982+
getPersistOptions(ctx),
983+
);
984+
if (updatedPost == null) return;
979985

980986
// Create quoted_update notifications for users who quoted this post
981987
if (existingPost != null) {

src/federation/post.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { createAccount } from "../../tests/helpers/oauth";
1717
import db from "../db";
1818
import { accounts, follows, instances, posts, timelinePosts } from "../schema";
1919
import type { Uuid } from "../uuid";
20+
import { toTemporalInstant } from "./date";
2021
import { onPostShared } from "./inbox";
2122
import { persistPost, persistSharingPost, toObject } from "./post";
2223

@@ -381,6 +382,102 @@ describe("persistPost", () => {
381382
expect(post?.repliesCount).toBe(3);
382383
expect(jobs.map((job) => job.repliesIri)).toEqual([repliesIri]);
383384
});
385+
386+
it("ignores posts with a published date more than 12 hours in the future", async () => {
387+
expect.assertions(3);
388+
const author = await seedRemoteAccount("author");
389+
const futureDate = new Date(Date.now() + 13 * 60 * 60 * 1000);
390+
const iri = "https://remote.test/@author/posts/future";
391+
392+
const result = await persistPost(
393+
db,
394+
new Note({
395+
id: new URL(iri),
396+
attribution: createPerson(author),
397+
content: "<p>From the future</p>",
398+
to: PUBLIC_COLLECTION,
399+
published: toTemporalInstant(futureDate),
400+
}),
401+
"https://hollo.test",
402+
{ account: author },
403+
);
404+
const row = await db.query.posts.findFirst({ where: eq(posts.iri, iri) });
405+
const timelineRows = await db.query.timelinePosts.findMany();
406+
407+
expect(result).toBeNull();
408+
expect(row).toBeUndefined();
409+
expect(timelineRows).toHaveLength(0);
410+
});
411+
412+
it("ignores posts with an updated date more than 12 hours in the future", async () => {
413+
expect.assertions(3);
414+
const author = await seedRemoteAccount("author");
415+
const futureDate = new Date(Date.now() + 13 * 60 * 60 * 1000);
416+
const iri = "https://remote.test/@author/posts/future-updated";
417+
418+
const result = await persistPost(
419+
db,
420+
new Note({
421+
id: new URL(iri),
422+
attribution: createPerson(author),
423+
content: "<p>Updated in the future</p>",
424+
to: PUBLIC_COLLECTION,
425+
updated: toTemporalInstant(futureDate),
426+
}),
427+
"https://hollo.test",
428+
{ account: author },
429+
);
430+
const row = await db.query.posts.findFirst({ where: eq(posts.iri, iri) });
431+
const timelineRows = await db.query.timelinePosts.findMany();
432+
433+
expect(result).toBeNull();
434+
expect(row).toBeUndefined();
435+
expect(timelineRows).toHaveLength(0);
436+
});
437+
438+
it("accepts posts with a published date slightly in the future (within 12 hours)", async () => {
439+
expect.assertions(1);
440+
const author = await seedRemoteAccount("author");
441+
const slightlyFutureDate = new Date(Date.now() + 11 * 60 * 60 * 1000);
442+
443+
const result = await persistPost(
444+
db,
445+
new Note({
446+
id: new URL("https://remote.test/@author/posts/near-future"),
447+
attribution: createPerson(author),
448+
content: "<p>Slightly future</p>",
449+
to: PUBLIC_COLLECTION,
450+
published: toTemporalInstant(slightlyFutureDate),
451+
}),
452+
"https://hollo.test",
453+
{ account: author },
454+
);
455+
456+
expect(result).not.toBeNull();
457+
});
458+
459+
it("accepts posts with a pre-epoch timestamp without crashing", async () => {
460+
expect.assertions(2);
461+
const author = await seedRemoteAccount("author");
462+
// 1963-11-22, before Unix epoch (1970-01-01)
463+
const preEpochDate = new Date("1963-11-22T12:30:00Z");
464+
465+
const result = await persistPost(
466+
db,
467+
new Note({
468+
id: new URL("https://remote.test/@author/posts/old-post"),
469+
attribution: createPerson(author),
470+
content: "<p>A very old post</p>",
471+
to: PUBLIC_COLLECTION,
472+
published: toTemporalInstant(preEpochDate),
473+
}),
474+
"https://hollo.test",
475+
{ account: author },
476+
);
477+
478+
expect(result).not.toBeNull();
479+
expect(result?.published).toEqual(preEpochDate);
480+
});
384481
});
385482

386483
describe("toObject", () => {

src/federation/post.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,21 @@ export async function persistPost(
187187
if (existingPost != null && existingPost.account.owner != null) {
188188
return existingPost;
189189
}
190+
const publishedRaw = toDate(object.published);
191+
const updatedRaw = toDate(object.updated);
192+
const now = Date.now();
193+
const twelveHoursMs = 12 * 60 * 60 * 1000;
194+
if (
195+
(publishedRaw != null && +publishedRaw > now + twelveHoursMs) ||
196+
(updatedRaw != null && +updatedRaw > now + twelveHoursMs)
197+
) {
198+
logger.debug(
199+
"Ignoring post {iri} with a timestamp too far in the future: " +
200+
"published={published}, updated={updated}",
201+
{ iri: object.id.href, published: publishedRaw, updated: updatedRaw },
202+
);
203+
return null;
204+
}
190205
const actor = await object.getAttribution(options);
191206
logger.debug("Fetched actor: {actor}", { actor });
192207
if (!isActor(actor)) return null;
@@ -325,8 +340,8 @@ export async function persistPost(
325340
const preservedQuoteAuthorizationIri =
326341
quoteAuthorizationIri ??
327342
(preserveAcceptedQuote ? existingPost.quoteAuthorizationIri : null);
328-
const published = toDate(object.published);
329-
const updated = toDate(object.updated) ?? published ?? new Date();
343+
const published = publishedRaw;
344+
const updated = updatedRaw ?? published ?? new Date();
330345
const values = {
331346
type:
332347
object instanceof Question
@@ -380,7 +395,7 @@ export async function persistPost(
380395
.values({
381396
...values,
382397
repliesCount: existingPost?.repliesCount ?? 0,
383-
id: uuidv7(+(published ?? updated)),
398+
id: uuidv7(Math.max(0, +(published ?? updated))),
384399
iri: object.id.href,
385400
})
386401
.onConflictDoUpdate({

0 commit comments

Comments
 (0)