From 9c9b3d3fe0ebebbbe7bc69ec0eea77c6bced137a Mon Sep 17 00:00:00 2001 From: SASAGAWA Kiyoshi Date: Wed, 14 Oct 2026 21:26:29 +0900 Subject: [PATCH] fix(security): enforce note visibility for followers-only and specified notes Followers-only and specified (DM) notes were readable by anyone through several endpoints. Introduce Misskey-compatible visibility rules and apply them everywhere notes are returned. - Add lib/noteVisibility.ts (canViewNote, isNoteVisibleTo, isPubliclyVisible) - Add optional TimelineOptions.visibleTo filter to INoteRepository, implemented in pg, mysql and sqlite repositories (EXISTS on follows, mentions match, reply-target author) - /api/notes/show, /user-notes, /replies: viewer-aware filtering (404 / empty) - Home, social and list timelines: filter non-visible notes (list membership does not imply following); hydrated reply/renote targets are filtered too - /api/mentions: apply viewer filter - AP /notes/:id and outbox: serve only public/home, non-local-only notes with correct addressing for home (unlisted) - oEmbed and reaction endpoints: hide non-public / non-visible notes - Reject replies to invisible notes and renotes of others' non-public notes - Stream pushes no longer embed non-public reply/renote targets; list notifications skip owners who cannot see the note - Tests: unit tests for visibility rules, route-level tests, sqlite integration tests for the repository filter Claude-Session: https://claude.ai/code/session_01Y29fBeRP344FME6de48wUv --- .../repositories/INoteRepository.ts | 23 ++ packages/backend/src/lib/noteVisibility.ts | 142 +++++++ .../repositories/mysql/MysqlNoteRepository.ts | 72 +++- .../repositories/pg/PostgresNoteRepository.ts | 76 +++- .../sqlite/SqliteNoteRepository.ts | 72 +++- packages/backend/src/routes/ap/note.ts | 9 + packages/backend/src/routes/ap/outbox.ts | 84 ++-- packages/backend/src/routes/mentions.ts | 2 + packages/backend/src/routes/notes.ts | 8 +- packages/backend/src/routes/oembed.ts | 4 +- packages/backend/src/routes/reactions.ts | 39 ++ packages/backend/src/services/ListService.ts | 10 +- packages/backend/src/services/NoteService.ts | 151 ++++++- .../integration/sqlite-repositories.test.ts | 146 ++++++- .../tests/unit/NoteVisibilityRoutes.test.ts | 385 ++++++++++++++++++ .../src/tests/unit/noteVisibility.test.ts | 184 +++++++++ 16 files changed, 1348 insertions(+), 59 deletions(-) create mode 100644 packages/backend/src/lib/noteVisibility.ts create mode 100644 packages/backend/src/tests/unit/NoteVisibilityRoutes.test.ts create mode 100644 packages/backend/src/tests/unit/noteVisibility.test.ts diff --git a/packages/backend/src/interfaces/repositories/INoteRepository.ts b/packages/backend/src/interfaces/repositories/INoteRepository.ts index 6a3d0df2..28f528e8 100644 --- a/packages/backend/src/interfaces/repositories/INoteRepository.ts +++ b/packages/backend/src/interfaces/repositories/INoteRepository.ts @@ -1,10 +1,33 @@ import type { Note } from "shared"; +/** + * Viewer-aware visibility filter for note list queries. + * + * When supplied, repositories only return notes the viewer is allowed to see + * (see `lib/noteVisibility.ts` for the rules): + * - `public` / `home`: always + * - `followers`: author, followers of the author (accepted follow), + * mentioned users, or the author of the replied-to note + * - `specified`: author or recipients (stored in `mentions`) + * + * A `viewerId` of `null` represents an unauthenticated viewer and restricts + * results to `public` / `home` notes. + */ +export interface NoteVisibilityFilter { + /** Viewing user ID, or null for unauthenticated viewers */ + viewerId: string | null; +} + export interface TimelineOptions { limit?: number; sinceId?: string; untilId?: string; userIds?: string[]; + /** + * Restrict results to notes visible to this viewer. + * When omitted, no visibility filtering is applied (trusted/internal use only). + */ + visibleTo?: NoteVisibilityFilter; } /** diff --git a/packages/backend/src/lib/noteVisibility.ts b/packages/backend/src/lib/noteVisibility.ts new file mode 100644 index 00000000..caf08259 --- /dev/null +++ b/packages/backend/src/lib/noteVisibility.ts @@ -0,0 +1,142 @@ +/** + * Note Visibility Rules + * + * Central implementation of Misskey-compatible note visibility semantics. + * Every code path that returns note content to a viewer (API, ActivityPub, + * oEmbed, streaming) must go through these helpers (or the equivalent + * repository-level `visibleTo` filter) so that followers-only and + * specified (direct) notes are never exposed to unauthorized viewers. + * + * Rules: + * - `public` / `home`: visible to anyone, including unauthenticated viewers + * - `followers`: visible to the author, users who follow the author, + * users mentioned in the note, and the author of the note being replied to + * - `specified`: visible to the author and the recipients + * (stored in the note's `mentions` array, which holds `visibleUserIds`) + * + * @module lib/noteVisibility + */ + +import type { IFollowRepository } from "../interfaces/repositories/IFollowRepository.js"; +import type { INoteRepository } from "../interfaces/repositories/INoteRepository.js"; + +/** + * Minimal note shape required to evaluate visibility. + */ +export interface NoteVisibilityFields { + /** Author user ID */ + userId: string; + /** Visibility level (public, home, followers, specified) */ + visibility: string; + /** Mentioned user IDs (for `specified` notes, the recipient user IDs) */ + mentions?: string[] | null; + /** ID of the note this note replies to */ + replyId?: string | null; +} + +/** + * Additional relationship facts between the viewer and the note, + * used to evaluate `followers` visibility. + */ +export interface NoteViewerRelation { + /** Whether the viewer follows the note author */ + isFollowing: boolean; + /** Whether the viewer is the author of the note being replied to */ + isReplyTargetAuthor?: boolean; +} + +/** + * Check whether a note is visible to everyone (including unauthenticated viewers). + * + * @param note - Note to check + * @returns True for `public` and `home` notes + */ +export function isPubliclyVisible(note: Pick): boolean { + return note.visibility === "public" || note.visibility === "home"; +} + +/** + * Pure visibility check for a note and a viewer. + * + * @param note - Note to check + * @param viewerId - Viewing user ID, or null for unauthenticated viewers + * @param relation - Whether the viewer follows the author (boolean shorthand), + * or a full relation object + * @returns True if the viewer may see the note + * + * @example + * ```typescript + * const isFollowing = await followRepository.exists(viewerId, note.userId); + * if (!canViewNote(note, viewerId, isFollowing)) { + * return c.json({ error: "Note not found" }, 404); + * } + * ``` + */ +export function canViewNote( + note: NoteVisibilityFields, + viewerId: string | null | undefined, + relation: boolean | NoteViewerRelation = false, +): boolean { + if (isPubliclyVisible(note)) return true; + if (!viewerId) return false; + if (note.userId === viewerId) return true; + + const rel: NoteViewerRelation = + typeof relation === "boolean" ? { isFollowing: relation } : relation; + const isMentioned = (note.mentions ?? []).includes(viewerId); + + if (note.visibility === "specified") { + return isMentioned; + } + + if (note.visibility === "followers") { + return rel.isFollowing || isMentioned || rel.isReplyTargetAuthor === true; + } + + // Unknown visibility values are treated as private + return false; +} + +/** + * Dependencies required by {@link isNoteVisibleTo}. + */ +export interface NoteVisibilityDeps { + /** Follow repository used to check whether the viewer follows the author */ + followRepository: Pick; + /** Optional note repository used to resolve the reply target author */ + noteRepository?: Pick; +} + +/** + * Asynchronous visibility check that resolves relationship facts on demand. + * + * Only performs database lookups when the result cannot be determined + * from the note itself (i.e. `followers` notes viewed by a non-author + * who is not mentioned). + * + * @param note - Note to check + * @param viewerId - Viewing user ID, or null for unauthenticated viewers + * @param deps - Repositories used to resolve relationships + * @returns True if the viewer may see the note + */ +export async function isNoteVisibleTo( + note: NoteVisibilityFields, + viewerId: string | null | undefined, + deps: NoteVisibilityDeps, +): Promise { + if (canViewNote(note, viewerId, false)) return true; + if (!viewerId || note.visibility !== "followers") return false; + + if (await deps.followRepository.exists(viewerId, note.userId)) { + return true; + } + + if (note.replyId && deps.noteRepository) { + const parent = await deps.noteRepository.findById(note.replyId); + if (parent && parent.userId === viewerId) { + return true; + } + } + + return false; +} diff --git a/packages/backend/src/repositories/mysql/MysqlNoteRepository.ts b/packages/backend/src/repositories/mysql/MysqlNoteRepository.ts index 665ca690..8917d1d3 100644 --- a/packages/backend/src/repositories/mysql/MysqlNoteRepository.ts +++ b/packages/backend/src/repositories/mysql/MysqlNoteRepository.ts @@ -6,13 +6,15 @@ * @module repositories/mysql/MysqlNoteRepository */ -import { eq, and, or, isNull, inArray, desc, gt, lt, sql } from "drizzle-orm"; +import { eq, and, or, isNull, inArray, desc, gt, lt, sql, exists, type SQL } from "drizzle-orm"; +import { alias } from "drizzle-orm/mysql-core"; import type { MySql2Database } from "drizzle-orm/mysql2"; -import { notes, users } from "../../db/schema/mysql.js"; +import { follows, notes, users } from "../../db/schema/mysql.js"; import type * as mysqlSchema from "../../db/schema/mysql.js"; import type { INoteRepository, NoteCreateInput, + NoteVisibilityFilter, TimelineOptions, } from "../../interfaces/repositories/INoteRepository.js"; import type { Note } from "shared"; @@ -22,6 +24,52 @@ type MysqlDatabase = MySql2Database; export class MysqlNoteRepository implements INoteRepository { constructor(private db: MysqlDatabase) {} + /** + * Build a viewer-aware visibility condition (Misskey semantics). + * + * - public / home: visible to anyone + * - followers: author, followers of the author, mentioned users, + * or the author of the replied-to note + * - specified: author or recipients (stored in `mentions`) + * + * @param filter - Viewer filter; `viewerId: null` means unauthenticated + * @returns SQL condition restricting notes to those visible to the viewer + */ + private buildVisibilityCondition(filter: NoteVisibilityFilter): SQL { + const publicCondition = inArray(notes.visibility, ["public", "home"]); + const viewerId = filter.viewerId; + if (!viewerId) { + return publicCondition; + } + + const isMentioned = sql`JSON_CONTAINS(${notes.mentions}, ${JSON.stringify(viewerId)})`; + const replyParent = alias(notes, "visibility_reply_parent"); + + return or( + publicCondition, + eq(notes.userId, viewerId), + and( + eq(notes.visibility, "followers"), + or( + exists( + this.db + .select({ one: sql`1` }) + .from(follows) + .where(and(eq(follows.followerId, viewerId), eq(follows.followeeId, notes.userId))), + ), + isMentioned, + exists( + this.db + .select({ one: sql`1` }) + .from(replyParent) + .where(and(eq(replyParent.id, notes.replyId), eq(replyParent.userId, viewerId))), + ), + ), + ), + and(eq(notes.visibility, "specified"), isMentioned), + )!; + } + async create(note: NoteCreateInput): Promise { const now = new Date(); await this.db.insert(notes).values({ @@ -142,6 +190,10 @@ export class MysqlNoteRepository implements INoteRepository { const conditions = [inArray(notes.userId, userIds), eq(notes.isDeleted, false)]; + if (options.visibleTo) { + conditions.push(this.buildVisibilityCondition(options.visibleTo)); + } + if (sinceId) { conditions.push(gt(notes.id, sinceId)); } @@ -192,6 +244,10 @@ export class MysqlNoteRepository implements INoteRepository { conditions.push(...localConditions); } + if (options.visibleTo) { + conditions.push(this.buildVisibilityCondition(options.visibleTo)); + } + if (sinceId) { conditions.push(gt(notes.id, sinceId)); } @@ -272,6 +328,10 @@ export class MysqlNoteRepository implements INoteRepository { const conditions = [eq(notes.userId, userId), eq(notes.isDeleted, false)]; + if (options.visibleTo) { + conditions.push(this.buildVisibilityCondition(options.visibleTo)); + } + if (sinceId) { conditions.push(gt(notes.id, sinceId)); } @@ -310,6 +370,10 @@ export class MysqlNoteRepository implements INoteRepository { const conditions = [eq(notes.replyId, noteId), eq(notes.isDeleted, false)]; + if (options.visibleTo) { + conditions.push(this.buildVisibilityCondition(options.visibleTo)); + } + if (sinceId) { conditions.push(gt(notes.id, sinceId)); } @@ -348,6 +412,10 @@ export class MysqlNoteRepository implements INoteRepository { const conditions = [eq(notes.renoteId, noteId), eq(notes.isDeleted, false)]; + if (options.visibleTo) { + conditions.push(this.buildVisibilityCondition(options.visibleTo)); + } + if (sinceId) { conditions.push(gt(notes.id, sinceId)); } diff --git a/packages/backend/src/repositories/pg/PostgresNoteRepository.ts b/packages/backend/src/repositories/pg/PostgresNoteRepository.ts index 13745cc7..a3a5492b 100644 --- a/packages/backend/src/repositories/pg/PostgresNoteRepository.ts +++ b/packages/backend/src/repositories/pg/PostgresNoteRepository.ts @@ -1,9 +1,11 @@ -import { eq, and, or, isNull, inArray, desc, gt, lt, sql } from "drizzle-orm"; +import { eq, and, or, isNull, inArray, desc, gt, lt, sql, exists, type SQL } from "drizzle-orm"; +import { alias } from "drizzle-orm/pg-core"; import type { Database } from "../../db/index.js"; -import { notes, users } from "../../db/schema/pg.js"; +import { follows, notes, users } from "../../db/schema/pg.js"; import type { INoteRepository, NoteCreateInput, + NoteVisibilityFilter, TimelineOptions, } from "../../interfaces/repositories/INoteRepository.js"; import type { Note } from "shared"; @@ -12,6 +14,52 @@ import { logger } from "../../lib/logger.js"; export class PostgresNoteRepository implements INoteRepository { constructor(private db: Database) {} + /** + * Build a viewer-aware visibility condition (Misskey semantics). + * + * - public / home: visible to anyone + * - followers: author, followers of the author, mentioned users, + * or the author of the replied-to note + * - specified: author or recipients (stored in `mentions`) + * + * @param filter - Viewer filter; `viewerId: null` means unauthenticated + * @returns SQL condition restricting notes to those visible to the viewer + */ + private buildVisibilityCondition(filter: NoteVisibilityFilter): SQL { + const publicCondition = inArray(notes.visibility, ["public", "home"]); + const viewerId = filter.viewerId; + if (!viewerId) { + return publicCondition; + } + + const isMentioned = sql`${notes.mentions}::jsonb @> ${JSON.stringify([viewerId])}::jsonb`; + const replyParent = alias(notes, "visibility_reply_parent"); + + return or( + publicCondition, + eq(notes.userId, viewerId), + and( + eq(notes.visibility, "followers"), + or( + exists( + this.db + .select({ one: sql`1` }) + .from(follows) + .where(and(eq(follows.followerId, viewerId), eq(follows.followeeId, notes.userId))), + ), + isMentioned, + exists( + this.db + .select({ one: sql`1` }) + .from(replyParent) + .where(and(eq(replyParent.id, notes.replyId), eq(replyParent.userId, viewerId))), + ), + ), + ), + and(eq(notes.visibility, "specified"), isMentioned), + )!; + } + async create(note: NoteCreateInput): Promise { const now = new Date(); const [result] = await this.db @@ -136,6 +184,10 @@ export class PostgresNoteRepository implements INoteRepository { eq(notes.isDeleted, false), // ソフトデリートされていない ]; + if (options.visibleTo) { + conditions.push(this.buildVisibilityCondition(options.visibleTo)); + } + if (sinceId) { conditions.push(gt(notes.id, sinceId)); } @@ -193,6 +245,10 @@ export class PostgresNoteRepository implements INoteRepository { conditions.push(...localConditions); } + if (options.visibleTo) { + conditions.push(this.buildVisibilityCondition(options.visibleTo)); + } + if (sinceId) { conditions.push(gt(notes.id, sinceId)); } @@ -277,6 +333,10 @@ export class PostgresNoteRepository implements INoteRepository { eq(notes.isDeleted, false), // ソフトデリートされていない ]; + if (options.visibleTo) { + conditions.push(this.buildVisibilityCondition(options.visibleTo)); + } + if (sinceId) { conditions.push(gt(notes.id, sinceId)); } @@ -318,6 +378,10 @@ export class PostgresNoteRepository implements INoteRepository { eq(notes.isDeleted, false), // ソフトデリートされていない ]; + if (options.visibleTo) { + conditions.push(this.buildVisibilityCondition(options.visibleTo)); + } + if (sinceId) { conditions.push(gt(notes.id, sinceId)); } @@ -359,6 +423,10 @@ export class PostgresNoteRepository implements INoteRepository { eq(notes.isDeleted, false), // ソフトデリートされていない ]; + if (options.visibleTo) { + conditions.push(this.buildVisibilityCondition(options.visibleTo)); + } + if (sinceId) { conditions.push(gt(notes.id, sinceId)); } @@ -553,6 +621,10 @@ export class PostgresNoteRepository implements INoteRepository { ), ]; + if (options.visibleTo) { + conditions.push(this.buildVisibilityCondition(options.visibleTo)); + } + if (sinceId) { conditions.push(gt(notes.id, sinceId)); } diff --git a/packages/backend/src/repositories/sqlite/SqliteNoteRepository.ts b/packages/backend/src/repositories/sqlite/SqliteNoteRepository.ts index b39ba0f0..9b230189 100644 --- a/packages/backend/src/repositories/sqlite/SqliteNoteRepository.ts +++ b/packages/backend/src/repositories/sqlite/SqliteNoteRepository.ts @@ -6,13 +6,15 @@ * @module repositories/sqlite/SqliteNoteRepository */ -import { eq, and, desc, lt, gt, inArray, isNull, sql, or } from "drizzle-orm"; +import { eq, and, desc, lt, gt, inArray, isNull, sql, or, exists, type SQL } from "drizzle-orm"; +import { alias } from "drizzle-orm/sqlite-core"; import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite"; -import { notes, users } from "../../db/schema/sqlite.js"; +import { follows, notes, users } from "../../db/schema/sqlite.js"; import type * as sqliteSchema from "../../db/schema/sqlite.js"; import type { INoteRepository, NoteCreateInput, + NoteVisibilityFilter, TimelineOptions, } from "../../interfaces/repositories/INoteRepository.js"; import type { Note } from "shared"; @@ -25,6 +27,52 @@ type SqliteDatabase = BunSQLiteDatabase; export class SqliteNoteRepository implements INoteRepository { constructor(private db: SqliteDatabase) {} + /** + * Build a viewer-aware visibility condition (Misskey semantics). + * + * - public / home: visible to anyone + * - followers: author, followers of the author, mentioned users, + * or the author of the replied-to note + * - specified: author or recipients (stored in `mentions`) + * + * @param filter - Viewer filter; `viewerId: null` means unauthenticated + * @returns SQL condition restricting notes to those visible to the viewer + */ + private buildVisibilityCondition(filter: NoteVisibilityFilter): SQL { + const publicCondition = inArray(notes.visibility, ["public", "home"]); + const viewerId = filter.viewerId; + if (!viewerId) { + return publicCondition; + } + + const isMentioned = sql`EXISTS (SELECT 1 FROM json_each(${notes.mentions}) WHERE json_each.value = ${viewerId})`; + const replyParent = alias(notes, "visibility_reply_parent"); + + return or( + publicCondition, + eq(notes.userId, viewerId), + and( + eq(notes.visibility, "followers"), + or( + exists( + this.db + .select({ one: sql`1` }) + .from(follows) + .where(and(eq(follows.followerId, viewerId), eq(follows.followeeId, notes.userId))), + ), + isMentioned, + exists( + this.db + .select({ one: sql`1` }) + .from(replyParent) + .where(and(eq(replyParent.id, notes.replyId), eq(replyParent.userId, viewerId))), + ), + ), + ), + and(eq(notes.visibility, "specified"), isMentioned), + )!; + } + /** * Maps a joined note+user result to a Note object with embedded user */ @@ -130,6 +178,10 @@ export class SqliteNoteRepository implements INoteRepository { const conditions = [inArray(notes.userId, userIds), eq(notes.isDeleted, false)]; + if (options.visibleTo) { + conditions.push(this.buildVisibilityCondition(options.visibleTo)); + } + if (sinceId) { conditions.push(gt(notes.id, sinceId)); } @@ -167,6 +219,10 @@ export class SqliteNoteRepository implements INoteRepository { conditions.push(...localConditions); } + if (options.visibleTo) { + conditions.push(this.buildVisibilityCondition(options.visibleTo)); + } + if (sinceId) { conditions.push(gt(notes.id, sinceId)); } @@ -221,6 +277,10 @@ export class SqliteNoteRepository implements INoteRepository { const conditions = [eq(notes.userId, userId), eq(notes.isDeleted, false)]; + if (options.visibleTo) { + conditions.push(this.buildVisibilityCondition(options.visibleTo)); + } + if (sinceId) { conditions.push(gt(notes.id, sinceId)); } @@ -246,6 +306,10 @@ export class SqliteNoteRepository implements INoteRepository { const conditions = [eq(notes.replyId, noteId), eq(notes.isDeleted, false)]; + if (options.visibleTo) { + conditions.push(this.buildVisibilityCondition(options.visibleTo)); + } + if (sinceId) { conditions.push(gt(notes.id, sinceId)); } @@ -271,6 +335,10 @@ export class SqliteNoteRepository implements INoteRepository { const conditions = [eq(notes.renoteId, noteId), eq(notes.isDeleted, false)]; + if (options.visibleTo) { + conditions.push(this.buildVisibilityCondition(options.visibleTo)); + } + if (sinceId) { conditions.push(gt(notes.id, sinceId)); } diff --git a/packages/backend/src/routes/ap/note.ts b/packages/backend/src/routes/ap/note.ts index 53b69a0d..af315be3 100644 --- a/packages/backend/src/routes/ap/note.ts +++ b/packages/backend/src/routes/ap/note.ts @@ -13,6 +13,7 @@ import type { Context } from "hono"; import { logger } from "../../lib/logger.js"; import { isActivityPubRequest } from "../../lib/crawlerDetection.js"; import { textToHtml } from "../../lib/ogp.js"; +import { isPubliclyVisible } from "../../lib/noteVisibility.js"; const note = new Hono(); @@ -54,6 +55,14 @@ note.get("/notes/:id", async (c: Context) => { return c.notFound(); } + // This endpoint does not authenticate the fetcher, so only notes visible to + // everyone may be served. Followers-only / specified notes reach their + // audience via inbox delivery; local-only notes are never federated. + // Respond 404 (not 403) so the note's existence is not disclosed. + if (!isPubliclyVisible(noteData) || noteData.localOnly) { + return c.notFound(); + } + const baseUrl = process.env.URL || "http://localhost:3000"; // 410 Gone if note is deleted (ActivityPub spec compliance) diff --git a/packages/backend/src/routes/ap/outbox.ts b/packages/backend/src/routes/ap/outbox.ts index 77de4790..8e15a485 100644 --- a/packages/backend/src/routes/ap/outbox.ts +++ b/packages/backend/src/routes/ap/outbox.ts @@ -12,6 +12,28 @@ import type { Context } from "hono"; const outbox = new Hono(); +const AS_PUBLIC = "https://www.w3.org/ns/activitystreams#Public"; + +/** + * Build ActivityPub addressing for a publicly visible note. + * + * - public: to=Public, cc=followers + * - home (unlisted): to=followers, cc=Public + * + * @param visibility - Note visibility (must be public or home) + * @param followersUrl - Author's followers collection URL + * @returns to/cc addressing arrays + */ +function getPublicAddressing( + visibility: string, + followersUrl: string, +): { to: string[]; cc: string[] } { + if (visibility === "home") { + return { to: [followersUrl], cc: [AS_PUBLIC] }; + } + return { to: [AS_PUBLIC], cc: [followersUrl] }; +} + /** * GET /users/:username/outbox * @@ -41,9 +63,11 @@ outbox.get("/:username/outbox", async (c: Context) => { if (!page) { const noteRepository = c.get("noteRepository"); - // Count total items (notes created by this user) - const notes = await noteRepository.findByUserId(user.id, {}); - const totalItems = notes.length; + // Count total items (publicly visible, federatable notes only) + const notes = await noteRepository.findByUserId(user.id, { + visibleTo: { viewerId: null }, + }); + const totalItems = notes.filter((note) => !note.localOnly).length; const collection = { "@context": "https://www.w3.org/ns/activitystreams", @@ -67,28 +91,40 @@ outbox.get("/:username/outbox", async (c: Context) => { const noteRepository = c.get("noteRepository"); const limit = 20; - // Get user's notes with pagination - const notes = await noteRepository.findByUserId(user.id, { limit }); - - // Convert notes to Create activities - const orderedItems = notes.map((note) => ({ - "@context": "https://www.w3.org/ns/activitystreams", - id: `${baseUrl}/activities/${note.id}`, - type: "Create", - actor: `${baseUrl}/users/${username}`, - published: note.createdAt.toISOString(), - to: ["https://www.w3.org/ns/activitystreams#Public"], - cc: [`${baseUrl}/users/${username}/followers`], - object: { - id: `${baseUrl}/notes/${note.id}`, - type: "Note", - attributedTo: `${baseUrl}/users/${username}`, - content: note.text, + // Get user's notes with pagination. + // The outbox is served to unauthenticated fetchers, so only public/home notes + // are included, and local-only notes are never federated. + const notes = ( + await noteRepository.findByUserId(user.id, { + limit, + visibleTo: { viewerId: null }, + }) + ).filter((note) => !note.localOnly); + + const followersUrl = `${baseUrl}/users/${username}/followers`; + + // Convert notes to Create activities with addressing matching their visibility + const orderedItems = notes.map((note) => { + const { to, cc } = getPublicAddressing(note.visibility, followersUrl); + return { + "@context": "https://www.w3.org/ns/activitystreams", + id: `${baseUrl}/activities/${note.id}`, + type: "Create", + actor: `${baseUrl}/users/${username}`, published: note.createdAt.toISOString(), - to: ["https://www.w3.org/ns/activitystreams#Public"], - cc: [`${baseUrl}/users/${username}/followers`], - }, - })); + to, + cc, + object: { + id: `${baseUrl}/notes/${note.id}`, + type: "Note", + attributedTo: `${baseUrl}/users/${username}`, + content: note.text, + published: note.createdAt.toISOString(), + to, + cc, + }, + }; + }); const collectionPage = { "@context": "https://www.w3.org/ns/activitystreams", diff --git a/packages/backend/src/routes/mentions.ts b/packages/backend/src/routes/mentions.ts index 555fb8a5..99fa6ec8 100644 --- a/packages/backend/src/routes/mentions.ts +++ b/packages/backend/src/routes/mentions.ts @@ -41,6 +41,8 @@ mentions.get("/", requireAuth(), async (c: Context) => { limit, sinceId, untilId, + // e.g. exclude DMs replying to the user's note that are not addressed to them + visibleTo: { viewerId: user.id }, }); return c.json(notes); diff --git a/packages/backend/src/routes/notes.ts b/packages/backend/src/routes/notes.ts index 343ad46c..1145aa87 100644 --- a/packages/backend/src/routes/notes.ts +++ b/packages/backend/src/routes/notes.ts @@ -126,6 +126,7 @@ notes.post( * @returns {Note} Note record */ notes.post("/show", optionalAuth(), async (c: Context) => { + const user = c.get("user"); const noteService = createNoteService(c); const body = await c.req.json(); const noteId = body.noteId; @@ -134,7 +135,8 @@ notes.post("/show", optionalAuth(), async (c: Context) => { return c.json({ error: "noteId is required" }, 400); } - const note = await noteService.findById(noteId); + // Non-visible notes are reported as "not found" to avoid leaking their existence + const note = await noteService.findById(noteId, { viewerId: user?.id ?? null }); if (!note) { return c.json({ error: "Note not found" }, 404); @@ -299,6 +301,7 @@ notes.get("/global-timeline", optionalAuth(), async (c: Context) => { * @returns {Note[]} List of notes */ notes.get("/user-notes", optionalAuth(), async (c: Context) => { + const user = c.get("user"); const noteService = createNoteService(c); const userId = c.req.query("userId"); @@ -314,6 +317,7 @@ notes.get("/user-notes", optionalAuth(), async (c: Context) => { limit, sinceId, untilId, + viewerId: user?.id ?? null, }); return c.json(timeline); @@ -332,6 +336,7 @@ notes.get("/user-notes", optionalAuth(), async (c: Context) => { * @returns {Note[]} Array of reply notes */ notes.get("/replies", optionalAuth(), async (c: Context) => { + const user = c.get("user"); const noteService = createNoteService(c); const noteId = c.req.query("noteId"); @@ -347,6 +352,7 @@ notes.get("/replies", optionalAuth(), async (c: Context) => { limit, sinceId, untilId, + viewerId: user?.id ?? null, }); return c.json(replies); diff --git a/packages/backend/src/routes/oembed.ts b/packages/backend/src/routes/oembed.ts index d634b65e..f93cc884 100644 --- a/packages/backend/src/routes/oembed.ts +++ b/packages/backend/src/routes/oembed.ts @@ -12,6 +12,7 @@ import { Hono } from "hono"; import type { Context } from "hono"; import { generateNoteOEmbed, generateUserOEmbed, type OEmbedResponse } from "../lib/oembed.js"; +import { isPubliclyVisible } from "../lib/noteVisibility.js"; const oembed = new Hono(); @@ -118,7 +119,8 @@ oembed.get("/", async (c: Context) => { const noteRepository = c.get("noteRepository"); const noteData = await noteRepository.findById(resource.id); - if (!noteData || noteData.isDeleted) { + // oEmbed is unauthenticated: only notes visible to everyone may be embedded + if (!noteData || noteData.isDeleted || !isPubliclyVisible(noteData)) { return c.json({ error: "Note not found" }, 404); } diff --git a/packages/backend/src/routes/reactions.ts b/packages/backend/src/routes/reactions.ts index 2abea29b..bd845e9d 100644 --- a/packages/backend/src/routes/reactions.ts +++ b/packages/backend/src/routes/reactions.ts @@ -11,9 +11,32 @@ import type { Context } from "hono"; import { requireAuth, optionalAuth } from "../middleware/auth.js"; import { userRateLimit, RateLimitPresets } from "../middleware/rateLimit.js"; import { ReactionService } from "../services/ReactionService.js"; +import { isNoteVisibleTo } from "../lib/noteVisibility.js"; const reactions = new Hono(); +/** + * Check whether the current viewer may see a note. + * + * Missing notes are reported as visible so that existing not-found handling + * (and response shapes) stay unchanged; callers respond to non-visible notes + * exactly as they would for a missing note to avoid leaking existence. + * + * @param c - Hono context + * @param noteId - Note ID + * @returns False only if the note exists and is not visible to the viewer + */ +async function canViewerSeeNote(c: Context, noteId: string): Promise { + const noteRepository = c.get("noteRepository"); + const note = await noteRepository.findById(noteId); + if (!note) return true; + const viewer = c.get("user"); + return isNoteVisibleTo(note, viewer?.id ?? null, { + followRepository: c.get("followRepository"), + noteRepository, + }); +} + /** * POST /api/notes/reactions/create * @@ -56,6 +79,10 @@ reactions.post( return c.json({ error: "reaction is required" }, 400); } + if (!(await canViewerSeeNote(c, body.noteId))) { + return c.json({ error: "Note not found" }, 400); + } + try { const reaction = await reactionService.create({ userId: user.id, @@ -149,6 +176,10 @@ reactions.get("/", optionalAuth(), async (c: Context) => { return c.json({ error: "noteId is required" }, 400); } + if (!(await canViewerSeeNote(c, noteId))) { + return c.json([]); + } + try { const reactionsList = await reactionService.getReactionsByNote(noteId, limit); return c.json(reactionsList); @@ -186,6 +217,10 @@ reactions.get("/counts", optionalAuth(), async (c: Context) => { return c.json({ error: "noteId is required" }, 400); } + if (!(await canViewerSeeNote(c, noteId))) { + return c.json({}); + } + try { const counts = await reactionService.getReactionCounts(noteId); return c.json(counts); @@ -230,6 +265,10 @@ reactions.get("/counts-with-emojis", optionalAuth(), async (c: Context) => { return c.json({ error: "noteId is required" }, 400); } + if (!(await canViewerSeeNote(c, noteId))) { + return c.json({ counts: {}, emojis: {} }); + } + try { // Check if this is a remote note and fetchRemote is requested if (fetchRemote) { diff --git a/packages/backend/src/services/ListService.ts b/packages/backend/src/services/ListService.ts index b85e4dc7..df57331f 100644 --- a/packages/backend/src/services/ListService.ts +++ b/packages/backend/src/services/ListService.ts @@ -423,7 +423,7 @@ export class ListService { async getListTimeline( listId: string, requesterId?: string, - options: Omit = {}, + options: Omit = {}, ): Promise { const list = await this.listRepository.findById(listId); if (!list) { @@ -441,15 +441,19 @@ export class ListService { return []; } - // Get timeline for those users + // Get timeline for those users, restricted to notes the requester may see. + // List membership does not imply following, so followers-only and specified + // notes of members must be filtered per requester. + const viewerId = requesterId ?? null; const notes = await this.noteRepository.getTimeline({ ...options, userIds: memberUserIds, + visibleTo: { viewerId }, }); // Hydrate renote and file information if noteService is available if (this.noteService) { - return await this.noteService.hydrateNotesForTimeline(notes); + return await this.noteService.hydrateNotesForTimeline(notes, viewerId); } return notes; diff --git a/packages/backend/src/services/NoteService.ts b/packages/backend/src/services/NoteService.ts index 77f732a9..7e5f550e 100644 --- a/packages/backend/src/services/NoteService.ts +++ b/packages/backend/src/services/NoteService.ts @@ -21,6 +21,7 @@ import { getTimelineStreamService } from "./TimelineStreamService.js"; import { logger } from "../lib/logger.js"; import type { EventBus } from "../lib/events.js"; import { toPluginUser, toPluginNote } from "../plugins/utils.js"; +import { isNoteVisibleTo, isPubliclyVisible } from "../lib/noteVisibility.js"; /** * Note creation input data @@ -56,6 +57,11 @@ export interface TimelineOptions { sinceId?: string; /** Get notes older than this ID */ untilId?: string; + /** + * Viewing user ID used for visibility filtering. + * `null` or omitted means an unauthenticated viewer (public/home notes only). + */ + viewerId?: string | null; } /** @@ -165,7 +171,8 @@ export class NoteService { // リプライ先の存在確認 if (replyId) { const replyNote = await this.noteRepository.findById(replyId); - if (!replyNote) { + // Treat notes the author cannot see as non-existent (no existence oracle) + if (!replyNote || !(await this.isVisibleTo(replyNote, userId))) { throw new Error("Reply target note not found"); } } @@ -174,9 +181,13 @@ export class NoteService { let renoteTarget: Note | null = null; if (renoteId) { renoteTarget = await this.noteRepository.findById(renoteId); - if (!renoteTarget) { + if (!renoteTarget || !(await this.isVisibleTo(renoteTarget, userId))) { throw new Error("Renote target note not found"); } + // Misskey semantics: followers-only / specified notes of other users cannot be renoted + if (!isPubliclyVisible(renoteTarget) && renoteTarget.userId !== userId) { + throw new Error("Cannot renote a non-public note"); + } } // Get author for event emission and delivery @@ -379,31 +390,93 @@ export class NoteService { /** * Get a note by ID * + * When `options` is supplied, the note is only returned if it is visible to + * `options.viewerId` (null = unauthenticated); otherwise null is returned, + * indistinguishable from a missing note. Omitting `options` skips the + * visibility check and is intended for trusted internal callers only. + * * @param noteId - Note ID - * @returns Note record or null if not found + * @param options - Optional viewer context for visibility enforcement + * @returns Note record or null if not found / not visible * * @example * ```typescript - * const note = await noteService.findById('note123'); + * const note = await noteService.findById('note123', { viewerId: user?.id ?? null }); * if (!note) { * throw new Error('Note not found'); * } * ``` */ - async findById(noteId: string): Promise { + async findById(noteId: string, options?: { viewerId: string | null }): Promise { const note = await this.noteRepository.findById(noteId); if (!note) return null; + if (options && !(await this.isVisibleTo(note, options.viewerId))) { + return null; + } + // Hydrate file information const [hydratedNote] = await this.hydrateFiles([note]); return hydratedNote ?? null; } + /** + * Check whether a single note is visible to a viewer. + * + * @param note - Note to check + * @param viewerId - Viewing user ID, or null for unauthenticated viewers + * @returns True if visible + * + * @private + */ + private async isVisibleTo(note: Note, viewerId: string | null): Promise { + return isNoteVisibleTo(note, viewerId, { + followRepository: this.followRepository, + noteRepository: this.noteRepository, + }); + } + + /** + * Filter a list of notes down to those visible to a viewer. + * + * Follow lookups are memoized per author to avoid redundant queries. + * + * @param notesList - Notes to filter + * @param viewerId - Viewing user ID, or null for unauthenticated viewers + * @returns Visible notes, preserving order + * + * @private + */ + private async filterVisibleNotes(notesList: Note[], viewerId: string | null): Promise { + const followCache = new Map>(); + const deps = { + followRepository: { + exists: (followerId: string, followeeId: string): Promise => { + const key = `${followerId}:${followeeId}`; + let cached = followCache.get(key); + if (!cached) { + cached = this.followRepository.exists(followerId, followeeId); + followCache.set(key, cached); + } + return cached; + }, + }, + noteRepository: this.noteRepository, + }; + + const visible = await Promise.all(notesList.map((n) => isNoteVisibleTo(n, viewerId, deps))); + return notesList.filter((_, i) => visible[i]); + } + /** * Hydrate renote information for a list of notes - * Fetches the renoted notes and their users for notes that have renoteId + * Fetches the renoted notes and their users for notes that have renoteId. + * Referenced notes that are not visible to the viewer are omitted. + * + * @param notesList - Notes to hydrate + * @param viewerId - Viewing user ID, or null for unauthenticated viewers */ - private async hydrateRenotes(notesList: Note[]): Promise { + private async hydrateRenotes(notesList: Note[], viewerId: string | null): Promise { // Collect unique renote IDs and reply IDs const renoteIds = [...new Set(notesList.filter((n) => n.renoteId).map((n) => n.renoteId!))]; const replyIds = [...new Set(notesList.filter((n) => n.replyId).map((n) => n.replyId!))]; @@ -416,8 +489,9 @@ export class NoteService { // Fetch all referenced notes in a single batch const referencedNotes = await Promise.all(allIds.map((id) => this.noteRepository.findById(id))); - // Hydrate files for referenced notes - const validNotes = referencedNotes.filter((n): n is Note => n !== null); + // Hydrate files for referenced notes (only those the viewer may see) + const existingNotes = referencedNotes.filter((n): n is Note => n !== null); + const validNotes = await this.filterVisibleNotes(existingNotes, viewerId); const notesWithFiles = await this.hydrateFiles(validNotes); // Create a map for quick lookup @@ -510,11 +584,17 @@ export class NoteService { * This is a public method that can be used by other services (like ListService) * to hydrate timeline notes with full renote/reply and file data. * + * Referenced reply/renote notes that are not visible to `viewerId` are omitted. + * * @param notesList - List of notes to hydrate + * @param viewerId - Viewing user ID (null/omitted = unauthenticated viewer) * @returns Notes with renote, reply, and files populated */ - async hydrateNotesForTimeline(notesList: Note[]): Promise { - const hydratedNotes = await this.hydrateRenotes(notesList); + async hydrateNotesForTimeline( + notesList: Note[], + viewerId: string | null = null, + ): Promise { + const hydratedNotes = await this.hydrateRenotes(notesList, viewerId); return await this.hydrateFiles(hydratedNotes); } @@ -640,7 +720,8 @@ export class NoteService { }); // Hydrate renote and file information - const hydratedNotes = await this.hydrateRenotes(notes); + // Results are cached and shared across viewers, so hydrate as an anonymous viewer + const hydratedNotes = await this.hydrateRenotes(notes, null); const notesWithFiles = await this.hydrateFiles(hydratedNotes); // Cache first page results @@ -682,10 +763,12 @@ export class NoteService { limit, sinceId: options.sinceId, untilId: options.untilId, + // Exclude e.g. DMs between followed users that do not involve the viewer + visibleTo: { viewerId: userId }, }); // Hydrate renote and file information - const hydratedNotes = await this.hydrateRenotes(notes); + const hydratedNotes = await this.hydrateRenotes(notes, userId); return await this.hydrateFiles(hydratedNotes); } @@ -724,10 +807,11 @@ export class NoteService { limit, sinceId: options.sinceId, untilId: options.untilId, + visibleTo: { viewerId: userId }, }); // Hydrate renote and file information - const hydratedNotes = await this.hydrateRenotes(notes); + const hydratedNotes = await this.hydrateRenotes(notes, userId); return await this.hydrateFiles(hydratedNotes); } @@ -768,7 +852,8 @@ export class NoteService { }); // Hydrate renote and file information - const hydratedNotes = await this.hydrateRenotes(notes); + // Results are cached and shared across viewers, so hydrate as an anonymous viewer + const hydratedNotes = await this.hydrateRenotes(notes, null); const notesWithFiles = await this.hydrateFiles(hydratedNotes); // Cache first page results @@ -784,58 +869,74 @@ export class NoteService { * * Returns posts from a specific user. * + * Only notes visible to `options.viewerId` are returned + * (unauthenticated viewers see public/home notes only). + * * @param userId - Target user ID - * @param options - Pagination options + * @param options - Pagination options and viewer context * @returns List of Note records * * @example * ```typescript * const notes = await noteService.getUserTimeline('user456', { * limit: 20, + * viewerId: currentUser?.id ?? null, * }); * ``` */ async getUserTimeline(userId: string, options: TimelineOptions = {}): Promise { const limit = this.normalizeLimit(options.limit); + const viewerId = options.viewerId ?? null; const notes = await this.noteRepository.findByUserId(userId, { limit, sinceId: options.sinceId, untilId: options.untilId, + visibleTo: { viewerId }, }); // Hydrate renote and file information - const hydratedNotes = await this.hydrateRenotes(notes); + const hydratedNotes = await this.hydrateRenotes(notes, viewerId); return await this.hydrateFiles(hydratedNotes); } /** * Get replies to a note * - * Returns all replies to the specified note with hydrated user and renote data. + * Returns replies to the specified note that are visible to `options.viewerId`, + * with hydrated user and renote data. If the parent note exists but is not + * visible to the viewer, an empty list is returned. * * @param noteId - Parent note ID - * @param options - Timeline options (limit, sinceId, untilId) + * @param options - Timeline options (limit, sinceId, untilId) and viewer context * @returns List of reply notes with hydrated data * * @example * ```ts * const replies = await noteService.getReplies(noteId, { * limit: 20, + * viewerId: currentUser?.id ?? null, * }); * ``` */ async getReplies(noteId: string, options: TimelineOptions = {}): Promise { const limit = this.normalizeLimit(options.limit); + const viewerId = options.viewerId ?? null; + + const parent = await this.noteRepository.findById(noteId); + if (parent && !(await this.isVisibleTo(parent, viewerId))) { + return []; + } const replies = await this.noteRepository.findReplies(noteId, { limit, sinceId: options.sinceId, untilId: options.untilId, + visibleTo: { viewerId }, }); // Hydrate renote and file information - const hydratedNotes = await this.hydrateRenotes(replies); + const hydratedNotes = await this.hydrateRenotes(replies, viewerId); return await this.hydrateFiles(hydratedNotes); } @@ -1083,9 +1184,11 @@ export class NoteService { }; // Hydrate reply information if this is a reply + // Stream channels (local/social) are shared with anonymous subscribers, so only + // embed referenced notes that are visible to everyone. if (note.replyId) { const replyNote = await this.noteRepository.findById(note.replyId); - if (replyNote) { + if (replyNote && isPubliclyVisible(replyNote)) { const [replyNoteWithFiles] = await this.hydrateFiles([replyNote]); const replyUser = await this.userRepository.findById(replyNote.userId); if (replyUser) { @@ -1108,7 +1211,7 @@ export class NoteService { // Hydrate renote information if this is a renote/quote if (note.renoteId) { const renoteNote = await this.noteRepository.findById(note.renoteId); - if (renoteNote) { + if (renoteNote && isPubliclyVisible(renoteNote)) { const [renoteNoteWithFiles] = await this.hydrateFiles([renoteNote]); const renoteUser = await this.userRepository.findById(renoteNote.userId); if (renoteUser) { @@ -1194,6 +1297,10 @@ export class NoteService { const listOwner = await this.userRepository.findById(list.userId); if (!listOwner || listOwner.host) continue; + // Do not notify list owners about notes they are not allowed to see + // (anyone can add any user to a list without following them) + if (!(await this.isVisibleTo(note, list.userId))) continue; + await this.notificationService.createListNoteNotification( list.userId, authorId, diff --git a/packages/backend/src/tests/integration/sqlite-repositories.test.ts b/packages/backend/src/tests/integration/sqlite-repositories.test.ts index 75808388..9d8096dd 100644 --- a/packages/backend/src/tests/integration/sqlite-repositories.test.ts +++ b/packages/backend/src/tests/integration/sqlite-repositories.test.ts @@ -49,6 +49,8 @@ function createTestNote(overrides: { text: string; visibility?: "public" | "home" | "followers" | "specified"; localOnly?: boolean; + mentions?: string[]; + replyId?: string | null; }): NoteCreateInput { return { id: overrides.id, @@ -57,10 +59,10 @@ function createTestNote(overrides: { cw: null, visibility: overrides.visibility ?? "public", localOnly: overrides.localOnly ?? false, - replyId: null, + replyId: overrides.replyId ?? null, renoteId: null, fileIds: [], - mentions: [], + mentions: overrides.mentions ?? [], emojis: [], tags: [], uri: null, @@ -513,6 +515,146 @@ describe("SQLite Repository Integration Tests", () => { }); }); + describe("SqliteNoteRepository visibility filtering", () => { + let noteRepo: SqliteNoteRepository; + let followRepo: SqliteFollowRepository; + const authorId = "vis-author"; + const followerId = "vis-follower"; + const strangerId = "vis-stranger"; + const recipientId = "vis-recipient"; + + const idsOf = (list: Note[]) => list.map((n) => n.id).sort(); + + beforeAll(() => { + noteRepo = new SqliteNoteRepository(db); + followRepo = new SqliteFollowRepository(db); + }); + + beforeEach(async () => { + sqlite.exec("DELETE FROM notes"); + sqlite.exec("DELETE FROM sessions"); + sqlite.exec("DELETE FROM follows"); + sqlite.exec("DELETE FROM users"); + + const userRepo = new SqliteUserRepository(db); + for (const id of [authorId, followerId, strangerId, recipientId]) { + await userRepo.create( + createTestUser({ id, username: id, email: `${id}@example.com`, passwordHash: "hash" }), + ); + } + await followRepo.create( + createTestFollow({ id: uuidv4(), followerId: followerId, followeeId: authorId }), + ); + + // Insert sequentially so ordering is deterministic + await noteRepo.create(createTestNote({ id: "v-public", userId: authorId, text: "p" })); + await noteRepo.create( + createTestNote({ id: "v-home", userId: authorId, text: "h", visibility: "home" }), + ); + await noteRepo.create( + createTestNote({ id: "v-followers", userId: authorId, text: "f", visibility: "followers" }), + ); + await noteRepo.create( + createTestNote({ + id: "v-specified", + userId: authorId, + text: "s", + visibility: "specified", + mentions: [recipientId], + }), + ); + }); + + test("no filter keeps legacy behavior (all notes)", async () => { + const notes = await noteRepo.findByUserId(authorId, {}); + expect(idsOf(notes)).toEqual(["v-followers", "v-home", "v-public", "v-specified"]); + }); + + test("anonymous viewers only see public and home notes", async () => { + const notes = await noteRepo.findByUserId(authorId, { visibleTo: { viewerId: null } }); + expect(idsOf(notes)).toEqual(["v-home", "v-public"]); + }); + + test("non-followers only see public and home notes", async () => { + const notes = await noteRepo.findByUserId(authorId, { visibleTo: { viewerId: strangerId } }); + expect(idsOf(notes)).toEqual(["v-home", "v-public"]); + }); + + test("followers additionally see followers-only notes", async () => { + const notes = await noteRepo.findByUserId(authorId, { visibleTo: { viewerId: followerId } }); + expect(idsOf(notes)).toEqual(["v-followers", "v-home", "v-public"]); + }); + + test("recipients additionally see specified notes", async () => { + const notes = await noteRepo.findByUserId(authorId, { + visibleTo: { viewerId: recipientId }, + }); + expect(idsOf(notes)).toEqual(["v-home", "v-public", "v-specified"]); + }); + + test("the author sees all of their own notes", async () => { + const notes = await noteRepo.findByUserId(authorId, { visibleTo: { viewerId: authorId } }); + expect(idsOf(notes)).toEqual(["v-followers", "v-home", "v-public", "v-specified"]); + }); + + test("mentioned users see followers-only notes", async () => { + await noteRepo.create( + createTestNote({ + id: "v-followers-mention", + userId: authorId, + text: "fm", + visibility: "followers", + mentions: [strangerId], + }), + ); + const notes = await noteRepo.findByUserId(authorId, { visibleTo: { viewerId: strangerId } }); + expect(idsOf(notes)).toEqual(["v-followers-mention", "v-home", "v-public"]); + }); + + test("getTimeline (list/home) applies the filter", async () => { + const notes = await noteRepo.getTimeline({ + userIds: [authorId], + visibleTo: { viewerId: strangerId }, + }); + expect(idsOf(notes)).toEqual(["v-home", "v-public"]); + }); + + test("findReplies applies the filter, including reply-target author access", async () => { + await noteRepo.create(createTestNote({ id: "v-parent", userId: strangerId, text: "parent" })); + await noteRepo.create( + createTestNote({ + id: "v-reply-followers", + userId: authorId, + text: "reply", + visibility: "followers", + replyId: "v-parent", + }), + ); + await noteRepo.create( + createTestNote({ + id: "v-reply-public", + userId: authorId, + text: "reply2", + replyId: "v-parent", + }), + ); + + const anon = await noteRepo.findReplies("v-parent", { visibleTo: { viewerId: null } }); + expect(idsOf(anon)).toEqual(["v-reply-public"]); + + // The parent's author may see a followers-only reply to their note + const parentAuthor = await noteRepo.findReplies("v-parent", { + visibleTo: { viewerId: strangerId }, + }); + expect(idsOf(parentAuthor)).toEqual(["v-reply-followers", "v-reply-public"]); + + const other = await noteRepo.findReplies("v-parent", { + visibleTo: { viewerId: recipientId }, + }); + expect(idsOf(other)).toEqual(["v-reply-public"]); + }); + }); + describe("SqliteSessionRepository", () => { let sessionRepo: SqliteSessionRepository; let testUserId: string; diff --git a/packages/backend/src/tests/unit/NoteVisibilityRoutes.test.ts b/packages/backend/src/tests/unit/NoteVisibilityRoutes.test.ts new file mode 100644 index 00000000..7c78adf9 --- /dev/null +++ b/packages/backend/src/tests/unit/NoteVisibilityRoutes.test.ts @@ -0,0 +1,385 @@ +/** + * Note Visibility Route Tests + * + * Verifies that note-returning endpoints enforce Misskey-compatible + * visibility (followers-only / specified notes must not leak). + * The real route modules are mounted with mocked repositories. + */ + +import { describe, test, expect, mock, beforeEach } from "bun:test"; +import { Hono } from "hono"; +import type { Context, Next } from "hono"; +import type { Note } from "shared"; +import notesRoute from "../../routes/notes.js"; +import noteAPRoute from "../../routes/ap/note.js"; +import outboxRoute from "../../routes/ap/outbox.js"; +import { NoteService } from "../../services/NoteService.js"; +import type { INoteRepository } from "../../interfaces/repositories/INoteRepository.js"; +import type { IDriveFileRepository } from "../../interfaces/repositories/IDriveFileRepository.js"; +import type { IFollowRepository } from "../../interfaces/repositories/IFollowRepository.js"; +import type { IUserRepository } from "../../interfaces/repositories/IUserRepository.js"; +import type { IListRepository } from "../../interfaces/repositories/IListRepository.js"; +import type { ActivityPubDeliveryService } from "../../services/ap/ActivityPubDeliveryService.js"; +import type { NotificationService } from "../../services/NotificationService.js"; + +const AUTHOR_ID = "author"; +const FOLLOWER_ID = "follower"; +const STRANGER_ID = "stranger"; +const RECIPIENT_ID = "recipient"; + +function makeNote(id: string, overrides: Partial = {}): Note { + return { + id, + userId: AUTHOR_ID, + text: `text of ${id}`, + cw: null, + visibility: "public", + localOnly: false, + replyId: null, + renoteId: null, + fileIds: [], + mentions: [], + emojis: [], + tags: [], + uri: null, + repliesCount: 0, + renoteCount: 0, + isDeleted: false, + deletedAt: null, + deletedById: null, + deletionReason: null, + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-01T00:00:00Z"), + ...overrides, + }; +} + +const authorUser = { + id: AUTHOR_ID, + username: "author", + host: null, + displayName: "Author", + avatarUrl: null, + profileEmojis: [], + uri: null, + followersUrl: null, +}; + +const notesById = new Map(); + +function setNotes(list: Note[]): void { + notesById.clear(); + for (const n of list) notesById.set(n.id, n); +} + +let noteRepo: { + findById: ReturnType; + findByUserId: ReturnType; + findReplies: ReturnType; +}; +let followRepo: { + exists: ReturnType; + findByFollowerId: ReturnType; + findByFolloweeId: ReturnType; +}; +let userRepo: { + findById: ReturnType; + findByUsername: ReturnType; +}; + +/** + * Build an app that mounts the real routers with mocked dependencies. + * + * @param viewerId - Authenticated viewer ID, or null for anonymous + */ +function buildApp(viewerId: string | null): Hono { + const app = new Hono(); + app.use("*", async (c: Context, next: Next) => { + if (viewerId) { + c.set("user", { id: viewerId, username: viewerId, host: null } as never); + } + c.set("noteRepository", noteRepo as never); + c.set("followRepository", followRepo as never); + c.set("userRepository", userRepo as never); + c.set("driveFileRepository", { findByIds: mock(() => Promise.resolve([])) } as never); + c.set("customEmojiRepository", { + findManyByNames: mock(() => Promise.resolve(new Map())), + } as never); + c.set("activityPubDeliveryService", {} as never); + c.set("cacheService", { isAvailable: () => false } as never); + await next(); + }); + app.route("/api/notes", notesRoute); + app.route("/", noteAPRoute); + app.route("/users", outboxRoute); + return app; +} + +beforeEach(() => { + setNotes([]); + noteRepo = { + findById: mock((id: string) => Promise.resolve(notesById.get(id) ?? null)), + findByUserId: mock(() => Promise.resolve([...notesById.values()])), + findReplies: mock(() => Promise.resolve([])), + }; + followRepo = { + exists: mock((followerId: string, followeeId: string) => + Promise.resolve(followerId === FOLLOWER_ID && followeeId === AUTHOR_ID), + ), + findByFollowerId: mock(() => Promise.resolve([])), + findByFolloweeId: mock(() => Promise.resolve([])), + }; + userRepo = { + findById: mock(() => Promise.resolve(authorUser)), + findByUsername: mock(() => Promise.resolve(authorUser)), + }; +}); + +async function show(viewerId: string | null, noteId: string): Promise { + return buildApp(viewerId).request("/api/notes/show", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ noteId }), + }); +} + +describe("POST /api/notes/show", () => { + test("public note is visible to anonymous viewers", async () => { + setNotes([makeNote("n1", { visibility: "public" })]); + expect((await show(null, "n1")).status).toBe(200); + }); + + test("home note is visible to anonymous viewers", async () => { + setNotes([makeNote("n1", { visibility: "home" })]); + expect((await show(null, "n1")).status).toBe(200); + }); + + test("followers-only note returns 404 to anonymous viewers", async () => { + setNotes([makeNote("n1", { visibility: "followers" })]); + const res = await show(null, "n1"); + expect(res.status).toBe(404); + expect(await res.text()).not.toContain("text of n1"); + }); + + test("followers-only note returns 404 to non-followers", async () => { + setNotes([makeNote("n1", { visibility: "followers" })]); + expect((await show(STRANGER_ID, "n1")).status).toBe(404); + }); + + test("followers-only note is visible to followers", async () => { + setNotes([makeNote("n1", { visibility: "followers" })]); + expect((await show(FOLLOWER_ID, "n1")).status).toBe(200); + }); + + test("followers-only note is visible to its author", async () => { + setNotes([makeNote("n1", { visibility: "followers" })]); + expect((await show(AUTHOR_ID, "n1")).status).toBe(200); + }); + + test("specified note returns 404 to followers who are not recipients", async () => { + setNotes([makeNote("n1", { visibility: "specified", mentions: [RECIPIENT_ID] })]); + expect((await show(FOLLOWER_ID, "n1")).status).toBe(404); + expect((await show(null, "n1")).status).toBe(404); + }); + + test("specified note is visible to recipients", async () => { + setNotes([makeNote("n1", { visibility: "specified", mentions: [RECIPIENT_ID] })]); + expect((await show(RECIPIENT_ID, "n1")).status).toBe(200); + }); +}); + +describe("GET /api/notes/user-notes", () => { + test("passes anonymous visibility filter to the repository", async () => { + await buildApp(null).request(`/api/notes/user-notes?userId=${AUTHOR_ID}`); + const options = noteRepo.findByUserId.mock.calls[0]?.[1] as { visibleTo?: unknown }; + expect(options.visibleTo).toEqual({ viewerId: null }); + }); + + test("passes the authenticated viewer to the repository", async () => { + await buildApp(STRANGER_ID).request(`/api/notes/user-notes?userId=${AUTHOR_ID}`); + const options = noteRepo.findByUserId.mock.calls[0]?.[1] as { visibleTo?: unknown }; + expect(options.visibleTo).toEqual({ viewerId: STRANGER_ID }); + }); + + test("does not embed renoted followers-only notes for anonymous viewers", async () => { + const hidden = makeNote("hidden", { visibility: "followers", userId: "other" }); + const renote = makeNote("renote", { renoteId: "hidden", text: null }); + setNotes([hidden]); + noteRepo.findByUserId = mock(() => Promise.resolve([renote])); + + const res = await buildApp(null).request(`/api/notes/user-notes?userId=${AUTHOR_ID}`); + const body = (await res.json()) as Array<{ renote?: unknown }>; + expect(body).toHaveLength(1); + expect(body[0]?.renote).toBeUndefined(); + expect(JSON.stringify(body)).not.toContain("text of hidden"); + }); +}); + +describe("GET /api/notes/replies", () => { + test("returns nothing when the parent note is not visible", async () => { + setNotes([makeNote("parent", { visibility: "followers" })]); + noteRepo.findReplies = mock(() => Promise.resolve([makeNote("reply", { replyId: "parent" })])); + + const res = await buildApp(STRANGER_ID).request("/api/notes/replies?noteId=parent"); + expect(await res.json()).toEqual([]); + expect(noteRepo.findReplies).not.toHaveBeenCalled(); + }); + + test("passes the viewer filter when the parent is visible", async () => { + setNotes([makeNote("parent", { visibility: "public" })]); + await buildApp(null).request("/api/notes/replies?noteId=parent"); + const options = noteRepo.findReplies.mock.calls[0]?.[1] as { visibleTo?: unknown }; + expect(options.visibleTo).toEqual({ viewerId: null }); + }); +}); + +describe("GET /notes/:id (ActivityPub)", () => { + async function fetchAP(noteId: string): Promise { + return buildApp(null).request(`/notes/${noteId}`, { + headers: { Accept: "application/activity+json" }, + }); + } + + test("serves public notes", async () => { + setNotes([makeNote("n1", { visibility: "public" })]); + expect((await fetchAP("n1")).status).toBe(200); + }); + + test("serves home notes", async () => { + setNotes([makeNote("n1", { visibility: "home" })]); + expect((await fetchAP("n1")).status).toBe(200); + }); + + test("returns 404 for followers-only notes", async () => { + setNotes([makeNote("n1", { visibility: "followers" })]); + const res = await fetchAP("n1"); + expect(res.status).toBe(404); + expect(await res.text()).not.toContain("text of n1"); + }); + + test("returns 404 for specified notes", async () => { + setNotes([makeNote("n1", { visibility: "specified", mentions: [RECIPIENT_ID] })]); + expect((await fetchAP("n1")).status).toBe(404); + }); + + test("returns 404 for local-only notes", async () => { + setNotes([makeNote("n1", { visibility: "public", localOnly: true })]); + expect((await fetchAP("n1")).status).toBe(404); + }); +}); + +describe("GET /users/:username/outbox", () => { + test("only requests publicly visible notes and addresses them correctly", async () => { + noteRepo.findByUserId = mock(() => + Promise.resolve([ + makeNote("pub", { visibility: "public" }), + makeNote("home", { visibility: "home" }), + makeNote("local", { visibility: "public", localOnly: true }), + ]), + ); + + const res = await buildApp(null).request("/users/author/outbox?page=1"); + expect(res.status).toBe(200); + + const options = noteRepo.findByUserId.mock.calls[0]?.[1] as { visibleTo?: unknown }; + expect(options.visibleTo).toEqual({ viewerId: null }); + + const body = (await res.json()) as { + orderedItems: Array<{ object: { id: string }; to: string[]; cc: string[] }>; + }; + const ids = body.orderedItems.map((i) => i.object.id.split("/").pop()); + expect(ids).toEqual(["pub", "home"]); + + const AS_PUBLIC = "https://www.w3.org/ns/activitystreams#Public"; + const [pub, home] = body.orderedItems; + expect(pub?.to).toEqual([AS_PUBLIC]); + expect(home?.to).not.toContain(AS_PUBLIC); + expect(home?.cc).toContain(AS_PUBLIC); + }); +}); + +describe("NoteService visibility enforcement", () => { + function createService(listRepository?: Partial) { + const notificationService = { + createListNoteNotification: mock(() => Promise.resolve()), + }; + const service = new NoteService( + noteRepo as unknown as INoteRepository, + { findByIds: mock(() => Promise.resolve([])) } as unknown as IDriveFileRepository, + followRepo as unknown as IFollowRepository, + { + ...userRepo, + findByUsername: mock(() => Promise.resolve(null)), + } as unknown as IUserRepository, + { + deliverCreateNote: mock(() => Promise.resolve()), + deliverAnnounceActivity: mock(() => Promise.resolve()), + } as unknown as ActivityPubDeliveryService, + undefined, + notificationService as unknown as NotificationService, + listRepository as IListRepository | undefined, + ); + return { service, notificationService }; + } + + test("findById without options keeps legacy (unfiltered) behavior", async () => { + setNotes([makeNote("n1", { visibility: "followers" })]); + const { service } = createService(); + expect(await service.findById("n1")).not.toBeNull(); + expect(await service.findById("n1", { viewerId: null })).toBeNull(); + }); + + test("rejects replying to a note the author cannot see", async () => { + setNotes([makeNote("secret", { visibility: "specified", mentions: [RECIPIENT_ID] })]); + const { service } = createService(); + await expect( + service.create({ userId: STRANGER_ID, text: "hi", replyId: "secret" }), + ).rejects.toThrow("Reply target note not found"); + }); + + test("rejects renoting another user's followers-only note", async () => { + setNotes([makeNote("fo", { visibility: "followers" })]); + const { service } = createService(); + await expect(service.create({ userId: FOLLOWER_ID, renoteId: "fo" })).rejects.toThrow( + "Cannot renote a non-public note", + ); + }); + + test("list notifications are only sent to owners who can see the note", async () => { + (noteRepo as unknown as { create: ReturnType }).create = mock( + (input: Partial) => Promise.resolve(makeNote("created", input)), + ); + const listRepository = { + findListsWithNotificationsForMember: mock(() => + Promise.resolve([ + { id: "list-stranger", userId: STRANGER_ID, notifyLevel: "all" }, + { id: "list-follower", userId: FOLLOWER_ID, notifyLevel: "all" }, + ]), + ), + findListsForMember: mock(() => Promise.resolve([])), + }; + const { service, notificationService } = createService( + listRepository as unknown as Partial, + ); + + await service.create({ userId: AUTHOR_ID, text: "followers only", visibility: "followers" }); + // List notifications are dispatched asynchronously + await new Promise((resolve) => setTimeout(resolve, 20)); + + const notifiedOwners = notificationService.createListNoteNotification.mock.calls.map( + (call) => (call as unknown[])[0], + ); + expect(notifiedOwners).toEqual([FOLLOWER_ID]); + }); + + test("home timeline passes the viewer filter", async () => { + const getTimeline = mock(() => Promise.resolve([])); + (noteRepo as unknown as { getTimeline: typeof getTimeline }).getTimeline = getTimeline; + const { service } = createService(); + await service.getHomeTimeline(FOLLOWER_ID); + const options = (getTimeline.mock.calls[0] as unknown[] | undefined)?.[0] as { + visibleTo?: unknown; + }; + expect(options.visibleTo).toEqual({ viewerId: FOLLOWER_ID }); + }); +}); diff --git a/packages/backend/src/tests/unit/noteVisibility.test.ts b/packages/backend/src/tests/unit/noteVisibility.test.ts new file mode 100644 index 00000000..8f599ca8 --- /dev/null +++ b/packages/backend/src/tests/unit/noteVisibility.test.ts @@ -0,0 +1,184 @@ +/** + * Note Visibility Unit Tests + * + * Tests the Misskey-compatible visibility rules in lib/noteVisibility.ts. + */ + +import { describe, test, expect, mock } from "bun:test"; +import { + canViewNote, + isNoteVisibleTo, + isPubliclyVisible, + type NoteVisibilityFields, +} from "../../lib/noteVisibility.js"; + +const AUTHOR = "author"; +const FOLLOWER = "follower"; +const STRANGER = "stranger"; +const RECIPIENT = "recipient"; + +function note( + visibility: string, + overrides: Partial = {}, +): NoteVisibilityFields { + return { userId: AUTHOR, visibility, mentions: [], replyId: null, ...overrides }; +} + +describe("isPubliclyVisible", () => { + test("public and home are publicly visible", () => { + expect(isPubliclyVisible(note("public"))).toBe(true); + expect(isPubliclyVisible(note("home"))).toBe(true); + }); + + test("followers and specified are not publicly visible", () => { + expect(isPubliclyVisible(note("followers"))).toBe(false); + expect(isPubliclyVisible(note("specified"))).toBe(false); + }); +}); + +describe("canViewNote", () => { + describe("public / home", () => { + test("visible to unauthenticated viewers", () => { + expect(canViewNote(note("public"), null, false)).toBe(true); + expect(canViewNote(note("home"), null, false)).toBe(true); + }); + + test("visible to any authenticated viewer", () => { + expect(canViewNote(note("public"), STRANGER, false)).toBe(true); + expect(canViewNote(note("home"), STRANGER, false)).toBe(true); + }); + }); + + describe("followers", () => { + test("hidden from unauthenticated viewers", () => { + expect(canViewNote(note("followers"), null, false)).toBe(false); + expect(canViewNote(note("followers"), undefined, true)).toBe(false); + }); + + test("visible to the author", () => { + expect(canViewNote(note("followers"), AUTHOR, false)).toBe(true); + }); + + test("visible to followers", () => { + expect(canViewNote(note("followers"), FOLLOWER, true)).toBe(true); + }); + + test("hidden from non-followers", () => { + expect(canViewNote(note("followers"), STRANGER, false)).toBe(false); + }); + + test("visible to mentioned users", () => { + expect(canViewNote(note("followers", { mentions: [STRANGER] }), STRANGER, false)).toBe(true); + }); + + test("visible to the reply target author", () => { + expect( + canViewNote(note("followers"), STRANGER, { isFollowing: false, isReplyTargetAuthor: true }), + ).toBe(true); + }); + }); + + describe("specified", () => { + test("hidden from unauthenticated viewers", () => { + expect(canViewNote(note("specified", { mentions: [RECIPIENT] }), null, false)).toBe(false); + }); + + test("visible to the author", () => { + expect(canViewNote(note("specified", { mentions: [RECIPIENT] }), AUTHOR, false)).toBe(true); + }); + + test("visible to recipients", () => { + expect(canViewNote(note("specified", { mentions: [RECIPIENT] }), RECIPIENT, false)).toBe( + true, + ); + }); + + test("hidden from followers who are not recipients", () => { + expect(canViewNote(note("specified", { mentions: [RECIPIENT] }), FOLLOWER, true)).toBe(false); + }); + + test("hidden from strangers", () => { + expect(canViewNote(note("specified", { mentions: [RECIPIENT] }), STRANGER, false)).toBe( + false, + ); + }); + + test("handles null mentions", () => { + expect(canViewNote(note("specified", { mentions: null }), STRANGER, false)).toBe(false); + }); + }); + + test("unknown visibility is treated as private", () => { + expect(canViewNote(note("weird"), STRANGER, true)).toBe(false); + expect(canViewNote(note("weird"), AUTHOR, false)).toBe(true); + }); +}); + +describe("isNoteVisibleTo", () => { + test("does not query follows for public notes", async () => { + const exists = mock(() => Promise.resolve(false)); + const visible = await isNoteVisibleTo(note("public"), null, { + followRepository: { exists }, + }); + expect(visible).toBe(true); + expect(exists).not.toHaveBeenCalled(); + }); + + test("does not query follows for unauthenticated viewers", async () => { + const exists = mock(() => Promise.resolve(true)); + const visible = await isNoteVisibleTo(note("followers"), null, { + followRepository: { exists }, + }); + expect(visible).toBe(false); + expect(exists).not.toHaveBeenCalled(); + }); + + test("does not query follows for specified notes", async () => { + const exists = mock(() => Promise.resolve(true)); + const visible = await isNoteVisibleTo(note("specified", { mentions: [RECIPIENT] }), FOLLOWER, { + followRepository: { exists }, + }); + expect(visible).toBe(false); + expect(exists).not.toHaveBeenCalled(); + }); + + test("allows followers of the author for followers-only notes", async () => { + const exists = mock((followerId: string, followeeId: string) => + Promise.resolve(followerId === FOLLOWER && followeeId === AUTHOR), + ); + expect( + await isNoteVisibleTo(note("followers"), FOLLOWER, { followRepository: { exists } }), + ).toBe(true); + expect(exists).toHaveBeenCalledWith(FOLLOWER, AUTHOR); + }); + + test("rejects non-followers for followers-only notes", async () => { + const exists = mock(() => Promise.resolve(false)); + expect( + await isNoteVisibleTo(note("followers"), STRANGER, { followRepository: { exists } }), + ).toBe(false); + }); + + test("allows the reply target author for followers-only replies", async () => { + const exists = mock(() => Promise.resolve(false)); + const findById = mock(() => Promise.resolve({ id: "parent", userId: STRANGER } as never)); + expect( + await isNoteVisibleTo(note("followers", { replyId: "parent" }), STRANGER, { + followRepository: { exists }, + noteRepository: { findById }, + }), + ).toBe(true); + expect(findById).toHaveBeenCalledWith("parent"); + }); + + test("rejects when the reply target belongs to someone else", async () => { + const exists = mock(() => Promise.resolve(false)); + const findById = mock(() => Promise.resolve({ id: "parent", userId: "other" } as never)); + expect( + await isNoteVisibleTo(note("followers", { replyId: "parent" }), STRANGER, { + followRepository: { exists }, + noteRepository: { findById }, + }), + ).toBe(false); + }); +});