From e2a024c88e264e3a3188a768f34013f5d1860011 Mon Sep 17 00:00:00 2001 From: SASAGAWA Kiyoshi Date: Wed, 14 Oct 2026 21:06:07 +0900 Subject: [PATCH] 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";