From 6990cc054473af4e8cb8ac98a52982baf9ce99d5 Mon Sep 17 00:00:00 2001 From: SASAGAWA Kiyoshi Date: Wed, 14 Oct 2026 20:47:13 +0900 Subject: [PATCH 1/9] ci: gate image build on CI success build-images.yml now calls ci.yml via workflow_call and the build job needs it, so a failing commit no longer publishes :dev (which keel auto-deploys). dev/main are excluded from ci.yml's push trigger to avoid running CI twice. Closes #288 Claude-Session: https://claude.ai/code/session_01Y29fBeRP344FME6de48wUv --- .github/workflows/build-images.yml | 8 ++++++++ .github/workflows/ci.yml | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/.github/workflows/build-images.yml b/.github/workflows/build-images.yml index fb198cc9..5f30c102 100644 --- a/.github/workflows/build-images.yml +++ b/.github/workflows/build-images.yml @@ -10,6 +10,10 @@ name: Build & Push Images # Note: we intentionally do NOT publish a moving `:latest` tag. The cluster # tracks the explicit `:dev` tag, and avoiding `:latest` sidesteps the tag # resolution ambiguity that previously affected keel on the ikaskey instance. +# +# CI gate: images are only built after ci.yml passes for the same commit. +# keel auto-deploys :dev, so publishing an image from a failing commit would +# ship it to production (#288). on: push: @@ -25,8 +29,12 @@ env: IMAGE_NAMESPACE: love-rox jobs: + ci: + uses: ./.github/workflows/ci.yml + build: name: Build ${{ matrix.component }} + needs: ci runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0fc73951..a2dc1736 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,10 @@ on: push: branches-ignore: - 'dependabot/**' + # dev / main run CI via build-images.yml (workflow_call), which gates + # image publishing on the result. Skip here to avoid running CI twice. + - dev + - main pull_request: branches: [main, dev] workflow_call: From e2a024c88e264e3a3188a768f34013f5d1860011 Mon Sep 17 00:00:00 2001 From: SASAGAWA Kiyoshi Date: Wed, 14 Oct 2026 21:06:07 +0900 Subject: [PATCH 2/9] fix(api): strip private fields from public user responses Use a shared toPublicUser() helper so user responses never include passwordHash, privateKey or email for other users, and sanitizeUser() for the caller's own profile. Claude-Session: https://claude.ai/code/session_01Y29fBeRP344FME6de48wUv --- packages/backend/src/lib/routeUtils.ts | 19 ++++++++++++++++++- packages/backend/src/routes/auth.ts | 9 +++++---- packages/backend/src/routes/following.ts | 17 +++++++++++++++-- packages/backend/src/routes/users.ts | 24 ++++++++---------------- 4 files changed, 46 insertions(+), 23 deletions(-) diff --git a/packages/backend/src/lib/routeUtils.ts b/packages/backend/src/lib/routeUtils.ts index 40bb4cd3..c210b6bf 100644 --- a/packages/backend/src/lib/routeUtils.ts +++ b/packages/backend/src/lib/routeUtils.ts @@ -15,7 +15,6 @@ import type { Context } from "hono"; interface UserWithSensitiveData { passwordHash?: string | null; privateKey?: string | null; - [key: string]: unknown; } /** @@ -41,6 +40,24 @@ export function sanitizeUser(user: T): Sanitize return userData as SanitizedUser; } +/** + * Removes credentials and private fields (passwordHash, privateKey, email) + * from a user object so it can be returned to anyone. + * + * Use this for every response that may contain a user other than the + * authenticated caller. Repositories return full rows (including the + * ActivityPub private key), so omitting a field here leaks it publicly. + * + * @param user - User object potentially containing sensitive data + * @returns User object safe for public responses + */ +export function toPublicUser< + T extends { passwordHash?: unknown; privateKey?: unknown; email?: unknown }, +>(user: T): Omit { + const { passwordHash: _p, privateKey: _pk, email: _e, ...publicUser } = user; + return publicUser; +} + /** * Pagination parameters extracted from query string */ diff --git a/packages/backend/src/routes/auth.ts b/packages/backend/src/routes/auth.ts index c112e0ef..72fc4c10 100644 --- a/packages/backend/src/routes/auth.ts +++ b/packages/backend/src/routes/auth.ts @@ -12,6 +12,7 @@ import { PasskeyService } from "../services/PasskeyService.js"; import { requireAuth } from "../middleware/auth.js"; import { rateLimit, RateLimitPresets } from "../middleware/rateLimit.js"; import { logger } from "../lib/logger.js"; +import { toPublicUser } from "../lib/routeUtils.js"; import type { Context } from "hono"; const app = new Hono(); @@ -182,7 +183,7 @@ app.post("/register", rateLimit(RateLimitPresets.register), async (c) => { } // Remove sensitive data from response - const { passwordHash: _passwordHash, email: _email, ...publicUser } = user; + const publicUser = toPublicUser(user); return c.json( { @@ -263,7 +264,7 @@ app.post("/session", rateLimit(RateLimitPresets.login), async (c) => { ); // パスワードハッシュとメールアドレスを除外してレスポンス - const { passwordHash: _passwordHash, email: _email, ...publicUser } = user; + const publicUser = toPublicUser(user); return c.json({ user: publicUser, @@ -354,7 +355,7 @@ app.get("/session", requireAuth(), async (c) => { } // パスワードハッシュとメールアドレスを除外 - const { passwordHash: _passwordHash, email: _email, ...publicUser } = user; + const publicUser = toPublicUser(user); return c.json({ user: publicUser, @@ -499,7 +500,7 @@ app.post("/passkey/authenticate/finish", rateLimit(RateLimitPresets.login), asyn const { user, session } = await passkeyService.verifyAuthentication(body.credential); // Remove sensitive data from response - const { passwordHash: _passwordHash, email: _email, ...publicUser } = user; + const publicUser = toPublicUser(user); return c.json({ user: publicUser, diff --git a/packages/backend/src/routes/following.ts b/packages/backend/src/routes/following.ts index 209377d2..be5cbbcb 100644 --- a/packages/backend/src/routes/following.ts +++ b/packages/backend/src/routes/following.ts @@ -8,9 +8,11 @@ import { Hono } from "hono"; import type { Context } from "hono"; +import type { Follow, User } from "shared"; import { requireAuth } from "../middleware/auth.js"; import { userRateLimit, RateLimitPresets } from "../middleware/rateLimit.js"; import { FollowService } from "../services/FollowService.js"; +import { toPublicUser } from "../lib/routeUtils.js"; const following = new Hono(); @@ -157,7 +159,13 @@ following.get("/users/followers", async (c: Context) => { try { const followers = await followService.getFollowers(userId, limit, offset); - return c.json(followers); + // Joined user rows are full DB rows (incl. passwordHash / privateKey) + return c.json( + (followers as Array).map((f) => ({ + ...f, + follower: f.follower ? toPublicUser(f.follower) : f.follower, + })), + ); } catch (error) { const message = error instanceof Error ? error.message : "Failed to get followers"; return c.json({ error: message }, 400); @@ -190,7 +198,12 @@ following.get("/users/following", async (c: Context) => { try { const following = await followService.getFollowing(userId, limit, offset); - return c.json(following); + return c.json( + (following as Array).map((f) => ({ + ...f, + followee: f.followee ? toPublicUser(f.followee) : f.followee, + })), + ); } catch (error) { const message = error instanceof Error ? error.message : "Failed to get following"; return c.json({ error: message }, 400); diff --git a/packages/backend/src/routes/users.ts b/packages/backend/src/routes/users.ts index 7fc8786d..152cbd9b 100644 --- a/packages/backend/src/routes/users.ts +++ b/packages/backend/src/routes/users.ts @@ -10,7 +10,7 @@ import { Hono } from "hono"; import { AuthService } from "../services/AuthService.js"; import { UserService } from "../services/UserService.js"; import { requireAuth, optionalAuth } from "../middleware/auth.js"; -import { errorResponse } from "../lib/routeUtils.js"; +import { errorResponse, sanitizeUser, toPublicUser } from "../lib/routeUtils.js"; import { verifyPassword } from "../utils/password.js"; const app = new Hono(); @@ -102,7 +102,7 @@ app.post("/", async (c) => { }); // パスワードハッシュを除外してレスポンス - const { passwordHash: _passwordHash, email: _email, ...publicUser } = user; + const publicUser = toPublicUser(user); return c.json( { @@ -156,7 +156,7 @@ app.get("/@me", requireAuth(), async (c) => { } // メールアドレスは含めるが、パスワードハッシュは除外 - const { passwordHash: _passwordHash, ...userData } = user; + const userData = sanitizeUser(user); return c.json(userData); }); @@ -222,15 +222,7 @@ app.get("/search", async (c) => { }); // Remove sensitive fields - const publicUsers = searchResults.map((user) => { - const { - passwordHash: _passwordHash, - email: _email, - privateKey: _privateKey, - ...publicUser - } = user; - return publicUser; - }); + const publicUsers = searchResults.map((user) => toPublicUser(user)); return c.json({ users: publicUsers }); }); @@ -286,7 +278,7 @@ app.get("/show", optionalAuth(), async (c) => { } // パスワードハッシュとメールアドレスを除外 - const { passwordHash: _passwordHash, email: _email, ...publicUser } = user; + const publicUser = toPublicUser(user); // Check if current user is following this user (if authenticated) let isFollowed = false; @@ -356,7 +348,7 @@ app.get("/resolve", optionalAuth(), async (c) => { const user = await remoteActorService.resolveActorByAcct(acct); // パスワードハッシュとメールアドレスを除外 - const { passwordHash: _passwordHash, email: _email, ...publicUser } = user; + const publicUser = toPublicUser(user); // Check if current user is following this user (if authenticated) let isFollowed = false; @@ -468,7 +460,7 @@ app.get("/:id", async (c) => { } // パスワードハッシュとメールアドレスを除外 - const { passwordHash: _passwordHash, email: _email, ...publicUser } = user; + const publicUser = toPublicUser(user); return c.json(publicUser); }); @@ -596,7 +588,7 @@ app.patch("/@me", requireAuth(), async (c) => { try { const updatedUser = await userService.updateProfile(user.id, updateData); - const { passwordHash: _passwordHash, ...userData } = updatedUser; + const userData = sanitizeUser(updatedUser); return c.json(userData); } catch (error) { const message = error instanceof Error ? error.message : "Failed to update profile"; From d5e38187976803fe0d37e1205e71d393b688b294 Mon Sep 17 00:00:00 2001 From: SASAGAWA Kiyoshi Date: Wed, 14 Oct 2026 21:19:00 +0900 Subject: [PATCH 3/9] fix(security): block SVG XSS in media proxy and add SSRF protection - Remove image/svg+xml from the proxy allowlist; re-validate cached content types; add sandboxed CSP, nosniff and Content-Disposition to every proxy response; stop echoing upstream status/content-type - /proxy/avatar now calls the proxy logic directly instead of self-fetching via the request Host - securityHeaders no longer overwrites a route-specific CSP - Add lib/safeFetch (isPrivateAddress, assertPublicUrl, safeFetch) with DNS resolution checks and per-hop redirect re-validation; ALLOW_PRIVATE_NETWORK_FETCH=true escape hatch for development - Use safeFetch for ActivityPub object fetches, inbox delivery, WebPush, nodeinfo, instance icons, remote emoji import and Misskey API calls - RemoteFetchService rejects non-JSON (activity/ld/json) responses; WebFinger accepts jrd+json Claude-Session: https://claude.ai/code/session_01Y29fBeRP344FME6de48wUv --- .env.example | 6 + packages/backend/src/lib/instance-icon.ts | 17 +- packages/backend/src/lib/safeFetch.ts | 409 ++++++++++++++++++ .../backend/src/middleware/securityHeaders.ts | 5 +- packages/backend/src/routes/emojis.ts | 3 +- packages/backend/src/routes/proxy.ts | 271 +++++++----- .../services/RemoteInstanceRefreshService.ts | 5 +- .../src/services/RemoteInstanceService.ts | 5 +- .../backend/src/services/WebPushService.ts | 20 +- .../services/ap/ActivityDeliveryService.ts | 4 +- .../src/services/ap/RemoteActorService.ts | 1 + .../src/services/ap/RemoteFetchService.ts | 57 ++- .../src/services/ap/RemoteLikesService.ts | 5 +- packages/backend/src/tests/unit/proxy.test.ts | 131 ++++++ .../backend/src/tests/unit/safeFetch.test.ts | 315 ++++++++++++++ 15 files changed, 1116 insertions(+), 138 deletions(-) create mode 100644 packages/backend/src/lib/safeFetch.ts create mode 100644 packages/backend/src/tests/unit/proxy.test.ts create mode 100644 packages/backend/src/tests/unit/safeFetch.test.ts diff --git a/.env.example b/.env.example index 205d814b..eacadc77 100644 --- a/.env.example +++ b/.env.example @@ -133,6 +133,12 @@ SESSION_EXPIRY_DAYS=30 # Not currently used but reserved for future JWT-based auth # JWT_SECRET=your-secure-random-secret-here +# Allow outbound fetches (federation, media proxy, WebPush, nodeinfo) to +# private / loopback / link-local addresses (default: false). +# SECURITY: only enable for local development or federation testing on +# localhost. Never enable in production (SSRF risk). +# ALLOW_PRIVATE_NETWORK_FETCH=false + # ============================================================================= # Registration & Moderation # ============================================================================= diff --git a/packages/backend/src/lib/instance-icon.ts b/packages/backend/src/lib/instance-icon.ts index 709abbc2..f45edfa6 100644 --- a/packages/backend/src/lib/instance-icon.ts +++ b/packages/backend/src/lib/instance-icon.ts @@ -7,6 +7,7 @@ */ import { logger } from "./logger.js"; +import { safeFetch } from "./safeFetch.js"; /** * Try to fetch instance icon from software-specific APIs, then fall back to favicon @@ -56,7 +57,7 @@ export async function fetchInstanceIconFromApi( normalizedName === "calckey" || normalizedName === "cherrypick" ) { - const response = await fetch(`https://${host}/api/meta`, { + const response = await safeFetch(`https://${host}/api/meta`, { method: "POST", headers: { "Content-Type": "application/json", @@ -85,7 +86,7 @@ export async function fetchInstanceIconFromApi( normalizedName === "glitch" || normalizedName === "kmyblue" ) { - const response = await fetch(`https://${host}/api/v2/instance`, { + const response = await safeFetch(`https://${host}/api/v2/instance`, { headers: { Accept: "application/json", }, @@ -102,7 +103,7 @@ export async function fetchInstanceIconFromApi( } // Fall back to v1 API for older Mastodon versions - const v1Response = await fetch(`https://${host}/api/v1/instance`, { + const v1Response = await safeFetch(`https://${host}/api/v1/instance`, { headers: { Accept: "application/json", }, @@ -119,7 +120,7 @@ export async function fetchInstanceIconFromApi( // Pleroma / Akkoma if (normalizedName === "pleroma" || normalizedName === "akkoma") { - const response = await fetch(`https://${host}/api/v1/instance`, { + const response = await safeFetch(`https://${host}/api/v1/instance`, { headers: { Accept: "application/json", }, @@ -136,7 +137,7 @@ export async function fetchInstanceIconFromApi( // GoToSocial if (normalizedName === "gotosocial") { - const response = await fetch(`https://${host}/api/v1/instance`, { + const response = await safeFetch(`https://${host}/api/v1/instance`, { headers: { Accept: "application/json", }, @@ -155,7 +156,7 @@ export async function fetchInstanceIconFromApi( if (!normalizedName) { // Try Mastodon API first (most common) try { - const response = await fetch(`https://${host}/api/v1/instance`, { + const response = await safeFetch(`https://${host}/api/v1/instance`, { headers: { Accept: "application/json" }, signal: AbortSignal.timeout(5000), }); @@ -172,7 +173,7 @@ export async function fetchInstanceIconFromApi( // Try Misskey API try { - const response = await fetch(`https://${host}/api/meta`, { + const response = await safeFetch(`https://${host}/api/meta`, { method: "POST", headers: { "Content-Type": "application/json", @@ -217,7 +218,7 @@ export async function fetchFavicon(host: string): Promise { for (const path of faviconPaths) { try { const url = `https://${host}${path}`; - const response = await fetch(url, { + const response = await safeFetch(url, { method: "HEAD", signal: AbortSignal.timeout(5000), }); diff --git a/packages/backend/src/lib/safeFetch.ts b/packages/backend/src/lib/safeFetch.ts new file mode 100644 index 00000000..78cf2cd6 --- /dev/null +++ b/packages/backend/src/lib/safeFetch.ts @@ -0,0 +1,409 @@ +/** + * SSRF-safe outbound HTTP helpers + * + * Provides utilities to make outbound HTTP requests to attacker-controllable + * URLs (remote ActivityPub objects, inboxes, media, WebPush endpoints, ...) + * without exposing internal network services. + * + * - {@link isPrivateAddress} classifies IPv4/IPv6 literals + * - {@link assertPublicUrl} validates the scheme and resolves the hostname, + * rejecting it if ANY resolved address is non-public + * - {@link safeFetch} wraps `fetch` with manual redirect handling and + * re-validates every redirect hop + * + * Set `ALLOW_PRIVATE_NETWORK_FETCH=true` to disable the private address + * checks (local development / federation testing on localhost only). + * + * Note: DNS is resolved once for validation and again by `fetch` itself, so a + * DNS rebinding attacker with a very short TTL could still race the check. + * This module significantly narrows the attack surface but is not a + * substitute for network-level egress filtering. + * + * @module lib/safeFetch + */ + +import { isIP } from "node:net"; +import { lookup as dnsLookup } from "node:dns/promises"; + +/** + * Error thrown when a URL is rejected by SSRF protection. + */ +export class UnsafeUrlError extends Error { + constructor(message: string) { + super(message); + this.name = "UnsafeUrlError"; + } +} + +/** + * DNS lookup function signature (subset of `dns.promises.lookup` with `all: true`). + */ +export type LookupFn = (hostname: string) => Promise>; + +/** + * Options for {@link assertPublicUrl}. + */ +export interface AssertPublicUrlOptions { + /** Custom DNS resolver (primarily for tests) */ + lookup?: LookupFn; + /** + * Allow private network destinations. Defaults to the + * `ALLOW_PRIVATE_NETWORK_FETCH` environment variable. + */ + allowPrivateNetwork?: boolean; +} + +/** + * Options for {@link safeFetch}. + */ +export interface SafeFetchOptions extends AssertPublicUrlOptions { + /** Maximum number of redirects to follow (default: 5). Use 0 to disable. */ + maxRedirects?: number; + /** Custom fetch implementation (primarily for tests). Defaults to global `fetch`. */ + fetchImpl?: typeof fetch; +} + +/** Default maximum number of redirect hops followed by {@link safeFetch}. */ +export const DEFAULT_MAX_REDIRECTS = 5; + +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); + +/** + * Check whether private network fetches are allowed via environment. + * + * @returns True when `ALLOW_PRIVATE_NETWORK_FETCH=true` + */ +export function isPrivateNetworkFetchAllowed(): boolean { + return process.env.ALLOW_PRIVATE_NETWORK_FETCH === "true"; +} + +/** + * Parse a dotted-quad IPv4 address into four octets. + */ +function parseIPv4(ip: string): [number, number, number, number] | null { + const parts = ip.split("."); + if (parts.length !== 4) return null; + const octets = parts.map((p) => (/^\d{1,3}$/.test(p) ? Number(p) : NaN)); + if (octets.some((o) => Number.isNaN(o) || o > 255)) return null; + return octets as [number, number, number, number]; +} + +/** + * Check whether an IPv4 address is non-public. + */ +function isPrivateIPv4(octets: [number, number, number, number]): boolean { + const [a, b, c] = octets; + return ( + a === 0 || // 0.0.0.0/8 "this network" / unspecified + a === 10 || // 10.0.0.0/8 private + (a === 100 && b >= 64 && b <= 127) || // 100.64.0.0/10 CGNAT + a === 127 || // 127.0.0.0/8 loopback + (a === 169 && b === 254) || // 169.254.0.0/16 link-local (cloud metadata) + (a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 private + (a === 192 && b === 0 && c === 0) || // 192.0.0.0/24 IETF protocol assignments + (a === 192 && b === 0 && c === 2) || // 192.0.2.0/24 TEST-NET-1 + (a === 192 && b === 88 && c === 99) || // 192.88.99.0/24 6to4 relay anycast + (a === 192 && b === 168) || // 192.168.0.0/16 private + (a === 198 && (b === 18 || b === 19)) || // 198.18.0.0/15 benchmarking + (a === 198 && b === 51 && c === 100) || // 198.51.100.0/24 TEST-NET-2 + (a === 203 && b === 0 && c === 113) || // 203.0.113.0/24 TEST-NET-3 + a >= 224 // 224.0.0.0/4 multicast, 240.0.0.0/4 reserved, broadcast + ); +} + +/** + * Parse an IPv6 address into eight 16-bit groups. + * Supports `::` compression and embedded IPv4 suffixes. + */ +function parseIPv6(ip: string): number[] | null { + let addr = ip; + // Embedded IPv4 (e.g. ::ffff:127.0.0.1) + const lastColon = addr.lastIndexOf(":"); + const tail = addr.slice(lastColon + 1); + if (tail.includes(".")) { + const v4 = parseIPv4(tail); + if (!v4) return null; + const hi = ((v4[0] << 8) | v4[1]).toString(16); + const lo = ((v4[2] << 8) | v4[3]).toString(16); + addr = `${addr.slice(0, lastColon + 1)}${hi}:${lo}`; + } + + const doubleColon = addr.indexOf("::"); + let groups: string[]; + if (doubleColon !== -1) { + if (addr.indexOf("::", doubleColon + 1) !== -1) return null; + const head = addr.slice(0, doubleColon); + const rest = addr.slice(doubleColon + 2); + const headParts = head ? head.split(":") : []; + const restParts = rest ? rest.split(":") : []; + const missing = 8 - headParts.length - restParts.length; + if (missing < 1) return null; + groups = [...headParts, ...Array(missing).fill("0"), ...restParts]; + } else { + groups = addr.split(":"); + } + + if (groups.length !== 8) return null; + const values = groups.map((g) => (/^[0-9a-f]{1,4}$/i.test(g) ? parseInt(g, 16) : NaN)); + if (values.some((v) => Number.isNaN(v))) return null; + return values; +} + +/** + * Extract an embedded IPv4 address from the last 32 bits of an IPv6 address. + */ +function embeddedIPv4(groups: number[], hiIndex = 6): [number, number, number, number] { + const hi = groups[hiIndex] ?? 0; + const lo = groups[hiIndex + 1] ?? 0; + return [hi >> 8, hi & 0xff, lo >> 8, lo & 0xff]; +} + +/** + * Check whether an IPv6 address is non-public. + */ +function isPrivateIPv6(g: number[]): boolean { + const [g0 = 0, g1 = 0, g2 = 0, g3 = 0, g4 = 0, g5 = 0] = g; + + // ::/96 — unspecified (::), loopback (::1), deprecated IPv4-compatible + if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0) return true; + // ::ffff:0:0/96 — IPv4-mapped + if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0xffff) { + return isPrivateIPv4(embeddedIPv4(g)); + } + // ::ffff:0:0:0/96 — IPv4-translated (SIIT) + if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0xffff && g5 === 0) return true; + // 64:ff9b::/96 — NAT64 well-known prefix + if (g0 === 0x64 && g1 === 0xff9b && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0) { + return isPrivateIPv4(embeddedIPv4(g)); + } + // 64:ff9b:1::/48 — local-use NAT64 + if (g0 === 0x64 && g1 === 0xff9b && g2 === 1) return true; + // 100::/64 — discard-only + if (g0 === 0x100 && g1 === 0 && g2 === 0 && g3 === 0) return true; + // 2001::/32 — Teredo (may tunnel to arbitrary IPv4) + if (g0 === 0x2001 && g1 === 0) return true; + // 2001:db8::/32 — documentation + if (g0 === 0x2001 && g1 === 0xdb8) return true; + // 2002::/16 — 6to4, check embedded IPv4 + if (g0 === 0x2002) return isPrivateIPv4(embeddedIPv4(g, 1)); + // fc00::/7 — unique local addresses + if ((g0 & 0xfe00) === 0xfc00) return true; + // fe80::/10 — link-local, fec0::/10 — deprecated site-local + if ((g0 & 0xffc0) === 0xfe80 || (g0 & 0xffc0) === 0xfec0) return true; + // ff00::/8 — multicast + if ((g0 & 0xff00) === 0xff00) return true; + + return false; +} + +/** + * Check whether an IP address literal is non-public. + * + * Returns true for loopback, private (RFC 1918 / ULA), link-local, + * unspecified, CGNAT, multicast, reserved, documentation ranges and + * IPv4-mapped / NAT64 / 6to4 IPv6 addresses wrapping such IPv4 addresses. + * Surrounding brackets and IPv6 zone identifiers are ignored. + * + * Unparseable input is treated as private (fail closed). + * + * @param ip - IPv4 or IPv6 address literal + * @returns True if the address must not be contacted + * + * @example + * ```typescript + * isPrivateAddress("127.0.0.1"); // true + * isPrivateAddress("[::ffff:10.0.0.1]"); // true + * isPrivateAddress("8.8.8.8"); // false + * ``` + */ +export function isPrivateAddress(ip: string): boolean { + let addr = ip.trim(); + if (addr.startsWith("[") && addr.endsWith("]")) addr = addr.slice(1, -1); + const zoneIndex = addr.indexOf("%"); + if (zoneIndex !== -1) addr = addr.slice(0, zoneIndex); + + const version = isIP(addr); + if (version === 4) { + const v4 = parseIPv4(addr); + return v4 ? isPrivateIPv4(v4) : true; + } + if (version === 6) { + const v6 = parseIPv6(addr.toLowerCase()); + return v6 ? isPrivateIPv6(v6) : true; + } + return true; +} + +/** + * Default DNS resolver using `dns.promises.lookup` with all addresses. + */ +const defaultLookup: LookupFn = (hostname) => dnsLookup(hostname, { all: true, verbatim: true }); + +/** + * Validate that a URL is safe to fetch from the server. + * + * - Only `http:` and `https:` are allowed + * - IP literal hosts are checked directly (brackets stripped) + * - `localhost` / `*.localhost` are rejected without resolving + * - Hostnames are resolved and rejected if ANY resolved address is private + * + * Private address checks are skipped when `ALLOW_PRIVATE_NETWORK_FETCH=true` + * (or `options.allowPrivateNetwork` is true); the scheme check always applies. + * + * @param input - URL to validate + * @param options - Validation options + * @returns The parsed URL + * @throws {UnsafeUrlError} If the URL is invalid or targets a non-public address + * + * @example + * ```typescript + * await assertPublicUrl("https://mastodon.social/users/alice"); // ok + * await assertPublicUrl("http://169.254.169.254/latest/meta-data"); // throws + * ``` + */ +export async function assertPublicUrl( + input: string | URL, + options: AssertPublicUrlOptions = {}, +): Promise { + let url: URL; + try { + url = new URL(input.toString()); + } catch { + throw new UnsafeUrlError("Invalid URL"); + } + + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new UnsafeUrlError("Only HTTP/HTTPS URLs are allowed"); + } + + const allowPrivate = options.allowPrivateNetwork ?? isPrivateNetworkFetchAllowed(); + if (allowPrivate) return url; + + let hostname = url.hostname.toLowerCase(); + if (hostname.startsWith("[") && hostname.endsWith("]")) hostname = hostname.slice(1, -1); + if (hostname.endsWith(".")) hostname = hostname.slice(0, -1); + + if (!hostname) { + throw new UnsafeUrlError("Missing hostname"); + } + + if (isIP(hostname) !== 0) { + if (isPrivateAddress(hostname)) { + throw new UnsafeUrlError("Private addresses are not allowed"); + } + return url; + } + + if (hostname === "localhost" || hostname.endsWith(".localhost")) { + throw new UnsafeUrlError("Private addresses are not allowed"); + } + + let addresses: Array<{ address: string; family: number }>; + try { + addresses = await (options.lookup ?? defaultLookup)(hostname); + } catch { + throw new UnsafeUrlError("Failed to resolve hostname"); + } + + if (addresses.length === 0) { + throw new UnsafeUrlError("Failed to resolve hostname"); + } + if (addresses.some((a) => isPrivateAddress(a.address))) { + throw new UnsafeUrlError("Private addresses are not allowed"); + } + + return url; +} + +/** + * SSRF-safe `fetch` wrapper. + * + * Validates the initial URL with {@link assertPublicUrl}, performs the request + * with `redirect: "manual"` and re-validates every redirect target before + * following it (up to `maxRedirects` hops). On redirect, the `Host` header is + * dropped and credentials (`Authorization`, `Cookie`) are removed when the + * origin changes. 303 responses (and 301/302 for POST) switch to GET without + * a body, matching browser semantics. + * + * @param input - URL to fetch + * @param init - Fetch init (the `redirect` option is ignored) + * @param options - Redirect and validation options + * @returns The final (non-redirect) response, or the last 3xx response if it has no Location + * @throws {UnsafeUrlError} If any hop targets a non-public address or the redirect limit is exceeded + * + * @example + * ```typescript + * const res = await safeFetch("https://remote.example/image.png", { + * headers: { Accept: "image/*" }, + * }); + * ``` + */ +export async function safeFetch( + input: string | URL, + init: RequestInit = {}, + options: SafeFetchOptions = {}, +): Promise { + const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS; + const fetchImpl = options.fetchImpl ?? fetch; + + let current = await assertPublicUrl(input, options); + let method = (init.method ?? "GET").toUpperCase(); + let body = init.body; + const headers = new Headers(init.headers); + + for (let hop = 0; ; hop++) { + const response = await fetchImpl(current.toString(), { + ...init, + method, + body, + headers, + redirect: "manual", + }); + + if (!REDIRECT_STATUSES.has(response.status)) { + return response; + } + + const location = response.headers.get("location"); + if (!location) { + return response; + } + + // Discard the redirect body + try { + await response.body?.cancel(); + } catch { + // ignore + } + + if (hop >= maxRedirects) { + throw new UnsafeUrlError("Too many redirects"); + } + + let next: URL; + try { + next = new URL(location, current); + } catch { + throw new UnsafeUrlError("Invalid redirect location"); + } + + if ( + response.status === 303 || + ((response.status === 301 || response.status === 302) && method === "POST") + ) { + method = "GET"; + body = undefined; + headers.delete("content-type"); + headers.delete("content-length"); + headers.delete("digest"); + } + + headers.delete("host"); + if (next.origin !== current.origin) { + headers.delete("authorization"); + headers.delete("cookie"); + } + + current = await assertPublicUrl(next, options); + } +} diff --git a/packages/backend/src/middleware/securityHeaders.ts b/packages/backend/src/middleware/securityHeaders.ts index 739e53da..70eeca98 100644 --- a/packages/backend/src/middleware/securityHeaders.ts +++ b/packages/backend/src/middleware/securityHeaders.ts @@ -65,7 +65,7 @@ const defaultOptions: Required = { * - X-Frame-Options: DENY (prevents clickjacking) * - X-XSS-Protection: 0 (legacy, modern browsers use CSP) * - Referrer-Policy: strict-origin-when-cross-origin - * - Content-Security-Policy: restrictive policy + * - Content-Security-Policy: restrictive policy (skipped if the route already set one) * - Strict-Transport-Security: enables HSTS (production only) * - Permissions-Policy: disables sensitive features * @@ -101,7 +101,8 @@ export function securityHeaders(options: SecurityHeadersOptions = {}) { c.header("Referrer-Policy", config.referrerPolicy); // Content Security Policy - if (config.contentSecurityPolicy) { + // Do not overwrite a stricter route-specific policy (e.g. the media proxy) + if (config.contentSecurityPolicy && !c.res.headers.has("Content-Security-Policy")) { c.header("Content-Security-Policy", config.contentSecurityPolicy); } diff --git a/packages/backend/src/routes/emojis.ts b/packages/backend/src/routes/emojis.ts index 6b6c85b8..2a36b339 100644 --- a/packages/backend/src/routes/emojis.ts +++ b/packages/backend/src/routes/emojis.ts @@ -13,6 +13,7 @@ import { generateId } from "shared"; import type { ICustomEmojiRepository } from "../interfaces/repositories/ICustomEmojiRepository.js"; import type { IFileStorage } from "../interfaces/IFileStorage.js"; import { logger } from "../lib/logger.js"; +import { safeFetch } from "../lib/safeFetch.js"; import { optionalAuth } from "../middleware/auth.js"; /** Allowed emoji file types */ @@ -737,7 +738,7 @@ app.post("/adopt", async (c: Context) => { let contentType: string; try { - const response = await fetch(remoteUrl, { + const response = await safeFetch(remoteUrl, { headers: { "User-Agent": "Rox/1.0 (ActivityPub)", }, diff --git a/packages/backend/src/routes/proxy.ts b/packages/backend/src/routes/proxy.ts index 95898f85..61197076 100644 --- a/packages/backend/src/routes/proxy.ts +++ b/packages/backend/src/routes/proxy.ts @@ -6,6 +6,15 @@ * - Reduce load on remote servers * - Provide content-type validation for security * + * Security notes: + * - Outbound requests go through {@link safeFetch}, which blocks private / + * loopback / link-local destinations and re-validates every redirect hop + * - Only raster image, audio and video MIME types are allowed. SVG is + * intentionally excluded because it can carry script and would execute on + * the application origin + * - Every proxied response carries a restrictive sandboxed CSP so that even a + * mislabeled payload cannot run script on the application origin + * * @module routes/proxy */ @@ -13,38 +22,56 @@ import { Hono } from "hono"; import type { Context } from "hono"; import { createHash } from "node:crypto"; import { logger } from "../lib/logger.js"; +import { safeFetch, UnsafeUrlError } from "../lib/safeFetch.js"; +import type { ICacheService } from "../interfaces/ICacheService.js"; const proxy = new Hono(); /** - * Allowed MIME types for proxying + * Allowed MIME types for proxying, mapped to the file extension used in the + * `Content-Disposition` filename. + * + * `image/svg+xml` is deliberately NOT allowed (stored XSS on the app origin). */ -const ALLOWED_MIME_TYPES = new Set([ +const ALLOWED_MIME_TYPES = new Map([ // Images - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - "image/avif", - "image/svg+xml", - "image/x-icon", - "image/vnd.microsoft.icon", + ["image/jpeg", "jpg"], + ["image/png", "png"], + ["image/gif", "gif"], + ["image/webp", "webp"], + ["image/avif", "avif"], + ["image/x-icon", "ico"], + ["image/vnd.microsoft.icon", "ico"], // Audio - "audio/mpeg", - "audio/ogg", - "audio/wav", - "audio/webm", + ["audio/mpeg", "mp3"], + ["audio/ogg", "ogg"], + ["audio/wav", "wav"], + ["audio/webm", "webm"], // Video - "video/mp4", - "video/webm", - "video/ogg", + ["video/mp4", "mp4"], + ["video/webm", "webm"], + ["video/ogg", "ogv"], ]); +/** + * Content Security Policy applied to every proxy response. + * + * `sandbox` prevents script execution and gives the document an opaque + * origin even if it is navigated to directly. + */ +export const PROXY_CSP = + "default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'; sandbox"; + /** * Maximum file size for proxying (10MB) */ const MAX_PROXY_SIZE = 10 * 1024 * 1024; +/** + * Upstream request timeout (10 seconds) + */ +const PROXY_TIMEOUT = 10000; + /** * Cache duration headers (1 day for success, 1 hour for errors) */ @@ -59,51 +86,51 @@ function getCacheKey(url: string): string { } /** - * GET /proxy + * Apply security headers shared by every proxy response. * - * Proxy remote media files with caching and validation. - * - * @query {string} url - URL of the remote media to proxy (URL-encoded) - * @returns Proxied media content with appropriate headers - * - * @example - * GET /proxy?url=https%3A%2F%2Fremote.server%2Fimage.jpg + * @param c - Hono context + * @param contentType - Content type of the body (if it is media) */ -proxy.get("/", async (c: Context) => { - const url = c.req.query("url"); - - if (!url) { - return c.json({ error: "url parameter is required" }, 400); +function applyProxySecurityHeaders(c: Context, contentType?: string): void { + c.header("Content-Security-Policy", PROXY_CSP); + c.header("X-Content-Type-Options", "nosniff"); + if (contentType) { + const ext = ALLOWED_MIME_TYPES.get(contentType) ?? "bin"; + c.header("Content-Disposition", `inline; filename="media.${ext}"`); } +} + +/** + * Result of fetching media for the proxy. + */ +type ProxyMediaResult = + | { ok: true; buffer: Buffer; contentType: string; cacheHit: boolean } + | { ok: false; status: 400 | 403 | 413 | 415 | 502 | 504; error: string }; +/** + * Fetch (or load from cache) remote media with validation. + * + * @param url - Remote media URL + * @param cacheService - Optional cache service + * @returns Media buffer and content type, or an error description safe to return to clients + */ +async function fetchProxiedMedia( + url: string, + cacheService: ICacheService | undefined, +): Promise { // Validate URL format let parsedUrl: URL; try { parsedUrl = new URL(url); } catch { - return c.json({ error: "Invalid URL format" }, 400); + return { ok: false, status: 400, error: "Invalid URL format" }; } // Only allow http/https if (!["http:", "https:"].includes(parsedUrl.protocol)) { - return c.json({ error: "Only HTTP/HTTPS URLs are allowed" }, 400); + return { ok: false, status: 400, error: "Only HTTP/HTTPS URLs are allowed" }; } - // Block localhost/private IPs for security - const hostname = parsedUrl.hostname.toLowerCase(); - if ( - hostname === "localhost" || - hostname === "127.0.0.1" || - hostname === "::1" || - hostname.startsWith("192.168.") || - hostname.startsWith("10.") || - hostname.startsWith("172.") || - hostname.endsWith(".local") - ) { - return c.json({ error: "Private addresses are not allowed" }, 403); - } - - const cacheService = c.get("cacheService"); const cacheKey = `proxy:${getCacheKey(url)}`; // Check cache first @@ -113,51 +140,56 @@ proxy.get("/", async (c: Context) => { contentType: string; }>(cacheKey); - if (cached) { - const buffer = Buffer.from(cached.data, "base64"); - c.header("Content-Type", cached.contentType); - c.header("Cache-Control", CACHE_SUCCESS); - c.header("X-Cache", "HIT"); - return c.body(buffer); + // Re-validate the cached content type: entries written before the + // allowlist was tightened (e.g. SVG) must not be served. + if (cached && ALLOWED_MIME_TYPES.has(cached.contentType)) { + return { + ok: true, + buffer: Buffer.from(cached.data, "base64"), + contentType: cached.contentType, + cacheHit: true, + }; } } - // Fetch from remote - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), PROXY_TIMEOUT); - const response = await fetch(url, { - signal: controller.signal, - headers: { - "User-Agent": "Rox/1.0 (ActivityPub; +https://github.com/example/rox)", - Accept: "image/*,video/*,audio/*", - }, - }); - - clearTimeout(timeout); + try { + let response: Response; + try { + response = await safeFetch(url, { + signal: controller.signal, + headers: { + "User-Agent": "Rox/1.0 (ActivityPub; +https://github.com/Love-Rox/rox)", + Accept: "image/*,video/*,audio/*", + }, + }); + } catch (error) { + if (error instanceof UnsafeUrlError) { + return { ok: false, status: 403, error: "URL is not allowed" }; + } + throw error; + } if (!response.ok) { - c.header("Cache-Control", CACHE_ERROR); - return c.json( - { error: `Remote server returned ${response.status}` }, - response.status as 400 | 404 | 500, - ); + await response.body?.cancel().catch(() => {}); + return { ok: false, status: 502, error: "Failed to fetch remote media" }; } // Validate content type const rawContentType = response.headers.get("content-type") || ""; - const contentType = (rawContentType.split(";")[0] ?? "").trim(); + const contentType = (rawContentType.split(";")[0] ?? "").trim().toLowerCase(); if (!contentType || !ALLOWED_MIME_TYPES.has(contentType)) { - c.header("Cache-Control", CACHE_ERROR); - return c.json({ error: `Content type not allowed: ${contentType}` }, 415); + await response.body?.cancel().catch(() => {}); + return { ok: false, status: 415, error: "Unsupported media type" }; } // Check content length const contentLength = response.headers.get("content-length"); if (contentLength && parseInt(contentLength, 10) > MAX_PROXY_SIZE) { - c.header("Cache-Control", CACHE_ERROR); - return c.json({ error: "File too large" }, 413); + await response.body?.cancel().catch(() => {}); + return { ok: false, status: 413, error: "File too large" }; } // Read body with size limit @@ -166,7 +198,7 @@ proxy.get("/", async (c: Context) => { const reader = response.body?.getReader(); if (!reader) { - return c.json({ error: "Failed to read response" }, 500); + return { ok: false, status: 502, error: "Failed to fetch remote media" }; } while (true) { @@ -175,15 +207,13 @@ proxy.get("/", async (c: Context) => { totalSize += value.length; if (totalSize > MAX_PROXY_SIZE) { - reader.cancel(); - c.header("Cache-Control", CACHE_ERROR); - return c.json({ error: "File too large" }, 413); + await reader.cancel(); + return { ok: false, status: 413, error: "File too large" }; } chunks.push(value); } - // Combine chunks const buffer = Buffer.concat(chunks); // Cache the result @@ -198,23 +228,52 @@ proxy.get("/", async (c: Context) => { ); } - // Return response - c.header("Content-Type", contentType); - c.header("Cache-Control", CACHE_SUCCESS); - c.header("X-Cache", "MISS"); - c.header("X-Content-Type-Options", "nosniff"); - return c.body(buffer); + return { ok: true, buffer, contentType, cacheHit: false }; } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + return { ok: false, status: 504, error: "Request timeout" }; + } logger.error({ err: error, url }, "Proxy fetch error"); + return { ok: false, status: 502, error: "Failed to fetch remote media" }; + } finally { + clearTimeout(timeout); + } +} - if (error instanceof Error && error.name === "AbortError") { +/** + * GET /proxy + * + * Proxy remote media files with caching and validation. + * + * @query {string} url - URL of the remote media to proxy (URL-encoded) + * @returns Proxied media content with appropriate headers + * + * @example + * GET /proxy?url=https%3A%2F%2Fremote.server%2Fimage.jpg + */ +proxy.get("/", async (c: Context) => { + const url = c.req.query("url"); + + applyProxySecurityHeaders(c); + + if (!url) { + return c.json({ error: "url parameter is required" }, 400); + } + + const result = await fetchProxiedMedia(url, c.get("cacheService")); + + if (!result.ok) { + if (result.status !== 400) { c.header("Cache-Control", CACHE_ERROR); - return c.json({ error: "Request timeout" }, 504); } - - c.header("Cache-Control", CACHE_ERROR); - return c.json({ error: "Failed to fetch remote media" }, 502); + return c.json({ error: result.error }, result.status); } + + applyProxySecurityHeaders(c, result.contentType); + c.header("Content-Type", result.contentType); + c.header("Cache-Control", CACHE_SUCCESS); + c.header("X-Cache", result.cacheHit ? "HIT" : "MISS"); + return c.body(new Uint8Array(result.buffer)); }); /** @@ -228,31 +287,21 @@ proxy.get("/", async (c: Context) => { proxy.get("/avatar", async (c: Context) => { const url = c.req.query("url"); - if (!url) { - // Return default avatar placeholder - c.header("Content-Type", "image/svg+xml"); - c.header("Cache-Control", CACHE_SUCCESS); - return c.body(DEFAULT_AVATAR_SVG); - } - - // Delegate to main proxy handler - // In case of error, return default avatar - try { - const proxyUrl = new URL("/proxy", c.req.url); - proxyUrl.searchParams.set("url", url); + applyProxySecurityHeaders(c); - const response = await fetch(proxyUrl.toString()); - if (response.ok) { - const contentType = response.headers.get("content-type") || "image/png"; - c.header("Content-Type", contentType); + if (url) { + const result = await fetchProxiedMedia(url, c.get("cacheService")); + if (result.ok) { + applyProxySecurityHeaders(c, result.contentType); + c.header("Content-Type", result.contentType); c.header("Cache-Control", CACHE_SUCCESS); - return c.body(await response.arrayBuffer()); + return c.body(new Uint8Array(result.buffer)); } - } catch { - // Fall through to default avatar } + // Return default avatar placeholder (static, trusted SVG) c.header("Content-Type", "image/svg+xml"); + c.header("Content-Disposition", 'inline; filename="avatar.svg"'); c.header("Cache-Control", CACHE_SUCCESS); return c.body(DEFAULT_AVATAR_SVG); }); diff --git a/packages/backend/src/services/RemoteInstanceRefreshService.ts b/packages/backend/src/services/RemoteInstanceRefreshService.ts index 45693af7..cde529c4 100644 --- a/packages/backend/src/services/RemoteInstanceRefreshService.ts +++ b/packages/backend/src/services/RemoteInstanceRefreshService.ts @@ -1,5 +1,6 @@ import type { IRemoteInstanceRepository } from "../interfaces/repositories/IRemoteInstanceRepository.js"; import { logger } from "../lib/logger.js"; +import { safeFetch } from "../lib/safeFetch.js"; import { fetchInstanceIcon } from "../lib/instance-icon.js"; import { withMonitor } from "../lib/sentry.js"; @@ -280,7 +281,7 @@ export class RemoteInstanceRefreshService { try { // First, fetch well-known nodeinfo to get the actual nodeinfo URL const wellKnownUrl = `https://${host}/.well-known/nodeinfo`; - const wellKnownResponse = await fetch(wellKnownUrl, { + const wellKnownResponse = await safeFetch(wellKnownUrl, { headers: { Accept: "application/json", }, @@ -305,7 +306,7 @@ export class RemoteInstanceRefreshService { } // Fetch actual nodeinfo - const nodeInfoResponse = await fetch(nodeInfoLink.href, { + const nodeInfoResponse = await safeFetch(nodeInfoLink.href, { headers: { Accept: "application/json", }, diff --git a/packages/backend/src/services/RemoteInstanceService.ts b/packages/backend/src/services/RemoteInstanceService.ts index 1bf44f6c..ebd0fafc 100644 --- a/packages/backend/src/services/RemoteInstanceService.ts +++ b/packages/backend/src/services/RemoteInstanceService.ts @@ -1,6 +1,7 @@ import type { IRemoteInstanceRepository } from "../interfaces/repositories/IRemoteInstanceRepository.js"; import type { RemoteInstance, PublicRemoteInstance } from "shared"; import { logger } from "../lib/logger.js"; +import { safeFetch } from "../lib/safeFetch.js"; import { fetchInstanceIcon } from "../lib/instance-icon.js"; /** @@ -213,7 +214,7 @@ export class RemoteInstanceService { try { // First, fetch well-known nodeinfo to get the actual nodeinfo URL const wellKnownUrl = `https://${host}/.well-known/nodeinfo`; - const wellKnownResponse = await fetch(wellKnownUrl, { + const wellKnownResponse = await safeFetch(wellKnownUrl, { headers: { Accept: "application/json", }, @@ -238,7 +239,7 @@ export class RemoteInstanceService { } // Fetch actual nodeinfo - const nodeInfoResponse = await fetch(nodeInfoLink.href, { + const nodeInfoResponse = await safeFetch(nodeInfoLink.href, { headers: { Accept: "application/json", }, diff --git a/packages/backend/src/services/WebPushService.ts b/packages/backend/src/services/WebPushService.ts index bb18216d..f25b16e5 100644 --- a/packages/backend/src/services/WebPushService.ts +++ b/packages/backend/src/services/WebPushService.ts @@ -9,6 +9,7 @@ import webpush from "web-push"; import { eq, and } from "drizzle-orm"; import { generateId } from "shared"; import { logger } from "../lib/logger.js"; +import { safeFetch } from "../lib/safeFetch.js"; import type { Database } from "../db/index.js"; import type { PushSubscription, NewPushSubscription, NotificationType } from "../db/schema/pg.js"; import type { InstanceSettingsService } from "./InstanceSettingsService.js"; @@ -230,13 +231,18 @@ export class WebPushService { // Convert Buffer to Uint8Array for fetch compatibility const body = requestDetails.body ? new Uint8Array(requestDetails.body) : undefined; - // Web Push always uses POST method - const response = await fetch(requestDetails.endpoint, { - method: "POST", - headers: requestDetails.headers as Record, - body, - signal: controller.signal, - }); + // Web Push always uses POST method. + // Endpoints are user-supplied, so guard against SSRF and never follow redirects. + const response = await safeFetch( + requestDetails.endpoint, + { + method: "POST", + headers: requestDetails.headers as Record, + body, + signal: controller.signal, + }, + { maxRedirects: 0 }, + ); clearTimeout(timeoutId); diff --git a/packages/backend/src/services/ap/ActivityDeliveryService.ts b/packages/backend/src/services/ap/ActivityDeliveryService.ts index 9f0f849b..d725cf71 100644 --- a/packages/backend/src/services/ap/ActivityDeliveryService.ts +++ b/packages/backend/src/services/ap/ActivityDeliveryService.ts @@ -10,6 +10,7 @@ import { signRequest, getSignedHeaders } from "../../utils/crypto.js"; import { logger } from "../../lib/logger.js"; import { recordActivityDelivery } from "../../lib/metrics.js"; +import { safeFetch } from "../../lib/safeFetch.js"; /** * Delivery timeout in milliseconds (30 seconds) @@ -79,7 +80,8 @@ export class ActivityDeliveryService { let response: Response; try { - response = await fetch(inboxUrl, { + // SSRF protection: inbox URLs come from remote actor documents + response = await safeFetch(inboxUrl, { method: "POST", headers: headersObj, body, diff --git a/packages/backend/src/services/ap/RemoteActorService.ts b/packages/backend/src/services/ap/RemoteActorService.ts index 61757207..07bd26a0 100644 --- a/packages/backend/src/services/ap/RemoteActorService.ts +++ b/packages/backend/src/services/ap/RemoteActorService.ts @@ -565,6 +565,7 @@ export class RemoteActorService { headers: { Accept: "application/jrd+json, application/json", }, + allowedContentTypes: ["application/jrd+json", "application/json"], }, ); diff --git a/packages/backend/src/services/ap/RemoteFetchService.ts b/packages/backend/src/services/ap/RemoteFetchService.ts index 0db8c361..306d715d 100644 --- a/packages/backend/src/services/ap/RemoteFetchService.ts +++ b/packages/backend/src/services/ap/RemoteFetchService.ts @@ -14,6 +14,17 @@ import { signRequest, getSignedHeaders } from "../../utils/crypto.js"; import { logger } from "../../lib/logger.js"; +import { safeFetch, UnsafeUrlError } from "../../lib/safeFetch.js"; + +/** + * Content types accepted for ActivityPub object responses by default. + * Some servers respond with plain `application/json`, which is accepted too. + */ +export const ACTIVITYPUB_CONTENT_TYPES = [ + "application/activity+json", + "application/ld+json", + "application/json", +]; /** * Signature configuration for authenticated fetches @@ -39,6 +50,11 @@ export interface RemoteFetchOptions { headers?: Record; /** HTTP Signature configuration for authenticated requests */ signature?: SignatureConfig; + /** + * Accepted response media types (without parameters). + * Defaults to {@link ACTIVITYPUB_CONTENT_TYPES}. + */ + allowedContentTypes?: string[]; } /** @@ -104,6 +120,7 @@ export class RemoteFetchService { initialRetryDelay = RemoteFetchService.DEFAULT_INITIAL_RETRY_DELAY, headers = {}, signature, + allowedContentTypes = ACTIVITYPUB_CONTENT_TYPES, } = options; let lastError: RemoteFetchResult["error"]; @@ -139,7 +156,12 @@ export class RemoteFetchService { }; } - const result = await this.performFetch(url, timeout, requestHeaders); + const result = await this.performFetch( + url, + timeout, + requestHeaders, + allowedContentTypes, + ); if (result.success) { return result; @@ -197,6 +219,7 @@ export class RemoteFetchService { * @param url - URL to fetch * @param timeout - Timeout in milliseconds * @param headers - HTTP headers + * @param allowedContentTypes - Accepted response media types * @returns Fetch result * * @private @@ -205,6 +228,7 @@ export class RemoteFetchService { url: string, timeout: number, headers: Record, + allowedContentTypes: string[], ): Promise> { try { // Create abort controller for timeout @@ -213,10 +237,22 @@ export class RemoteFetchService { let response: Response; try { - response = await fetch(url, { + // SSRF protection: blocks private destinations and re-validates redirects + response = await safeFetch(url, { headers, signal: controller.signal, }); + } catch (error) { + if (error instanceof UnsafeUrlError) { + return { + success: false, + error: { + type: "invalid_response", + message: `URL rejected: ${error.message}`, + }, + }; + } + throw error; } finally { clearTimeout(timeoutId); } @@ -260,6 +296,23 @@ export class RemoteFetchService { }; } + // Validate content type before parsing + const contentType = (response.headers.get("content-type") ?? "") + .split(";")[0]! + .trim() + .toLowerCase(); + if (!allowedContentTypes.includes(contentType)) { + await response.body?.cancel().catch(() => {}); + return { + success: false, + error: { + type: "invalid_response", + message: `Unexpected content type: ${contentType || "(none)"}`, + statusCode: response.status, + }, + }; + } + // Parse JSON response let data: T; try { diff --git a/packages/backend/src/services/ap/RemoteLikesService.ts b/packages/backend/src/services/ap/RemoteLikesService.ts index fb95de29..c82f8c69 100644 --- a/packages/backend/src/services/ap/RemoteLikesService.ts +++ b/packages/backend/src/services/ap/RemoteLikesService.ts @@ -15,6 +15,7 @@ import type { IUserRepository } from "../../interfaces/repositories/IUserReposit import type { ICustomEmojiRepository } from "../../interfaces/repositories/ICustomEmojiRepository.js"; import { generateId } from "shared"; import { logger } from "../../lib/logger.js"; +import { safeFetch } from "../../lib/safeFetch.js"; /** * ActivityPub Collection interface (OrderedCollection or Collection) @@ -358,7 +359,7 @@ export class RemoteLikesService { // Try Misskey API endpoint const apiUrl = `${url.origin}/api/notes/reactions`; - const response = await fetch(apiUrl, { + const response = await safeFetch(apiUrl, { method: "POST", headers: { "Content-Type": "application/json", @@ -430,7 +431,7 @@ export class RemoteLikesService { ): Promise> { try { const apiUrl = `${baseUrl}/api/emojis`; - const response = await fetch(apiUrl, { + const response = await safeFetch(apiUrl, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/packages/backend/src/tests/unit/proxy.test.ts b/packages/backend/src/tests/unit/proxy.test.ts new file mode 100644 index 00000000..76d15b3d --- /dev/null +++ b/packages/backend/src/tests/unit/proxy.test.ts @@ -0,0 +1,131 @@ +/** + * Unit tests for the media proxy route + * + * Verifies SSRF protection, content-type allowlisting (no SVG) and the + * security headers applied to proxied responses. + * + * @module tests/unit/proxy + */ + +import { describe, test, expect, spyOn, beforeEach, afterEach } from "bun:test"; +import { Hono } from "hono"; +import proxyRoute, { PROXY_CSP } from "../../routes/proxy.js"; +import { securityHeaders } from "../../middleware/securityHeaders.js"; + +function createApp() { + const app = new Hono(); + app.use("*", securityHeaders()); + app.route("/api/proxy", proxyRoute); + return app; +} + +function proxyPath(url: string, sub = ""): string { + return `/api/proxy${sub}?url=${encodeURIComponent(url)}`; +} + +describe("Media proxy", () => { + let fetchSpy: ReturnType; + const originalEnv = process.env.ALLOW_PRIVATE_NETWORK_FETCH; + + beforeEach(() => { + delete process.env.ALLOW_PRIVATE_NETWORK_FETCH; + }); + + afterEach(() => { + fetchSpy?.mockRestore(); + if (originalEnv === undefined) delete process.env.ALLOW_PRIVATE_NETWORK_FETCH; + else process.env.ALLOW_PRIVATE_NETWORK_FETCH = originalEnv; + }); + + test("rejects SVG content type and does not echo upstream details", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockImplementation( + (async () => + new Response('', { + status: 200, + headers: { "Content-Type": "image/svg+xml" }, + })) as any, + ); + + const res = await createApp().request(proxyPath("https://93.184.216.34/evil.svg")); + expect(res.status).toBe(415); + const body = await res.text(); + expect(body).not.toContain("svg"); + expect(res.headers.get("content-type")).toContain("application/json"); + expect(res.headers.get("content-security-policy")).toBe(PROXY_CSP); + }); + + test("serves allowed images with sandboxed CSP, nosniff and Content-Disposition", async () => { + const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + fetchSpy = spyOn(globalThis, "fetch").mockImplementation( + (async () => + new Response(png, { status: 200, headers: { "Content-Type": "image/png" } })) as any, + ); + + const res = await createApp().request(proxyPath("https://93.184.216.34/ok.png")); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("image/png"); + // Global securityHeaders middleware must not overwrite the proxy CSP + expect(res.headers.get("content-security-policy")).toBe(PROXY_CSP); + expect(res.headers.get("x-content-type-options")).toBe("nosniff"); + expect(res.headers.get("content-disposition")).toBe('inline; filename="media.png"'); + expect(new Uint8Array(await res.arrayBuffer())).toEqual(png); + }); + + test.each([ + "http://169.254.169.254/latest/meta-data/", + "http://[::1]/", + "http://[::ffff:127.0.0.1]/", + "http://0.0.0.0/", + "http://100.64.0.1/", + "http://localhost/", + ])("blocks private destination %s without fetching", async (url) => { + fetchSpy = spyOn(globalThis, "fetch").mockImplementation( + (async () => new Response("secret")) as any, + ); + + const res = await createApp().request(proxyPath(url)); + expect(res.status).toBe(403); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + test("blocks redirects to private addresses", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockImplementation( + (async () => + new Response(null, { + status: 302, + headers: { Location: "http://169.254.169.254/" }, + })) as any, + ); + + const res = await createApp().request(proxyPath("https://93.184.216.34/redirect.png")); + expect(res.status).toBe(403); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + test("returns generic 502 without upstream status on upstream errors", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockImplementation( + (async () => new Response("teapot", { status: 418 })) as any, + ); + + const res = await createApp().request(proxyPath("https://93.184.216.34/x.png")); + expect(res.status).toBe(502); + expect(await res.text()).not.toContain("418"); + }); + + test("avatar endpoint falls back to default avatar for SVG upstream", async () => { + fetchSpy = spyOn(globalThis, "fetch").mockImplementation( + (async () => + new Response("", { + status: 200, + headers: { "Content-Type": "image/svg+xml" }, + })) as any, + ); + + const res = await createApp().request(proxyPath("https://93.184.216.34/a.svg", "/avatar")); + expect(res.status).toBe(200); + expect(res.headers.get("content-security-policy")).toBe(PROXY_CSP); + const body = await res.text(); + expect(body).not.toContain("script"); + expect(body).toContain(" [{ address: "93.184.216.34", family: 4 }]; + +describe("isPrivateAddress", () => { + test.each([ + "127.0.0.1", + "127.1.2.3", + "10.0.0.1", + "172.16.0.1", + "172.31.255.255", + "192.168.1.1", + "169.254.169.254", + "0.0.0.0", + "100.64.0.1", + "100.127.255.255", + "192.0.2.1", + "198.18.0.1", + "224.0.0.1", + "255.255.255.255", + "::1", + "[::1]", + "::", + "::ffff:127.0.0.1", + "[::ffff:127.0.0.1]", + "::ffff:7f00:1", + "::ffff:169.254.169.254", + "::ffff:10.0.0.1", + "fd00::1", + "fc00::1", + "fe80::1", + "fe80::1%eth0", + "ff02::1", + "2001:db8::1", + "64:ff9b::7f00:1", + "2002:7f00:1::1", + ])("blocks %s", (ip) => { + expect(isPrivateAddress(ip)).toBe(true); + }); + + test.each([ + "8.8.8.8", + "1.1.1.1", + "93.184.216.34", + "172.32.0.1", + "100.63.255.255", + "100.128.0.1", + "2606:4700:4700::1111", + "2001:4860:4860::8888", + "::ffff:8.8.8.8", + "64:ff9b::808:808", + ])("allows %s", (ip) => { + expect(isPrivateAddress(ip)).toBe(false); + }); + + test("treats unparseable input as private", () => { + expect(isPrivateAddress("not-an-ip")).toBe(true); + expect(isPrivateAddress("999.1.1.1")).toBe(true); + }); +}); + +describe("assertPublicUrl", () => { + const originalEnv = process.env.ALLOW_PRIVATE_NETWORK_FETCH; + + beforeEach(() => { + delete process.env.ALLOW_PRIVATE_NETWORK_FETCH; + }); + + afterEach(() => { + if (originalEnv === undefined) delete process.env.ALLOW_PRIVATE_NETWORK_FETCH; + else process.env.ALLOW_PRIVATE_NETWORK_FETCH = originalEnv; + }); + + test.each([ + "http://127.0.0.1/", + "http://[::1]/", + "http://[::ffff:127.0.0.1]/", + "http://169.254.169.254/latest/meta-data/", + "http://0.0.0.0:3000/", + "http://100.64.0.1/", + "http://[fd00::1]/", + "http://localhost:3000/", + "http://foo.localhost/", + "http://2130706433/", // 127.0.0.1 in decimal, normalized by URL parser + "http://0x7f.0.0.1/", + ])("rejects %s", async (url) => { + await expect(assertPublicUrl(url, { lookup: publicLookup })).rejects.toBeInstanceOf( + UnsafeUrlError, + ); + }); + + test("rejects non-HTTP schemes", async () => { + await expect(assertPublicUrl("file:///etc/passwd")).rejects.toBeInstanceOf(UnsafeUrlError); + await expect(assertPublicUrl("gopher://example.com/")).rejects.toBeInstanceOf(UnsafeUrlError); + }); + + test("rejects invalid URLs", async () => { + await expect(assertPublicUrl("not a url")).rejects.toBeInstanceOf(UnsafeUrlError); + }); + + test("allows public IP literals", async () => { + const url = await assertPublicUrl("https://93.184.216.34/image.png"); + expect(url.hostname).toBe("93.184.216.34"); + await expect(assertPublicUrl("https://[2606:4700:4700::1111]/")).resolves.toBeInstanceOf(URL); + }); + + test("allows hostnames resolving only to public addresses", async () => { + const url = await assertPublicUrl("https://remote.example/users/alice", { + lookup: async () => [ + { address: "93.184.216.34", family: 4 }, + { address: "2606:2800:220:1:248:1893:25c8:1946", family: 6 }, + ], + }); + expect(url.hostname).toBe("remote.example"); + }); + + test("rejects hostnames where ANY resolved address is private", async () => { + await expect( + assertPublicUrl("https://evil.example/", { + lookup: async () => [ + { address: "93.184.216.34", family: 4 }, + { address: "10.0.0.5", family: 4 }, + ], + }), + ).rejects.toBeInstanceOf(UnsafeUrlError); + + await expect( + assertPublicUrl("https://evil6.example/", { + lookup: async () => [{ address: "::ffff:127.0.0.1", family: 6 }], + }), + ).rejects.toBeInstanceOf(UnsafeUrlError); + }); + + test("rejects when DNS resolution fails", async () => { + await expect( + assertPublicUrl("https://nxdomain.example/", { + lookup: async () => { + throw new Error("ENOTFOUND"); + }, + }), + ).rejects.toBeInstanceOf(UnsafeUrlError); + }); + + test("ALLOW_PRIVATE_NETWORK_FETCH=true disables private address checks", async () => { + process.env.ALLOW_PRIVATE_NETWORK_FETCH = "true"; + await expect(assertPublicUrl("http://127.0.0.1:3000/")).resolves.toBeInstanceOf(URL); + // Scheme check still applies + await expect(assertPublicUrl("file:///etc/passwd")).rejects.toBeInstanceOf(UnsafeUrlError); + }); +}); + +describe("safeFetch", () => { + const originalEnv = process.env.ALLOW_PRIVATE_NETWORK_FETCH; + + beforeEach(() => { + delete process.env.ALLOW_PRIVATE_NETWORK_FETCH; + }); + + afterEach(() => { + if (originalEnv === undefined) delete process.env.ALLOW_PRIVATE_NETWORK_FETCH; + else process.env.ALLOW_PRIVATE_NETWORK_FETCH = originalEnv; + }); + + function redirect(location: string, status = 302): Response { + return new Response(null, { status, headers: { Location: location } }); + } + + test("does not call fetch for private initial URLs", async () => { + const fetchImpl = mock(async () => new Response("ok")); + await expect( + safeFetch("http://169.254.169.254/", {}, { fetchImpl: fetchImpl as any }), + ).rejects.toBeInstanceOf(UnsafeUrlError); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + test("uses manual redirect mode", async () => { + const fetchImpl = mock(async (_url: string, _init?: RequestInit) => new Response("ok")); + await safeFetch("https://93.184.216.34/", {}, { fetchImpl: fetchImpl as any }); + expect(fetchImpl.mock.calls[0]?.[1]?.redirect).toBe("manual"); + }); + + test("re-validates redirect targets and blocks redirects to private addresses", async () => { + const fetchImpl = mock(async (url: string) => { + if (url.startsWith("https://93.184.216.34/")) { + return redirect("http://169.254.169.254/latest/meta-data/"); + } + return new Response("secret"); + }); + + await expect( + safeFetch("https://93.184.216.34/start", {}, { fetchImpl: fetchImpl as any }), + ).rejects.toBeInstanceOf(UnsafeUrlError); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + test("blocks redirects to hostnames resolving to private addresses", async () => { + const lookup: LookupFn = async (host) => + host === "internal.example" + ? [{ address: "10.1.2.3", family: 4 }] + : [{ address: "93.184.216.34", family: 4 }]; + const fetchImpl = mock(async () => redirect("https://internal.example/admin")); + + await expect( + safeFetch("https://public.example/", {}, { fetchImpl: fetchImpl as any, lookup }), + ).rejects.toBeInstanceOf(UnsafeUrlError); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + test("follows redirects to public addresses (relative Location)", async () => { + const fetchImpl = mock(async (url: string) => { + if (url === "https://93.184.216.34/a") return redirect("/b", 301); + return new Response("final", { status: 200 }); + }); + + const res = await safeFetch("https://93.184.216.34/a", {}, { fetchImpl: fetchImpl as any }); + expect(res.status).toBe(200); + expect(await res.text()).toBe("final"); + expect(fetchImpl.mock.calls[1]?.[0]).toBe("https://93.184.216.34/b"); + }); + + test("enforces maximum redirects", async () => { + let n = 0; + const fetchImpl = mock(async () => redirect(`https://93.184.216.34/${++n}`)); + + await expect( + safeFetch("https://93.184.216.34/0", {}, { fetchImpl: fetchImpl as any, maxRedirects: 3 }), + ).rejects.toThrow("Too many redirects"); + expect(fetchImpl).toHaveBeenCalledTimes(4); + }); + + test("maxRedirects: 0 rejects any redirect", async () => { + const fetchImpl = mock(async () => redirect("https://93.184.216.34/next")); + await expect( + safeFetch("https://93.184.216.34/", {}, { fetchImpl: fetchImpl as any, maxRedirects: 0 }), + ).rejects.toBeInstanceOf(UnsafeUrlError); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + test("303 switches POST to GET and drops credentials across origins", async () => { + const fetchImpl = mock(async (url: string, _init?: RequestInit) => { + if (url === "https://93.184.216.34/post") return redirect("https://8.8.8.8/done", 303); + return new Response("ok"); + }); + + await safeFetch( + "https://93.184.216.34/post", + { + method: "POST", + body: "data", + headers: { Authorization: "Bearer x", "Content-Type": "text/plain", Host: "a" }, + }, + { fetchImpl: fetchImpl as any }, + ); + + const second = fetchImpl.mock.calls[1]?.[1]; + expect(second?.method).toBe("GET"); + expect(second?.body).toBeUndefined(); + const headers = new Headers(second?.headers); + expect(headers.has("authorization")).toBe(false); + expect(headers.has("content-type")).toBe(false); + expect(headers.has("host")).toBe(false); + }); + + test("works against a local server when ALLOW_PRIVATE_NETWORK_FETCH=true", async () => { + process.env.ALLOW_PRIVATE_NETWORK_FETCH = "true"; + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + const { pathname } = new URL(req.url); + if (pathname === "/start") return redirect("/end"); + if (pathname === "/file") return redirect("file:///etc/passwd"); + return new Response("reached"); + }, + }); + try { + const base = `http://127.0.0.1:${server.port}`; + const res = await safeFetch(`${base}/start`); + expect(await res.text()).toBe("reached"); + + // Scheme is still re-validated on each hop + await expect(safeFetch(`${base}/file`)).rejects.toBeInstanceOf(UnsafeUrlError); + } finally { + server.stop(true); + } + }); + + test("blocks a local server without the escape hatch", async () => { + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: () => new Response("should not be reached"), + }); + try { + await expect(safeFetch(`http://127.0.0.1:${server.port}/`)).rejects.toBeInstanceOf( + UnsafeUrlError, + ); + } finally { + server.stop(true); + } + }); +}); From de321023638d46503027eb4d02d5e986dcbeac0a Mon Sep 17 00:00:00 2001 From: SASAGAWA Kiyoshi Date: Wed, 14 Oct 2026 21:22:42 +0900 Subject: [PATCH 4/9] fix(federation): bind HTTP signatures to verified key owner and enforce object origin - verifySignature: validate keyId (http(s), no dot segments/backslashes, not local host), require fetched document's publicKey.id === keyId and owner === document id (or same-origin standalone Key doc), cache {publicKeyPem, owner} (legacy string entries treated as a miss), and expose the verified owner as c.get("signatureActor") - activityValidation/inbox: replace keyId.startsWith(actor) with exact activity.actor === verified owner; reject local-host actors - CreateHandler: require attributedTo === actor and same-origin http(s) id - AnnounceHandler: require fetched id same-origin with requested URI, reject local authors, resolve local notes by id instead of self-fetch - RemoteNoteService.processNote: reject non-http(s) ids (stored XSS via note.uri), local attributedTo, cross-origin id/author; return existing local note for local ids instead of creating - Update/DeleteHandler: refuse to modify local users/notes - Add unit tests for all of the above Claude-Session: https://claude.ai/code/session_01Y29fBeRP344FME6de48wUv --- .../backend/src/middleware/verifySignature.ts | 205 ++++++++++--- packages/backend/src/routes/ap/inbox.ts | 6 +- .../src/services/ap/RemoteNoteService.ts | 38 ++- .../ap/inbox/handlers/AnnounceHandler.ts | 27 ++ .../ap/inbox/handlers/CreateHandler.ts | 14 + .../ap/inbox/handlers/DeleteHandler.ts | 6 + .../ap/inbox/handlers/UpdateHandler.ts | 7 + .../src/tests/unit/RemoteNoteService.test.ts | 73 +++++ .../inbox-handlers/AnnounceHandler.test.ts | 84 +++++- .../unit/inbox-handlers/CreateHandler.test.ts | 73 +++++ .../unit/inbox-handlers/DeleteHandler.test.ts | 19 ++ .../unit/inbox-handlers/UpdateHandler.test.ts | 138 +++++++++ .../src/tests/unit/verifySignature.test.ts | 273 +++++++++++++++++- .../backend/src/utils/activityValidation.ts | 54 ++-- packages/backend/src/utils/federationUrl.ts | 125 ++++++++ 15 files changed, 1073 insertions(+), 69 deletions(-) create mode 100644 packages/backend/src/tests/unit/inbox-handlers/UpdateHandler.test.ts create mode 100644 packages/backend/src/utils/federationUrl.ts diff --git a/packages/backend/src/middleware/verifySignature.ts b/packages/backend/src/middleware/verifySignature.ts index b86913b1..8e348056 100644 --- a/packages/backend/src/middleware/verifySignature.ts +++ b/packages/backend/src/middleware/verifySignature.ts @@ -21,11 +21,18 @@ import type { IUserRepository } from "../interfaces/repositories/IUserRepository import { CacheTTL, CachePrefix } from "../adapters/cache/DragonflyCacheAdapter.js"; import { RemoteFetchService, type SignatureConfig } from "../services/ap/RemoteFetchService.js"; import { logger } from "../lib/logger.js"; +import { + hasAmbiguousPath, + isLocalUrl, + isSameOrigin, + normalizeUrl, + parseHttpUrl, +} from "../utils/federationUrl.js"; /** * In-memory cache fallback for public keys when Redis is unavailable */ -const publicKeyMemoryCache = new Map(); +const publicKeyMemoryCache = new Map(); /** * In-memory cache for fetch failures to prevent repeated requests to failing servers @@ -147,10 +154,143 @@ async function getSignatureConfig( } } +/** + * Public key bound to its verified owner (actor URI) + */ +export interface VerifiedPublicKey { + /** PEM-encoded public key */ + publicKeyPem: string; + /** Actor URI that owns this key (verified against the fetched document) */ + owner: string; +} + +/** + * Validate a signature keyId before fetching it. + * + * Rejects keyIds that are not http(s) URLs, contain ambiguous path components + * (dot segments, backslashes, whitespace) that URL normalization would rewrite, + * or point at this instance (local users never deliver to our inbox over HTTP). + * + * @param keyId - KeyId from the Signature header + * @returns URL to fetch the key document from (keyId without fragment) + * @throws Error if the keyId is not acceptable + */ +export function validateKeyId(keyId: string): string { + if (!parseHttpUrl(keyId)) { + throw new Error("Invalid keyId: not an http(s) URL"); + } + if (hasAmbiguousPath(keyId)) { + throw new Error("Invalid keyId: ambiguous path"); + } + if (isLocalUrl(keyId)) { + throw new Error("Invalid keyId: points to local host"); + } + const fetchUrl = keyId.split("#")[0]; + if (!fetchUrl || normalizeUrl(fetchUrl) === null) { + throw new Error("Invalid keyId format"); + } + return fetchUrl; +} + +/** + * Extract the public key for `keyId` from a fetched document and verify its owner. + * + * Supported document shapes: + * - Actor document (Mastodon/Misskey/GoToSocial/etc.): `id` must be on the same origin + * as the fetched URL, one of `publicKey` (object or array) must have `id === keyId`, + * and its `owner` (defaulting to the document id) must equal the document id. + * - Standalone Key document (e.g. `/users/x/publickey`): `id` must equal keyId and + * `owner` must be an http(s) URL on the same origin as the keyId. + * + * @param keyId - KeyId from the Signature header + * @param fetchUrl - URL the document was fetched from + * @param doc - Fetched JSON document + * @returns Verified PEM and owner actor URI + * @throws Error if the document does not prove ownership of the key + */ +export function extractVerifiedPublicKey( + keyId: string, + fetchUrl: string, + doc: unknown, +): VerifiedPublicKey { + if (!doc || typeof doc !== "object") { + throw new Error("Key document is not an object"); + } + const document = doc as { + id?: unknown; + owner?: unknown; + publicKey?: unknown; + publicKeyPem?: unknown; + }; + const normalizedKeyId = normalizeUrl(keyId); + if (!normalizedKeyId) { + throw new Error("Invalid keyId"); + } + const docId = typeof document.id === "string" ? document.id : null; + + if (document.publicKey) { + // Actor document. Usually id === fetchUrl (Mastodon/Misskey `#main-key`), but + // GoToSocial serves a stub actor at `/users/x/main-key` whose id is `/users/x`, + // so require same origin here; the exact publicKey.id === keyId match below + // is what binds the key. + if (!docId || !isSameOrigin(docId, fetchUrl)) { + throw new Error("Actor document id is not on the keyId origin"); + } + const candidates: unknown[] = Array.isArray(document.publicKey) + ? document.publicKey + : [document.publicKey]; + const key = candidates.find( + (k): k is { id: string; owner?: unknown; publicKeyPem?: unknown } => + !!k && + typeof k === "object" && + typeof (k as { id?: unknown }).id === "string" && + normalizeUrl((k as { id: string }).id) === normalizedKeyId, + ); + if (!key) { + throw new Error("publicKey.id does not match keyId"); + } + if (typeof key.publicKeyPem !== "string" || !key.publicKeyPem) { + throw new Error("Public key not found in actor document"); + } + const owner = typeof key.owner === "string" ? key.owner : docId; + if (owner !== docId) { + throw new Error("publicKey.owner does not match actor document id"); + } + return { publicKeyPem: key.publicKeyPem, owner }; + } + + if (typeof document.publicKeyPem === "string" && document.publicKeyPem) { + // Standalone Key document + if (!docId || normalizeUrl(docId) !== normalizedKeyId) { + throw new Error("Key document id does not match keyId"); + } + if (typeof document.owner !== "string" || !isSameOrigin(document.owner, keyId)) { + throw new Error("Key document owner is missing or cross-origin"); + } + return { publicKeyPem: document.publicKeyPem, owner: document.owner }; + } + + throw new Error("Public key not found in actor document"); +} + +/** + * Check whether a cached value has the current {@link VerifiedPublicKey} shape. + * Legacy cache entries (plain PEM strings) are treated as a miss. + */ +function isVerifiedPublicKey(value: unknown): value is VerifiedPublicKey { + return ( + !!value && + typeof value === "object" && + typeof (value as VerifiedPublicKey).publicKeyPem === "string" && + typeof (value as VerifiedPublicKey).owner === "string" + ); +} + /** * Fetch remote actor's public key * - * Retrieves the public key from the remote actor's document. + * Retrieves the public key from the remote key/actor document and verifies + * that the document actually binds the key to its owner. * Results are cached in Redis (1 hour) with in-memory fallback. * * For Authorized Fetch servers (which return 401/403 on unsigned requests), @@ -160,21 +300,25 @@ async function getSignatureConfig( * @param cacheService - Optional cache service for Redis caching * @param userRepository - Optional user repository for signed fetch retry * @param baseUrl - Optional base URL of this instance for signed fetch - * @returns PEM-formatted public key - * @throws Error if key cannot be fetched + * @returns PEM-formatted public key and its verified owner + * @throws Error if key cannot be fetched or verified */ async function fetchPublicKey( keyId: string, cacheService?: ICacheService, userRepository?: IUserRepository, baseUrl?: string, -): Promise { +): Promise { + // keyId format: https://example.com/users/alice#main-key + // Fetch URL: https://example.com/users/alice + const actorUrl = validateKeyId(keyId); + const cacheKey = `${CachePrefix.PUBLIC_KEY}:${keyId}`; // Check Redis cache first if (cacheService?.isAvailable()) { - const cached = await cacheService.get(cacheKey); - if (cached) { + const cached = await cacheService.get(cacheKey); + if (isVerifiedPublicKey(cached)) { return cached; } } else { @@ -185,15 +329,6 @@ async function fetchPublicKey( } } - // Fetch actor document - // keyId format: https://example.com/users/alice#main-key - // Actor URL: https://example.com/users/alice - const actorUrl = keyId.split("#")[0]; - - if (!actorUrl) { - throw new Error("Invalid keyId format"); - } - // Check if we have a cached failure for this actor const cachedFailure = getCachedFailure(actorUrl); if (cachedFailure) { @@ -213,9 +348,7 @@ async function fetchPublicKey( logger.debug({ keyId, actorUrl }, "Fetching public key for signature verification"); // First, try unsigned fetch (works for most servers) - let result = await fetchService.fetchActivityPubObject<{ - publicKey?: { publicKeyPem?: string }; - }>(actorUrl, { maxRetries: 1 }); + let result = await fetchService.fetchActivityPubObject(actorUrl, { maxRetries: 1 }); // If unsigned fetch failed with 401/403, retry with signed request // This is required for Authorized Fetch servers (e.g., some Mastodon instances) @@ -234,9 +367,7 @@ async function fetchPublicKey( const signatureConfig = await getSignatureConfig(userRepository, baseUrl); if (signatureConfig) { - result = await fetchService.fetchActivityPubObject<{ - publicKey?: { publicKeyPem?: string }; - }>(actorUrl, { + result = await fetchService.fetchActivityPubObject(actorUrl, { signature: signatureConfig, maxRetries: 2, }); @@ -266,28 +397,21 @@ async function fetchPublicKey( throw new Error(`Failed to fetch actor: ${errorMessage}`); } - const actor = result.data!; - - // Extract public key - let publicKey: string; - if (actor.publicKey && actor.publicKey.publicKeyPem) { - publicKey = actor.publicKey.publicKeyPem; - } else { - throw new Error("Public key not found in actor document"); - } + // Verify that the fetched document binds keyId to its owner + const verified = extractVerifiedPublicKey(keyId, actorUrl, result.data); // Cache in Redis (1 hour) if (cacheService?.isAvailable()) { - await cacheService.set(cacheKey, publicKey, { ttl: CacheTTL.LONG }); + await cacheService.set(cacheKey, verified, { ttl: CacheTTL.LONG }); } else { // Fallback to in-memory cache publicKeyMemoryCache.set(keyId, { - key: publicKey, + key: verified, expires: Date.now() + CacheTTL.LONG * 1000, }); } - return publicKey; + return verified; } /** @@ -339,9 +463,14 @@ export async function verifySignatureMiddleware(c: Context, next: Next): Promise // Fetch public key (with Redis caching and signed fetch retry) logger.debug({ keyId: params.keyId }, "Fetching public key"); - const publicKey = await fetchPublicKey(params.keyId, cacheService, userRepository, baseUrl); + const { publicKeyPem: publicKey, owner: signatureActor } = await fetchPublicKey( + params.keyId, + cacheService, + userRepository, + baseUrl, + ); logger.debug( - { keyId: params.keyId, keyLength: publicKey.length }, + { keyId: params.keyId, owner: signatureActor, keyLength: publicKey.length }, "Public key fetched successfully", ); @@ -389,8 +518,10 @@ export async function verifySignatureMiddleware(c: Context, next: Next): Promise c.set("requestBody", body); } - // Store keyId for activity validation + // Store keyId and the verified key owner for activity validation. + // The inbox route must require activity.actor === signatureActor. c.set("signatureKeyId", params.keyId); + c.set("signatureActor", signatureActor); logger.debug({ keyId: params.keyId }, "Signature verified successfully"); diff --git a/packages/backend/src/routes/ap/inbox.ts b/packages/backend/src/routes/ap/inbox.ts index 25b64785..00901a71 100644 --- a/packages/backend/src/routes/ap/inbox.ts +++ b/packages/backend/src/routes/ap/inbox.ts @@ -74,7 +74,8 @@ inbox.post( // Enhanced activity validation const signatureKeyId = c.get("signatureKeyId"); - const validationResult = validateActivity(activity, signatureKeyId); + const signatureActor = c.get("signatureActor"); + const validationResult = validateActivity(activity, signatureKeyId, signatureActor); if (!validationResult.valid) { logger.warn( @@ -207,7 +208,8 @@ inbox.post( // Enhanced activity validation const signatureKeyId = c.get("signatureKeyId"); - const validationResult = validateActivity(activity, signatureKeyId); + const signatureActor = c.get("signatureActor"); + const validationResult = validateActivity(activity, signatureKeyId, signatureActor); if (!validationResult.valid) { logger.warn( diff --git a/packages/backend/src/services/ap/RemoteNoteService.ts b/packages/backend/src/services/ap/RemoteNoteService.ts index 43041f9f..3684934f 100644 --- a/packages/backend/src/services/ap/RemoteNoteService.ts +++ b/packages/backend/src/services/ap/RemoteNoteService.ts @@ -15,6 +15,7 @@ import type { Note } from "shared"; import { generateId } from "shared"; import { RemoteActorService } from "./RemoteActorService.js"; import { logger } from "../../lib/logger.js"; +import { extractRefUri, isHttpUrl, isLocalUrl, isSameOrigin } from "../../utils/federationUrl.js"; /** * ActivityPub Note object @@ -76,7 +77,9 @@ export class RemoteNoteService { * Resolves the author and creates the note in the database. * * @param noteObject - ActivityPub Note object - * @returns Created Note record + * @returns Created Note record (or the existing note when already known / local) + * @throws Error if the note id is not an http(s) URL, the note is attributed to a + * local user, or its id origin does not match its author's origin * * @example * ```typescript @@ -85,6 +88,11 @@ export class RemoteNoteService { * ``` */ async processNote(noteObject: APNote): Promise { + // Note id must be an http(s) URL (rejects e.g. `javascript:` ids stored as note.uri) + if (!isHttpUrl(noteObject?.id)) { + throw new Error("Invalid note: id must be an http(s) URL"); + } + // Check if note already exists const existing = await this.noteRepository.findByUri(noteObject.id); if (existing) { @@ -92,13 +100,31 @@ export class RemoteNoteService { return existing; } + // Local notes are never created from remote documents: look up the existing one. + if (isLocalUrl(noteObject.id)) { + const localId = new URL(noteObject.id).pathname.match(/^\/notes\/([^/]+)$/)?.[1]; + const localNote = localId ? await this.noteRepository.findById(localId) : null; + if (localNote) { + return localNote; + } + throw new Error(`Local note not found: ${noteObject.id}`); + } + + const authorUri = extractRefUri(noteObject.attributedTo); + if (!isHttpUrl(authorUri)) { + throw new Error("Invalid note: attributedTo must be an http(s) URL"); + } + // Remote notes must not be attributed to local users, and must be + // hosted on the same origin as their author. + if (isLocalUrl(authorUri)) { + throw new Error("Invalid note: remote note attributed to a local user"); + } + if (!isSameOrigin(authorUri, noteObject.id)) { + throw new Error("Invalid note: id origin does not match attributedTo origin"); + } + // Resolve author (use injected service or create new instance for backward compatibility) const actorService = this.remoteActorService ?? new RemoteActorService(this.userRepository); - const authorUri = - typeof noteObject.attributedTo === "string" - ? noteObject.attributedTo - : noteObject.attributedTo.id; - const author = await actorService.resolveActor(authorUri); // Extract text content diff --git a/packages/backend/src/services/ap/inbox/handlers/AnnounceHandler.ts b/packages/backend/src/services/ap/inbox/handlers/AnnounceHandler.ts index ac25e495..4ef93669 100644 --- a/packages/backend/src/services/ap/inbox/handlers/AnnounceHandler.ts +++ b/packages/backend/src/services/ap/inbox/handlers/AnnounceHandler.ts @@ -11,6 +11,7 @@ import { getObjectUri } from "../types.js"; import { BaseHandler } from "./BaseHandler.js"; import { RemoteFetchService, type SignatureConfig } from "../../RemoteFetchService.js"; import { logger } from "../../../../lib/logger.js"; +import { extractRefUri, isLocalUrl, isSameOrigin } from "../../../../utils/federationUrl.js"; /** * Handler for Announce activities @@ -74,6 +75,16 @@ export class AnnounceHandler extends BaseHandler { const remoteNoteService = this.getRemoteNoteService(c); let targetNote = await noteRepository.findByUri(objectUri); + // ローカルノートは HTTP で自サーバーを取得せず、ID で直接検索する + if (!targetNote && isLocalUrl(objectUri)) { + const localId = new URL(objectUri).pathname.match(/^\/notes\/([^/]+)$/)?.[1]; + targetNote = localId ? await noteRepository.findById(localId) : null; + if (!targetNote) { + this.warn(`Local note not found: ${objectUri}`); + return this.failure(`Local note not found: ${objectUri}`); + } + } + // If note doesn't exist locally, fetch it from remote if (!targetNote) { this.log("ℹ️", `Target note not found locally, fetching: ${objectUri}`); @@ -117,6 +128,22 @@ export class AnnounceHandler extends BaseHandler { } const noteObject = result.data as any; + + // The fetched document must be served from the origin we requested, + // and must not claim to be authored by a local user. + if ( + !noteObject || + typeof noteObject.id !== "string" || + !isSameOrigin(noteObject.id, objectUri) + ) { + this.warn(`Announce rejected: fetched object id is not same-origin with ${objectUri}`); + return this.failure("Fetched object id origin does not match requested URI"); + } + if (isLocalUrl(extractRefUri(noteObject.attributedTo))) { + this.warn("Announce rejected: fetched object claims a local author"); + return this.failure("Fetched object claims a local author"); + } + targetNote = await remoteNoteService.processNote(noteObject); } diff --git a/packages/backend/src/services/ap/inbox/handlers/CreateHandler.ts b/packages/backend/src/services/ap/inbox/handlers/CreateHandler.ts index 0ba7f26d..c9ee2c4e 100644 --- a/packages/backend/src/services/ap/inbox/handlers/CreateHandler.ts +++ b/packages/backend/src/services/ap/inbox/handlers/CreateHandler.ts @@ -9,6 +9,7 @@ import type { Activity, HandlerContext, HandlerResult } from "../types.js"; import { BaseHandler } from "./BaseHandler.js"; import { getTimelineStreamService } from "../../../TimelineStreamService.js"; +import { extractRefUri, isHttpUrl, isSameOrigin } from "../../../../utils/federationUrl.js"; /** * Handler for Create activities @@ -45,7 +46,20 @@ export class CreateHandler extends BaseHandler { to?: string | string[]; cc?: string | string[]; id?: string; + attributedTo?: unknown; }; + + // 署名済みアクター以外の投稿を作成させない (author/origin binding) + const actorUri = this.getActorUri(activity); + const attributedTo = extractRefUri(apObject.attributedTo); + if (!attributedTo || attributedTo !== actorUri) { + this.warn(`Create rejected: attributedTo (${attributedTo}) does not match actor`); + return this.failure("Invalid Create activity: attributedTo does not match actor"); + } + if (!isHttpUrl(apObject.id) || !isSameOrigin(apObject.id, actorUri)) { + this.warn(`Create rejected: object id is not an http(s) URL on the actor's origin`); + return this.failure("Invalid Create activity: object id origin does not match actor"); + } this.log( "📥", `Create: Receiving ${objectType} from ${activity.actor} | to: ${JSON.stringify(apObject.to)} | cc: ${JSON.stringify(apObject.cc)}`, diff --git a/packages/backend/src/services/ap/inbox/handlers/DeleteHandler.ts b/packages/backend/src/services/ap/inbox/handlers/DeleteHandler.ts index 9b6dafc3..7d36a963 100644 --- a/packages/backend/src/services/ap/inbox/handlers/DeleteHandler.ts +++ b/packages/backend/src/services/ap/inbox/handlers/DeleteHandler.ts @@ -44,6 +44,12 @@ export class DeleteHandler extends BaseHandler { // Otherwise, try to resolve the actor and handle note deletion const remoteActor = await this.resolveActor(actorUri, c); + // ローカルユーザーのノートを連合経由で削除させない + if (remoteActor.host === null) { + this.warn(`Delete rejected: actor ${actorUri} resolves to a local user`); + return this.failure("Cannot delete local notes via federation"); + } + this.log("📥", `Delete: ${remoteActor.username}@${remoteActor.host} → ${objectUri}`); // Try to find and delete the note diff --git a/packages/backend/src/services/ap/inbox/handlers/UpdateHandler.ts b/packages/backend/src/services/ap/inbox/handlers/UpdateHandler.ts index e0b7c0eb..e4c4918f 100644 --- a/packages/backend/src/services/ap/inbox/handlers/UpdateHandler.ts +++ b/packages/backend/src/services/ap/inbox/handlers/UpdateHandler.ts @@ -32,6 +32,13 @@ export class UpdateHandler extends BaseHandler { const actorUri = this.getActorUri(activity); const remoteActor = await this.resolveActor(actorUri, c); + + // ローカルユーザー/ローカルノートを連合経由で更新させない + if (remoteActor.host === null) { + this.warn(`Update rejected: actor ${actorUri} resolves to a local user`); + return this.failure("Cannot update local users or notes via federation"); + } + const objectType = (object as { type?: string }).type; // Handle Person update (profile update) diff --git a/packages/backend/src/tests/unit/RemoteNoteService.test.ts b/packages/backend/src/tests/unit/RemoteNoteService.test.ts index 4ea02f8f..10c4fb47 100644 --- a/packages/backend/src/tests/unit/RemoteNoteService.test.ts +++ b/packages/backend/src/tests/unit/RemoteNoteService.test.ts @@ -148,6 +148,79 @@ describe("RemoteNoteService", () => { expect(mockNoteRepository.create).not.toHaveBeenCalled(); }); + describe("origin validation", () => { + beforeEach(() => { + process.env.URL = "http://localhost:3000"; + }); + + test("should reject notes with a javascript: id", async () => { + await expect( + service.processNote({ + id: "javascript:alert(document.cookie)", + type: "Note", + attributedTo: "https://remote.example/users/testuser", + content: "

x

", + }), + ).rejects.toThrow(); + expect(mockNoteRepository.findByUri).not.toHaveBeenCalled(); + expect(mockNoteRepository.create).not.toHaveBeenCalled(); + }); + + test("should reject remote notes attributed to a local user", async () => { + await expect( + service.processNote({ + id: "https://remote.example/notes/evil", + type: "Note", + attributedTo: "http://localhost:3000/users/alice", + content: "

x

", + }), + ).rejects.toThrow(); + expect(mockNoteRepository.create).not.toHaveBeenCalled(); + }); + + test("should reject notes whose id origin differs from attributedTo", async () => { + await expect( + service.processNote({ + id: "https://evil.example/notes/1", + type: "Note", + attributedTo: "https://remote.example/users/testuser", + content: "

x

", + }), + ).rejects.toThrow(); + expect(mockNoteRepository.create).not.toHaveBeenCalled(); + }); + + test("should return existing local note instead of creating for local ids", async () => { + const localNote = { id: "local-1", userId: "alice", uri: null }; + mockNoteRepository.findById = mock(() => Promise.resolve(localNote as any)); + + const result = await service.processNote({ + id: "http://localhost:3000/notes/local-1", + type: "Note", + attributedTo: "http://localhost:3000/users/alice", + content: "

x

", + }); + + expect(result.id).toBe("local-1"); + expect(mockNoteRepository.findById).toHaveBeenCalledWith("local-1"); + expect(mockNoteRepository.create).not.toHaveBeenCalled(); + }); + + test("should throw for unknown local note ids", async () => { + mockNoteRepository.findById = mock(() => Promise.resolve(null)); + + await expect( + service.processNote({ + id: "http://localhost:3000/notes/missing", + type: "Note", + attributedTo: "http://localhost:3000/users/alice", + content: "

x

", + }), + ).rejects.toThrow(); + expect(mockNoteRepository.create).not.toHaveBeenCalled(); + }); + }); + test("should process note with attachments", async () => { const apNote = { id: "https://remote.example/notes/456", diff --git a/packages/backend/src/tests/unit/inbox-handlers/AnnounceHandler.test.ts b/packages/backend/src/tests/unit/inbox-handlers/AnnounceHandler.test.ts index 8ab57ec9..0eb767a1 100644 --- a/packages/backend/src/tests/unit/inbox-handlers/AnnounceHandler.test.ts +++ b/packages/backend/src/tests/unit/inbox-handlers/AnnounceHandler.test.ts @@ -12,18 +12,22 @@ import { AnnounceHandler } from "../../../services/ap/inbox/handlers/AnnounceHan import type { Activity, HandlerContext } from "../../../services/ap/inbox/types"; import type { Context } from "hono"; +// Document returned by the mocked RemoteFetchService (overridable per test) +const defaultFetchedNote = { + type: "Note", + id: "https://another.example.com/notes/1", + content: "

Test note

", + attributedTo: "https://another.example.com/users/author", +}; +let fetchedNote: Record = { ...defaultFetchedNote }; + // Mock the RemoteFetchService mock.module("../../../services/ap/RemoteFetchService", () => ({ RemoteFetchService: class { fetchActivityPubObject = mock(() => Promise.resolve({ success: true, - data: { - type: "Note", - id: "https://remote.example.com/notes/1", - content: "

Test note

", - attributedTo: "https://remote.example.com/users/remoteuser", - }, + data: fetchedNote, }), ); }, @@ -81,6 +85,7 @@ describe("AnnounceHandler", () => { }; beforeEach(() => { + fetchedNote = { ...defaultFetchedNote }; handler = new AnnounceHandler(); const honoContext = createMockHonoContext() as Context; @@ -134,6 +139,73 @@ describe("AnnounceHandler", () => { expect(mockRemoteNoteService.processNote).toHaveBeenCalled(); }); + test("should reject fetched note whose id is cross-origin with the requested URI", async () => { + mockNoteRepository.findByUri = mock(() => Promise.resolve(null)); + fetchedNote = { + ...defaultFetchedNote, + id: "https://evil.example.com/notes/1", + attributedTo: "https://evil.example.com/users/eve", + }; + + const activity: Activity = { + "@context": "https://www.w3.org/ns/activitystreams", + type: "Announce", + id: "https://remote.example.com/activities/announce-1", + actor: "https://remote.example.com/users/remoteuser", + object: "https://another.example.com/notes/1", + }; + + const result = await handler.handle(activity, mockContext); + + expect(result.success).toBe(false); + expect(result.message).toContain("origin does not match"); + expect(mockRemoteNoteService.processNote).not.toHaveBeenCalled(); + expect(mockNoteRepository.create).not.toHaveBeenCalled(); + }); + + test("should reject fetched note attributed to a local user", async () => { + mockNoteRepository.findByUri = mock(() => Promise.resolve(null)); + fetchedNote = { + ...defaultFetchedNote, + attributedTo: "http://localhost:3000/users/alice", + }; + + const activity: Activity = { + "@context": "https://www.w3.org/ns/activitystreams", + type: "Announce", + id: "https://remote.example.com/activities/announce-1", + actor: "https://remote.example.com/users/remoteuser", + object: "https://another.example.com/notes/1", + }; + + const result = await handler.handle(activity, mockContext); + + expect(result.success).toBe(false); + expect(result.message).toContain("local author"); + expect(mockRemoteNoteService.processNote).not.toHaveBeenCalled(); + }); + + test("should resolve local note by id without fetching when uri lookup misses", async () => { + mockNoteRepository.findByUri = mock(() => Promise.resolve(null)); + mockNoteRepository.findById = mock(() => + Promise.resolve({ id: "note-123", uri: null, text: "Local note" }), + ); + + const activity: Activity = { + "@context": "https://www.w3.org/ns/activitystreams", + type: "Announce", + id: "https://remote.example.com/activities/announce-1", + actor: "https://remote.example.com/users/remoteuser", + object: "http://localhost:3000/notes/note-123", + }; + + const result = await handler.handle(activity, mockContext); + + expect(result.success).toBe(true); + expect(mockNoteRepository.findById).toHaveBeenCalledWith("note-123"); + expect(mockRemoteNoteService.processNote).not.toHaveBeenCalled(); + }); + test("should reject activity with missing object", async () => { const activity: Activity = { "@context": "https://www.w3.org/ns/activitystreams", diff --git a/packages/backend/src/tests/unit/inbox-handlers/CreateHandler.test.ts b/packages/backend/src/tests/unit/inbox-handlers/CreateHandler.test.ts index 74d0c52d..065edf00 100644 --- a/packages/backend/src/tests/unit/inbox-handlers/CreateHandler.test.ts +++ b/packages/backend/src/tests/unit/inbox-handlers/CreateHandler.test.ts @@ -168,4 +168,77 @@ describe("CreateHandler", () => { expect(result.success).toBe(false); expect(result.message).toContain("Failed to handle Create activity"); }); + describe("author/origin binding", () => { + const baseActivity = (object: Record): Activity => ({ + "@context": "https://www.w3.org/ns/activitystreams", + type: "Create", + id: "https://remote.example.com/activities/create-x", + actor: "https://remote.example.com/users/remoteuser", + object: { type: "Note", content: "

hi

", ...object }, + }); + + test("should reject Create whose attributedTo differs from actor", async () => { + const result = await handler.handle( + baseActivity({ + id: "https://remote.example.com/notes/1", + attributedTo: "https://remote.example.com/users/victim", + }), + mockContext, + ); + + expect(result.success).toBe(false); + expect(result.message).toContain("attributedTo does not match actor"); + expect(mockRemoteNoteService.processNote).not.toHaveBeenCalled(); + }); + + test("should reject Create with missing attributedTo", async () => { + const result = await handler.handle( + baseActivity({ id: "https://remote.example.com/notes/1" }), + mockContext, + ); + + expect(result.success).toBe(false); + expect(mockRemoteNoteService.processNote).not.toHaveBeenCalled(); + }); + + test("should accept attributedTo given as an object with matching id", async () => { + const result = await handler.handle( + baseActivity({ + id: "https://remote.example.com/notes/1", + attributedTo: { id: "https://remote.example.com/users/remoteuser", type: "Person" }, + }), + mockContext, + ); + + expect(result.success).toBe(true); + expect(mockRemoteNoteService.processNote).toHaveBeenCalled(); + }); + + test("should reject Create with a javascript: object id", async () => { + const result = await handler.handle( + baseActivity({ + id: "javascript:alert(1)", + attributedTo: "https://remote.example.com/users/remoteuser", + }), + mockContext, + ); + + expect(result.success).toBe(false); + expect(result.message).toContain("object id origin does not match actor"); + expect(mockRemoteNoteService.processNote).not.toHaveBeenCalled(); + }); + + test("should reject Create whose object id is on another origin", async () => { + const result = await handler.handle( + baseActivity({ + id: "https://victim.example.com/notes/1", + attributedTo: "https://remote.example.com/users/remoteuser", + }), + mockContext, + ); + + expect(result.success).toBe(false); + expect(mockRemoteNoteService.processNote).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/backend/src/tests/unit/inbox-handlers/DeleteHandler.test.ts b/packages/backend/src/tests/unit/inbox-handlers/DeleteHandler.test.ts index d970a4e4..296aca40 100644 --- a/packages/backend/src/tests/unit/inbox-handlers/DeleteHandler.test.ts +++ b/packages/backend/src/tests/unit/inbox-handlers/DeleteHandler.test.ts @@ -113,6 +113,25 @@ describe("DeleteHandler", () => { expect(mockNoteRepository.delete).not.toHaveBeenCalled(); }); + test("should reject note deletion when the actor resolves to a local user", async () => { + mockRemoteActorService.resolveActor = mock(() => + Promise.resolve({ id: "remote-user-456", username: "alice", host: null }), + ); + + const activity: Activity = { + "@context": "https://www.w3.org/ns/activitystreams", + type: "Delete", + id: "http://localhost:3000/activities/delete-1", + actor: "http://localhost:3000/users/alice", + object: "http://localhost:3000/notes/1", + }; + + const result = await handler.handle(activity, mockContext); + + expect(result.success).toBe(false); + expect(mockNoteRepository.delete).not.toHaveBeenCalled(); + }); + test("should handle non-existent note gracefully", async () => { mockNoteRepository.findByUri = mock(() => Promise.resolve(null)); mockUserRepository.findByUri = mock(() => Promise.resolve(null)); diff --git a/packages/backend/src/tests/unit/inbox-handlers/UpdateHandler.test.ts b/packages/backend/src/tests/unit/inbox-handlers/UpdateHandler.test.ts new file mode 100644 index 00000000..bf90ad44 --- /dev/null +++ b/packages/backend/src/tests/unit/inbox-handlers/UpdateHandler.test.ts @@ -0,0 +1,138 @@ +/** + * UpdateHandler Unit Tests + * + * Tests the Update activity handler including: + * - Remote profile updates + * - Rejection of updates targeting local users / other actors + * - Note edit ownership checks + */ + +import { describe, test, expect, beforeEach, mock } from "bun:test"; +import { UpdateHandler } from "../../../services/ap/inbox/handlers/UpdateHandler"; +import type { Activity, HandlerContext } from "../../../services/ap/inbox/types"; +import type { Context } from "hono"; + +describe("UpdateHandler", () => { + let handler: UpdateHandler; + let mockContext: HandlerContext; + let mockUserRepository: any; + let mockNoteRepository: any; + let mockRemoteActorService: any; + let resolvedActor: { id: string; username: string; host: string | null }; + + const actorUri = "https://remote.example.com/users/remoteuser"; + + beforeEach(() => { + handler = new UpdateHandler(); + resolvedActor = { id: "remote-user-456", username: "remoteuser", host: "remote.example.com" }; + + mockUserRepository = { + update: mock(() => Promise.resolve()), + }; + mockNoteRepository = { + findByUri: mock(() => + Promise.resolve({ + id: "note-1", + userId: "remote-user-456", + uri: "https://remote.example.com/notes/1", + }), + ), + update: mock(() => Promise.resolve()), + }; + mockRemoteActorService = { + resolveActor: mock(() => Promise.resolve(resolvedActor)), + }; + + const contextMap = new Map(); + contextMap.set("userRepository", mockUserRepository); + contextMap.set("noteRepository", mockNoteRepository); + contextMap.set("remoteActorService", mockRemoteActorService); + + mockContext = { + c: { get: (key: string) => contextMap.get(key) } as Context, + recipientId: null, + baseUrl: "http://localhost:3000", + }; + }); + + test("should update the actor's own remote profile", async () => { + const activity: Activity = { + type: "Update", + id: `${actorUri}#updates/1`, + actor: actorUri, + object: { type: "Person", id: actorUri, name: "New Name" }, + }; + + const result = await handler.handle(activity, mockContext); + + expect(result.success).toBe(true); + expect(mockUserRepository.update).toHaveBeenCalledWith("remote-user-456", { + name: "New Name", + }); + }); + + test("should reject Update of a local user", async () => { + resolvedActor = { id: "local-user-1", username: "alice", host: null }; + const localUri = "http://localhost:3000/users/alice"; + + const activity: Activity = { + type: "Update", + id: `${localUri}#updates/1`, + actor: localUri, + object: { type: "Person", id: localUri, name: "pwned" }, + }; + + const result = await handler.handle(activity, mockContext); + + expect(result.success).toBe(false); + expect(mockUserRepository.update).not.toHaveBeenCalled(); + }); + + test("should reject Update of another actor", async () => { + const activity: Activity = { + type: "Update", + id: `${actorUri}#updates/1`, + actor: actorUri, + object: { type: "Person", id: "https://remote.example.com/users/other", name: "x" }, + }; + + const result = await handler.handle(activity, mockContext); + + expect(result.success).toBe(false); + expect(mockUserRepository.update).not.toHaveBeenCalled(); + }); + + test("should reject Note update by a non-owner", async () => { + mockNoteRepository.findByUri = mock(() => + Promise.resolve({ id: "note-1", userId: "someone-else", uri: "https://x/notes/1" }), + ); + + const activity: Activity = { + type: "Update", + id: `${actorUri}#updates/2`, + actor: actorUri, + object: { type: "Note", id: "https://remote.example.com/notes/1", content: "edited" }, + }; + + const result = await handler.handle(activity, mockContext); + + expect(result.success).toBe(false); + expect(mockNoteRepository.update).not.toHaveBeenCalled(); + }); + + test("should reject Note update when the actor resolves to a local user", async () => { + resolvedActor = { id: "remote-user-456", username: "alice", host: null }; + + const activity: Activity = { + type: "Update", + id: `${actorUri}#updates/3`, + actor: actorUri, + object: { type: "Note", id: "http://localhost:3000/notes/1", content: "edited" }, + }; + + const result = await handler.handle(activity, mockContext); + + expect(result.success).toBe(false); + expect(mockNoteRepository.update).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/backend/src/tests/unit/verifySignature.test.ts b/packages/backend/src/tests/unit/verifySignature.test.ts index 7c9235d9..0fe9abb6 100644 --- a/packages/backend/src/tests/unit/verifySignature.test.ts +++ b/packages/backend/src/tests/unit/verifySignature.test.ts @@ -1,14 +1,18 @@ /** * Verify Signature Middleware Unit Tests * - * Tests the fetch failure caching functionality in the HTTP Signature verification middleware. + * Tests the fetch failure caching functionality in the HTTP Signature verification middleware, + * keyId validation, key document → owner binding, and activity actor binding. */ import { describe, test, expect, beforeEach } from "bun:test"; import { clearFetchFailureCache, + extractVerifiedPublicKey, getFetchFailureCacheStats, + validateKeyId, } from "../../middleware/verifySignature.js"; +import { validateActivity, ValidationErrorType } from "../../utils/activityValidation.js"; describe("verifySignature fetch failure cache", () => { beforeEach(() => { @@ -38,3 +42,270 @@ describe("verifySignature fetch failure cache", () => { expect(Array.isArray(stats.entries)).toBe(true); }); }); + +describe("validateKeyId", () => { + beforeEach(() => { + process.env.URL = "http://localhost:3000"; + }); + + test("accepts a normal keyId and strips the fragment", () => { + expect(validateKeyId("https://remote.example/users/bob#main-key")).toBe( + "https://remote.example/users/bob", + ); + }); + + test("rejects dot-segment keyIds", () => { + expect(() => validateKeyId("https://victim.example/users/bob/../eve#main-key")).toThrow(); + expect(() => validateKeyId("https://victim.example/users/bob/%2e%2e/eve#main-key")).toThrow(); + expect(() => validateKeyId("https://victim.example/users/./bob#main-key")).toThrow(); + expect(() => validateKeyId("https://victim.example/users/bob\\..\\eve#main-key")).toThrow(); + }); + + test("rejects non-http(s) keyIds", () => { + expect(() => validateKeyId("javascript:alert(1)")).toThrow(); + expect(() => validateKeyId("not a url")).toThrow(); + }); + + test("rejects keyIds on the local host (e.g. /api/proxy)", () => { + expect(() => + validateKeyId("http://localhost:3000/api/proxy?url=https://evil.example/key#main-key"), + ).toThrow(); + }); +}); + +describe("extractVerifiedPublicKey", () => { + const pem = "-----BEGIN PUBLIC KEY-----\nAAA\n-----END PUBLIC KEY-----"; + + test("accepts Mastodon/Misskey-style actor document", () => { + const result = extractVerifiedPublicKey( + "https://remote.example/users/bob#main-key", + "https://remote.example/users/bob", + { + id: "https://remote.example/users/bob", + type: "Person", + publicKey: { + id: "https://remote.example/users/bob#main-key", + owner: "https://remote.example/users/bob", + publicKeyPem: pem, + }, + }, + ); + expect(result).toEqual({ publicKeyPem: pem, owner: "https://remote.example/users/bob" }); + }); + + test("defaults owner to the document id when publicKey.owner is missing", () => { + const result = extractVerifiedPublicKey( + "https://remote.example/users/bob#main-key", + "https://remote.example/users/bob", + { + id: "https://remote.example/users/bob", + publicKey: { id: "https://remote.example/users/bob#main-key", publicKeyPem: pem }, + }, + ); + expect(result.owner).toBe("https://remote.example/users/bob"); + }); + + test("finds the matching key in a publicKey array", () => { + const result = extractVerifiedPublicKey( + "https://remote.example/users/bob#key-2", + "https://remote.example/users/bob", + { + id: "https://remote.example/users/bob", + publicKey: [ + { id: "https://remote.example/users/bob#key-1", publicKeyPem: "other" }, + { id: "https://remote.example/users/bob#key-2", publicKeyPem: pem }, + ], + }, + ); + expect(result.publicKeyPem).toBe(pem); + }); + + test("accepts a standalone Key document on a different path (/publickey)", () => { + const result = extractVerifiedPublicKey( + "https://remote.example/users/bob/publickey", + "https://remote.example/users/bob/publickey", + { + id: "https://remote.example/users/bob/publickey", + type: "Key", + owner: "https://remote.example/users/bob", + publicKeyPem: pem, + }, + ); + expect(result.owner).toBe("https://remote.example/users/bob"); + }); + + test("rejects a document whose publicKey.id does not match keyId", () => { + expect(() => + extractVerifiedPublicKey( + "https://remote.example/users/bob2#main-key", + "https://remote.example/users/bob2", + { + id: "https://remote.example/users/bob2", + publicKey: { + id: "https://remote.example/users/bob2#other-key", + owner: "https://remote.example/users/bob2", + publicKeyPem: pem, + }, + }, + ), + ).toThrow(); + }); + + test("accepts GoToSocial-style stub actor served at the key path", () => { + const result = extractVerifiedPublicKey( + "https://gts.example/users/bob/main-key", + "https://gts.example/users/bob/main-key", + { + id: "https://gts.example/users/bob", + type: "Person", + publicKey: { + id: "https://gts.example/users/bob/main-key", + owner: "https://gts.example/users/bob", + publicKeyPem: pem, + }, + }, + ); + expect(result.owner).toBe("https://gts.example/users/bob"); + }); + + test("rejects an actor document whose id is on a different origin", () => { + expect(() => + extractVerifiedPublicKey( + "https://evil.example/users/eve#main-key", + "https://evil.example/users/eve", + { + id: "https://victim.example/users/bob", + publicKey: { + id: "https://evil.example/users/eve#main-key", + owner: "https://victim.example/users/bob", + publicKeyPem: pem, + }, + }, + ), + ).toThrow(); + }); + + test("dot-segment keyId resolves to the normalized target's owner, never the prefix actor", () => { + // Even if validateKeyId were bypassed, the verified owner is eve (the document + // actually fetched), so activity.actor === bob would fail the binding check. + const result = extractVerifiedPublicKey( + "https://victim.example/users/bob/../eve#main-key", + "https://victim.example/users/bob/../eve", + { + id: "https://victim.example/users/eve", + publicKey: { + id: "https://victim.example/users/eve#main-key", + owner: "https://victim.example/users/eve", + publicKeyPem: pem, + }, + }, + ); + expect(result.owner).toBe("https://victim.example/users/eve"); + }); + + test("rejects owner mismatch in actor document", () => { + expect(() => + extractVerifiedPublicKey( + "https://evil.example/users/eve#main-key", + "https://evil.example/users/eve", + { + id: "https://evil.example/users/eve", + publicKey: { + id: "https://evil.example/users/eve#main-key", + owner: "https://victim.example/users/bob", + publicKeyPem: pem, + }, + }, + ), + ).toThrow(); + }); + + test("rejects cross-origin owner in standalone Key document", () => { + expect(() => + extractVerifiedPublicKey("https://evil.example/keys/1", "https://evil.example/keys/1", { + id: "https://evil.example/keys/1", + owner: "https://victim.example/users/bob", + publicKeyPem: pem, + }), + ).toThrow(); + }); + + test("rejects document without a public key", () => { + expect(() => + extractVerifiedPublicKey( + "https://remote.example/users/bob#main-key", + "https://remote.example/users/bob", + { id: "https://remote.example/users/bob" }, + ), + ).toThrow(); + }); +}); + +describe("validateActivity actor binding", () => { + beforeEach(() => { + process.env.URL = "http://localhost:3000"; + }); + + const activity = (actor: string) => ({ + "@context": "https://www.w3.org/ns/activitystreams", + id: `${actor}/activities/1`, + type: "Follow", + actor, + object: "http://localhost:3000/users/alice", + }); + + test("accepts actor equal to the verified key owner", () => { + const result = validateActivity( + activity("https://remote.example/users/bob"), + "https://remote.example/users/bob#main-key", + "https://remote.example/users/bob", + ); + expect(result.valid).toBe(true); + }); + + test("accepts keyId on a different path as long as the owner matches", () => { + const result = validateActivity( + activity("https://remote.example/users/bob"), + "https://remote.example/users/bob/publickey", + "https://remote.example/users/bob", + ); + expect(result.valid).toBe(true); + }); + + test("rejects actor that only shares a prefix with the keyId (bob vs bob2)", () => { + const result = validateActivity( + activity("https://remote.example/users/bob"), + "https://remote.example/users/bob2#main-key", + "https://remote.example/users/bob2", + ); + expect(result.valid).toBe(false); + expect(result.errors[0]?.type).toBe(ValidationErrorType.ACTOR_MISMATCH); + }); + + test("rejects dot-segment keyId whose verified owner is a different actor", () => { + const result = validateActivity( + activity("https://victim.example/users/bob"), + "https://victim.example/users/bob/../eve#main-key", + "https://victim.example/users/eve", + ); + expect(result.valid).toBe(false); + }); + + test("rejects when keyId is present but owner was not verified", () => { + const result = validateActivity( + activity("https://remote.example/users/bob"), + "https://remote.example/users/bob#main-key", + ); + expect(result.valid).toBe(false); + }); + + test("rejects activities whose actor is on the local host", () => { + const result = validateActivity( + activity("http://localhost:3000/users/alice"), + "http://localhost:3000/users/alice#main-key", + "http://localhost:3000/users/alice", + ); + expect(result.valid).toBe(false); + expect(result.errors[0]?.type).toBe(ValidationErrorType.ACTOR_MISMATCH); + }); +}); diff --git a/packages/backend/src/utils/activityValidation.ts b/packages/backend/src/utils/activityValidation.ts index bb36fe70..fa3284de 100644 --- a/packages/backend/src/utils/activityValidation.ts +++ b/packages/backend/src/utils/activityValidation.ts @@ -7,6 +7,8 @@ * @module utils/activityValidation */ +import { isLocalUrl } from "./federationUrl.js"; + /** * Validation error types */ @@ -297,42 +299,55 @@ export function validateTimestamps(activity: any): ValidationResult { } /** - * Validate actor/keyId consistency + * Validate actor/signature consistency + * + * Ensures that the activity actor is exactly the owner of the key that signed + * the request, as verified by the signature middleware against the fetched + * key document. A string prefix comparison between keyId and actor is NOT + * sufficient (e.g. `.../users/bob2#main-key` or `.../users/bob/../eve#main-key`). * - * Ensures that the signature keyId matches the actor. - * The keyId should be in format: {actorUri}#main-key or similar. + * Also rejects activities whose actor is on the local host: local users never + * deliver to our own inbox over HTTP. * * @param activity - ActivityPub activity * @param keyId - KeyId from HTTP Signature + * @param signatureActor - Key owner verified by the signature middleware * @returns Validation result */ export function validateActorKeyIdConsistency( activity: any, keyId: string | undefined, + signatureActor?: string, ): ValidationResult { const errors: ValidationError[] = []; - if (!keyId) { - // If no keyId provided, skip this validation - // (signature verification middleware should handle this) - return { valid: true, errors: [] }; - } - const actorUri = extractActorUri(activity); if (!actorUri) { // Actor validation is handled by validateRequiredFields return { valid: true, errors: [] }; } - // KeyId should start with actor URI - // Common formats: - // - https://example.com/users/alice#main-key - // - https://example.com/users/alice/publickey - if (!keyId.startsWith(actorUri)) { + if (isLocalUrl(actorUri)) { + errors.push({ + type: ValidationErrorType.ACTOR_MISMATCH, + field: "actor", + message: "Activity actor must not be a local user", + }); + return { valid: false, errors }; + } + + if (!keyId && !signatureActor) { + // If no signature info provided, skip this validation + // (signature verification middleware should handle this) + return { valid: true, errors: [] }; + } + + // Exact comparison against the verified key owner + if (!signatureActor || actorUri !== signatureActor) { errors.push({ type: ValidationErrorType.ACTOR_MISMATCH, field: "actor", - message: "Activity actor does not match signature keyId", + message: "Activity actor does not match signature key owner", }); } @@ -349,13 +364,18 @@ export function validateActorKeyIdConsistency( * * @param activity - ActivityPub activity * @param keyId - Optional keyId from HTTP Signature + * @param signatureActor - Optional key owner verified by the signature middleware * @returns Validation result */ -export function validateActivity(activity: any, keyId?: string): ValidationResult { +export function validateActivity( + activity: any, + keyId?: string, + signatureActor?: string, +): ValidationResult { const results: ValidationResult[] = [ validateRequiredFields(activity), validateTimestamps(activity), - validateActorKeyIdConsistency(activity, keyId), + validateActorKeyIdConsistency(activity, keyId, signatureActor), ]; const allErrors = results.flatMap((r) => r.errors); diff --git a/packages/backend/src/utils/federationUrl.ts b/packages/backend/src/utils/federationUrl.ts new file mode 100644 index 00000000..d17d96bb --- /dev/null +++ b/packages/backend/src/utils/federationUrl.ts @@ -0,0 +1,125 @@ +/** + * Federation URL Utilities + * + * Helpers for validating ActivityPub identifiers received from remote servers + * (actor URIs, keyIds, object ids). Used to bind HTTP Signatures to actors and + * to enforce origin checks on inbound objects. + * + * @module utils/federationUrl + */ + +/** + * Get the hostname of this instance (derived from `process.env.URL`). + * + * @returns Local hostname (e.g. "rox.example.com") + */ +export function getLocalHostname(): string { + try { + return new URL(process.env.URL || "http://localhost:3000").hostname; + } catch { + return "localhost"; + } +} + +/** + * Parse a value as an absolute http(s) URL. + * + * @param value - Value to parse + * @returns Parsed URL, or null if the value is not a string or not an http(s) URL + */ +export function parseHttpUrl(value: unknown): URL | null { + if (typeof value !== "string" || value.length === 0) { + return null; + } + try { + const url = new URL(value); + if (url.protocol !== "https:" && url.protocol !== "http:") { + return null; + } + return url; + } catch { + return null; + } +} + +/** + * Check whether a value is an absolute http(s) URL. + * + * @param value - Value to check + * @returns True if the value is an http(s) URL + */ +export function isHttpUrl(value: unknown): value is string { + return parseHttpUrl(value) !== null; +} + +/** + * Check whether a URL points at this instance. + * + * @param value - URL to check + * @returns True if the URL's hostname equals the local hostname + */ +export function isLocalUrl(value: unknown): boolean { + const url = parseHttpUrl(value); + return url !== null && url.hostname === getLocalHostname(); +} + +/** + * Check whether two URLs share the same origin (scheme, host and port). + * + * @param a - First URL + * @param b - Second URL + * @returns True if both are http(s) URLs with the same origin + */ +export function isSameOrigin(a: unknown, b: unknown): boolean { + const urlA = parseHttpUrl(a); + const urlB = parseHttpUrl(b); + return urlA !== null && urlB !== null && urlA.origin === urlB.origin; +} + +/** + * Normalize a URL string (WHATWG serialization). + * + * @param value - URL to normalize + * @returns Normalized href, or null if the value is not an http(s) URL + */ +export function normalizeUrl(value: unknown): string | null { + return parseHttpUrl(value)?.href ?? null; +} + +/** + * Check whether a raw URL string would be rewritten by URL path normalization. + * + * Detects dot segments (`.`, `..`, including percent-encoded forms), + * backslashes (treated as `/` by WHATWG parsers) and whitespace/control + * characters (silently stripped by WHATWG parsers). Such identifiers can make + * a string look like it belongs to one actor while resolving to another. + * + * @param value - Raw URL string + * @returns True if the string contains ambiguous path components + */ +export function hasAmbiguousPath(value: string): boolean { + // oxlint-disable-next-line no-control-regex + if (/[\\\s\u0000-\u001f\u007f]/.test(value)) { + return true; + } + return /\/(?:\.|%2e){1,2}(?=\/|\?|#|$)/i.test(value); +} + +/** + * Extract the URI of an `attributedTo`/`actor`-style reference. + * + * @param ref - String URI or object with an `id` + * @returns URI string, or null if it cannot be determined + */ +export function extractRefUri(ref: unknown): string | null { + if (typeof ref === "string") { + return ref; + } + if (ref && typeof ref === "object" && !Array.isArray(ref)) { + const id = (ref as { id?: unknown }).id; + if (typeof id === "string") { + return id; + } + } + return null; +} From ac0d8d24e5326dbfd31b7f3e84cdddb9c9d4b098 Mon Sep 17 00:00:00 2001 From: SASAGAWA Kiyoshi Date: Wed, 14 Oct 2026 21:24:51 +0900 Subject: [PATCH 5/9] fix(security): block OAuth email takeover, gate OAuth sign-up, drop legacy register, guard remote URLs - OAuthService: never auto-link a provider login to an existing local account by email; refuse with "An account with this email already exists. Log in and link the provider from settings." Only verified provider emails are used (GitHub /user/emails verified, Google verified_email/email_verified, Discord verified); otherwise a placeholder email is stored. - OAuth sign-up now honours registration.enabled, invite-only / REQUIRE_INVITATION, blocked usernames and the user:beforeRegister plugin hook. Already-linked users can still log in. - routes/oauth: call requireAuth() (it was passed uncalled, so link/unlink/ accounts never worked). Add POST /:provider/link/authorize; the link state cookie is bound to the initiating session token and the callback links to that session's user. POST /:provider/link requires matching state. - unlinkAccount treats OAuth placeholder password hashes as no password. - Extract registration policy helpers to lib/registrationPolicy.ts (shared by /api/auth/register and OAuth). - Remove legacy POST /api/users, which bypassed all registration gating (unused by the frontend). - Frontend: add toSafeHttpUrl and use it for remote note.uri in NoteCardMenu (open in new tab / copy / share) and MFM url/link nodes. Claude-Session: https://claude.ai/code/session_01Y29fBeRP344FME6de48wUv --- .../backend/src/lib/registrationPolicy.ts | 82 ++++ packages/backend/src/lib/validation.ts | 2 +- packages/backend/src/routes/auth.ts | 31 +- packages/backend/src/routes/oauth.ts | 215 +++++++-- packages/backend/src/routes/users.ts | 111 +---- packages/backend/src/services/OAuthService.ts | 450 +++++++++++++----- .../tests/unit/OAuthRoutesSecurity.test.ts | 333 +++++++++++++ .../src/tests/unit/OAuthService.test.ts | 292 +++++++++++- .../src/components/mfm/MfmRenderer.tsx | 20 +- .../src/components/note/NoteCardMenu.tsx | 9 +- packages/frontend/src/lib/utils/safeUrl.ts | 31 ++ packages/frontend/src/tests/safeUrl.test.ts | 38 ++ 12 files changed, 1323 insertions(+), 291 deletions(-) create mode 100644 packages/backend/src/lib/registrationPolicy.ts create mode 100644 packages/backend/src/tests/unit/OAuthRoutesSecurity.test.ts create mode 100644 packages/frontend/src/lib/utils/safeUrl.ts create mode 100644 packages/frontend/src/tests/safeUrl.test.ts diff --git a/packages/backend/src/lib/registrationPolicy.ts b/packages/backend/src/lib/registrationPolicy.ts new file mode 100644 index 00000000..854f6134 --- /dev/null +++ b/packages/backend/src/lib/registrationPolicy.ts @@ -0,0 +1,82 @@ +/** + * Registration Policy + * + * Single source of truth for whether new local accounts may be created, shared by + * password sign-up (`/api/auth/register`) and OAuth sign-up. + * + * @module lib/registrationPolicy + */ + +import type { InstanceSettingsService } from "../services/InstanceSettingsService.js"; + +/** + * Subset of InstanceSettingsService needed to evaluate the registration policy. + */ +export type RegistrationSettingsSource = Pick< + InstanceSettingsService, + "isRegistrationEnabled" | "isInviteOnly" +>; + +/** + * Effective registration policy for the instance. + */ +export interface RegistrationPolicy { + /** Whether new accounts may be created at all */ + registrationEnabled: boolean; + /** Whether new accounts require an invitation code */ + inviteOnly: boolean; +} + +/** + * Check if registration requires an invitation code. + * + * Uses the DB setting first, falling back to the `REQUIRE_INVITATION` env var. + * + * @param settings - Instance settings source + * @returns True if invite-only mode is active + */ +export async function isInviteOnly(settings: RegistrationSettingsSource): Promise { + if (await settings.isInviteOnly()) return true; + + // Fall back to environment variable for backwards compatibility + const requireInvitation = process.env.REQUIRE_INVITATION; + return requireInvitation === "true" || requireInvitation === "1"; +} + +/** + * Check if registration is enabled. + * + * Uses the DB setting first; if it is false, `ENABLE_REGISTRATION=false|0` + * keeps it disabled. + * + * @param settings - Instance settings source + * @returns True if registration is enabled + */ +export async function isRegistrationEnabled( + settings: RegistrationSettingsSource, +): Promise { + const dbSetting = await settings.isRegistrationEnabled(); + + if (!dbSetting) { + const envSetting = process.env.ENABLE_REGISTRATION; + if (envSetting === "false" || envSetting === "0") return false; + } + + return dbSetting; +} + +/** + * Resolve the full registration policy. + * + * @param settings - Instance settings source + * @returns Effective registration policy + */ +export async function getRegistrationPolicy( + settings: RegistrationSettingsSource, +): Promise { + const [registrationEnabled, inviteOnly] = await Promise.all([ + isRegistrationEnabled(settings), + isInviteOnly(settings), + ]); + return { registrationEnabled, inviteOnly }; +} diff --git a/packages/backend/src/lib/validation.ts b/packages/backend/src/lib/validation.ts index b55becb1..d5147ff8 100644 --- a/packages/backend/src/lib/validation.ts +++ b/packages/backend/src/lib/validation.ts @@ -192,7 +192,7 @@ export const repliesQuerySchema = z.object({ // ============================================ /** - * POST /api/users - User registration (same as auth register) + * User registration schema (alias of registerSchema; the legacy POST /api/users endpoint was removed) */ export const createUserSchema = registerSchema; diff --git a/packages/backend/src/routes/auth.ts b/packages/backend/src/routes/auth.ts index c112e0ef..b0083119 100644 --- a/packages/backend/src/routes/auth.ts +++ b/packages/backend/src/routes/auth.ts @@ -12,6 +12,7 @@ import { PasskeyService } from "../services/PasskeyService.js"; import { requireAuth } from "../middleware/auth.js"; import { rateLimit, RateLimitPresets } from "../middleware/rateLimit.js"; import { logger } from "../lib/logger.js"; +import { isInviteOnly, isRegistrationEnabled } from "../lib/registrationPolicy.js"; import type { Context } from "hono"; const app = new Hono(); @@ -20,37 +21,15 @@ const app = new Hono(); * Check if registration requires an invitation code * Uses DB setting first, falls back to environment variable */ -const checkIsInviteOnly = async (c: Context): Promise => { - // First check DB setting - const instanceSettingsService = c.get("instanceSettingsService"); - const dbSetting = await instanceSettingsService.isInviteOnly(); - - // If DB says invite-only, use that - if (dbSetting) return true; - - // Fall back to environment variable for backwards compatibility - const requireInvitation = process.env.REQUIRE_INVITATION; - return requireInvitation === "true" || requireInvitation === "1"; -}; +const checkIsInviteOnly = (c: Context): Promise => + isInviteOnly(c.get("instanceSettingsService")); /** * Check if registration is enabled * Uses DB setting first, falls back to environment variable */ -const checkIsRegistrationEnabled = async (c: Context): Promise => { - const instanceSettingsService = c.get("instanceSettingsService"); - const dbSetting = await instanceSettingsService.isRegistrationEnabled(); - - // If DB setting exists and is false, registration is disabled - // Fall back to env var ENABLE_REGISTRATION if DB doesn't have a setting - if (!dbSetting) { - const envSetting = process.env.ENABLE_REGISTRATION; - // If env var is explicitly 'false' or '0', registration is disabled - if (envSetting === "false" || envSetting === "0") return false; - } - - return dbSetting; -}; +const checkIsRegistrationEnabled = (c: Context): Promise => + isRegistrationEnabled(c.get("instanceSettingsService")); /** * Registration Settings diff --git a/packages/backend/src/routes/oauth.ts b/packages/backend/src/routes/oauth.ts index dfb9c5cb..47e70efd 100644 --- a/packages/backend/src/routes/oauth.ts +++ b/packages/backend/src/routes/oauth.ts @@ -9,18 +9,23 @@ import { Hono } from "hono"; import { setCookie, getCookie } from "hono/cookie"; -import { OAuthService } from "../services/OAuthService.js"; +import { OAuthService, OAuthError } from "../services/OAuthService.js"; +import { AuthService } from "../services/AuthService.js"; import { requireAuth } from "../middleware/auth.js"; import { rateLimit, RateLimitPresets } from "../middleware/rateLimit.js"; import { logger } from "../lib/logger.js"; +import { getRegistrationPolicy } from "../lib/registrationPolicy.js"; import { generateId } from "shared"; import type { Context } from "hono"; import type { OAuthProvider } from "../interfaces/repositories/IOAuthAccountRepository.js"; const app = new Hono(); -// State cookie name for CSRF protection +// State cookie name for CSRF protection (login / sign-up flow) const STATE_COOKIE_NAME = "oauth_state"; +// State cookie for the account-linking flow. Its value binds the state to the +// initiating session token, so it cannot be forged for another user. +const LINK_STATE_COOKIE_NAME = "oauth_link_state"; const STATE_COOKIE_MAX_AGE = 600; // 10 minutes /** @@ -33,7 +38,13 @@ const getOAuthService = (c: Context): OAuthService => { const oauthAccountRepository = c.get("oauthAccountRepository"); const userRepository = c.get("userRepository"); const sessionRepository = c.get("sessionRepository"); - return new OAuthService(oauthAccountRepository, userRepository, sessionRepository); + return new OAuthService( + oauthAccountRepository, + userRepository, + sessionRepository, + c.get("blockedUsernameService"), + c.get("eventBus"), + ); }; /** @@ -46,6 +57,59 @@ const isValidProvider = (provider: string): provider is OAuthProvider => { return ["github", "google", "discord", "mastodon"].includes(provider); }; +/** + * Set (or clear, when maxAge is 0) a short-lived httpOnly OAuth state cookie. + * + * @param c - Hono request context + * @param name - Cookie name + * @param value - Cookie value + * @param maxAge - Lifetime in seconds + */ +const setStateCookie = (c: Context, name: string, value: string, maxAge: number): void => { + setCookie(c, name, value, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "Lax", + maxAge, + path: "/", + }); +}; + +/** + * Build the link-state cookie value binding a state to a session token. + * + * @param state - Random OAuth state + * @param sessionToken - Session token of the user who initiated linking + * @returns Cookie value + */ +const buildLinkStateValue = (state: string, sessionToken: string): string => + `${state}.${sessionToken}`; + +/** + * Parse a link-state cookie value. + * + * @param value - Cookie value + * @returns State and session token, or null if malformed + */ +const parseLinkStateValue = ( + value: string | undefined, +): { state: string; sessionToken: string } | null => { + if (!value) return null; + const idx = value.indexOf("."); + if (idx <= 0 || idx === value.length - 1) return null; + return { state: value.substring(0, idx), sessionToken: value.substring(idx + 1) }; +}; + +/** + * Get a user-safe message for an OAuth failure. + * + * @param error - Caught error + * @param fallback - Message to use for unexpected errors + * @returns Message safe to show to end users + */ +const toUserMessage = (error: unknown, fallback: string): string => + error instanceof OAuthError ? error.message : fallback; + /** * Get enabled OAuth providers * @@ -89,15 +153,9 @@ app.get("/:provider/authorize", rateLimit(RateLimitPresets.login), async (c) => // Generate state for CSRF protection const state = generateId(); - - // Store state in cookie - setCookie(c, STATE_COOKIE_NAME, state, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "Lax", - maxAge: STATE_COOKIE_MAX_AGE, - path: "/", - }); + setStateCookie(c, STATE_COOKIE_NAME, state, STATE_COOKIE_MAX_AGE); + // A plain login must never be mistaken for a pending link flow + setStateCookie(c, LINK_STATE_COOKIE_NAME, "", 0); // Get authorization URL const authUrl = oauthService.getAuthorizationUrl(provider, state); @@ -113,13 +171,55 @@ app.get("/:provider/authorize", rateLimit(RateLimitPresets.login), async (c) => } }); +/** + * Initiate OAuth account linking for the authenticated user + * + * POST /api/auth/oauth/:provider/link/authorize + * + * Returns the provider authorization URL (the client navigates to it) and sets an + * httpOnly state cookie bound to the caller's session token. When the provider + * redirects back to the callback, the provider account is linked to that session's + * user instead of performing a login. Requires authentication. + */ +app.post( + "/:provider/link/authorize", + requireAuth(), + rateLimit(RateLimitPresets.login), + async (c) => { + const provider = c.req.param("provider")!; + const session = c.get("session")!; + + if (!isValidProvider(provider)) { + return c.json({ error: "Invalid OAuth provider" }, 400); + } + + const oauthService = getOAuthService(c); + if (!oauthService.isProviderEnabled(provider)) { + return c.json({ error: `${provider} OAuth is not configured` }, 400); + } + + const state = generateId(); + setStateCookie( + c, + LINK_STATE_COOKIE_NAME, + buildLinkStateValue(state, session.token), + STATE_COOKIE_MAX_AGE, + ); + // Ensure a stale login state can't be combined with this flow + setStateCookie(c, STATE_COOKIE_NAME, "", 0); + + return c.json({ url: oauthService.getAuthorizationUrl(provider, state), state }); + }, +); + /** * OAuth callback handler * * GET /api/auth/oauth/:provider/callback * * Handles the OAuth callback from the provider. - * Verifies state, exchanges code for tokens, and creates/links user. + * Verifies state, exchanges code for tokens, then either links the provider to the + * user who initiated a link flow, or logs in / signs up. */ app.get("/:provider/callback", rateLimit(RateLimitPresets.login), async (c) => { const provider = c.req.param("provider")!; @@ -142,30 +242,49 @@ app.get("/:provider/callback", rateLimit(RateLimitPresets.login), async (c) => { return c.redirect("/login?error=Missing+OAuth+parameters"); } - try { - // Verify state against cookie - const storedState = getCookie(c, STATE_COOKIE_NAME); - if (!storedState || storedState !== state) { - logger.warn({ provider }, "OAuth state mismatch"); - return c.redirect("/login?error=Invalid+OAuth+state"); + const storedState = getCookie(c, STATE_COOKIE_NAME); + const linkState = parseLinkStateValue(getCookie(c, LINK_STATE_COOKIE_NAME)); + + // Clear both state cookies (single use) + setStateCookie(c, STATE_COOKIE_NAME, "", 0); + setStateCookie(c, LINK_STATE_COOKIE_NAME, "", 0); + + // ---- Link flow (state bound to an authenticated session) ---- + if (linkState && linkState.state === state) { + try { + const authService = new AuthService(c.get("userRepository"), c.get("sessionRepository")); + const auth = await authService.validateSession(linkState.sessionToken); + if (!auth) { + return c.redirect("/login?error=Session+expired.+Log+in+and+try+linking+again."); + } + + const oauthService = getOAuthService(c); + const { tokens, profile } = await oauthService.handleCallback(provider, code); + await oauthService.linkProfile(auth.user.id, provider, profile, tokens); + + return c.redirect(`/settings?oauth_linked=${encodeURIComponent(provider)}`); + } catch (err) { + logger.error({ err, provider }, "OAuth link callback error"); + const message = toUserMessage(err, "Failed to link OAuth account"); + return c.redirect(`/settings?error=${encodeURIComponent(message)}`); } + } - // Clear state cookie - setCookie(c, STATE_COOKIE_NAME, "", { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "Lax", - maxAge: 0, - path: "/", - }); + // ---- Login / sign-up flow ---- + if (!storedState || storedState !== state) { + logger.warn({ provider }, "OAuth state mismatch"); + return c.redirect("/login?error=Invalid+OAuth+state"); + } + try { const oauthService = getOAuthService(c); // Exchange code for tokens and get user profile const { tokens, profile } = await oauthService.handleCallback(provider, code); - // Find or create user - const { user, isNew } = await oauthService.findOrCreateUser(provider, profile, tokens); + // Find existing linked user, or create one subject to registration policy + const policy = await getRegistrationPolicy(c.get("instanceSettingsService")); + const { user, isNew } = await oauthService.findOrCreateUser(provider, profile, tokens, policy); // Check if user is suspended if (user.isSuspended) { @@ -198,35 +317,54 @@ app.get("/:provider/callback", rateLimit(RateLimitPresets.login), async (c) => { const redirectUrl = isNew ? "/settings/profile" : "/"; return c.redirect(redirectUrl); } catch (error) { - logger.error({ err: error, provider }, "OAuth callback error"); + if (error instanceof OAuthError) { + logger.warn({ provider, code: error.code }, "OAuth login refused"); + } else { + logger.error({ err: error, provider }, "OAuth callback error"); + } const message = error instanceof Error ? error.message : "OAuth authentication failed"; return c.redirect(`/login?error=${encodeURIComponent(message)}`); } }); /** - * Link OAuth account to existing user + * Link OAuth account to existing user (code exchange from a client-side callback) * * POST /api/auth/oauth/:provider/link * * Links an OAuth provider account to the currently authenticated user. + * The `state` must match the httpOnly link-state cookie issued by + * `POST /:provider/link/authorize` for the same session. * Requires authentication. */ -app.post("/:provider/link", requireAuth, rateLimit(RateLimitPresets.login), async (c) => { +app.post("/:provider/link", requireAuth(), rateLimit(RateLimitPresets.login), async (c) => { const provider = c.req.param("provider")!; const user = c.get("user")!; + const session = c.get("session")!; if (!isValidProvider(provider)) { return c.json({ error: "Invalid OAuth provider" }, 400); } try { - const body = await c.req.json<{ code: string }>(); + const body = await c.req.json<{ code?: string; state?: string }>(); - if (!body.code) { + if (!body.code || typeof body.code !== "string") { return c.json({ error: "Missing authorization code" }, 400); } + const linkState = parseLinkStateValue(getCookie(c, LINK_STATE_COOKIE_NAME)); + setStateCookie(c, LINK_STATE_COOKIE_NAME, "", 0); + if ( + !linkState || + typeof body.state !== "string" || + linkState.state !== body.state || + linkState.sessionToken !== session.token + ) { + logger.warn({ provider, userId: user.id }, "OAuth link state mismatch"); + return c.json({ error: "Invalid OAuth state" }, 400); + } + const oauthService = getOAuthService(c); const oauthAccount = await oauthService.linkAccount(user.id, provider, body.code); @@ -242,10 +380,7 @@ app.post("/:provider/link", requireAuth, rateLimit(RateLimitPresets.login), asyn }); } catch (error) { logger.error({ err: error, provider, userId: user.id }, "OAuth link error"); - return c.json( - { error: error instanceof Error ? error.message : "Failed to link OAuth account" }, - 400, - ); + return c.json({ error: toUserMessage(error, "Failed to link OAuth account") }, 400); } }); @@ -257,7 +392,7 @@ app.post("/:provider/link", requireAuth, rateLimit(RateLimitPresets.login), asyn * Removes an OAuth provider link from the authenticated user. * Requires authentication. */ -app.delete("/:provider", requireAuth, rateLimit(RateLimitPresets.login), async (c) => { +app.delete("/:provider", requireAuth(), rateLimit(RateLimitPresets.login), async (c) => { const provider = c.req.param("provider")!; const user = c.get("user")!; @@ -289,7 +424,7 @@ app.delete("/:provider", requireAuth, rateLimit(RateLimitPresets.login), async ( * Returns list of OAuth providers linked to the authenticated user. * Requires authentication. */ -app.get("/accounts", requireAuth, async (c) => { +app.get("/accounts", requireAuth(), async (c) => { const user = c.get("user")!; try { diff --git a/packages/backend/src/routes/users.ts b/packages/backend/src/routes/users.ts index 7fc8786d..be18cad5 100644 --- a/packages/backend/src/routes/users.ts +++ b/packages/backend/src/routes/users.ts @@ -1,13 +1,14 @@ /** * User API Routes * - * Provides endpoints for user registration, and retrieving and updating user information. + * Provides endpoints for retrieving and updating user information. + * Registration lives at `POST /api/auth/register` (the legacy `POST /api/users` + * endpoint was removed because it bypassed registration gating). * * @module routes/users */ import { Hono } from "hono"; -import { AuthService } from "../services/AuthService.js"; import { UserService } from "../services/UserService.js"; import { requireAuth, optionalAuth } from "../middleware/auth.js"; import { errorResponse } from "../lib/routeUtils.js"; @@ -15,112 +16,6 @@ import { verifyPassword } from "../utils/password.js"; const app = new Hono(); -/** - * User Registration - * - * POST /api/users - * - * Registers a new user, automatically logs in, and issues a session token. - * Only enabled when ENABLE_REGISTRATION environment variable is true. - * - * @remarks - * Request Body: - * ```json - * { - * "username": "alice", - * "email": "alice@example.com", - * "password": "securePassword123", - * "name": "Alice Smith" // optional - * } - * ``` - * - * Validation: - * - username: 3-20 characters, alphanumeric and underscores only - * - email: Valid email address format - * - password: Minimum 8 characters - * - * Response (201): - * ```json - * { - * "user": { "id": "...", "username": "alice", ... }, - * "token": "a3f2e1d0c9b8a7f6..." - * } - * ``` - * - * Errors: - * - 400: Validation error - * - 403: Registration disabled - * - 409: Username or email already exists - */ -app.post("/", async (c) => { - // 登録機能が無効な場合 - if (process.env.ENABLE_REGISTRATION !== "true") { - return c.json({ error: "Registration is disabled" }, 403); - } - - const body = await c.req.json(); - - // バリデーション - if (!body.username || typeof body.username !== "string") { - return c.json({ error: "Username is required" }, 400); - } - if (!body.email || typeof body.email !== "string") { - return c.json({ error: "Email is required" }, 400); - } - if (!body.password || typeof body.password !== "string") { - return c.json({ error: "Password is required" }, 400); - } - - // ユーザー名のバリデーション(英数字とアンダースコアのみ、3-20文字) - if (!/^[a-zA-Z0-9_]{3,20}$/.test(body.username)) { - return c.json( - { - error: - "Username must be 3-20 characters and contain only letters, numbers, and underscores", - }, - 400, - ); - } - - // メールアドレスの簡易バリデーション - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) { - return c.json({ error: "Invalid email address" }, 400); - } - - // パスワードの長さチェック(最低8文字) - if (body.password.length < 8) { - return c.json({ error: "Password must be at least 8 characters" }, 400); - } - - try { - const authService = new AuthService(c.get("userRepository"), c.get("sessionRepository")); - const { user, session } = await authService.register({ - username: body.username, - email: body.email, - password: body.password, - name: body.name, - }); - - // パスワードハッシュを除外してレスポンス - const { passwordHash: _passwordHash, email: _email, ...publicUser } = user; - - return c.json( - { - user: publicUser, - token: session.token, - }, - 201, - ); - } catch (error) { - if (error instanceof Error) { - if (error.message.includes("already exists")) { - return c.json({ error: error.message }, 409); - } - } - throw error; - } -}); - /** * Get Own User Information * diff --git a/packages/backend/src/services/OAuthService.ts b/packages/backend/src/services/OAuthService.ts index e7784aa8..d2856fb6 100644 --- a/packages/backend/src/services/OAuthService.ts +++ b/packages/backend/src/services/OAuthService.ts @@ -17,6 +17,96 @@ import type { ISessionRepository } from "../interfaces/repositories/ISessionRepo import type { User, OAuthAccount } from "../db/schema/pg.js"; import { logger } from "../lib/logger.js"; import { generateKeyPair } from "../utils/crypto.js"; +import type { BlockedUsernameService } from "./BlockedUsernameService.js"; +import type { EventBus } from "../lib/events.js"; +import { toPluginUser } from "../plugins/utils.js"; +import type { RegistrationPolicy } from "../lib/registrationPolicy.js"; + +/** + * Prefix of the placeholder password hash given to OAuth-created users. + * Such hashes can never match a password. + */ +const OAUTH_PASSWORD_HASH_PREFIX = "oauth:"; + +/** + * Registration policy applied when an OAuth login would create a new account. + */ +export type OAuthRegistrationPolicy = RegistrationPolicy; + +/** + * Error codes raised by OAuthService for user-facing failures. + */ +export type OAuthErrorCode = + | "email_exists" + | "registration_disabled" + | "invite_only" + | "registration_cancelled" + | "username_not_allowed" + | "already_linked" + | "linked_to_other_user"; + +/** + * User-facing OAuth error. The message is safe to show to end users. + */ +export class OAuthError extends Error { + /** + * @param code - Machine-readable error code + * @param message - Human-readable message safe to display + */ + constructor( + public readonly code: OAuthErrorCode, + message: string, + ) { + super(message); + this.name = "OAuthError"; + } +} + +/** + * Whether a stored password hash can actually be used for password login. + * + * @param passwordHash - Stored password hash (may be an OAuth placeholder) + * @returns False for empty hashes and OAuth placeholder hashes + */ +export function hasUsablePassword(passwordHash: string | null | undefined): boolean { + return !!passwordHash && !passwordHash.startsWith(OAUTH_PASSWORD_HASH_PREFIX); +} + +/** + * Select a verified email from GitHub's `/user/emails` response. + * + * Prefers the public profile email when it is listed as verified, otherwise the + * primary verified email. Unverified addresses are never returned. + * + * @param publicEmail - Email from the public profile (carries no verification flag) + * @param emails - Entries from `GET /user/emails` + * @returns A verified email or null + */ +export function selectVerifiedGitHubEmail( + publicEmail: string | null, + emails: GitHubEmail[], +): string | null { + if (publicEmail) { + const match = emails.find( + (e) => e.verified && e.email.toLowerCase() === publicEmail.toLowerCase(), + ); + if (match) return match.email; + } + return emails.find((e) => e.primary && e.verified)?.email ?? null; +} + +/** + * Whether a Google userinfo response reports the email as verified. + * + * @param user - Google userinfo response (v2 or OIDC shape) + * @returns True only when Google explicitly marks the email as verified + */ +export function isGoogleEmailVerified(user: { + verified_email?: boolean; + email_verified?: boolean; +}): boolean { + return user.verified_email === true || user.email_verified === true; +} /** * GitHub user profile response @@ -32,7 +122,7 @@ interface GitHubUser { /** * GitHub email response */ -interface GitHubEmail { +export interface GitHubEmail { email: string; primary: boolean; verified: boolean; @@ -44,6 +134,10 @@ interface GitHubEmail { interface GoogleUser { id: string; email?: string; + /** Whether Google has verified the email address (v2 userinfo) */ + verified_email?: boolean; + /** Whether Google has verified the email address (OIDC userinfo) */ + email_verified?: boolean; name?: string; picture?: string; } @@ -56,6 +150,8 @@ interface DiscordUser { username: string; global_name?: string; email?: string; + /** Whether the email on the Discord account has been verified */ + verified?: boolean; avatar?: string; } @@ -105,6 +201,10 @@ interface OAuthTokenResponse { export interface OAuthUserProfile { id: string; username: string; + /** + * Email address reported by the provider. + * Only set when the provider has verified the address; otherwise null. + */ email: string | null; displayName: string | null; avatarUrl: string | null; @@ -120,6 +220,8 @@ export class OAuthService { private oauthAccountRepository: IOAuthAccountRepository, private userRepository: IUserRepository, private sessionRepository: ISessionRepository, + private blockedUsernameService?: Pick, + private eventBus?: EventBus, ) { // Load configs from environment this.loadConfigs(); @@ -456,21 +558,19 @@ export class OAuthService { const user = (await userResponse.json()) as GitHubUser; - // Get primary email if not public - let email: string | null = user.email; - if (!email) { - const emailResponse = await fetch("https://api.github.com/user/emails", { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/vnd.github.v3+json", - }, - }); + // Only trust emails that GitHub reports as verified. The public profile + // email carries no verification flag, so always consult /user/emails. + let email: string | null = null; + const emailResponse = await fetch("https://api.github.com/user/emails", { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/vnd.github.v3+json", + }, + }); - if (emailResponse.ok) { - const emails = (await emailResponse.json()) as GitHubEmail[]; - const primaryEmail = emails.find((e) => e.primary && e.verified); - email = primaryEmail?.email || null; - } + if (emailResponse.ok) { + const emails = (await emailResponse.json()) as GitHubEmail[]; + email = selectVerifiedGitHubEmail(user.email, emails); } return { @@ -498,7 +598,7 @@ export class OAuthService { return { id: user.id, username: user.email?.split("@")[0] || user.id, - email: user.email || null, + email: isGoogleEmailVerified(user) ? user.email || null : null, displayName: user.name || null, avatarUrl: user.picture || null, }; @@ -527,7 +627,7 @@ export class OAuthService { return { id: user.id, username: user.username, - email: user.email || null, + email: user.verified === true ? user.email || null : null, displayName: user.global_name || user.username, avatarUrl, }; @@ -563,13 +663,28 @@ export class OAuthService { } /** - * Find or create user from OAuth profile - * Returns the user and a flag indicating if it's a new user + * Find or create a user from an OAuth profile. + * + * Security rules: + * - A provider identity is only ever resolved through an existing OAuth link row. + * - It is never auto-linked to an existing local account by email. Rox does not + * verify local emails, and some providers report unverified emails, so an email + * match is not proof of ownership. Users must log in and link explicitly. + * - New accounts are subject to the same registration gating as password sign-up + * (registration enabled, invite-only mode, blocked usernames, plugin veto). + * + * @param provider - OAuth provider + * @param profile - Normalized provider profile (email is only set when verified) + * @param tokens - Provider token response + * @param policy - Current instance registration policy + * @returns The user, whether it was newly created, and the OAuth account link + * @throws {OAuthError} When an account with the same email exists, or sign-up is not allowed */ async findOrCreateUser( provider: OAuthProvider, profile: OAuthUserProfile, tokens: OAuthTokenResponse, + policy: OAuthRegistrationPolicy, ): Promise<{ user: User; isNew: boolean; oauthAccount: OAuthAccount }> { // Check if OAuth account already exists const existingOAuthAccount = await this.oauthAccountRepository.findByProviderAccount( @@ -600,86 +715,183 @@ export class OAuthService { return { user, isNew: false, oauthAccount: updatedAccount }; } - // Check if user with same email exists (to link accounts) - let user: User | null = null; - let isNew = false; - + // Never auto-link by email: refuse if a local account already uses this email. if (profile.email) { - user = await this.userRepository.findByEmail(profile.email); + const emailOwner = await this.userRepository.findByEmail(profile.email); + if (emailOwner) { + logger.warn( + { provider, userId: emailOwner.id }, + "OAuth login refused: email already belongs to an existing account", + ); + throw new OAuthError( + "email_exists", + "An account with this email already exists. Log in and link the provider from settings.", + ); + } } - if (!user) { - // Create new user - const baseUsername = this.sanitizeUsername(profile.username); + // New account: apply registration gating + if (!policy.registrationEnabled) { + throw new OAuthError("registration_disabled", "Registration is currently disabled"); + } + if (policy.inviteOnly) { + throw new OAuthError( + "invite_only", + "Registration on this server requires an invitation code. Sign up with an invitation code first, then link the provider from settings.", + ); + } + + const username = await this.pickAvailableUsername(profile.username); + + // Let plugins veto or adjust the registration (same hook as password sign-up) + let displayName = profile.displayName; + if (this.eventBus) { + const beforeResult = await this.eventBus.emitBefore("user:beforeRegister", { + username, + displayName, + }); + if (beforeResult.cancelled) { + throw new OAuthError( + "registration_cancelled", + beforeResult.cancelReason || "Registration cancelled by plugin", + ); + } + if (beforeResult.modifiedPayload) { + displayName = beforeResult.modifiedPayload.displayName ?? displayName; + } + } + + // Generate ActivityPub keypair for OAuth users + const { publicKey, privateKey } = generateKeyPair(); + const baseUrl = process.env.URL || "http://localhost:3000"; + + const user = await this.userRepository.create({ + id: generateId(), + username, + // Only verified provider emails are stored; otherwise use a non-routable placeholder + // (the column is NOT NULL + UNIQUE). + email: profile.email || `${profile.id}@${provider}.oauth`, + // OAuth-only users get a random unusable password hash + // This hash is not a valid bcrypt hash, so password login will always fail + passwordHash: `${OAUTH_PASSWORD_HASH_PREFIX}${provider}:${generateId()}`, + displayName, + host: null, // Local user + avatarUrl: profile.avatarUrl, + bannerUrl: null, + bio: null, + isAdmin: false, + isSuspended: false, + isDeleted: false, + deletedAt: null, + isSystemUser: false, + publicKey, + privateKey, + customCss: null, + uiSettings: null, + // ActivityPub fields for local users + inbox: `${baseUrl}/users/${username}/inbox`, + outbox: `${baseUrl}/users/${username}/outbox`, + followersUrl: `${baseUrl}/users/${username}/followers`, + followingUrl: `${baseUrl}/users/${username}/following`, + uri: `${baseUrl}/users/${username}`, + sharedInbox: null, + // Account migration fields + alsoKnownAs: [], + movedTo: null, + movedAt: null, + // Profile emojis + profileEmojis: [], + // Storage quota + storageQuotaMb: null, + // Remote actor fetch status (not applicable to local users) + goneDetectedAt: null, + fetchFailureCount: 0, + lastFetchAttemptAt: null, + lastFetchError: null, + // Follower/following counts start at 0 + followersCount: 0, + followingCount: 0, + }); + + logger.info({ provider, userId: user.id, username }, "OAuth login: new user created"); + + if (this.eventBus) { + this.eventBus.emit("user:afterRegister", { user: toPluginUser(user) }).catch((error) => { + logger.error({ err: error, userId: user.id }, "Failed to emit user:afterRegister event"); + }); + } + + const oauthAccount = await this.createLink(user.id, provider, profile, tokens); + + return { user, isNew: true, oauthAccount }; + } + + /** + * Pick a unique, non-blocked username derived from the provider username. + * + * @param providerUsername - Username reported by the provider + * @returns An available username + * @throws {OAuthError} When no allowed username could be derived + */ + private async pickAvailableUsername(providerUsername: string): Promise { + // Fallback base (random) is used if the derived name is blocked (e.g. reserved words) + const candidates = [ + this.sanitizeUsername(providerUsername), + this.sanitizeUsername(`user${generateId().substring(0, 8)}`), + ]; + + for (const baseUsername of candidates) { + if (await this.isUsernameBlocked(baseUsername)) continue; + let username = baseUsername; let attempt = 0; - - // Ensure unique username while (await this.userRepository.findByUsername(username)) { attempt++; - username = `${baseUsername}${attempt}`; + const suffix = String(attempt); + username = `${baseUsername.substring(0, 20 - suffix.length)}${suffix}`; } - // Generate ActivityPub keypair for OAuth users - const { publicKey, privateKey } = generateKeyPair(); - const baseUrl = process.env.URL || "http://localhost:3000"; + if (username === baseUsername || !(await this.isUsernameBlocked(username))) { + return username; + } + } - user = await this.userRepository.create({ - id: generateId(), - username, - email: profile.email || `${profile.id}@${provider}.oauth`, - // OAuth-only users get a random unusable password hash - // This hash is not a valid bcrypt hash, so password login will always fail - passwordHash: `oauth:${provider}:${generateId()}`, - displayName: profile.displayName, - host: null, // Local user - avatarUrl: profile.avatarUrl, - bannerUrl: null, - bio: null, - isAdmin: false, - isSuspended: false, - isDeleted: false, - deletedAt: null, - isSystemUser: false, - publicKey, - privateKey, - customCss: null, - uiSettings: null, - // ActivityPub fields for local users - inbox: `${baseUrl}/users/${username}/inbox`, - outbox: `${baseUrl}/users/${username}/outbox`, - followersUrl: `${baseUrl}/users/${username}/followers`, - followingUrl: `${baseUrl}/users/${username}/following`, - uri: `${baseUrl}/users/${username}`, - sharedInbox: null, - // Account migration fields - alsoKnownAs: [], - movedTo: null, - movedAt: null, - // Profile emojis - profileEmojis: [], - // Storage quota - storageQuotaMb: null, - // Remote actor fetch status (not applicable to local users) - goneDetectedAt: null, - fetchFailureCount: 0, - lastFetchAttemptAt: null, - lastFetchError: null, - // Follower/following counts start at 0 - followersCount: 0, - followingCount: 0, - }); + throw new OAuthError( + "username_not_allowed", + "Could not derive an allowed username from your account. Please register with a password instead.", + ); + } - isNew = true; - logger.info({ provider, userId: user.id, username }, "OAuth login: new user created"); - } else { - logger.info({ provider, userId: user.id }, "OAuth login: linked to existing user by email"); - } + /** + * Check a username against the blocked/reserved username list. + * + * @param username - Candidate username + * @returns True if the username must not be used + */ + private async isUsernameBlocked(username: string): Promise { + if (!this.blockedUsernameService) return false; + const result = await this.blockedUsernameService.isUsernameBlocked(username); + return result.blocked; + } - // Create OAuth account link - const oauthAccount = await this.oauthAccountRepository.create({ + /** + * Persist an OAuth link row for a user. + * + * @param userId - Local user ID + * @param provider - OAuth provider + * @param profile - Provider profile + * @param tokens - Provider token response + * @returns The created OAuth account link + */ + private createLink( + userId: string, + provider: OAuthProvider, + profile: OAuthUserProfile, + tokens: OAuthTokenResponse, + ): Promise { + return this.oauthAccountRepository.create({ id: generateId(), - userId: user.id, + userId, provider, providerAccountId: profile.id, accessToken: tokens.access_token, @@ -690,8 +902,6 @@ export class OAuthService { providerUsername: profile.username, providerEmail: profile.email, }); - - return { user, isNew, oauthAccount }; } /** @@ -719,38 +929,61 @@ export class OAuthService { } /** - * Link OAuth account to existing user + * Link an OAuth account to an existing (authenticated) user by exchanging a code. + * + * @param userId - ID of the authenticated user + * @param provider - OAuth provider + * @param code - Authorization code returned by the provider + * @returns The created OAuth account link */ async linkAccount(userId: string, provider: OAuthProvider, code: string): Promise { - // Check if already linked + // Check if already linked (before spending the code) const existing = await this.oauthAccountRepository.findByUserAndProvider(userId, provider); if (existing) { - throw new Error(`${provider} account is already linked`); + throw new OAuthError("already_linked", `${provider} account is already linked`); } // Exchange code and get profile const { tokens, profile } = await this.handleCallback(provider, code); + return this.linkProfile(userId, provider, profile, tokens); + } + + /** + * Link an already-fetched provider profile to an existing (authenticated) user. + * + * The caller is responsible for having authenticated `userId` and for having + * verified the OAuth state that binds this callback to that user. + * + * @param userId - ID of the authenticated user + * @param provider - OAuth provider + * @param profile - Provider profile obtained from the callback + * @param tokens - Provider token response + * @returns The created OAuth account link + * @throws {OAuthError} When the user already has this provider linked, or the + * provider account is linked to another user + */ + async linkProfile( + userId: string, + provider: OAuthProvider, + profile: OAuthUserProfile, + tokens: OAuthTokenResponse, + ): Promise { + const existing = await this.oauthAccountRepository.findByUserAndProvider(userId, provider); + if (existing) { + throw new OAuthError("already_linked", `${provider} account is already linked`); + } + // Check if this provider account is linked to another user const otherLink = await this.oauthAccountRepository.findByProviderAccount(provider, profile.id); if (otherLink) { - throw new Error(`This ${provider} account is already linked to another user`); + throw new OAuthError( + "linked_to_other_user", + `This ${provider} account is already linked to another user`, + ); } - // Create link - const oauthAccount = await this.oauthAccountRepository.create({ - id: generateId(), - userId, - provider, - providerAccountId: profile.id, - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || null, - tokenExpiresAt: tokens.expires_in ? new Date(Date.now() + tokens.expires_in * 1000) : null, - scope: tokens.scope || null, - tokenType: tokens.token_type || "Bearer", - providerUsername: profile.username, - providerEmail: profile.email, - }); + const oauthAccount = await this.createLink(userId, provider, profile, tokens); logger.info({ provider, userId }, "OAuth account linked"); return oauthAccount; @@ -769,8 +1002,9 @@ export class OAuthService { // Count linked OAuth accounts const linkedAccounts = await this.oauthAccountRepository.countByUserId(userId); - // User must have password or at least one other OAuth account - if (!user.passwordHash && linkedAccounts <= 1) { + // User must have a usable password or at least one other OAuth account. + // OAuth-created users carry a placeholder hash that can never be used to log in. + if (!hasUsablePassword(user.passwordHash) && linkedAccounts <= 1) { throw new Error("Cannot unlink the only login method. Set a password first."); } diff --git a/packages/backend/src/tests/unit/OAuthRoutesSecurity.test.ts b/packages/backend/src/tests/unit/OAuthRoutesSecurity.test.ts new file mode 100644 index 00000000..59acd892 --- /dev/null +++ b/packages/backend/src/tests/unit/OAuthRoutesSecurity.test.ts @@ -0,0 +1,333 @@ +/** + * OAuth / Registration Route Security Tests + * + * Covers: + * - OAuth callback refuses email auto-linking and gated sign-ups + * - OAuth link/unlink/accounts endpoints actually require authentication + * - Link-flow callback binds the provider to the session that initiated it + * - Legacy `POST /api/users` registration endpoint is removed + * - Shared registration policy helpers + */ + +import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test"; +import { Hono } from "hono"; +import oauthApp from "../../routes/oauth.js"; +import usersApp from "../../routes/users.js"; +import { getRegistrationPolicy } from "../../lib/registrationPolicy.js"; +import type { User, OAuthAccount } from "../../db/schema/pg.js"; + +const baseUser: User = { + id: "user1", + username: "alice", + email: "alice@example.com", + passwordHash: "$argon2id$hash", + displayName: "Alice", + host: null, + avatarUrl: null, + bannerUrl: null, + bio: null, + isAdmin: false, + isSuspended: false, + isDeleted: false, + deletedAt: null, + isSystemUser: false, + publicKey: "pk", + privateKey: "sk", + inbox: null, + outbox: null, + followersUrl: null, + followingUrl: null, + uri: null, + sharedInbox: null, + customCss: null, + uiSettings: null, + alsoKnownAs: [], + movedTo: null, + movedAt: null, + profileEmojis: [], + storageQuotaMb: null, + goneDetectedAt: null, + fetchFailureCount: 0, + lastFetchAttemptAt: null, + lastFetchError: null, + followersCount: 0, + followingCount: 0, + createdAt: new Date(), + updatedAt: new Date(), +} as User; + +const linkRow: OAuthAccount = { + id: "oa1", + userId: "user1", + provider: "github", + providerAccountId: "42", + accessToken: "tok", + refreshToken: null, + tokenExpiresAt: null, + scope: null, + tokenType: "Bearer", + providerUsername: "gh", + providerEmail: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +describe("OAuth routes security", () => { + const originalEnv = { ...process.env }; + const originalFetch = globalThis.fetch; + + let userRepo: Record>; + let oauthRepo: Record>; + let sessionRepo: Record>; + let settings: { + isRegistrationEnabled: ReturnType; + isInviteOnly: ReturnType; + }; + let app: Hono; + + beforeEach(() => { + process.env.URL = "http://localhost:3000"; + process.env.GITHUB_CLIENT_ID = "id"; + process.env.GITHUB_CLIENT_SECRET = "secret"; + delete process.env.REQUIRE_INVITATION; + delete process.env.ENABLE_REGISTRATION; + + // GitHub: token exchange, /user, /user/emails (verified primary email) + globalThis.fetch = mock((input: string | URL | Request) => { + const url = String(input); + let body: unknown = {}; + if (url.includes("/login/oauth/access_token")) { + body = { access_token: "tok", token_type: "bearer" }; + } else if (url.endsWith("/user")) { + body = { id: 42, login: "victim", name: null, email: null, avatar_url: null }; + } else if (url.endsWith("/user/emails")) { + body = [{ email: "alice@example.com", primary: true, verified: true }]; + } + return Promise.resolve(new Response(JSON.stringify(body), { status: 200 })); + }) as unknown as typeof fetch; + + userRepo = { + findByEmail: mock(() => Promise.resolve(null)), + findByUsername: mock(() => Promise.resolve(null)), + findById: mock(() => Promise.resolve(baseUser)), + create: mock((u: User) => Promise.resolve({ ...baseUser, ...u })), + }; + oauthRepo = { + findByProviderAccount: mock(() => Promise.resolve(null)), + findByUserAndProvider: mock(() => Promise.resolve(null)), + create: mock(() => Promise.resolve(linkRow)), + updateTokens: mock(() => Promise.resolve(linkRow)), + findByUserId: mock(() => Promise.resolve([linkRow])), + }; + sessionRepo = { + create: mock(() => Promise.resolve({})), + findByToken: mock((token: string) => + Promise.resolve( + token === "valid-session" + ? { + id: "s1", + userId: "user1", + token, + expiresAt: new Date(Date.now() + 60_000), + } + : null, + ), + ), + delete: mock(() => Promise.resolve()), + }; + settings = { + isRegistrationEnabled: mock(() => Promise.resolve(true)), + isInviteOnly: mock(() => Promise.resolve(false)), + }; + + app = new Hono(); + app.use("*", async (c, next) => { + c.set("userRepository" as any, userRepo as any); + c.set("oauthAccountRepository" as any, oauthRepo as any); + c.set("sessionRepository" as any, sessionRepo as any); + c.set("instanceSettingsService" as any, settings as any); + c.set( + "blockedUsernameService" as any, + { + isUsernameBlocked: mock(() => Promise.resolve({ blocked: false })), + } as any, + ); + await next(); + }); + app.route("/api/auth/oauth", oauthApp); + app.route("/api/users", usersApp); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + globalThis.fetch = originalFetch; + }); + + const callback = (cookie: string) => + app.request("/api/auth/oauth/github/callback?code=abc&state=st", { + headers: { Cookie: cookie }, + }); + + const errorOf = (res: Response) => + new URL(res.headers.get("location")!, "http://x").searchParams.get("error"); + + test("callback refuses to auto-link an existing account by email", async () => { + userRepo.findByEmail!.mockResolvedValue(baseUser); + + const res = await callback("oauth_state=st"); + + expect(res.status).toBe(302); + expect(res.headers.get("location")!.startsWith("/login?")).toBe(true); + expect(errorOf(res)).toContain("An account with this email already exists"); + expect(oauthRepo.create).not.toHaveBeenCalled(); + expect(sessionRepo.create).not.toHaveBeenCalled(); + }); + + test("callback refuses OAuth sign-up when invite-only", async () => { + settings.isInviteOnly.mockResolvedValue(true); + + const res = await callback("oauth_state=st"); + + expect(errorOf(res)).toContain("invitation code"); + expect(userRepo.create).not.toHaveBeenCalled(); + }); + + test("callback refuses OAuth sign-up when REQUIRE_INVITATION env is set", async () => { + process.env.REQUIRE_INVITATION = "true"; + + const res = await callback("oauth_state=st"); + + expect(errorOf(res)).toContain("invitation code"); + expect(userRepo.create).not.toHaveBeenCalled(); + }); + + test("callback refuses OAuth sign-up when registration is disabled", async () => { + settings.isRegistrationEnabled.mockResolvedValue(false); + + const res = await callback("oauth_state=st"); + + expect(errorOf(res)).toBe("Registration is currently disabled"); + expect(userRepo.create).not.toHaveBeenCalled(); + }); + + test("callback still logs in already-linked users when invite-only", async () => { + settings.isInviteOnly.mockResolvedValue(true); + oauthRepo.findByProviderAccount!.mockResolvedValue(linkRow); + + const res = await callback("oauth_state=st"); + + expect(res.headers.get("location")).toBe("/"); + expect(sessionRepo.create).toHaveBeenCalled(); + }); + + test("callback rejects state mismatch", async () => { + const res = await callback("oauth_state=other"); + expect(errorOf(res)).toBe("Invalid OAuth state"); + }); + + test("link-flow callback links to the session that initiated it", async () => { + const res = await callback("oauth_link_state=st.valid-session"); + + expect(res.headers.get("location")).toBe("/settings?oauth_linked=github"); + expect(oauthRepo.create).toHaveBeenCalled(); + const created = oauthRepo.create!.mock.calls[0]![0] as OAuthAccount; + expect(created.userId).toBe("user1"); + expect(sessionRepo.create).not.toHaveBeenCalled(); + }); + + test("link-flow callback with an invalid session does not link", async () => { + const res = await callback("oauth_link_state=st.forged-or-expired"); + + expect(res.headers.get("location")!.startsWith("/login?")).toBe(true); + expect(oauthRepo.create).not.toHaveBeenCalled(); + }); + + test("link/unlink/accounts endpoints require authentication", async () => { + const accounts = await app.request("/api/auth/oauth/accounts"); + expect(accounts.status).toBe(401); + + const link = await app.request("/api/auth/oauth/github/link", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: "x", state: "y" }), + }); + expect(link.status).toBe(401); + + const linkAuthorize = await app.request("/api/auth/oauth/github/link/authorize", { + method: "POST", + }); + expect(linkAuthorize.status).toBe(401); + + const unlink = await app.request("/api/auth/oauth/github", { method: "DELETE" }); + expect(unlink.status).toBe(401); + }); + + test("authenticated user can list linked accounts", async () => { + const res = await app.request("/api/auth/oauth/accounts", { + headers: { Authorization: "Bearer valid-session" }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { accounts: unknown[] }; + expect(body.accounts.length).toBe(1); + }); + + test("POST /link requires state bound to the caller's session", async () => { + const res = await app.request("/api/auth/oauth/github/link", { + method: "POST", + headers: { + Authorization: "Bearer valid-session", + "Content-Type": "application/json", + Cookie: "oauth_link_state=st.some-other-session", + }, + body: JSON.stringify({ code: "abc", state: "st" }), + }); + expect(res.status).toBe(400); + expect(oauthRepo.create).not.toHaveBeenCalled(); + }); + + test("legacy POST /api/users registration endpoint is removed", async () => { + process.env.ENABLE_REGISTRATION = "true"; + const res = await app.request("/api/users", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: "mallory", + email: "mallory@example.com", + password: "password123", + }), + }); + expect(res.status).toBe(404); + expect(userRepo.create).not.toHaveBeenCalled(); + }); +}); + +describe("getRegistrationPolicy", () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + const source = (enabled: boolean, inviteOnly: boolean) => ({ + isRegistrationEnabled: () => Promise.resolve(enabled), + isInviteOnly: () => Promise.resolve(inviteOnly), + }); + + test("reflects DB settings", async () => { + delete process.env.REQUIRE_INVITATION; + expect(await getRegistrationPolicy(source(true, false))).toEqual({ + registrationEnabled: true, + inviteOnly: false, + }); + expect(await getRegistrationPolicy(source(false, true))).toEqual({ + registrationEnabled: false, + inviteOnly: true, + }); + }); + + test("REQUIRE_INVITATION env forces invite-only", async () => { + process.env.REQUIRE_INVITATION = "1"; + const policy = await getRegistrationPolicy(source(true, false)); + expect(policy.inviteOnly).toBe(true); + }); +}); diff --git a/packages/backend/src/tests/unit/OAuthService.test.ts b/packages/backend/src/tests/unit/OAuthService.test.ts index def2bb01..2b596500 100644 --- a/packages/backend/src/tests/unit/OAuthService.test.ts +++ b/packages/backend/src/tests/unit/OAuthService.test.ts @@ -6,7 +6,13 @@ */ import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test"; -import { OAuthService } from "../../services/OAuthService"; +import { + OAuthService, + OAuthError, + hasUsablePassword, + isGoogleEmailVerified, + selectVerifiedGitHubEmail, +} from "../../services/OAuthService"; import type { IOAuthAccountRepository } from "../../interfaces/repositories/IOAuthAccountRepository"; import type { IUserRepository } from "../../interfaces/repositories/IUserRepository"; import type { ISessionRepository } from "../../interfaces/repositories/ISessionRepository"; @@ -421,4 +427,288 @@ describe("OAuthService", () => { expect(url).toContain("email"); }); }); + + describe("findOrCreateUser security", () => { + const tokens = { access_token: "tok", token_type: "Bearer" }; + const openPolicy = { registrationEnabled: true, inviteOnly: false }; + const profile = { + id: "999", + username: "victim", + email: "victim@example.com", + displayName: "Victim", + avatarUrl: null, + }; + + const makeService = (blocked?: (name: string) => boolean) => + new OAuthService( + mockOAuthRepo, + mockUserRepo as IUserRepository, + mockSessionRepo as ISessionRepository, + blocked + ? { + isUsernameBlocked: mock((name: string) => + Promise.resolve({ blocked: blocked(name) }), + ), + } + : undefined, + ); + + test("logs in an already-linked provider account", async () => { + (mockOAuthRepo.findByProviderAccount as ReturnType).mockResolvedValue( + mockOAuthAccount, + ); + + const result = await makeService().findOrCreateUser("github", profile, tokens, { + registrationEnabled: false, + inviteOnly: true, + }); + + // Existing links keep working even when registration is closed + expect(result.isNew).toBe(false); + expect(result.user.id).toBe("user1"); + expect(mockUserRepo.create).not.toHaveBeenCalled(); + expect(mockOAuthRepo.create).not.toHaveBeenCalled(); + }); + + test("never auto-links to an existing account with the same email", async () => { + (mockUserRepo.findByEmail as ReturnType).mockResolvedValue(mockUser); + + await expect( + makeService().findOrCreateUser("github", profile, tokens, openPolicy), + ).rejects.toThrow("An account with this email already exists"); + + expect(mockOAuthRepo.create).not.toHaveBeenCalled(); + expect(mockUserRepo.create).not.toHaveBeenCalled(); + }); + + test("throws OAuthError with email_exists code", async () => { + (mockUserRepo.findByEmail as ReturnType).mockResolvedValue(mockUser); + + try { + await makeService().findOrCreateUser("github", profile, tokens, openPolicy); + throw new Error("expected rejection"); + } catch (err) { + expect(err).toBeInstanceOf(OAuthError); + expect((err as OAuthError).code).toBe("email_exists"); + } + }); + + test("refuses sign-up when registration is disabled", async () => { + await expect( + makeService().findOrCreateUser("github", profile, tokens, { + registrationEnabled: false, + inviteOnly: false, + }), + ).rejects.toThrow("Registration is currently disabled"); + expect(mockUserRepo.create).not.toHaveBeenCalled(); + }); + + test("refuses sign-up when invite-only", async () => { + await expect( + makeService().findOrCreateUser("github", profile, tokens, { + registrationEnabled: true, + inviteOnly: true, + }), + ).rejects.toThrow("invitation code"); + expect(mockUserRepo.create).not.toHaveBeenCalled(); + }); + + test("creates a new user with verified email when allowed", async () => { + const result = await makeService().findOrCreateUser("github", profile, tokens, openPolicy); + + expect(result.isNew).toBe(true); + const created = (mockUserRepo.create as ReturnType).mock.calls[0]![0]; + expect(created.email).toBe("victim@example.com"); + expect(created.username).toBe("victim"); + expect(mockOAuthRepo.create).toHaveBeenCalled(); + }); + + test("uses a placeholder email when the provider email is not verified", async () => { + await makeService().findOrCreateUser( + "discord", + { ...profile, email: null }, + tokens, + openPolicy, + ); + + expect(mockUserRepo.findByEmail).not.toHaveBeenCalled(); + const created = (mockUserRepo.create as ReturnType).mock.calls[0]![0]; + expect(created.email).toBe("999@discord.oauth"); + }); + + test("does not use a blocked username", async () => { + await makeService((name) => name === "admin").findOrCreateUser( + "github", + { ...profile, username: "admin" }, + tokens, + openPolicy, + ); + + const created = (mockUserRepo.create as ReturnType).mock.calls[0]![0]; + expect(created.username).not.toBe("admin"); + expect(created.username.startsWith("user")).toBe(true); + }); + + test("refuses sign-up if no allowed username can be derived", async () => { + await expect( + makeService(() => true).findOrCreateUser("github", profile, tokens, openPolicy), + ).rejects.toThrow("allowed username"); + expect(mockUserRepo.create).not.toHaveBeenCalled(); + }); + }); + + describe("provider email verification", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + const mockFetchSequence = (bodies: unknown[]) => { + let i = 0; + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify(bodies[i++]), { status: 200 })), + ) as unknown as typeof fetch; + }; + + const getProfile = (service: OAuthService, method: string, ...args: unknown[]) => + (service as any)[method](...args) as Promise<{ email: string | null }>; + + test("drops unverified Discord email", async () => { + mockFetchSequence([{ id: "1", username: "u", email: "v@example.com", verified: false }]); + const service = new OAuthService( + mockOAuthRepo, + mockUserRepo as IUserRepository, + mockSessionRepo as ISessionRepository, + ); + expect((await getProfile(service, "getDiscordProfile", "t")).email).toBeNull(); + }); + + test("keeps verified Discord email", async () => { + mockFetchSequence([{ id: "1", username: "u", email: "v@example.com", verified: true }]); + const service = new OAuthService( + mockOAuthRepo, + mockUserRepo as IUserRepository, + mockSessionRepo as ISessionRepository, + ); + expect((await getProfile(service, "getDiscordProfile", "t")).email).toBe("v@example.com"); + }); + + test("drops unverified Google email", async () => { + mockFetchSequence([{ id: "1", email: "v@example.com", verified_email: false }]); + const service = new OAuthService( + mockOAuthRepo, + mockUserRepo as IUserRepository, + mockSessionRepo as ISessionRepository, + ); + expect((await getProfile(service, "getGoogleProfile", "t")).email).toBeNull(); + }); + + test("keeps verified Google email", async () => { + mockFetchSequence([{ id: "1", email: "v@example.com", verified_email: true }]); + const service = new OAuthService( + mockOAuthRepo, + mockUserRepo as IUserRepository, + mockSessionRepo as ISessionRepository, + ); + expect((await getProfile(service, "getGoogleProfile", "t")).email).toBe("v@example.com"); + }); + + test("drops unverified GitHub public email", async () => { + mockFetchSequence([ + { id: 1, login: "u", name: null, email: "v@example.com", avatar_url: null }, + [{ email: "v@example.com", primary: true, verified: false }], + ]); + const service = new OAuthService( + mockOAuthRepo, + mockUserRepo as IUserRepository, + mockSessionRepo as ISessionRepository, + ); + expect((await getProfile(service, "getGitHubProfile", "t")).email).toBeNull(); + }); + + test("selectVerifiedGitHubEmail prefers verified public email, then primary verified", () => { + const emails = [ + { email: "primary@example.com", primary: true, verified: true }, + { email: "public@example.com", primary: false, verified: true }, + { email: "unverified@example.com", primary: false, verified: false }, + ]; + expect(selectVerifiedGitHubEmail("public@example.com", emails)).toBe("public@example.com"); + expect(selectVerifiedGitHubEmail("unverified@example.com", emails)).toBe( + "primary@example.com", + ); + expect(selectVerifiedGitHubEmail(null, emails)).toBe("primary@example.com"); + expect( + selectVerifiedGitHubEmail(null, [{ email: "a@b.c", primary: true, verified: false }]), + ).toBeNull(); + }); + + test("isGoogleEmailVerified requires an explicit true flag", () => { + expect(isGoogleEmailVerified({})).toBe(false); + expect(isGoogleEmailVerified({ verified_email: false })).toBe(false); + expect(isGoogleEmailVerified({ verified_email: true })).toBe(true); + expect(isGoogleEmailVerified({ email_verified: true })).toBe(true); + }); + }); + + describe("linkProfile", () => { + const tokens = { access_token: "tok", token_type: "Bearer" }; + const profile = { + id: "555", + username: "gh", + email: null, + displayName: null, + avatarUrl: null, + }; + + test("links provider to the given user", async () => { + const service = new OAuthService( + mockOAuthRepo, + mockUserRepo as IUserRepository, + mockSessionRepo as ISessionRepository, + ); + await service.linkProfile("user1", "github", profile, tokens); + const created = (mockOAuthRepo.create as ReturnType).mock.calls[0]![0]; + expect(created.userId).toBe("user1"); + expect(created.providerAccountId).toBe("555"); + }); + + test("refuses if provider account is linked to another user", async () => { + (mockOAuthRepo.findByProviderAccount as ReturnType).mockResolvedValue({ + ...mockOAuthAccount, + userId: "other", + }); + const service = new OAuthService( + mockOAuthRepo, + mockUserRepo as IUserRepository, + mockSessionRepo as ISessionRepository, + ); + await expect(service.linkProfile("user1", "github", profile, tokens)).rejects.toThrow( + "already linked to another user", + ); + expect(mockOAuthRepo.create).not.toHaveBeenCalled(); + }); + }); + + describe("hasUsablePassword", () => { + test("treats OAuth placeholder hashes as unusable", () => { + expect(hasUsablePassword("oauth:github:abc")).toBe(false); + expect(hasUsablePassword(null)).toBe(false); + expect(hasUsablePassword("")).toBe(false); + expect(hasUsablePassword("$argon2id$v=19$hash")).toBe(true); + }); + + test("prevents OAuth-only users from unlinking their only login method", async () => { + // mockUser has an OAuth placeholder hash and a single link + (mockOAuthRepo.countByUserId as ReturnType).mockResolvedValue(1); + const service = new OAuthService( + mockOAuthRepo, + mockUserRepo as IUserRepository, + mockSessionRepo as ISessionRepository, + ); + await expect(service.unlinkAccount("user1", "github")).rejects.toThrow( + "Cannot unlink the only login method", + ); + }); + }); }); diff --git a/packages/frontend/src/components/mfm/MfmRenderer.tsx b/packages/frontend/src/components/mfm/MfmRenderer.tsx index 44d5aed0..fae8da56 100644 --- a/packages/frontend/src/components/mfm/MfmRenderer.tsx +++ b/packages/frontend/src/components/mfm/MfmRenderer.tsx @@ -23,6 +23,7 @@ import katex from "katex"; import { lookupEmojis } from "../../lib/atoms/customEmoji"; import { containsHtml, htmlToMfm } from "../../lib/utils/htmlToText"; import { getProxiedImageUrl } from "../../lib/utils/imageProxy"; +import { toSafeHttpUrl } from "../../lib/utils/safeUrl"; /** * Props for MfmRenderer component @@ -287,11 +288,16 @@ function MfmRendererComponent({ ); - case "url": + case "url": { + // Remote content: only render http(s) URLs as links + const safeUrl = toSafeHttpUrl(node.props.url); + if (!safeUrl) { + return {node.props.url}; + } return ( ); + } - case "link": + case "link": { + const safeLinkUrl = toSafeHttpUrl(node.props.url); + if (!safeLinkUrl) { + return {node.children?.map((child, i) => renderNode(child, i))}; + } return ( renderNode(child, i))} ); + } case "mention": const mentionUsername = node.props.username; diff --git a/packages/frontend/src/components/note/NoteCardMenu.tsx b/packages/frontend/src/components/note/NoteCardMenu.tsx index 62e9497d..fe8dc178 100644 --- a/packages/frontend/src/components/note/NoteCardMenu.tsx +++ b/packages/frontend/src/components/note/NoteCardMenu.tsx @@ -5,6 +5,7 @@ import { Trans } from "@lingui/react/macro"; import { t } from "@lingui/core/macro"; import { MoreHorizontal, Share, Copy, ExternalLink, Trash2, Flag } from "lucide-react"; import { Button } from "../ui/Button"; +import { toSafeHttpUrl } from "../../lib/utils/safeUrl"; /** * Props for the NoteCardMenu component @@ -66,11 +67,13 @@ export function NoteCardMenu({ }, [showMoreMenu]); /** - * Get the public URL for this note + * Get the public URL for this note. + * Remote `noteUri` is only used when it is an http(s) URL. */ const getNoteUrl = (): string => { - if (noteUri) { - return noteUri; + const safeRemoteUrl = toSafeHttpUrl(noteUri); + if (safeRemoteUrl) { + return safeRemoteUrl; } const baseUrl = typeof window !== "undefined" ? window.location.origin : ""; return `${baseUrl}/notes/${noteId}`; diff --git a/packages/frontend/src/lib/utils/safeUrl.ts b/packages/frontend/src/lib/utils/safeUrl.ts new file mode 100644 index 00000000..3c2c0ad9 --- /dev/null +++ b/packages/frontend/src/lib/utils/safeUrl.ts @@ -0,0 +1,31 @@ +/** + * Safe URL utilities + * + * Guards against remote-controlled URLs (e.g. ActivityPub `note.uri`, MFM links) + * with dangerous schemes such as `javascript:` or `data:` being placed into + * `href`, `window.open`, `location`, or the clipboard. + */ + +/** + * Return the URL only if it is an absolute `http:` or `https:` URL. + * + * @param url - Untrusted URL string + * @returns The normalized URL string, or null if it is missing, unparsable, or + * uses any other scheme + * + * @example + * ```ts + * toSafeHttpUrl("https://example.com/notes/1"); // "https://example.com/notes/1" + * toSafeHttpUrl("javascript:alert(1)"); // null + * ``` + */ +export function toSafeHttpUrl(url: string | null | undefined): string | null { + if (!url || typeof url !== "string") return null; + try { + const parsed = new URL(url.trim()); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + return parsed.href; + } catch { + return null; + } +} diff --git a/packages/frontend/src/tests/safeUrl.test.ts b/packages/frontend/src/tests/safeUrl.test.ts new file mode 100644 index 00000000..998984b7 --- /dev/null +++ b/packages/frontend/src/tests/safeUrl.test.ts @@ -0,0 +1,38 @@ +/** + * safeUrl Utility Tests + * + * Ensures remote-controlled URLs with dangerous schemes are rejected. + */ + +import { describe, test, expect } from "bun:test"; +import { toSafeHttpUrl } from "../lib/utils/safeUrl"; + +describe("toSafeHttpUrl", () => { + test("accepts http and https URLs", () => { + expect(toSafeHttpUrl("https://remote.example/notes/1")).toBe("https://remote.example/notes/1"); + expect(toSafeHttpUrl("http://remote.example/@alice")).toBe("http://remote.example/@alice"); + expect(toSafeHttpUrl(" https://remote.example/x ")).toBe("https://remote.example/x"); + }); + + test("rejects javascript: URLs in any casing or with whitespace", () => { + expect(toSafeHttpUrl("javascript:alert(1)")).toBeNull(); + expect(toSafeHttpUrl("JavaScript:alert(1)")).toBeNull(); + expect(toSafeHttpUrl(" javascript:alert(document.cookie)")).toBeNull(); + expect(toSafeHttpUrl("java\tscript:alert(1)")).toBeNull(); + }); + + test("rejects other non-http schemes", () => { + expect(toSafeHttpUrl("data:text/html,")).toBeNull(); + expect(toSafeHttpUrl("vbscript:msgbox(1)")).toBeNull(); + expect(toSafeHttpUrl("file:///etc/passwd")).toBeNull(); + expect(toSafeHttpUrl("ftp://example.com/file")).toBeNull(); + }); + + test("rejects relative, empty, and malformed values", () => { + expect(toSafeHttpUrl("/notes/1")).toBeNull(); + expect(toSafeHttpUrl("")).toBeNull(); + expect(toSafeHttpUrl(null)).toBeNull(); + expect(toSafeHttpUrl(undefined)).toBeNull(); + expect(toSafeHttpUrl("not a url")).toBeNull(); + }); +}); From 9c9b3d3fe0ebebbbe7bc69ec0eea77c6bced137a Mon Sep 17 00:00:00 2001 From: SASAGAWA Kiyoshi Date: Wed, 14 Oct 2026 21:26:29 +0900 Subject: [PATCH 6/9] 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); + }); +}); From 01687f14f07edd1040bb15e725ba406dfb2c28f8 Mon Sep 17 00:00:00 2001 From: SASAGAWA Kiyoshi Date: Wed, 14 Oct 2026 21:36:44 +0900 Subject: [PATCH 7/9] fix(federation): verify key owner via actor document, harden Announce - verifySignature: replace same-origin trust with Mastodon-style owner verification. Accept the key document directly only when it is the owner's actor document served from exactly the requested URL (no redirect); otherwise fetch the owner actor (no redirect, id === owner) and require it to list a key with id === keyId and identical PEM. Covers GoToSocial /main-key stubs and /publickey Key documents. - safeFetch: add safeFetchWithFinalUrl; RemoteFetchService exposes finalUrl and rejects cross-origin redirects by default (allowCrossOriginRedirect opt-in, used for WebFinger) - AnnounceHandler: require fetched id === requested URI, otherwise refetch once by canonical id and require an exact match; reject cross-origin final URLs; reject non-public/home, localOnly or deleted targets; derive renote visibility from to/cc; require activity.id to be http(s) on the actor's origin - activityValidation: activity.id must be http(s) and same origin as the actor (prevents javascript: ids and dedup/URI squatting) - federationUrl/RemoteActorService: normalize hostnames (lowercase, trailing dot) for local-host detection - Tests for all of the above Claude-Session: https://claude.ai/code/session_01Y29fBeRP344FME6de48wUv --- packages/backend/src/lib/safeFetch.ts | 23 +- .../backend/src/middleware/verifySignature.ts | 296 ++++++++++------ .../src/services/ap/RemoteActorService.ts | 10 +- .../src/services/ap/RemoteFetchService.ts | 33 +- .../ap/inbox/handlers/AnnounceHandler.ts | 182 +++++++--- .../unit/RemoteFetchService.redirect.test.ts | 87 +++++ .../inbox-handlers/AnnounceHandler.test.ts | 181 +++++++++- .../src/tests/unit/verifySignature.test.ts | 334 +++++++++++------- .../backend/src/utils/activityValidation.ts | 15 +- packages/backend/src/utils/federationUrl.ts | 27 +- 10 files changed, 889 insertions(+), 299 deletions(-) create mode 100644 packages/backend/src/tests/unit/RemoteFetchService.redirect.test.ts diff --git a/packages/backend/src/lib/safeFetch.ts b/packages/backend/src/lib/safeFetch.ts index 78cf2cd6..6527e54b 100644 --- a/packages/backend/src/lib/safeFetch.ts +++ b/packages/backend/src/lib/safeFetch.ts @@ -343,6 +343,25 @@ export async function safeFetch( init: RequestInit = {}, options: SafeFetchOptions = {}, ): Promise { + return (await safeFetchWithFinalUrl(input, init, options)).response; +} + +/** + * Same as {@link safeFetch}, but also returns the URL of the final hop + * (after following redirects). Callers that bind trust to the requested URL + * (e.g. ActivityPub object/key fetches) must check that it did not change. + * + * @param input - URL to fetch + * @param init - Fetch init (the `redirect` option is ignored) + * @param options - Redirect and validation options + * @returns The final response and the URL it was served from + * @throws {UnsafeUrlError} If any hop targets a non-public address or the redirect limit is exceeded + */ +export async function safeFetchWithFinalUrl( + input: string | URL, + init: RequestInit = {}, + options: SafeFetchOptions = {}, +): Promise<{ response: Response; finalUrl: URL }> { const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS; const fetchImpl = options.fetchImpl ?? fetch; @@ -361,12 +380,12 @@ export async function safeFetch( }); if (!REDIRECT_STATUSES.has(response.status)) { - return response; + return { response, finalUrl: current }; } const location = response.headers.get("location"); if (!location) { - return response; + return { response, finalUrl: current }; } // Discard the redirect body diff --git a/packages/backend/src/middleware/verifySignature.ts b/packages/backend/src/middleware/verifySignature.ts index 8e348056..7f0be3e7 100644 --- a/packages/backend/src/middleware/verifySignature.ts +++ b/packages/backend/src/middleware/verifySignature.ts @@ -24,7 +24,6 @@ import { logger } from "../lib/logger.js"; import { hasAmbiguousPath, isLocalUrl, - isSameOrigin, normalizeUrl, parseHttpUrl, } from "../utils/federationUrl.js"; @@ -193,26 +192,53 @@ export function validateKeyId(keyId: string): string { } /** - * Extract the public key for `keyId` from a fetched document and verify its owner. - * - * Supported document shapes: - * - Actor document (Mastodon/Misskey/GoToSocial/etc.): `id` must be on the same origin - * as the fetched URL, one of `publicKey` (object or array) must have `id === keyId`, - * and its `owner` (defaulting to the document id) must equal the document id. - * - Standalone Key document (e.g. `/users/x/publickey`): `id` must equal keyId and - * `owner` must be an http(s) URL on the same origin as the keyId. - * - * @param keyId - KeyId from the Signature header - * @param fetchUrl - URL the document was fetched from - * @param doc - Fetched JSON document - * @returns Verified PEM and owner actor URI - * @throws Error if the document does not prove ownership of the key + * A fetched key/actor document together with the URL it was served from. */ -export function extractVerifiedPublicKey( - keyId: string, - fetchUrl: string, - doc: unknown, -): VerifiedPublicKey { +export interface FetchedKeyDocument { + /** Parsed JSON document */ + data: unknown; + /** URL of the final response (after redirects) */ + finalUrl: string; +} + +/** + * Fetcher used by {@link resolveVerifiedPublicKey} to retrieve documents. + * Must throw on network/HTTP failure. + */ +export type KeyDocumentFetcher = (url: string) => Promise; + +/** + * Key entry found in a document, with the owner it claims. + */ +interface KeyClaim { + publicKeyPem: string; + owner: string; +} + +/** + * Find the key whose `id` equals `keyId` inside an actor document's `publicKey` + * (object or array). + */ +function findActorKey( + doc: { publicKey?: unknown }, + normalizedKeyId: string, +): { publicKeyPem?: unknown; owner?: unknown } | null { + const candidates: unknown[] = Array.isArray(doc.publicKey) ? doc.publicKey : [doc.publicKey]; + const key = candidates.find( + (k) => + !!k && + typeof k === "object" && + typeof (k as { id?: unknown }).id === "string" && + normalizeUrl((k as { id: string }).id) === normalizedKeyId, + ); + return (key as { publicKeyPem?: unknown; owner?: unknown } | undefined) ?? null; +} + +/** + * Extract the key claim for `keyId` from a fetched document (actor document + * with `publicKey`, or standalone Key document). Does not establish trust. + */ +function extractKeyClaim(keyId: string, doc: unknown): KeyClaim { if (!doc || typeof doc !== "object") { throw new Error("Key document is not an object"); } @@ -228,49 +254,95 @@ export function extractVerifiedPublicKey( } const docId = typeof document.id === "string" ? document.id : null; + let claim: { publicKeyPem?: unknown; owner?: unknown } | null = null; if (document.publicKey) { - // Actor document. Usually id === fetchUrl (Mastodon/Misskey `#main-key`), but - // GoToSocial serves a stub actor at `/users/x/main-key` whose id is `/users/x`, - // so require same origin here; the exact publicKey.id === keyId match below - // is what binds the key. - if (!docId || !isSameOrigin(docId, fetchUrl)) { - throw new Error("Actor document id is not on the keyId origin"); - } - const candidates: unknown[] = Array.isArray(document.publicKey) - ? document.publicKey - : [document.publicKey]; - const key = candidates.find( - (k): k is { id: string; owner?: unknown; publicKeyPem?: unknown } => - !!k && - typeof k === "object" && - typeof (k as { id?: unknown }).id === "string" && - normalizeUrl((k as { id: string }).id) === normalizedKeyId, - ); - if (!key) { + claim = findActorKey(document, normalizedKeyId); + if (!claim) { throw new Error("publicKey.id does not match keyId"); } - if (typeof key.publicKeyPem !== "string" || !key.publicKeyPem) { - throw new Error("Public key not found in actor document"); - } - const owner = typeof key.owner === "string" ? key.owner : docId; - if (owner !== docId) { - throw new Error("publicKey.owner does not match actor document id"); - } - return { publicKeyPem: key.publicKeyPem, owner }; - } - - if (typeof document.publicKeyPem === "string" && document.publicKeyPem) { - // Standalone Key document + claim = { publicKeyPem: claim.publicKeyPem, owner: claim.owner ?? docId }; + } else if (typeof document.publicKeyPem === "string") { if (!docId || normalizeUrl(docId) !== normalizedKeyId) { throw new Error("Key document id does not match keyId"); } - if (typeof document.owner !== "string" || !isSameOrigin(document.owner, keyId)) { - throw new Error("Key document owner is missing or cross-origin"); - } - return { publicKeyPem: document.publicKeyPem, owner: document.owner }; + claim = { publicKeyPem: document.publicKeyPem, owner: document.owner }; + } + + if (!claim || typeof claim.publicKeyPem !== "string" || !claim.publicKeyPem) { + throw new Error("Public key not found in key document"); + } + if (typeof claim.owner !== "string" || !parseHttpUrl(claim.owner)) { + throw new Error("Key owner is missing or not an http(s) URL"); + } + if (isLocalUrl(claim.owner)) { + throw new Error("Key owner points to local host"); + } + return { publicKeyPem: claim.publicKeyPem, owner: claim.owner }; +} + +/** + * Resolve the public key for `keyId` and verify which actor owns it. + * + * Trust is established Mastodon-style: + * 1. The key document (keyId without fragment) is fetched and the entry with + * `id === keyId` is located; its owner is `publicKey.owner` (or the document id). + * 2. If the document is the owner's actor document served from exactly the + * requested URL (document id === owner === fetched URL, no redirect), it is + * accepted directly. + * 3. Otherwise (GoToSocial `/main-key` stubs, standalone `/publickey` Key + * documents, redirected responses, …) the owner's actor document is fetched + * from the owner URL itself. It must be served without redirect, have + * `id === owner`, and list a key with `id === keyId` and the identical PEM. + * + * Same-origin content (open redirects, user-uploaded JSON, proxies) therefore + * cannot bind a key to an actor unless the actor document itself lists it. + * + * @param keyId - KeyId from the Signature header + * @param fetchUrl - URL the key document is fetched from (keyId without fragment) + * @param fetchDocument - Fetcher for documents (throws on failure) + * @returns Verified PEM and owner actor URI + * @throws Error if ownership cannot be verified + */ +export async function resolveVerifiedPublicKey( + keyId: string, + fetchUrl: string, + fetchDocument: KeyDocumentFetcher, +): Promise { + const first = await fetchDocument(fetchUrl); + const claim = extractKeyClaim(keyId, first.data); + + const normalizedFetchUrl = normalizeUrl(fetchUrl); + const firstDocId = (first.data as { id?: unknown }).id; + const servedFromRequestedUrl = normalizeUrl(first.finalUrl) === normalizedFetchUrl; + + if ( + servedFromRequestedUrl && + typeof firstDocId === "string" && + normalizeUrl(firstDocId) === normalizedFetchUrl && + claim.owner === firstDocId && + (first.data as { publicKey?: unknown }).publicKey + ) { + return claim; } - throw new Error("Public key not found in actor document"); + // Fetch the owner's actor document and require it to list the same key + const ownerDoc = await fetchDocument(claim.owner); + if (normalizeUrl(ownerDoc.finalUrl) !== normalizeUrl(claim.owner)) { + throw new Error("Owner actor document was served from a different URL"); + } + const actor = ownerDoc.data as { id?: unknown; publicKey?: unknown } | null; + if (!actor || typeof actor !== "object" || actor.id !== claim.owner || !actor.publicKey) { + throw new Error("Owner actor document id does not match key owner"); + } + const normalizedKeyId = normalizeUrl(keyId)!; + const ownerKey = findActorKey(actor, normalizedKeyId); + if (!ownerKey || ownerKey.publicKeyPem !== claim.publicKeyPem) { + throw new Error("Owner actor document does not list this key"); + } + if (ownerKey.owner !== undefined && ownerKey.owner !== claim.owner) { + throw new Error("Owner actor document key has a different owner"); + } + return claim; } /** @@ -287,54 +359,24 @@ function isVerifiedPublicKey(value: unknown): value is VerifiedPublicKey { } /** - * Fetch remote actor's public key - * - * Retrieves the public key from the remote key/actor document and verifies - * that the document actually binds the key to its owner. - * Results are cached in Redis (1 hour) with in-memory fallback. + * Fetch a key/actor document for signature verification. * - * For Authorized Fetch servers (which return 401/403 on unsigned requests), - * automatically retries with a signed request using local admin credentials. + * Tries an unsigned fetch first, then retries with an HTTP Signature for + * Authorized Fetch servers (401/403). Failures are cached per URL. * - * @param keyId - Public key identifier URL - * @param cacheService - Optional cache service for Redis caching - * @param userRepository - Optional user repository for signed fetch retry - * @param baseUrl - Optional base URL of this instance for signed fetch - * @returns PEM-formatted public key and its verified owner - * @throws Error if key cannot be fetched or verified + * @throws Error if the document cannot be fetched */ -async function fetchPublicKey( - keyId: string, - cacheService?: ICacheService, +async function fetchKeyDocument( + url: string, userRepository?: IUserRepository, baseUrl?: string, -): Promise { - // keyId format: https://example.com/users/alice#main-key - // Fetch URL: https://example.com/users/alice - const actorUrl = validateKeyId(keyId); - - const cacheKey = `${CachePrefix.PUBLIC_KEY}:${keyId}`; - - // Check Redis cache first - if (cacheService?.isAvailable()) { - const cached = await cacheService.get(cacheKey); - if (isVerifiedPublicKey(cached)) { - return cached; - } - } else { - // Fallback to in-memory cache - const cached = publicKeyMemoryCache.get(keyId); - if (cached && cached.expires > Date.now()) { - return cached.key; - } - } - - // Check if we have a cached failure for this actor - const cachedFailure = getCachedFailure(actorUrl); +): Promise { + // Check if we have a cached failure for this URL + const cachedFailure = getCachedFailure(url); if (cachedFailure) { logger.debug( { - actorUrl, + actorUrl: url, errorType: cachedFailure.errorType, ttlRemaining: cachedFailure.expires - Date.now(), }, @@ -345,10 +387,8 @@ async function fetchPublicKey( const fetchService = new RemoteFetchService(); - logger.debug({ keyId, actorUrl }, "Fetching public key for signature verification"); - // First, try unsigned fetch (works for most servers) - let result = await fetchService.fetchActivityPubObject(actorUrl, { maxRetries: 1 }); + let result = await fetchService.fetchActivityPubObject(url, { maxRetries: 1 }); // If unsigned fetch failed with 401/403, retry with signed request // This is required for Authorized Fetch servers (e.g., some Mastodon instances) @@ -360,14 +400,14 @@ async function fetchPublicKey( baseUrl ) { logger.debug( - { actorUrl, statusCode: result.error.statusCode }, + { actorUrl: url, statusCode: result.error.statusCode }, "Unsigned fetch failed, retrying with HTTP Signature for Authorized Fetch server", ); const signatureConfig = await getSignatureConfig(userRepository, baseUrl); if (signatureConfig) { - result = await fetchService.fetchActivityPubObject(actorUrl, { + result = await fetchService.fetchActivityPubObject(url, { signature: signatureConfig, maxRetries: 2, }); @@ -379,17 +419,17 @@ async function fetchPublicKey( const errorType = categorizeError(result.error); const errorMessage = result.error?.message || "Unknown error"; - cacheFailure(actorUrl, errorType, errorMessage); + cacheFailure(url, errorType, errorMessage); // Use appropriate log level based on error type if (errorType === "permanent_error") { logger.warn( - { err: result.error, actorUrl, errorType }, + { err: result.error, actorUrl: url, errorType }, "Failed to fetch public key (permanent error)", ); } else { logger.debug( - { err: result.error, actorUrl, errorType }, + { err: result.error, actorUrl: url, errorType }, "Failed to fetch public key (transient error)", ); } @@ -397,8 +437,54 @@ async function fetchPublicKey( throw new Error(`Failed to fetch actor: ${errorMessage}`); } - // Verify that the fetched document binds keyId to its owner - const verified = extractVerifiedPublicKey(keyId, actorUrl, result.data); + return { data: result.data, finalUrl: result.finalUrl ?? url }; +} + +/** + * Fetch remote actor's public key + * + * Retrieves the public key for `keyId` and verifies which actor owns it + * (see {@link resolveVerifiedPublicKey}). + * Results are cached in Redis (1 hour) with in-memory fallback. + * + * @param keyId - Public key identifier URL + * @param cacheService - Optional cache service for Redis caching + * @param userRepository - Optional user repository for signed fetch retry + * @param baseUrl - Optional base URL of this instance for signed fetch + * @returns PEM-formatted public key and its verified owner + * @throws Error if key cannot be fetched or verified + */ +async function fetchPublicKey( + keyId: string, + cacheService?: ICacheService, + userRepository?: IUserRepository, + baseUrl?: string, +): Promise { + // keyId format: https://example.com/users/alice#main-key + // Fetch URL: https://example.com/users/alice + const actorUrl = validateKeyId(keyId); + + const cacheKey = `${CachePrefix.PUBLIC_KEY}:${keyId}`; + + // Check Redis cache first + if (cacheService?.isAvailable()) { + const cached = await cacheService.get(cacheKey); + if (isVerifiedPublicKey(cached)) { + return cached; + } + } else { + // Fallback to in-memory cache + const cached = publicKeyMemoryCache.get(keyId); + if (cached && cached.expires > Date.now()) { + return cached.key; + } + } + + logger.debug({ keyId, actorUrl }, "Fetching public key for signature verification"); + + const verified = await resolveVerifiedPublicKey(keyId, actorUrl, (url) => + fetchKeyDocument(url, userRepository, baseUrl), + ); // Cache in Redis (1 hour) if (cacheService?.isAvailable()) { diff --git a/packages/backend/src/services/ap/RemoteActorService.ts b/packages/backend/src/services/ap/RemoteActorService.ts index 07bd26a0..f938a668 100644 --- a/packages/backend/src/services/ap/RemoteActorService.ts +++ b/packages/backend/src/services/ap/RemoteActorService.ts @@ -15,6 +15,7 @@ import { generateId } from "shared"; import { RemoteFetchService, type SignatureConfig } from "./RemoteFetchService.js"; import { CacheTTL, CachePrefix } from "../../adapters/cache/DragonflyCacheAdapter.js"; import { logger } from "../../lib/logger.js"; +import { isLocalHostname } from "../../utils/federationUrl.js"; /** * ActivityPub Emoji tag @@ -170,10 +171,9 @@ export class RemoteActorService { */ async resolveActor(actorUri: string, forceRefresh = false): Promise { // Check if this is a local URI - if so, find user by URI directly (local users have host=null) - const localHost = new URL(process.env.URL || "http://localhost:3000").hostname; const actorHost = new URL(actorUri).hostname; - if (actorHost === localHost) { + if (isLocalHostname(actorHost)) { // This is a local user URI - find by URI (which includes local users) const localUser = await this.userRepository.findByUri(actorUri); if (localUser) { @@ -539,8 +539,7 @@ export class RemoteActorService { } // Check if local user - const localHost = new URL(process.env.URL || "http://localhost:3000").hostname; - if (host === localHost) { + if (isLocalHostname(host)) { const localUser = await this.userRepository.findByUsername(username, null); if (!localUser) { throw new Error(`Local user not found: ${username}`); @@ -566,6 +565,9 @@ export class RemoteActorService { Accept: "application/jrd+json, application/json", }, allowedContentTypes: ["application/jrd+json", "application/json"], + // Split-domain setups redirect WebFinger to the AP host; the actor + // document itself is fetched (same-origin only) afterwards. + allowCrossOriginRedirect: true, }, ); diff --git a/packages/backend/src/services/ap/RemoteFetchService.ts b/packages/backend/src/services/ap/RemoteFetchService.ts index 306d715d..b61d7b23 100644 --- a/packages/backend/src/services/ap/RemoteFetchService.ts +++ b/packages/backend/src/services/ap/RemoteFetchService.ts @@ -14,7 +14,7 @@ import { signRequest, getSignedHeaders } from "../../utils/crypto.js"; import { logger } from "../../lib/logger.js"; -import { safeFetch, UnsafeUrlError } from "../../lib/safeFetch.js"; +import { safeFetchWithFinalUrl, UnsafeUrlError } from "../../lib/safeFetch.js"; /** * Content types accepted for ActivityPub object responses by default. @@ -55,6 +55,13 @@ export interface RemoteFetchOptions { * Defaults to {@link ACTIVITYPUB_CONTENT_TYPES}. */ allowedContentTypes?: string[]; + /** + * Allow redirects to a different origin than the requested URL (default: false). + * ActivityPub objects are trusted by their URL's origin, so cross-origin + * redirects are rejected unless explicitly allowed (e.g. WebFinger on + * split-domain setups). + */ + allowCrossOriginRedirect?: boolean; } /** @@ -63,6 +70,8 @@ export interface RemoteFetchOptions { export interface RemoteFetchResult { success: boolean; data?: T; + /** URL the response was actually served from (after redirects), on success */ + finalUrl?: string; error?: { type: "timeout" | "network" | "rate_limit" | "server_error" | "invalid_response"; message: string; @@ -121,6 +130,7 @@ export class RemoteFetchService { headers = {}, signature, allowedContentTypes = ACTIVITYPUB_CONTENT_TYPES, + allowCrossOriginRedirect = false, } = options; let lastError: RemoteFetchResult["error"]; @@ -161,6 +171,7 @@ export class RemoteFetchService { timeout, requestHeaders, allowedContentTypes, + allowCrossOriginRedirect, ); if (result.success) { @@ -220,6 +231,7 @@ export class RemoteFetchService { * @param timeout - Timeout in milliseconds * @param headers - HTTP headers * @param allowedContentTypes - Accepted response media types + * @param allowCrossOriginRedirect - Whether the final URL may differ in origin from `url` * @returns Fetch result * * @private @@ -229,6 +241,7 @@ export class RemoteFetchService { timeout: number, headers: Record, allowedContentTypes: string[], + allowCrossOriginRedirect = false, ): Promise> { try { // Create abort controller for timeout @@ -236,12 +249,13 @@ export class RemoteFetchService { const timeoutId = setTimeout(() => controller.abort(), timeout); let response: Response; + let finalUrl: URL; try { // SSRF protection: blocks private destinations and re-validates redirects - response = await safeFetch(url, { + ({ response, finalUrl } = await safeFetchWithFinalUrl(url, { headers, signal: controller.signal, - }); + })); } catch (error) { if (error instanceof UnsafeUrlError) { return { @@ -257,6 +271,18 @@ export class RemoteFetchService { clearTimeout(timeoutId); } + // Reject cross-origin redirects: the content would be attributed to the requested origin + if (!allowCrossOriginRedirect && finalUrl.origin !== new URL(url).origin) { + await response.body?.cancel().catch(() => {}); + return { + success: false, + error: { + type: "invalid_response", + message: `Cross-origin redirect rejected: ${finalUrl.origin}`, + }, + }; + } + // Handle HTTP errors if (!response.ok) { // Rate limiting @@ -331,6 +357,7 @@ export class RemoteFetchService { return { success: true, data, + finalUrl: finalUrl.href, }; } catch (error) { // Timeout or network error diff --git a/packages/backend/src/services/ap/inbox/handlers/AnnounceHandler.ts b/packages/backend/src/services/ap/inbox/handlers/AnnounceHandler.ts index 4ef93669..ad2add9f 100644 --- a/packages/backend/src/services/ap/inbox/handlers/AnnounceHandler.ts +++ b/packages/backend/src/services/ap/inbox/handlers/AnnounceHandler.ts @@ -11,7 +11,20 @@ import { getObjectUri } from "../types.js"; import { BaseHandler } from "./BaseHandler.js"; import { RemoteFetchService, type SignatureConfig } from "../../RemoteFetchService.js"; import { logger } from "../../../../lib/logger.js"; -import { extractRefUri, isLocalUrl, isSameOrigin } from "../../../../utils/federationUrl.js"; +import { + extractRefUri, + isHttpUrl, + isLocalUrl, + isSameOrigin, + normalizeUrl, +} from "../../../../utils/federationUrl.js"; + +/** ActivityStreams public collection identifiers */ +const PUBLIC_ADDRESSES = new Set([ + "https://www.w3.org/ns/activitystreams#Public", + "as:Public", + "Public", +]); /** * Handler for Announce activities @@ -53,6 +66,80 @@ export class AnnounceHandler extends BaseHandler { } } + /** + * Fetch a remote object, retrying with an HTTP Signature for Authorized Fetch servers. + * + * @returns The fetched document and the URL it was served from, or null on failure + */ + private async fetchRemoteObject( + url: string, + c: import("hono").Context, + baseUrl: string, + ): Promise<{ data: any; finalUrl: string } | null> { + const fetchService = new RemoteFetchService(); + + // Try unsigned fetch first + let result = await fetchService.fetchActivityPubObject(url); + + // If failed with 401/403/404, try with HTTP Signature (Authorized Fetch) + if ( + !result.success && + result.error?.statusCode && + [401, 403, 404].includes(result.error.statusCode) + ) { + logger.debug( + { noteUri: url, statusCode: result.error.statusCode }, + "Unsigned fetch failed, retrying with HTTP Signature", + ); + + // Get a local user's credentials for signing + const signatureConfig = await this.getSignatureConfig(c, baseUrl); + + if (signatureConfig) { + result = await fetchService.fetchActivityPubObject(url, { + signature: signatureConfig, + }); + } + } + + if (!result.success) { + // 404 errors are common (deleted notes) - log at debug level + // Other errors (network issues, auth failures) are logged at warn level + const is404 = result.error?.statusCode === 404; + const logLevel = is404 ? "debug" : "warn"; + logger[logLevel]( + { noteUri: url, err: result.error }, + is404 ? "Remote note not found (possibly deleted)" : "Failed to fetch remote note", + ); + return null; + } + + const finalUrl = result.finalUrl ?? url; + if (!isSameOrigin(finalUrl, url)) { + logger.warn({ noteUri: url, finalUrl }, "Remote note served from a different origin"); + return null; + } + + return { data: result.data, finalUrl }; + } + + /** + * Derive the renote visibility from the Announce addressing. + * + * - Public collection in `to` → public + * - Public collection in `cc` → home (unlisted) + * - Otherwise → followers + */ + private determineRenoteVisibility(activity: Activity): "public" | "home" | "followers" { + const toArray = (v: unknown): string[] => + Array.isArray(v) ? v.filter((x) => typeof x === "string") : typeof v === "string" ? [v] : []; + const isPublic = (uri: string) => PUBLIC_ADDRESSES.has(uri); + + if (toArray(activity.to).some(isPublic)) return "public"; + if (toArray(activity.cc).some(isPublic)) return "home"; + return "followers"; + } + async handle(activity: Activity, context: HandlerContext): Promise { const { c } = context; @@ -66,8 +153,16 @@ export class AnnounceHandler extends BaseHandler { this.log("📥", `Announce: ${activity.actor} → ${objectUri}`); - // Resolve remote actor const actorUri = this.getActorUri(activity); + + // Announce の id は renote の URI として保存されるため、actor と同一オリジンの + // http(s) URL のみ許可する (javascript: や他サーバー URI の乗っ取りを防ぐ) + if (!isHttpUrl(activity.id) || !isSameOrigin(activity.id, actorUri)) { + this.warn(`Announce rejected: invalid activity id ${String(activity.id)}`); + return this.failure("Invalid Announce activity: id must be on the actor's origin"); + } + + // Resolve remote actor const remoteActor = await this.resolveActor(actorUri, c); // Find or fetch the note being announced @@ -89,56 +184,35 @@ export class AnnounceHandler extends BaseHandler { if (!targetNote) { this.log("ℹ️", `Target note not found locally, fetching: ${objectUri}`); - const fetchService = new RemoteFetchService(); - - // Try unsigned fetch first - let result = await fetchService.fetchActivityPubObject(objectUri); - - // If failed with 401/403/404, try with HTTP Signature (Authorized Fetch) - if ( - !result.success && - result.error?.statusCode && - [401, 403, 404].includes(result.error.statusCode) - ) { - logger.debug( - { noteUri: objectUri, statusCode: result.error.statusCode }, - "Unsigned fetch failed, retrying with HTTP Signature", - ); - - // Get a local user's credentials for signing - const signatureConfig = await this.getSignatureConfig(c, context.baseUrl); - - if (signatureConfig) { - result = await fetchService.fetchActivityPubObject(objectUri, { - signature: signatureConfig, - }); - } - } - - if (!result.success) { - // 404 errors are common (deleted notes) - log at debug level - // Other errors (network issues, auth failures) are logged at warn level - const is404 = result.error?.statusCode === 404; - const logLevel = is404 ? "debug" : "warn"; - logger[logLevel]( - { noteUri: objectUri, err: result.error }, - is404 ? "Remote note not found (possibly deleted)" : "Failed to fetch remote note", - ); + let fetched = await this.fetchRemoteObject(objectUri, c, context.baseUrl); + if (!fetched) { return this.failure(`Failed to fetch remote note: ${objectUri}`); } - const noteObject = result.data as any; - - // The fetched document must be served from the origin we requested, - // and must not claim to be authored by a local user. - if ( - !noteObject || - typeof noteObject.id !== "string" || - !isSameOrigin(noteObject.id, objectUri) - ) { - this.warn(`Announce rejected: fetched object id is not same-origin with ${objectUri}`); + let noteObject = fetched.data; + if (!noteObject || typeof noteObject !== "object" || !isHttpUrl(noteObject.id)) { + this.warn(`Announce rejected: fetched object has no valid id (${objectUri})`); return this.failure("Fetched object id origin does not match requested URI"); } + + // The fetched document must be the object we asked for. If it claims a + // different id, refetch it from that id once and require an exact match. + if (normalizeUrl(noteObject.id) !== normalizeUrl(objectUri)) { + const canonicalUri: string = noteObject.id; + this.log("ℹ️", `Fetched id differs (${canonicalUri}), refetching canonical object`); + fetched = await this.fetchRemoteObject(canonicalUri, c, context.baseUrl); + noteObject = fetched?.data; + if ( + !noteObject || + typeof noteObject !== "object" || + typeof noteObject.id !== "string" || + normalizeUrl(noteObject.id) !== normalizeUrl(canonicalUri) + ) { + this.warn(`Announce rejected: canonical object id mismatch for ${canonicalUri}`); + return this.failure("Fetched object id origin does not match requested URI"); + } + } + if (isLocalUrl(extractRefUri(noteObject.attributedTo))) { this.warn("Announce rejected: fetched object claims a local author"); return this.failure("Fetched object claims a local author"); @@ -147,6 +221,16 @@ export class AnnounceHandler extends BaseHandler { targetNote = await remoteNoteService.processNote(noteObject); } + // 公開範囲の確認: public / home 以外 (followers / specified / localOnly) は renote 不可 + if ( + targetNote.isDeleted || + targetNote.localOnly || + (targetNote.visibility !== "public" && targetNote.visibility !== "home") + ) { + this.warn(`Announce rejected: target note ${targetNote.id} is not publicly renotable`); + return this.failure("Target note cannot be renoted"); + } + // Create a renote (quote without text = pure boost) const id = this.generateId(); await noteRepository.create({ @@ -154,7 +238,7 @@ export class AnnounceHandler extends BaseHandler { userId: remoteActor.id, text: null, // No text = pure boost cw: null, - visibility: "public", + visibility: this.determineRenoteVisibility(activity), localOnly: false, replyId: null, renoteId: targetNote.id, @@ -162,7 +246,7 @@ export class AnnounceHandler extends BaseHandler { mentions: [], emojis: [], tags: [], - uri: activity.id ?? null, // Use the Announce activity ID as the note URI + uri: activity.id, // Use the Announce activity ID as the note URI (validated above) isDeleted: false, deletedAt: null, deletedById: null, diff --git a/packages/backend/src/tests/unit/RemoteFetchService.redirect.test.ts b/packages/backend/src/tests/unit/RemoteFetchService.redirect.test.ts new file mode 100644 index 00000000..9eada13a --- /dev/null +++ b/packages/backend/src/tests/unit/RemoteFetchService.redirect.test.ts @@ -0,0 +1,87 @@ +/** + * RemoteFetchService redirect origin tests + * + * ActivityPub objects are trusted by the origin of their URL, so a redirect to + * another origin must not be followed silently. Also verifies that the final + * URL is exposed to callers. + */ + +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { safeFetchWithFinalUrl } from "../../lib/safeFetch.js"; +import { RemoteFetchService } from "../../services/ap/RemoteFetchService.js"; + +describe("RemoteFetchService cross-origin redirects", () => { + let serverA: ReturnType; + let serverB: ReturnType; + let baseA: string; + let baseB: string; + let previousAllow: string | undefined; + + const json = (body: unknown) => + new Response(JSON.stringify(body), { + headers: { "Content-Type": "application/activity+json" }, + }); + + beforeAll(() => { + previousAllow = process.env.ALLOW_PRIVATE_NETWORK_FETCH; + process.env.ALLOW_PRIVATE_NETWORK_FETCH = "true"; + serverB = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: () => json({ id: "attacker" }), + }); + baseB = `http://127.0.0.1:${serverB.port}`; + serverA = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(req) { + const { pathname } = new URL(req.url); + if (pathname === "/cross") { + return new Response(null, { status: 302, headers: { Location: `${baseB}/doc` } }); + } + if (pathname === "/same") { + return new Response(null, { status: 302, headers: { Location: "/doc" } }); + } + return json({ id: `${baseA}/doc` }); + }, + }); + baseA = `http://127.0.0.1:${serverA.port}`; + }); + + afterAll(() => { + serverA.stop(true); + serverB.stop(true); + if (previousAllow === undefined) delete process.env.ALLOW_PRIVATE_NETWORK_FETCH; + else process.env.ALLOW_PRIVATE_NETWORK_FETCH = previousAllow; + }); + + test("safeFetchWithFinalUrl exposes the final URL after redirects", async () => { + const { response, finalUrl } = await safeFetchWithFinalUrl(`${baseA}/same`); + await response.body?.cancel(); + expect(finalUrl.href).toBe(`${baseA}/doc`); + }); + + test("rejects a cross-origin redirect by default", async () => { + const service = new RemoteFetchService(); + const result = await service.fetchActivityPubObject(`${baseA}/cross`, { maxRetries: 0 }); + expect(result.success).toBe(false); + expect(result.error?.message).toContain("Cross-origin redirect"); + }); + + test("follows a same-origin redirect and reports finalUrl", async () => { + const service = new RemoteFetchService(); + const result = await service.fetchActivityPubObject(`${baseA}/same`, { maxRetries: 0 }); + expect(result.success).toBe(true); + expect(result.finalUrl).toBe(`${baseA}/doc`); + }); + + test("allows a cross-origin redirect when explicitly enabled", async () => { + const service = new RemoteFetchService(); + const result = await service.fetchActivityPubObject(`${baseA}/cross`, { + maxRetries: 0, + allowCrossOriginRedirect: true, + }); + expect(result.success).toBe(true); + expect(result.finalUrl).toBe(`${baseB}/doc`); + }); +}); diff --git a/packages/backend/src/tests/unit/inbox-handlers/AnnounceHandler.test.ts b/packages/backend/src/tests/unit/inbox-handlers/AnnounceHandler.test.ts index 0eb767a1..ad9623a2 100644 --- a/packages/backend/src/tests/unit/inbox-handlers/AnnounceHandler.test.ts +++ b/packages/backend/src/tests/unit/inbox-handlers/AnnounceHandler.test.ts @@ -20,19 +20,27 @@ const defaultFetchedNote = { attributedTo: "https://another.example.com/users/author", }; let fetchedNote: Record = { ...defaultFetchedNote }; +// Per-URL documents (take precedence over fetchedNote) and recorded fetch URLs +let fetchedDocs: Record = {}; +let fetchCalls: string[] = []; +let fetchedFinalUrls: Record = {}; // Mock the RemoteFetchService mock.module("../../../services/ap/RemoteFetchService", () => ({ RemoteFetchService: class { - fetchActivityPubObject = mock(() => - Promise.resolve({ + fetchActivityPubObject = mock((url: string) => { + fetchCalls.push(url); + return Promise.resolve({ success: true, - data: fetchedNote, - }), - ); + data: fetchedDocs[url] ?? fetchedNote, + finalUrl: fetchedFinalUrls[url] ?? url, + }); + }); }, })); +const renotable = { visibility: "public", localOnly: false, isDeleted: false }; + describe("AnnounceHandler", () => { let handler: AnnounceHandler; let mockContext: HandlerContext; @@ -49,6 +57,7 @@ describe("AnnounceHandler", () => { id: "note-123", uri: "http://localhost:3000/notes/note-123", text: "Original note", + ...renotable, }), ), create: mock(() => Promise.resolve({ id: "renote-456" })), @@ -61,6 +70,7 @@ describe("AnnounceHandler", () => { id: "remote-note-789", uri: "https://remote.example.com/notes/1", text: "Remote note", + ...renotable, }), ), }; @@ -86,6 +96,10 @@ describe("AnnounceHandler", () => { beforeEach(() => { fetchedNote = { ...defaultFetchedNote }; + fetchedDocs = {}; + fetchCalls = []; + fetchedFinalUrls = {}; + process.env.URL = "http://localhost:3000"; handler = new AnnounceHandler(); const honoContext = createMockHonoContext() as Context; @@ -139,12 +153,23 @@ describe("AnnounceHandler", () => { expect(mockRemoteNoteService.processNote).toHaveBeenCalled(); }); - test("should reject fetched note whose id is cross-origin with the requested URI", async () => { + test("should not trust a cross-origin id served by the requested origin", async () => { mockNoteRepository.findByUri = mock(() => Promise.resolve(null)); - fetchedNote = { - ...defaultFetchedNote, - id: "https://evil.example.com/notes/1", - attributedTo: "https://evil.example.com/users/eve", + // another.example.com serves a document claiming to be victim's note + fetchedDocs = { + "https://another.example.com/notes/1": { + ...defaultFetchedNote, + id: "https://victim.example.com/notes/1", + attributedTo: "https://victim.example.com/users/bob", + content: "

forged

", + }, + // victim.example.com's real document for that id + "https://victim.example.com/notes/1": { + ...defaultFetchedNote, + id: "https://victim.example.com/notes/1", + attributedTo: "https://victim.example.com/users/bob", + content: "

real

", + }, }; const activity: Activity = { @@ -157,8 +182,30 @@ describe("AnnounceHandler", () => { const result = await handler.handle(activity, mockContext); + expect(result.success).toBe(true); + expect(fetchCalls).toEqual([ + "https://another.example.com/notes/1", + "https://victim.example.com/notes/1", + ]); + // Only the document fetched from the victim's own origin is processed + expect(mockRemoteNoteService.processNote.mock.calls[0][0].content).toBe("

real

"); + }); + + test("should reject a fetch whose final URL is on another origin", async () => { + mockNoteRepository.findByUri = mock(() => Promise.resolve(null)); + fetchedFinalUrls = { "https://another.example.com/notes/1": "https://evil.example.com/x" }; + + const activity: Activity = { + "@context": "https://www.w3.org/ns/activitystreams", + type: "Announce", + id: "https://remote.example.com/activities/announce-1", + actor: "https://remote.example.com/users/remoteuser", + object: "https://another.example.com/notes/1", + }; + + const result = await handler.handle(activity, mockContext); + expect(result.success).toBe(false); - expect(result.message).toContain("origin does not match"); expect(mockRemoteNoteService.processNote).not.toHaveBeenCalled(); expect(mockNoteRepository.create).not.toHaveBeenCalled(); }); @@ -188,7 +235,7 @@ describe("AnnounceHandler", () => { test("should resolve local note by id without fetching when uri lookup misses", async () => { mockNoteRepository.findByUri = mock(() => Promise.resolve(null)); mockNoteRepository.findById = mock(() => - Promise.resolve({ id: "note-123", uri: null, text: "Local note" }), + Promise.resolve({ id: "note-123", uri: null, text: "Local note", ...renotable }), ); const activity: Activity = { @@ -271,4 +318,114 @@ describe("AnnounceHandler", () => { expect(result.success).toBe(false); expect(result.message).toContain("Failed to handle Announce activity"); }); + + describe("security checks", () => { + const announce = (overrides: Partial = {}): Activity => ({ + "@context": "https://www.w3.org/ns/activitystreams", + type: "Announce", + id: "https://remote.example.com/activities/announce-1", + actor: "https://remote.example.com/users/remoteuser", + object: "http://localhost:3000/notes/note-123", + ...overrides, + }); + + test("should refetch by canonical id when fetched id differs, and accept exact match", async () => { + mockNoteRepository.findByUri = mock(() => Promise.resolve(null)); + const requested = "https://another.example.com/@author/1"; + const canonical = "https://another.example.com/notes/1"; + fetchedDocs = { + [requested]: { ...defaultFetchedNote, id: canonical }, + [canonical]: { ...defaultFetchedNote, id: canonical }, + }; + + const result = await handler.handle(announce({ object: requested }), mockContext); + + expect(result.success).toBe(true); + expect(fetchCalls).toEqual([requested, canonical]); + expect(mockRemoteNoteService.processNote.mock.calls[0][0].id).toBe(canonical); + }); + + test("should reject when the canonical refetch returns yet another id", async () => { + mockNoteRepository.findByUri = mock(() => Promise.resolve(null)); + const requested = "https://another.example.com/@author/1"; + const canonical = "https://another.example.com/notes/1"; + fetchedDocs = { + [requested]: { ...defaultFetchedNote, id: canonical }, + [canonical]: { ...defaultFetchedNote, id: "https://another.example.com/notes/2" }, + }; + + const result = await handler.handle(announce({ object: requested }), mockContext); + + expect(result.success).toBe(false); + expect(mockRemoteNoteService.processNote).not.toHaveBeenCalled(); + }); + + test("should reject renote of a followers-only local note", async () => { + mockNoteRepository.findByUri = mock(() => + Promise.resolve({ id: "note-123", ...renotable, visibility: "followers" }), + ); + + const result = await handler.handle(announce(), mockContext); + + expect(result.success).toBe(false); + expect(mockNoteRepository.create).not.toHaveBeenCalled(); + }); + + test("should reject renote of a specified (DM) note found via id fallback", async () => { + mockNoteRepository.findByUri = mock(() => Promise.resolve(null)); + mockNoteRepository.findById = mock(() => + Promise.resolve({ id: "note-123", uri: null, ...renotable, visibility: "specified" }), + ); + + const result = await handler.handle(announce(), mockContext); + + expect(result.success).toBe(false); + expect(mockNoteRepository.create).not.toHaveBeenCalled(); + }); + + test("should reject renote of a localOnly note", async () => { + mockNoteRepository.findByUri = mock(() => + Promise.resolve({ id: "note-123", ...renotable, localOnly: true }), + ); + + const result = await handler.handle(announce(), mockContext); + + expect(result.success).toBe(false); + expect(mockNoteRepository.create).not.toHaveBeenCalled(); + }); + + test("should derive renote visibility from to/cc", async () => { + const PUBLIC = "https://www.w3.org/ns/activitystreams#Public"; + + await handler.handle(announce({ to: [PUBLIC] }), mockContext); + await handler.handle( + announce({ to: ["https://remote.example.com/users/remoteuser/followers"], cc: [PUBLIC] }), + mockContext, + ); + await handler.handle( + announce({ to: ["https://remote.example.com/users/remoteuser/followers"] }), + mockContext, + ); + + const visibilities = mockNoteRepository.create.mock.calls.map((c: any[]) => c[0].visibility); + expect(visibilities).toEqual(["public", "home", "followers"]); + }); + + test("should reject a javascript: activity id", async () => { + const result = await handler.handle(announce({ id: "javascript:alert(1)" }), mockContext); + + expect(result.success).toBe(false); + expect(mockNoteRepository.create).not.toHaveBeenCalled(); + }); + + test("should reject an activity id on another origin (URI squatting)", async () => { + const result = await handler.handle( + announce({ id: "https://victim.example.com/notes/42" }), + mockContext, + ); + + expect(result.success).toBe(false); + expect(mockNoteRepository.create).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/backend/src/tests/unit/verifySignature.test.ts b/packages/backend/src/tests/unit/verifySignature.test.ts index 0fe9abb6..94101f9b 100644 --- a/packages/backend/src/tests/unit/verifySignature.test.ts +++ b/packages/backend/src/tests/unit/verifySignature.test.ts @@ -8,11 +8,13 @@ import { describe, test, expect, beforeEach } from "bun:test"; import { clearFetchFailureCache, - extractVerifiedPublicKey, getFetchFailureCacheStats, + type KeyDocumentFetcher, + resolveVerifiedPublicKey, validateKeyId, } from "../../middleware/verifySignature.js"; import { validateActivity, ValidationErrorType } from "../../utils/activityValidation.js"; +import { isLocalHostname, isLocalUrl } from "../../utils/federationUrl.js"; describe("verifySignature fetch failure cache", () => { beforeEach(() => { @@ -73,171 +75,208 @@ describe("validateKeyId", () => { }); }); -describe("extractVerifiedPublicKey", () => { +describe("resolveVerifiedPublicKey", () => { const pem = "-----BEGIN PUBLIC KEY-----\nAAA\n-----END PUBLIC KEY-----"; + const otherPem = "-----BEGIN PUBLIC KEY-----\nBBB\n-----END PUBLIC KEY-----"; - test("accepts Mastodon/Misskey-style actor document", () => { - const result = extractVerifiedPublicKey( + beforeEach(() => { + process.env.URL = "http://localhost:3000"; + }); + + /** + * Build a fetcher from a URL → document map. Documents are served from the + * requested URL unless `redirects` maps the URL to a different final URL. + */ + const makeFetcher = ( + docs: Record, + redirects: Record = {}, + ): KeyDocumentFetcher & { calls: string[] } => { + const calls: string[] = []; + const fetcher = (async (url: string) => { + calls.push(url); + if (!(url in docs)) throw new Error(`404 ${url}`); + return { data: docs[url], finalUrl: redirects[url] ?? url }; + }) as KeyDocumentFetcher & { calls: string[] }; + fetcher.calls = calls; + return fetcher; + }; + + const actorDoc = (id: string, keyId: string, keyPem = pem, owner: string | undefined = id) => ({ + id, + type: "Person", + publicKey: { id: keyId, ...(owner ? { owner } : {}), publicKeyPem: keyPem }, + }); + + test("accepts Mastodon/Misskey actor document without a second fetch", async () => { + const fetcher = makeFetcher({ + "https://remote.example/users/bob": actorDoc( + "https://remote.example/users/bob", + "https://remote.example/users/bob#main-key", + ), + }); + const result = await resolveVerifiedPublicKey( "https://remote.example/users/bob#main-key", "https://remote.example/users/bob", - { - id: "https://remote.example/users/bob", - type: "Person", - publicKey: { - id: "https://remote.example/users/bob#main-key", - owner: "https://remote.example/users/bob", - publicKeyPem: pem, - }, - }, + fetcher, ); expect(result).toEqual({ publicKeyPem: pem, owner: "https://remote.example/users/bob" }); + expect(fetcher.calls).toHaveLength(1); }); - test("defaults owner to the document id when publicKey.owner is missing", () => { - const result = extractVerifiedPublicKey( + test("defaults owner to the document id when publicKey.owner is missing", async () => { + const fetcher = makeFetcher({ + "https://remote.example/users/bob": actorDoc( + "https://remote.example/users/bob", + "https://remote.example/users/bob#main-key", + pem, + undefined, + ), + }); + const result = await resolveVerifiedPublicKey( "https://remote.example/users/bob#main-key", "https://remote.example/users/bob", - { - id: "https://remote.example/users/bob", - publicKey: { id: "https://remote.example/users/bob#main-key", publicKeyPem: pem }, - }, + fetcher, ); expect(result.owner).toBe("https://remote.example/users/bob"); }); - test("finds the matching key in a publicKey array", () => { - const result = extractVerifiedPublicKey( - "https://remote.example/users/bob#key-2", - "https://remote.example/users/bob", - { - id: "https://remote.example/users/bob", - publicKey: [ - { id: "https://remote.example/users/bob#key-1", publicKeyPem: "other" }, - { id: "https://remote.example/users/bob#key-2", publicKeyPem: pem }, - ], - }, - ); - expect(result.publicKeyPem).toBe(pem); + test("accepts GoToSocial stub via a separate owner fetch", async () => { + const keyId = "https://gts.example/users/bob/main-key"; + const fetcher = makeFetcher({ + [keyId]: actorDoc("https://gts.example/users/bob", keyId), + "https://gts.example/users/bob": actorDoc("https://gts.example/users/bob", keyId), + }); + const result = await resolveVerifiedPublicKey(keyId, keyId, fetcher); + expect(result.owner).toBe("https://gts.example/users/bob"); + expect(fetcher.calls).toEqual([keyId, "https://gts.example/users/bob"]); }); - test("accepts a standalone Key document on a different path (/publickey)", () => { - const result = extractVerifiedPublicKey( - "https://remote.example/users/bob/publickey", - "https://remote.example/users/bob/publickey", - { - id: "https://remote.example/users/bob/publickey", + test("accepts a standalone Key document when the owner actor lists the key", async () => { + const keyId = "https://remote.example/users/bob/publickey"; + const fetcher = makeFetcher({ + [keyId]: { + id: keyId, type: "Key", owner: "https://remote.example/users/bob", publicKeyPem: pem, }, - ); + "https://remote.example/users/bob": actorDoc("https://remote.example/users/bob", keyId), + }); + const result = await resolveVerifiedPublicKey(keyId, keyId, fetcher); expect(result.owner).toBe("https://remote.example/users/bob"); }); - test("rejects a document whose publicKey.id does not match keyId", () => { - expect(() => - extractVerifiedPublicKey( - "https://remote.example/users/bob2#main-key", - "https://remote.example/users/bob2", - { - id: "https://remote.example/users/bob2", - publicKey: { - id: "https://remote.example/users/bob2#other-key", - owner: "https://remote.example/users/bob2", - publicKeyPem: pem, - }, - }, + test("rejects same-origin attacker Key document when owner's PEM differs", async () => { + // e.g. user-uploaded JSON on the victim's origin claiming bob's ownership + const keyId = "https://victim.example/files/evil.json"; + const fetcher = makeFetcher({ + [keyId]: { + id: keyId, + owner: "https://victim.example/users/bob", + publicKeyPem: otherPem, + }, + "https://victim.example/users/bob": actorDoc( + "https://victim.example/users/bob", + "https://victim.example/users/bob#main-key", ), - ).toThrow(); + }); + await expect(resolveVerifiedPublicKey(keyId, keyId, fetcher)).rejects.toThrow(); }); - test("accepts GoToSocial-style stub actor served at the key path", () => { - const result = extractVerifiedPublicKey( - "https://gts.example/users/bob/main-key", - "https://gts.example/users/bob/main-key", + test("rejects when the owner lists the keyId but with a different PEM", async () => { + const keyId = "https://gts.example/users/bob/main-key"; + const fetcher = makeFetcher({ + [keyId]: actorDoc("https://gts.example/users/bob", keyId, otherPem), + "https://gts.example/users/bob": actorDoc("https://gts.example/users/bob", keyId, pem), + }); + await expect(resolveVerifiedPublicKey(keyId, keyId, fetcher)).rejects.toThrow(); + }); + + test("rejects a redirected key fetch unless the owner document confirms it", async () => { + // keyId URL redirects (open redirect) to an attacker document claiming bob + const keyId = "https://victim.example/users/bob#main-key"; + const fetcher = makeFetcher( { - id: "https://gts.example/users/bob", - type: "Person", - publicKey: { - id: "https://gts.example/users/bob/main-key", - owner: "https://gts.example/users/bob", - publicKeyPem: pem, - }, + "https://victim.example/users/bob": actorDoc( + "https://victim.example/users/bob", + keyId, + otherPem, + ), }, + { "https://victim.example/users/bob": "https://victim.example/redirect-target" }, ); - expect(result.owner).toBe("https://gts.example/users/bob"); - }); - - test("rejects an actor document whose id is on a different origin", () => { - expect(() => - extractVerifiedPublicKey( - "https://evil.example/users/eve#main-key", - "https://evil.example/users/eve", - { - id: "https://victim.example/users/bob", - publicKey: { - id: "https://evil.example/users/eve#main-key", - owner: "https://victim.example/users/bob", - publicKeyPem: pem, - }, - }, - ), - ).toThrow(); + await expect( + resolveVerifiedPublicKey(keyId, "https://victim.example/users/bob", fetcher), + ).rejects.toThrow(); }); - test("dot-segment keyId resolves to the normalized target's owner, never the prefix actor", () => { - // Even if validateKeyId were bypassed, the verified owner is eve (the document - // actually fetched), so activity.actor === bob would fail the binding check. - const result = extractVerifiedPublicKey( - "https://victim.example/users/bob/../eve#main-key", - "https://victim.example/users/bob/../eve", + test("rejects when the owner actor document itself was redirected", async () => { + const keyId = "https://gts.example/users/bob/main-key"; + const fetcher = makeFetcher( { - id: "https://victim.example/users/eve", - publicKey: { - id: "https://victim.example/users/eve#main-key", - owner: "https://victim.example/users/eve", - publicKeyPem: pem, - }, + [keyId]: actorDoc("https://gts.example/users/bob", keyId), + "https://gts.example/users/bob": actorDoc("https://gts.example/users/bob", keyId), }, + { "https://gts.example/users/bob": "https://gts.example/elsewhere" }, ); - expect(result.owner).toBe("https://victim.example/users/eve"); + await expect(resolveVerifiedPublicKey(keyId, keyId, fetcher)).rejects.toThrow(); }); - test("rejects owner mismatch in actor document", () => { - expect(() => - extractVerifiedPublicKey( - "https://evil.example/users/eve#main-key", + test("rejects owner mismatch (attacker doc claiming a victim owner)", async () => { + const keyId = "https://evil.example/users/eve#main-key"; + const fetcher = makeFetcher({ + "https://evil.example/users/eve": actorDoc( "https://evil.example/users/eve", - { - id: "https://evil.example/users/eve", - publicKey: { - id: "https://evil.example/users/eve#main-key", - owner: "https://victim.example/users/bob", - publicKeyPem: pem, - }, - }, + keyId, + pem, + "https://victim.example/users/bob", ), - ).toThrow(); + "https://victim.example/users/bob": actorDoc( + "https://victim.example/users/bob", + "https://victim.example/users/bob#main-key", + otherPem, + ), + }); + await expect( + resolveVerifiedPublicKey(keyId, "https://evil.example/users/eve", fetcher), + ).rejects.toThrow(); }); - test("rejects cross-origin owner in standalone Key document", () => { - expect(() => - extractVerifiedPublicKey("https://evil.example/keys/1", "https://evil.example/keys/1", { - id: "https://evil.example/keys/1", - owner: "https://victim.example/users/bob", - publicKeyPem: pem, - }), - ).toThrow(); + test("rejects a document whose publicKey.id does not match keyId", async () => { + const fetcher = makeFetcher({ + "https://remote.example/users/bob2": actorDoc( + "https://remote.example/users/bob2", + "https://remote.example/users/bob2#other-key", + ), + }); + await expect( + resolveVerifiedPublicKey( + "https://remote.example/users/bob2#main-key", + "https://remote.example/users/bob2", + fetcher, + ), + ).rejects.toThrow(); }); - test("rejects document without a public key", () => { - expect(() => - extractVerifiedPublicKey( + test("rejects a key owner on the local host", async () => { + const keyId = "https://evil.example/keys/1"; + const fetcher = makeFetcher({ + [keyId]: { id: keyId, owner: "http://localhost:3000/users/alice", publicKeyPem: pem }, + }); + await expect(resolveVerifiedPublicKey(keyId, keyId, fetcher)).rejects.toThrow(); + }); + + test("rejects document without a public key", async () => { + const fetcher = makeFetcher({ + "https://remote.example/users/bob": { id: "https://remote.example/users/bob" }, + }); + await expect( + resolveVerifiedPublicKey( "https://remote.example/users/bob#main-key", "https://remote.example/users/bob", - { id: "https://remote.example/users/bob" }, + fetcher, ), - ).toThrow(); + ).rejects.toThrow(); }); }); @@ -308,4 +347,57 @@ describe("validateActivity actor binding", () => { expect(result.valid).toBe(false); expect(result.errors[0]?.type).toBe(ValidationErrorType.ACTOR_MISMATCH); }); + + test("rejects activities from a trailing-dot local hostname", () => { + const result = validateActivity( + activity("http://LOCALHOST.:3000/users/alice"), + "http://LOCALHOST.:3000/users/alice#main-key", + "http://LOCALHOST.:3000/users/alice", + ); + expect(result.valid).toBe(false); + }); + + test("rejects a javascript: activity id", () => { + const actor = "https://remote.example/users/bob"; + const result = validateActivity( + { ...activity(actor), id: "javascript:alert(1)" }, + `${actor}#main-key`, + actor, + ); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.field === "id")).toBe(true); + }); + + test("rejects an activity id on another origin than the actor", () => { + const actor = "https://remote.example/users/bob"; + const result = validateActivity( + { ...activity(actor), id: "https://victim.example/users/alice/statuses/1/activity" }, + `${actor}#main-key`, + actor, + ); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.field === "id")).toBe(true); + }); +}); + +describe("federationUrl local host detection", () => { + beforeEach(() => { + process.env.URL = "https://rox.example.com"; + }); + + test("treats trailing-dot and uppercase hostnames as local", () => { + expect(isLocalUrl("https://rox.example.com./users/alice")).toBe(true); + expect(isLocalUrl("https://ROX.Example.COM/users/alice")).toBe(true); + expect(isLocalHostname("rox.example.com.")).toBe(true); + }); + + test("does not treat other hosts as local", () => { + expect(isLocalUrl("https://rox.example.com.evil.example/users/alice")).toBe(false); + expect(isLocalHostname("other.example.com")).toBe(false); + }); + + test("normalizes a trailing dot in the configured URL", () => { + process.env.URL = "https://rox.example.com."; + expect(isLocalUrl("https://rox.example.com/users/alice")).toBe(true); + }); }); diff --git a/packages/backend/src/utils/activityValidation.ts b/packages/backend/src/utils/activityValidation.ts index fa3284de..b06ddb49 100644 --- a/packages/backend/src/utils/activityValidation.ts +++ b/packages/backend/src/utils/activityValidation.ts @@ -7,7 +7,7 @@ * @module utils/activityValidation */ -import { isLocalUrl } from "./federationUrl.js"; +import { isHttpUrl, isLocalUrl, isSameOrigin } from "./federationUrl.js"; /** * Validation error types @@ -181,6 +181,19 @@ export function validateRequiredFields(activity: any): ValidationResult { }); } + // Activity id (when present) must be an http(s) URI on the actor's origin. + // Prevents `javascript:` ids and squatting of other servers' activity ids + // (which are persisted as note URIs and used for deduplication). + if (activity.id !== undefined && activity.id !== null) { + if (!isHttpUrl(activity.id) || (actorUri && !isSameOrigin(activity.id, actorUri))) { + errors.push({ + type: ValidationErrorType.INVALID_URI, + field: "id", + message: "Activity id must be an HTTP(S) URI on the actor's origin", + }); + } + } + // Type-specific validation switch (activity.type) { case "Create": diff --git a/packages/backend/src/utils/federationUrl.ts b/packages/backend/src/utils/federationUrl.ts index d17d96bb..912d933d 100644 --- a/packages/backend/src/utils/federationUrl.ts +++ b/packages/backend/src/utils/federationUrl.ts @@ -15,12 +15,35 @@ */ export function getLocalHostname(): string { try { - return new URL(process.env.URL || "http://localhost:3000").hostname; + return normalizeHostname(new URL(process.env.URL || "http://localhost:3000").hostname); } catch { return "localhost"; } } +/** + * Normalize a hostname for comparison (lowercase, trailing dot removed). + * + * `example.com.` is the fully-qualified form of `example.com` and resolves to + * the same host, so both must compare equal. + * + * @param hostname - Hostname to normalize + * @returns Normalized hostname + */ +export function normalizeHostname(hostname: string): string { + return hostname.toLowerCase().replace(/\.+$/, ""); +} + +/** + * Check whether a hostname refers to this instance. + * + * @param hostname - Hostname to check (e.g. from `new URL(x).hostname`) + * @returns True if it equals the local hostname after normalization + */ +export function isLocalHostname(hostname: string): boolean { + return normalizeHostname(hostname) === getLocalHostname(); +} + /** * Parse a value as an absolute http(s) URL. * @@ -60,7 +83,7 @@ export function isHttpUrl(value: unknown): value is string { */ export function isLocalUrl(value: unknown): boolean { const url = parseHttpUrl(value); - return url !== null && url.hostname === getLocalHostname(); + return url !== null && isLocalHostname(url.hostname); } /** From c5c0695104a7d7dde664ac839ec34cbc1b54d6f0 Mon Sep 17 00:00:00 2001 From: SASAGAWA Kiyoshi Date: Wed, 14 Oct 2026 21:38:53 +0900 Subject: [PATCH 8/9] chore: bump version to 2026.10.2 Claude-Session: https://claude.ai/code/session_01Y29fBeRP344FME6de48wUv --- package.json | 2 +- packages/backend/package.json | 2 +- packages/frontend/package.json | 2 +- packages/shared/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index fcac0a26..13b17dd4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rox", - "version": "2026.10.1", + "version": "2026.10.2", "license": "AGPL-3.0-only", "devDependencies": { "@playwright/test": "^1.64.0", diff --git a/packages/backend/package.json b/packages/backend/package.json index ef8ea4c0..a0a57c9b 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "hono_rox", - "version": "1.10.1", + "version": "1.10.2", "private": true, "license": "AGPL-3.0-only", "type": "module", diff --git a/packages/frontend/package.json b/packages/frontend/package.json index a7e7dcf8..64e62fb4 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -1,6 +1,6 @@ { "name": "waku_rox", - "version": "1.10.1", + "version": "1.10.2", "private": true, "license": "AGPL-3.0-only", "type": "module", diff --git a/packages/shared/package.json b/packages/shared/package.json index ec43ec02..88d40c0c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "shared", - "version": "1.10.1", + "version": "1.10.2", "private": true, "license": "AGPL-3.0-only", "type": "module", From 32556161b466815f76431d6f90626da6eae8404b Mon Sep 17 00:00:00 2001 From: SASAGAWA Kiyoshi Date: Wed, 14 Oct 2026 21:39:05 +0900 Subject: [PATCH 9/9] chore: update bun.lock for 2026.10.2 Claude-Session: https://claude.ai/code/session_01Y29fBeRP344FME6de48wUv --- bun.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index bccd0c8e..4954f538 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,7 @@ }, "packages/backend": { "name": "hono_rox", - "version": "1.10.1", + "version": "1.10.2", "dependencies": { "@aws-sdk/client-s3": "^3.1150.0", "@hono/zod-validator": "^0.9.1", @@ -55,7 +55,7 @@ }, "packages/frontend": { "name": "waku_rox", - "version": "1.10.1", + "version": "1.10.2", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -105,7 +105,7 @@ }, "packages/shared": { "name": "shared", - "version": "1.10.1", + "version": "1.10.2", "dependencies": { "vite-plus": "^1.1.0", },