# 安全开发规范 > **版本:** v1.1 > **创建日期:** 2026-03-02 > **最后更新:** 2026-03-03(v1.1:采纳架构师审查意见,补充 OSS 签名 URL、导出审计、接口限流、依赖安全) > **强制等级:** P0 必须遵守 > **适用范围:** 前端(React/TypeScript)+ 后端(Node.js/TypeScript)+ 部署(Docker/SAE) > **背景:** 本规范基于 2026 年 3 月安全评估中发现的实际漏洞总结而成,所有条目均对应真实代码问题 --- ## 目录 1. [API 访问控制规范(防 IDOR)](#1-api-访问控制规范防-idor) 2. [数据库查询安全规范(防 SQL 注入)](#2-数据库查询安全规范防-sql-注入) 3. [LLM 调用安全规范(PII 脱敏)](#3-llm-调用安全规范pii-脱敏) 4. [认证与授权规范](#4-认证与授权规范) 5. [敏感信息管理规范(防泄露)](#5-敏感信息管理规范防泄露) 6. [前端安全规范](#6-前端安全规范) 7. [日志与错误处理安全规范](#7-日志与错误处理安全规范) 8. [部署与网络安全规范](#8-部署与网络安全规范) 9. [Code Review 安全检查清单](#9-code-review-安全检查清单) --- ## 1. API 访问控制规范(防 IDOR) > IDOR(Insecure Direct Object Reference,不安全的直接对象引用)是本系统曾出现的最大面积漏洞,影响 ~29 个接口。以下规范**强制执行**。 ### 1.1 核心原则 **任何通过 ID 访问资源的 API,必须在查询条件中包含归属校验。** - 个人资源(会话、文档、任务):必须加 `userId` - 租户资源(项目、团队数据):必须加 `tenantId` - 禁止仅凭资源 ID 查询/修改/删除 ### 1.2 正确写法 ```typescript // ✅ 正确:查询条件包含 userId const session = await prisma.ssaSession.findFirst({ where: { id: sessionId, userId }, }); if (!session) { return reply.status(404).send({ error: '资源不存在' }); } ``` ```typescript // ✅ 正确:通过关联关系校验归属 const result = await prisma.aslExtractionResult.findFirst({ where: { id: resultId, task: { userId }, }, }); ``` ```typescript // ✅ 正确:租户资源按 tenantId 过滤 const project = await prisma.iitProject.findFirst({ where: { id: projectId, tenantId, deletedAt: null }, }); ``` ### 1.3 错误写法(禁止) ```typescript // ❌ 禁止:仅凭 id 查询,任何登录用户都可访问 const session = await prisma.ssaSession.findUnique({ where: { id }, }); // ❌ 禁止:update 时不校验归属 await prisma.ssaSession.update({ where: { id }, data: { ... }, }); // ❌ 禁止:通过子资源 ID 查询不校验父资源归属 const messages = await prisma.ssaMessage.findMany({ where: { sessionId: id }, }); ``` ### 1.4 Controller → Service 传参规范 Controller 层**必须**从 request 获取 userId/tenantId 并传入 Service: ```typescript // Controller 层 const userId = getUserId(request); const result = await myService.getResource(resourceId, userId); // Service 层 async getResource(resourceId: string, userId: string) { const resource = await prisma.myTable.findFirst({ where: { id: resourceId, userId }, }); if (!resource) throw new NotFoundError('资源不存在'); return resource; } ``` ### 1.5 特殊场景处理 | 场景 | 处理方式 | |------|---------| | SUPER_ADMIN 查看任意资源 | Service 方法判断角色,SUPER_ADMIN 可跳过 userId 校验 | | Worker / 后台任务 | 保留无 userId 参数的内部方法,方法名加 `Internal` 后缀(如 `getSessionInternal`) | | 多人协作资源(IIT 项目) | 使用 `tenantId` 而非 `userId`,同租户成员共享访问 | | 资源不存在 vs 无权限 | 统一返回 **404**(不返回 403,避免暴露资源存在性) | ### 1.6 已有的正确实现(参考模板) 新模块开发时,参考以下已正确实现归属校验的模块: - **PKB**:`documentService.getDocumentById(userId, id)` — `where: { id, userId }` - **RVW**:`reviewService.getTaskDetail(userId, taskId)` — `where: { id: taskId, userId }` - **AIA**:`conversationService.getConversationById(userId, id)` — `where: { id, userId }` ### 1.7 OSS 签名 URL 安全要求 文件下载是 IDOR 的重灾区。所有通过 OSS 签名 URL 提供文件下载的接口,必须遵守: **归属校验(与 1.1 一致)**:生成签名 URL 前,必须先校验文件/会话/任务归属当前用户。 ```typescript // ✅ 正确:先校验归属,再生成签名 URL const document = await prisma.document.findFirst({ where: { id: documentId, userId }, }); if (!document) return reply.status(404).send({ error: '文件不存在' }); const signedUrl = await storage.getSignedUrl(document.ossKey, 300); return reply.send({ url: signedUrl }); ``` ```typescript // ❌ 禁止:不校验归属直接生成签名 URL const document = await prisma.document.findUnique({ where: { id: documentId } }); const signedUrl = await storage.getSignedUrl(document.ossKey); ``` **签名 URL 有效期**: | 场景 | 最大有效期 | 说明 | |------|-----------|------| | 文档预览/下载 | 300 秒(5 分钟) | 用户点击后立即下载,无需长时效 | | 大文件下载(>100MB) | 900 秒(15 分钟) | 为慢速网络留余量 | | 内部服务间传递 | 3600 秒(1 小时) | Worker 处理数据时使用,非用户直接访问 | ```typescript // ✅ 正确:显式设置短有效期 const signedUrl = await storage.getSignedUrl(ossKey, 300); // ❌ 不推荐:使用默认有效期(当前默认 3600 秒,对用户下载偏长) const signedUrl = await storage.getSignedUrl(ossKey); ``` **Bucket 权限**:代码中严禁调用 `putBucketAcl` 或将 Bucket 设置为 `public-read`(静态资源 Bucket 除外)。 --- ## 2. 数据库查询安全规范(防 SQL 注入) ### 2.1 核心原则 **禁止将用户输入直接拼接到 SQL 字符串中。必须使用参数化查询。** ### 2.2 Prisma ORM 查询(推荐) ```typescript // ✅ 正确:使用 Prisma 标准 API(自动参数化) const records = await prisma.qcRecord.findMany({ where: { projectId, status }, skip: offset, take: pageSize, }); ``` ### 2.3 原始 SQL 查询 当必须使用原始 SQL 时(如复杂聚合、向量搜索),使用 Prisma 的参数化原始查询: ```typescript // ✅ 正确:$queryRaw 使用模板字面量(自动参数化) const results = await prisma.$queryRaw` SELECT * FROM iit_qc_records WHERE project_id = ${projectId} AND status = ${status} LIMIT ${pageSize} OFFSET ${offset} `; ``` ```typescript // ✅ 正确:Prisma.sql 拼接动态条件 import { Prisma } from '@prisma/client'; const conditions: Prisma.Sql[] = [Prisma.sql`project_id = ${projectId}`]; if (status) { conditions.push(Prisma.sql`status = ${status}`); } const whereClause = Prisma.join(conditions, ' AND '); const results = await prisma.$queryRaw` SELECT * FROM iit_qc_records WHERE ${whereClause} `; ``` ### 2.4 禁止写法 ```typescript // ❌ 致命:$queryRawUnsafe + 字符串拼接 const results = await prisma.$queryRawUnsafe(` SELECT * FROM records WHERE project_id = '${projectId}' AND status = '${status}' LIMIT ${pageSize} OFFSET ${offset} `); // ❌ 危险:单引号转义不能防止所有注入 const safeKbId = kbId.replace(/'/g, "''"); const sql = `SELECT * FROM documents WHERE kb_id = '${safeKbId}'`; ``` ### 2.5 向量搜索场景 ```typescript // ✅ 正确:pgvector 查询使用参数化 const results = await prisma.$queryRaw` SELECT id, content, 1 - (embedding <=> ${vectorParam}::vector) AS similarity FROM document_chunks WHERE kb_id = ${kbId} ORDER BY embedding <=> ${vectorParam}::vector LIMIT ${topK} `; ``` --- ## 3. LLM 调用安全规范(PII 脱敏) ### 3.1 核心原则 **所有发送给外部 LLM 的用户数据必须经过 PII 脱敏处理。** ### 3.2 架构方案 系统通过 `LLMFactory.getAdapter()` 统一创建 LLM 适配器。全局脱敏通过 `MaskedLLMAdapter` 代理实现,**默认开启**,无需各模块单独处理。 ``` 业务模块 → LLMFactory.getAdapter() → MaskedLLMAdapter(自动脱敏)→ 真实 Adapter → 外部 LLM ``` ### 3.3 脱敏覆盖范围 | PII 类型 | 脱敏规则 | 示例 | |----------|---------|------| | 手机号 | 保留前 3 后 4 | `13800138000` → `138****8000` | | 身份证号 | 保留前 6 后 4 | `330102199001011234` → `330102********1234` | | 患者姓名 | 保留姓氏 | `患者张三丰` → `患者张**` | | 邮箱地址 | 保留首字符和域名 | `zhangsan@example.com` → `z***@example.com` | ### 3.4 开发时注意事项 - **不要在业务代码中重复实现脱敏**:全局脱敏由 `MaskedLLMAdapter` 自动完成 - **System Prompt 不会被脱敏**:只有 `user` 和 `assistant` 角色的消息会被脱敏 - **如需关闭脱敏**(如调试):设置环境变量 `PII_MASKING_ENABLED=false` - **如需扩展脱敏规则**:修改 `backend/src/common/services/piiMaskingService.ts` ### 3.5 使用通用脱敏服务 当业务代码需要在非 LLM 场景下脱敏(如日志、API 响应): ```typescript import { PiiMaskingService } from '@/common/services/piiMaskingService'; // 脱敏单段文本 const masked = PiiMaskingService.maskText(rawText); // 脱敏 LLM 消息数组 const maskedMessages = PiiMaskingService.maskMessages(messages); ``` --- ## 4. 认证与授权规范 ### 4.1 路由注册规范 **所有 API 路由必须挂载 `authenticate` 中间件,除非有明确的豁免理由。** ```typescript // ✅ 正确:路由级别挂载认证 app.register(async (protectedApp) => { protectedApp.addHook('preHandler', authenticate); protectedApp.register(myRoutes, { prefix: '/api/v2/my-module' }); }); ``` ```typescript // ❌ 禁止:未挂载认证的业务路由 app.register(myRoutes, { prefix: '/api/v1/my-module' }); ``` ### 4.2 角色与模块权限 ```typescript // 需要特定角色 app.get('/admin/users', { preHandler: [authenticate, requireRoles(['SUPER_ADMIN', 'ORG_ADMIN'])], }, handler); // 需要模块订阅 app.get('/ssa/sessions', { preHandler: [authenticate, requireModule('SSA')], }, handler); ``` ### 4.3 测试/调试端点 ```typescript // ✅ 正确:生产环境禁用测试端点 if (process.env.NODE_ENV !== 'production') { app.register(testRoutes, { prefix: '/test' }); } ``` ```typescript // ❌ 禁止:测试端点无条件注册且无认证 app.register(testRoutes, { prefix: '/test' }); ``` --- ## 5. 敏感信息管理规范(防泄露) ### 5.1 环境变量与密钥 | 规则 | 说明 | |------|------| | 密钥只通过环境变量注入 | 数据库密码、API Key、JWT Secret 等**严禁硬编码** | | `.env` 文件不提交 Git | 确认在 `.gitignore` 中 | | `.env.example` 使用占位符 | 示例值写 `your-api-key-here`,**不写真实密钥** | | 文档中不记录密钥 | 部署文档写"见阿里云控制台",不写实际密码 | | 脚本/测试代码不硬编码密钥 | 从 `process.env` 或配置文件读取 | ### 5.2 正确写法 ```typescript // ✅ 正确:从环境变量读取 const apiKey = process.env.DEEPSEEK_API_KEY; if (!apiKey) throw new Error('DEEPSEEK_API_KEY is required'); ``` ```typescript // ❌ 禁止:硬编码密钥 const apiKey = 'sk-7f8cc37a79fa4799860b38fc7ba2e150'; ``` ### 5.3 `.env.example` 规范 ```env # ✅ 正确 DATABASE_URL=postgresql://postgres:your-password@localhost:5432/ai_clinical_research JWT_SECRET=replace-with-a-strong-64-char-secret DEEPSEEK_API_KEY=your-api-key-here # ❌ 错误 DATABASE_URL=postgresql://postgres:Xibahe@fengzhibo117@rds-xxx.rds.aliyuncs.com:5432/ai_clinical_research DEEPSEEK_API_KEY=sk-7f8cc37a79fa4799860b38fc7ba2e150 ``` ### 5.4 密钥泄露应急流程 如果发现密钥已泄露(提交到 Git、发到聊天群等): 1. **立即轮换**:更换泄露的密钥/密码 2. **清理 Git 历史**:使用 BFG Repo-Cleaner 或 `git filter-branch` 3. **排查影响**:检查日志确认是否有异常访问 4. **通知团队**:更新相关服务的配置 --- ## 6. 前端安全规范 ### 6.1 Token 存储 当前使用 `localStorage` 存储 JWT Token。后续优化方向: - 考虑迁移到 `httpOnly` Cookie(防 XSS 读取) - Access Token 设置较短过期时间(当前 2h,合理) - Refresh Token 仅在续期时使用 ### 6.2 XSS 防护 ```typescript // ✅ 正确:React JSX 自动转义