Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions src/federation/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
97 changes: 97 additions & 0 deletions src/federation/post.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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: "<p>From the future</p>",
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: "<p>Updated in the future</p>",
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: "<p>Slightly future</p>",
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: "<p>A very old post</p>",
to: PUBLIC_COLLECTION,
published: toTemporalInstant(preEpochDate),
}),
"https://hollo.test",
{ account: author },
);

expect(result).not.toBeNull();
expect(result?.published).toEqual(preEpochDate);
});
});

describe("toObject", () => {
Expand Down
21 changes: 18 additions & 3 deletions src/federation/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down
Loading