Kaynağa Gözat

fix(越权批1): drafts/notification/playlist/video/publish 加用户隔离

系统性 IDOR 修复第1批(独立模块):
- drafts GET/PUT/DELETE /:id: where 加 userId,非本人 404
- notifications markAsRead: update→updateMany+userId,只能标记本人通知
- playlist GET/:id 及 items 增/排序/删: 校验列表归属当前用户
- video-generator: 加 optionalAuth(原来无认证!),修 ctx.state.user.id→userId 笔误,
  列表强制用当前用户(原读 query.userId 客户端可控),详情/改/删/生成/状态加归属校验
- publish getVideoProjectForPublish: 加 userId 校验,不能用别人的视频项目发布
- 顺带修复 createVideoProjectFromBook(bookId,userId) 参数错位(userId 被当 chapterId)
MyFramework User 2 ay önce
ebeveyn
işleme
d3c8809346

+ 28 - 6
server/src/modules/drafts/drafts.controller.ts

@@ -34,10 +34,17 @@ router.get('/', optionalAuth, async (ctx: Context) => {
 
 // 获取单个草稿
 router.get('/:id', optionalAuth, async (ctx: Context) => {
+  const userId = ctx.state.user?.userId || TEST_USER_ID;
   const { id } = ctx.params;
-  const draft = await prisma.draft.findUnique({
-    where: { id: parseInt(id) },
+  // 归属校验:只能读自己的草稿
+  const draft = await prisma.draft.findFirst({
+    where: { id: parseInt(id), userId: parseInt(userId) },
   });
+  if (!draft) {
+    ctx.status = 404;
+    ctx.body = { code: 1, message: '草稿不存在' };
+    return;
+  }
   ctx.body = { code: 0, message: 'success', data: draft };
 });
 
@@ -65,25 +72,40 @@ router.post('/', optionalAuth, async (ctx: Context) => {
 
 // 更新草稿
 router.put('/:id', optionalAuth, async (ctx: Context) => {
+  const userId = ctx.state.user?.userId || TEST_USER_ID;
   const { id } = ctx.params;
   const data = ctx.request.body as {
     title?: string;
     content?: string;
     metadata?: string;
   };
-  const draft = await prisma.draft.update({
-    where: { id: parseInt(id) },
+  // 归属校验:只更新属于本人的草稿
+  const result = await prisma.draft.updateMany({
+    where: { id: parseInt(id), userId: parseInt(userId) },
     data: { ...data, autoSavedAt: new Date() },
   });
+  if (result.count === 0) {
+    ctx.status = 404;
+    ctx.body = { code: 1, message: '草稿不存在' };
+    return;
+  }
+  const draft = await prisma.draft.findUnique({ where: { id: parseInt(id) } });
   ctx.body = { code: 0, message: 'success', data: draft };
 });
 
 // 删除草稿
 router.delete('/:id', optionalAuth, async (ctx: Context) => {
+  const userId = ctx.state.user?.userId || TEST_USER_ID;
   const { id } = ctx.params;
-  await prisma.draft.delete({
-    where: { id: parseInt(id) },
+  // 归属校验:只删除属于本人的草稿
+  const result = await prisma.draft.deleteMany({
+    where: { id: parseInt(id), userId: parseInt(userId) },
   });
+  if (result.count === 0) {
+    ctx.status = 404;
+    ctx.body = { code: 1, message: '草稿不存在' };
+    return;
+  }
   ctx.body = { code: 0, message: 'success' };
 });
 

+ 2 - 1
server/src/modules/notifications/notifications.controller.ts

@@ -58,8 +58,9 @@ router.get('/', optionalAuth, async (ctx) => {
  */
 router.post('/read', optionalAuth, async (ctx) => {
   try {
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
     const { id } = ctx.request.body as { id: string };
-    await notificationsService.markAsRead(id);
+    await notificationsService.markAsRead(id, String(userId));
 
     ctx.body = {
       code: 0,

+ 12 - 9
server/src/modules/notifications/notifications.service.ts

@@ -19,21 +19,24 @@ export class NotificationsService {
   }
 
   /**
-   * 标记通知为已读
+   * 标记通知为已读(仅限本人的通知)
    */
-  async markAsRead(id: string) {
+  async markAsRead(id: string, userId?: string) {
     if (!id) {
       throw new NotFoundError('通知不存在');
     }
-    // 先检查是否存在,避免 P2025
-    const existing = await prisma.notification.findUnique({ where: { id } });
-    if (!existing) {
-      throw new NotFoundError('通知不存在');
-    }
-    return await prisma.notification.update({
-      where: { id },
+    // 归属校验:用 updateMany + userId,只标记属于本人的通知
+    const uid = userId ? parseInt(userId) : NaN;
+    const where: any = { id };
+    if (!isNaN(uid)) where.userId = uid;
+    const result = await prisma.notification.updateMany({
+      where,
       data: { isRead: true },
     });
+    if (result.count === 0) {
+      throw new NotFoundError('通知不存在');
+    }
+    return result;
   }
 }
 

+ 49 - 7
server/src/modules/player/playlist.controller.ts

@@ -33,19 +33,38 @@ router.post('/', optionalAuth, async (ctx: Context) => {
 
 // 获取单个播放列表详情
 router.get('/:id', optionalAuth, async (ctx: Context) => {
+  const userId = ctx.state.user?.userId || TEST_USER_ID;
   const { id } = ctx.params;
-  const playlist = await prisma.playlist.findUnique({
-    where: { id: parseInt(id) },
+  // 归属校验:只能看自己的播放列表
+  const playlist = await prisma.playlist.findFirst({
+    where: { id: parseInt(id), userId: parseInt(userId) },
     include: { items: { orderBy: { order: 'asc' }, include: { chapter: true } } },
   });
+  if (!playlist) {
+    ctx.status = 404;
+    ctx.body = { code: 1, message: '播放列表不存在' };
+    return;
+  }
   ctx.body = { code: 0, message: 'success', data: playlist };
 });
 
 // 添加项目到播放列表
 router.post('/:id/items', optionalAuth, async (ctx: Context) => {
+  const userId = ctx.state.user?.userId || TEST_USER_ID;
   const { id } = ctx.params;
   const { chapterId, audioId } = ctx.request.body as { chapterId?: number; audioId?: string };
 
+  // 归属校验:该播放列表必须属于当前用户
+  const owned = await prisma.playlist.findFirst({
+    where: { id: parseInt(id), userId: parseInt(userId) },
+    select: { id: true },
+  });
+  if (!owned) {
+    ctx.status = 404;
+    ctx.body = { code: 1, message: '播放列表不存在' };
+    return;
+  }
+
   const maxOrder = await prisma.playlistItem.aggregate({
     where: { playlistId: parseInt(id) },
     _max: { order: true },
@@ -65,11 +84,23 @@ router.post('/:id/items', optionalAuth, async (ctx: Context) => {
 
 // 重新排序项目
 router.put('/:id/items/reorder', optionalAuth, async (ctx: Context) => {
+  const userId = ctx.state.user?.userId || TEST_USER_ID;
   const { id } = ctx.params;
   const { items } = ctx.request.body as { items: { id: number; order: number }[] };
+  // 归属校验:该播放列表必须属于当前用户
+  const owned = await prisma.playlist.findFirst({
+    where: { id: parseInt(id), userId: parseInt(userId) },
+    select: { id: true },
+  });
+  if (!owned) {
+    ctx.status = 404;
+    ctx.body = { code: 1, message: '播放列表不存在' };
+    return;
+  }
   for (const item of items) {
-    await prisma.playlistItem.update({
-      where: { id: item.id },
+    // 只更新归属于该播放列表的条目,防止跨列表越权改 order
+    await prisma.playlistItem.updateMany({
+      where: { id: item.id, playlistId: parseInt(id) },
       data: { order: item.order },
     });
   }
@@ -78,10 +109,21 @@ router.put('/:id/items/reorder', optionalAuth, async (ctx: Context) => {
 
 // 删除项目
 router.delete('/:id/items/:itemId', optionalAuth, async (ctx: Context) => {
-  const { itemId } = ctx.params;
-  await prisma.playlistItem.delete({
-    where: { id: parseInt(itemId) },
+  const userId = ctx.state.user?.userId || TEST_USER_ID;
+  const { id, itemId } = ctx.params;
+  // 归属校验:条目必须属于当前用户名下的该播放列表
+  const result = await prisma.playlistItem.deleteMany({
+    where: {
+      id: parseInt(itemId),
+      playlistId: parseInt(id),
+      playlist: { userId: parseInt(userId) },
+    },
   });
+  if (result.count === 0) {
+    ctx.status = 404;
+    ctx.body = { code: 1, message: '播放列表条目不存在' };
+    return;
+  }
   ctx.body = { code: 0, message: 'success' };
 });
 

+ 3 - 3
server/src/modules/publish/publish.controller.ts

@@ -226,8 +226,8 @@ router.post('/tasks', async (ctx) => {
   let finalCoverUrl = coverUrl;
 
   if (videoProjectId) {
-    // 方式1:通过 videoProjectId 获取视频信息
-    const projectInfo = await getVideoProjectForPublish(videoProjectId);
+    // 方式1:通过 videoProjectId 获取视频信息(仅限本人项目)
+    const projectInfo = await getVideoProjectForPublish(videoProjectId, userId);
     if (!projectInfo) {
       ctx.status = 404;
       ctx.body = { success: false, error: '视频项目不存在或未生成视频' };
@@ -318,7 +318,7 @@ router.post('/tasks/:id/publish', async (ctx) => {
  */
 router.get('/video/:projectId/preview', async (ctx) => {
   const projectId = Number(ctx.params.projectId);
-  const projectInfo = await getVideoProjectForPublish(projectId);
+  const projectInfo = await getVideoProjectForPublish(projectId, getUserId(ctx));
 
   if (!projectInfo) {
     ctx.status = 404;

+ 8 - 1
server/src/modules/publish/publish.service.ts

@@ -602,13 +602,16 @@ async function calculateMD5(filePath: string): Promise<string> {
 
 /**
  * 获取视频项目信息(用于发布预览)
+ * 传 userId 时做归属校验,只能用自己的视频项目发布
  */
 export async function getVideoProjectForPublish(
-  projectId: number
+  projectId: number,
+  userId?: number
 ): Promise<{ title: string; description: string; videoUrl: string; coverUrl?: string } | null> {
   const project = await prisma.videoProject.findUnique({
     where: { id: projectId },
     select: {
+      userId: true,
       title: true,
       description: true,
       outputUrl: true,
@@ -619,6 +622,10 @@ export async function getVideoProjectForPublish(
   if (!project || !project.outputUrl) {
     return null;
   }
+  // 归属校验:非本人且非公共数据 → 视为不存在(userId 为 NaN/undefined 时跳过,兼容无认证预览)
+  if (userId !== undefined && !Number.isNaN(userId) && project.userId != null && project.userId !== userId) {
+    return null;
+  }
 
   return {
     title: project.title,

+ 31 - 23
server/src/modules/video-generator/video-generator.controller.ts

@@ -3,6 +3,7 @@
  */
 
 import Router from '@koa/router';
+import { optionalAuth } from '../../middleware/auth';
 import {
   createVideoProject,
   getVideoProjects,
@@ -19,15 +20,22 @@ import {
 
 const router = new Router();
 
+// 测试用户兜底(与其他模块一致)
+const TEST_USER_ID = 1;
+function currentUserId(ctx: any): number {
+  const uid = ctx.state.user?.userId;
+  return uid ? parseInt(String(uid)) : TEST_USER_ID;
+}
+
 // ============ 视频项目管理 ============
 
 /**
  * GET /api/video/projects
- * 获取视频项目列表
+ * 获取视频项目列表(只返回当前用户的项目)
  */
-router.get('/projects', async (ctx) => {
+router.get('/projects', optionalAuth, async (ctx) => {
   const query = {
-    userId: ctx.query.userId ? Number(ctx.query.userId) : undefined,
+    userId: currentUserId(ctx),  // 强制用当前登录用户,忽略客户端传入的 userId
     status: ctx.query.status as any,
     page: ctx.query.page ? Number(ctx.query.page) : 1,
     pageSize: ctx.query.pageSize ? Number(ctx.query.pageSize) : 10,
@@ -41,9 +49,9 @@ router.get('/projects', async (ctx) => {
  * POST /api/video/projects
  * 创建视频项目
  */
-router.post('/projects', async (ctx) => {
+router.post('/projects', optionalAuth, async (ctx) => {
   const body = ctx.request.body as any;
-  const userId = ctx.state.user?.id;
+  const userId = currentUserId(ctx);
 
   const project = await createVideoProject(body, userId);
   ctx.body = { success: true, data: project };
@@ -53,9 +61,9 @@ router.post('/projects', async (ctx) => {
  * GET /api/video/projects/:id
  * 获取视频项目详情
  */
-router.get('/projects/:id', async (ctx) => {
+router.get('/projects/:id', optionalAuth, async (ctx) => {
   const id = Number(ctx.params.id);
-  const project = await getVideoProject(id);
+  const project = await getVideoProject(id, currentUserId(ctx));
 
   if (!project) {
     ctx.status = 404;
@@ -70,11 +78,11 @@ router.get('/projects/:id', async (ctx) => {
  * PUT /api/video/projects/:id
  * 更新视频项目
  */
-router.put('/projects/:id', async (ctx) => {
+router.put('/projects/:id', optionalAuth, async (ctx) => {
   const id = Number(ctx.params.id);
   const body = ctx.request.body as any;
 
-  const project = await updateVideoProject(id, body);
+  const project = await updateVideoProject(id, body, currentUserId(ctx));
 
   if (!project) {
     ctx.status = 404;
@@ -89,9 +97,9 @@ router.put('/projects/:id', async (ctx) => {
  * DELETE /api/video/projects/:id
  * 删除视频项目
  */
-router.delete('/projects/:id', async (ctx) => {
+router.delete('/projects/:id', optionalAuth, async (ctx) => {
   const id = Number(ctx.params.id);
-  const success = await deleteVideoProject(id);
+  const success = await deleteVideoProject(id, currentUserId(ctx));
 
   if (!success) {
     ctx.status = 404;
@@ -108,9 +116,9 @@ router.delete('/projects/:id', async (ctx) => {
  * POST /api/video/projects/:id/generate
  * 开始生成视频
  */
-router.post('/projects/:id/generate', async (ctx) => {
+router.post('/projects/:id/generate', optionalAuth, async (ctx) => {
   const id = Number(ctx.params.id);
-  const result = await generateVideoForProject(id);
+  const result = await generateVideoForProject(id, currentUserId(ctx));
 
   if (!result.success) {
     ctx.status = 400;
@@ -132,9 +140,9 @@ router.post('/projects/:id/generate', async (ctx) => {
  * GET /api/video/projects/:id/status
  * 获取生成状态
  */
-router.get('/projects/:id/status', async (ctx) => {
+router.get('/projects/:id/status', optionalAuth, async (ctx) => {
   const id = Number(ctx.params.id);
-  const status = await getGenerateProgress(id);
+  const status = await getGenerateProgress(id, currentUserId(ctx));
 
   ctx.body = { success: true, data: status };
 });
@@ -145,9 +153,9 @@ router.get('/projects/:id/status', async (ctx) => {
  * GET /api/video/materials
  * 获取素材列表
  */
-router.get('/materials', async (ctx) => {
+router.get('/materials', optionalAuth, async (ctx) => {
   const query = {
-    userId: ctx.query.userId ? Number(ctx.query.userId) : undefined,
+    userId: currentUserId(ctx),
     type: ctx.query.type as any,
     category: ctx.query.category as string,
     page: ctx.query.page ? Number(ctx.query.page) : 1,
@@ -162,8 +170,8 @@ router.get('/materials', async (ctx) => {
  * POST /api/video/materials/upload
  * 上传素材(处理 multipart/form-data 文件上传)
  */
-router.post('/materials/upload', async (ctx) => {
-  const userId = ctx.state.user?.id;
+router.post('/materials/upload', optionalAuth, async (ctx) => {
+  const userId = currentUserId(ctx);
 
   // 处理 multipart form data
   const body = ctx.request.body as any;
@@ -206,7 +214,7 @@ router.post('/materials/upload', async (ctx) => {
  * DELETE /api/video/materials/:id
  * 删除素材
  */
-router.delete('/materials/:id', async (ctx) => {
+router.delete('/materials/:id', optionalAuth, async (ctx) => {
   const id = Number(ctx.params.id);
   const success = await deleteMaterial(id);
 
@@ -225,11 +233,11 @@ router.delete('/materials/:id', async (ctx) => {
  * POST /api/video/books/:bookId/generate
  * 从书籍生成视频项目
  */
-router.post('/books/:bookId/generate', async (ctx) => {
+router.post('/books/:bookId/generate', optionalAuth, async (ctx) => {
   const bookId = Number(ctx.params.bookId);
-  const userId = ctx.state.user?.id;
+  const userId = currentUserId(ctx);
 
-  const project = await createVideoProjectFromBook(bookId, userId);
+  const project = await createVideoProjectFromBook(bookId, undefined, userId);
 
   if (!project) {
     ctx.status = 404;

+ 29 - 11
server/src/modules/video-generator/video-generator.service.ts

@@ -93,14 +93,18 @@ export async function getVideoProjects(query: GetVideoProjectsQuery): Promise<{
 }
 
 /**
- * 获取视频项目详情
+ * 获取视频项目详情(传 userId 时做归属校验,只返回本人项目)
  */
-export async function getVideoProject(id: number): Promise<VideoProjectResponse | null> {
+export async function getVideoProject(id: number, userId?: number): Promise<VideoProjectResponse | null> {
   const project = await prisma.videoProject.findUnique({
     where: { id },
   });
 
   if (!project) return null;
+  // 归属校验:传了 userId 就必须匹配(null 归属视为公共/历史数据放行)
+  if (userId !== undefined && project.userId != null && project.userId !== userId) {
+    return null;
+  }
 
   return {
     ...project,
@@ -109,12 +113,17 @@ export async function getVideoProject(id: number): Promise<VideoProjectResponse
 }
 
 /**
- * 更新视频项目
+ * 更新视频项目(传 userId 时做归属校验)
  */
 export async function updateVideoProject(
   id: number,
-  data: UpdateVideoProjectRequest
+  data: UpdateVideoProjectRequest,
+  userId?: number
 ): Promise<VideoProjectResponse | null> {
+  // 先校验归属
+  const existing = await getVideoProject(id, userId);
+  if (!existing) return null;
+
   const updateData: any = {};
 
   if (data.title !== undefined) updateData.title = data.title;
@@ -134,9 +143,12 @@ export async function updateVideoProject(
 }
 
 /**
- * 删除视频项目
+ * 删除视频项目(传 userId 时做归属校验)
  */
-export async function deleteVideoProject(id: number): Promise<boolean> {
+export async function deleteVideoProject(id: number, userId?: number): Promise<boolean> {
+  // 先校验归属
+  const existing = await getVideoProject(id, userId);
+  if (!existing) return false;
   try {
     await prisma.videoProject.delete({
       where: { id },
@@ -152,15 +164,15 @@ export async function deleteVideoProject(id: number): Promise<boolean> {
 /**
  * 生成视频
  */
-export async function generateVideoForProject(projectId: number): Promise<{
+export async function generateVideoForProject(projectId: number, userId?: number): Promise<{
   success: boolean;
   outputUrl?: string;
   duration?: number;
   fileSize?: number;
   error?: string;
 }> {
-  // 1. 获取项目
-  const project = await getVideoProject(projectId);
+  // 1. 获取项目(带归属校验)
+  const project = await getVideoProject(projectId, userId);
   if (!project) {
     return { success: false, error: '项目不存在' };
   }
@@ -312,7 +324,7 @@ export async function generateVideoForProject(projectId: number): Promise<{
 /**
  * 获取生成进度
  */
-export async function getGenerateProgress(projectId: number): Promise<{
+export async function getGenerateProgress(projectId: number, userId?: number): Promise<{
   status: VideoProjectStatus;
   progress: number;
   outputUrl?: string;
@@ -323,6 +335,7 @@ export async function getGenerateProgress(projectId: number): Promise<{
   const project = await prisma.videoProject.findUnique({
     where: { id: projectId },
     select: {
+      userId: true,
       status: true,
       progress: true,
       outputUrl: true,
@@ -335,8 +348,13 @@ export async function getGenerateProgress(projectId: number): Promise<{
   if (!project) {
     return { status: 'failed', progress: 0, errorMsg: '项目不存在' };
   }
+  // 归属校验:非本人且非公共数据 → 视为不存在
+  if (userId !== undefined && project.userId != null && project.userId !== userId) {
+    return { status: 'failed', progress: 0, errorMsg: '项目不存在' };
+  }
 
-  return project as any;
+  const { userId: _omit, ...rest } = project as any;
+  return rest as any;
 }
 
 // ============ 素材管理 ============