Skip to content

Commit 6e1fe99

Browse files
committed
Restrict Atom feeds to public posts
Limit profile Atom feeds to public and unlisted posts so followers-only posts and direct messages are no longer exposed through a public endpoint. Add a regression test for /@handle/atom.xml and document the security fix in CHANGES.md. Fixes #440 Assisted-by: Codex:gpt-5.4
1 parent 2094797 commit 6e1fe99

3 files changed

Lines changed: 73 additions & 1 deletion

File tree

CHANGES.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ Version 0.7.11
66

77
To be released.
88

9+
- Fixed a security vulnerability where a public profile's Atom feed could
10+
expose followers-only posts and direct messages. The Atom feed now only
11+
serves public and unlisted posts. [[#440]]
12+
13+
[#440]: https://github.com/fedify-dev/hollo/issues/440
14+
915

1016
Version 0.7.10
1117
--------------

src/pages/profile/index.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { Hono } from "hono";
2+
import { beforeEach, describe, expect, it } from "vitest";
3+
import { cleanDatabase } from "../../../tests/helpers";
4+
import { createAccount } from "../../../tests/helpers/oauth";
5+
import db from "../../db";
6+
import { type PostVisibility, posts } from "../../schema";
7+
import type { Uuid } from "../../uuid";
8+
import profile from "./index";
9+
10+
const app = new Hono();
11+
12+
app.route("/:handle{@[^/]+}", profile);
13+
14+
async function createPost(
15+
accountId: string,
16+
visibility: PostVisibility,
17+
content: string,
18+
) {
19+
const id = crypto.randomUUID() as Uuid;
20+
await db.insert(posts).values({
21+
id,
22+
iri: `https://hollo.test/@hollo/${id}`,
23+
type: "Note",
24+
accountId: accountId as Uuid,
25+
visibility,
26+
contentHtml: `<p>${content}</p>`,
27+
content,
28+
url: `https://hollo.test/@hollo/${id}`,
29+
published: new Date(),
30+
});
31+
}
32+
33+
describe("Profile Atom feed", () => {
34+
let accountId: string;
35+
36+
beforeEach(async () => {
37+
await cleanDatabase();
38+
const account = await createAccount({ generateKeyPair: true });
39+
accountId = account.id;
40+
});
41+
42+
it("only includes public and unlisted posts", async () => {
43+
expect.assertions(6);
44+
45+
await createPost(accountId, "public", "public post");
46+
await createPost(accountId, "unlisted", "unlisted post");
47+
await createPost(accountId, "private", "followers-only post");
48+
await createPost(accountId, "direct", "secret DM");
49+
50+
const response = await app.request("/@hollo/atom.xml");
51+
52+
expect(response.status).toBe(200);
53+
expect(response.headers.get("content-type")).toContain(
54+
"application/atom+xml",
55+
);
56+
57+
const body = await response.text();
58+
expect(body).toContain("public post");
59+
expect(body).toContain("unlisted post");
60+
expect(body).not.toContain("followers-only post");
61+
expect(body).not.toContain("secret DM");
62+
});
63+
});

src/pages/profile/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,10 @@ profile.get("/atom.xml", async (c) => {
321321
if (owner == null) return c.notFound();
322322
const postList = await db.query.posts.findMany({
323323
with: { account: true },
324-
where: eq(posts.accountId, owner.id),
324+
where: and(
325+
eq(posts.accountId, owner.id),
326+
or(eq(posts.visibility, "public"), eq(posts.visibility, "unlisted")),
327+
),
325328
orderBy: desc(posts.published),
326329
limit: 100,
327330
});

0 commit comments

Comments
 (0)