- 新增安全评估报告与P0修复方案(IDOR水平越权~29接口 + 全局LLM脱敏) - 新增安全开发规范v1.1(9大安全规范 + Code Review检查清单) - 采纳架构师审查意见补充OSS签名URL、导出审计、接口限流、依赖安全 - 更新开发规范README索引 Made-with: Cursor
724 lines
21 KiB
Markdown
724 lines
21 KiB
Markdown
# 安全开发规范
|
||
|
||
> **版本:** 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 自动转义
|
||
<div>{userInput}</div>
|
||
|
||
// ❌ 禁止:dangerouslySetInnerHTML 使用未经净化的内容
|
||
<div dangerouslySetInnerHTML={{ __html: userInput }} />
|
||
|
||
// ✅ 如必须渲染 HTML,先使用 DOMPurify 净化
|
||
import DOMPurify from 'dompurify';
|
||
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(htmlContent) }} />
|
||
```
|
||
|
||
### 6.3 API 请求
|
||
|
||
```typescript
|
||
// ✅ 正确:使用统一的 apiClient(自动带 Token、处理 401)
|
||
import apiClient from '@/common/api/axios';
|
||
const { data } = await apiClient.get('/api/v2/xxx');
|
||
|
||
// ❌ 禁止:直接用 fetch/axios 不带认证
|
||
const res = await fetch('/api/v2/xxx');
|
||
```
|
||
|
||
---
|
||
|
||
## 7. 日志与错误处理安全规范
|
||
|
||
### 7.1 日志脱敏
|
||
|
||
```typescript
|
||
// ✅ 正确:日志中脱敏敏感信息
|
||
logger.info('验证码已发送', { phone: phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') });
|
||
|
||
// ❌ 禁止:明文记录验证码、密码、Token
|
||
logger.info('验证码已生成', { phone, code });
|
||
console.log(`验证码: ${code}`);
|
||
```
|
||
|
||
### 7.2 错误响应脱敏
|
||
|
||
```typescript
|
||
// ✅ 正确:生产环境返回通用错误信息
|
||
return reply.status(500).send({
|
||
error: '服务内部错误',
|
||
...(process.env.NODE_ENV !== 'production' && { stack: error.stack }),
|
||
});
|
||
|
||
// ❌ 禁止:生产环境返回完整错误栈
|
||
return reply.status(500).send({
|
||
error: error.message,
|
||
stack: error.stack,
|
||
});
|
||
```
|
||
|
||
### 7.3 管理与高危操作审计日志
|
||
|
||
以下两类操作**必须**记录审计日志:
|
||
|
||
**管理员增删改操作**:
|
||
|
||
```typescript
|
||
await prisma.adminOperationLog.create({
|
||
data: {
|
||
userId: currentUser.id,
|
||
action: 'DELETE_USER',
|
||
targetId: targetUserId,
|
||
details: JSON.stringify({ reason }),
|
||
ip: request.ip,
|
||
userAgent: request.headers['user-agent'],
|
||
},
|
||
});
|
||
```
|
||
|
||
**数据导出/批量下载操作**:
|
||
|
||
凡涉及"报表导出"、"Excel 数据下载"、"批量文献导出"、"文档下载"的接口,必须调用 `activityService.log()` 记录导出日志。这是发生数据泄露时的唯一溯源手段。
|
||
|
||
```typescript
|
||
// ✅ 正确:导出接口记录审计日志
|
||
app.get('/sessions/:id/export', async (request, reply) => {
|
||
const userId = getUserId(request);
|
||
const session = await sessionService.getSession(id, userId);
|
||
|
||
// 执行导出...
|
||
const exportData = await sessionService.exportData(session);
|
||
|
||
// 记录导出日志
|
||
await activityService.log({
|
||
userId,
|
||
action: 'EXPORT',
|
||
module: 'DC_TOOL_C',
|
||
targetId: session.id,
|
||
details: JSON.stringify({
|
||
exportType: 'excel',
|
||
rowCount: exportData.rowCount,
|
||
}),
|
||
ip: request.ip,
|
||
});
|
||
|
||
return reply.send(exportData.buffer);
|
||
});
|
||
```
|
||
|
||
**必须记录导出日志的接口清单**(新增接口时参照补充):
|
||
|
||
| 接口 | 模块 |
|
||
|------|------|
|
||
| `/dc/tool-b/tasks/:taskId/export` | DC 数据提取 |
|
||
| `/dc/tool-c/sessions/:id/export` | DC 数据清洗 |
|
||
| `/asl/extraction/tasks/:taskId/export` | ASL 提取结果导出 |
|
||
| `/asl/fulltext-screening/tasks/:taskId/export` | ASL 全文筛选导出 |
|
||
| `/asl/research/tasks/:taskId/export-word` | ASL 深度研究导出 |
|
||
| `/aia/protocol-agent/export/docx` | 研究方案导出 |
|
||
| `/ssa/sessions/:id/download-code` | SSA 代码下载 |
|
||
| `/ssa/sessions/:id/download-sap` | SSA SAP 下载 |
|
||
| `/admin/system-kb/:id/documents/:docId/download` | 系统知识库下载 |
|
||
|
||
---
|
||
|
||
## 8. 部署与网络安全规范
|
||
|
||
### 8.1 Docker 安全
|
||
|
||
```dockerfile
|
||
# ✅ 正确:使用非 root 用户运行
|
||
RUN addgroup --system appgroup && adduser --system appuser --ingroup appgroup
|
||
USER appuser
|
||
|
||
# ✅ 正确:多阶段构建,减少攻击面
|
||
FROM node:20-slim AS builder
|
||
# ... 构建 ...
|
||
FROM node:20-slim AS runner
|
||
COPY --from=builder /app/dist ./dist
|
||
```
|
||
|
||
### 8.2 Nginx 安全头
|
||
|
||
```nginx
|
||
# 必须配置的安全响应头
|
||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||
add_header X-Content-Type-Options "nosniff" always;
|
||
add_header X-XSS-Protection "1; mode=block" always;
|
||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||
|
||
# HTTPS 部署后启用
|
||
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||
# add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'" always;
|
||
```
|
||
|
||
### 8.3 CORS 配置
|
||
|
||
```typescript
|
||
// ✅ 正确:生产环境使用白名单
|
||
app.register(cors, {
|
||
origin: process.env.NODE_ENV === 'production'
|
||
? ['https://iit.xunzhengyixue.com']
|
||
: true,
|
||
credentials: true,
|
||
});
|
||
|
||
// ❌ 禁止:生产环境允许所有来源
|
||
app.register(cors, { origin: true });
|
||
```
|
||
|
||
### 8.4 数据库连接
|
||
|
||
```env
|
||
# ✅ 正确:启用 SSL
|
||
DATABASE_URL=postgresql://user:pass@host:5432/db?sslmode=require
|
||
|
||
# ❌ 不推荐:无 SSL
|
||
DATABASE_URL=postgresql://user:pass@host:5432/db
|
||
```
|
||
|
||
### 8.5 接口限流与防刷规范
|
||
|
||
所有暴露到公网的接口必须配置速率限制,防止暴力破解和数据爬取。使用 `@fastify/rate-limit` 中间件。
|
||
|
||
**全局默认限流**(在 `backend/src/index.ts` 中注册):
|
||
|
||
```typescript
|
||
import rateLimit from '@fastify/rate-limit';
|
||
|
||
await app.register(rateLimit, {
|
||
max: 100,
|
||
timeWindow: '1 minute',
|
||
});
|
||
```
|
||
|
||
**敏感接口独立限流**(更严格):
|
||
|
||
```typescript
|
||
// 登录接口:单 IP 每分钟最多 5 次
|
||
app.post('/api/v1/auth/login', {
|
||
config: {
|
||
rateLimit: { max: 5, timeWindow: '1 minute' },
|
||
},
|
||
}, loginHandler);
|
||
|
||
// 验证码接口:单手机号每分钟 1 次(业务层已有),单 IP 每分钟 5 次
|
||
app.post('/api/v1/auth/send-code', {
|
||
config: {
|
||
rateLimit: { max: 5, timeWindow: '1 minute' },
|
||
},
|
||
}, sendCodeHandler);
|
||
```
|
||
|
||
**登录失败锁定**(在 `auth.service.ts` 中实现):
|
||
|
||
- 同一账号连续登录失败 5 次 → 锁定 15 分钟
|
||
- 锁定期间返回 `429 Too Many Requests`
|
||
- 锁定计数存入 cache(如 `PostgresCacheAdapter`),成功登录后重置
|
||
|
||
### 8.6 依赖安全审查
|
||
|
||
每次发版前必须检查 npm 依赖的已知漏洞。
|
||
|
||
**本地检查**:
|
||
|
||
```bash
|
||
# 检查后端依赖漏洞
|
||
cd backend && npm audit
|
||
|
||
# 检查前端依赖漏洞
|
||
cd frontend-v2 && npm audit
|
||
```
|
||
|
||
**CI/CD 集成**(搭建流水线时必须配置):
|
||
|
||
```yaml
|
||
# GitHub Actions 示例
|
||
- name: Security audit
|
||
run: npm audit --audit-level=high
|
||
# high/critical 级别漏洞必须修复后才能上线
|
||
```
|
||
|
||
**package.json 中添加审查脚本**:
|
||
|
||
```json
|
||
{
|
||
"scripts": {
|
||
"audit": "npm audit --audit-level=high",
|
||
"audit:fix": "npm audit fix"
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 9. Code Review 安全检查清单
|
||
|
||
每次 Code Review 时,以下安全项**必须检查**:
|
||
|
||
### 9.1 访问控制
|
||
|
||
- [ ] 新增/修改的 API 是否挂载了 `authenticate` 中间件?
|
||
- [ ] 通过 ID 查询的接口是否在 `where` 条件中包含 `userId` 或 `tenantId`?
|
||
- [ ] `findUnique`/`findFirst` 查询是否只按 `id`?(如果是,很可能有 IDOR 风险)
|
||
- [ ] `update`/`delete` 操作是否校验了资源归属?
|
||
|
||
### 9.2 输入安全
|
||
|
||
- [ ] 是否使用了 `$queryRawUnsafe`?(需特别审查)
|
||
- [ ] 原始 SQL 中是否有字符串拼接用户输入?
|
||
- [ ] 用户上传文件是否做了类型和大小校验?
|
||
|
||
### 9.3 敏感信息
|
||
|
||
- [ ] 代码中是否有硬编码的密钥、密码、API Key?
|
||
- [ ] 日志中是否打印了敏感信息(验证码、Token、密码)?
|
||
- [ ] 错误响应是否泄露了内部实现(stack trace、SQL 语句)?
|
||
|
||
### 9.4 LLM 安全
|
||
|
||
- [ ] 新增的 LLM 调用是否通过 `LLMFactory.getAdapter()` 统一入口?
|
||
- [ ] 是否有绕过全局脱敏直接调用外部 API 的代码?
|
||
|
||
### 9.5 前端安全
|
||
|
||
- [ ] 是否使用了 `dangerouslySetInnerHTML`?如是,内容是否经过 DOMPurify 净化?
|
||
- [ ] API 调用是否使用统一的 `apiClient`?
|
||
- [ ] 是否有敏感信息写入 `localStorage`/`console.log`?
|
||
|
||
### 9.6 文件下载与数据导出
|
||
|
||
- [ ] 生成的文件下载链接是否为短时效(<= 300 秒)的预签名 URL?
|
||
- [ ] 导出/下载接口是否记录了审计日志(`activityService.log` 或 `adminOperationLog`)?
|
||
|
||
### 9.7 限流与防刷
|
||
|
||
- [ ] 暴露到公网的查询、登录接口是否配置了 Rate Limit?
|
||
|
||
---
|
||
|
||
## 常见问题 FAQ
|
||
|
||
### Q: 为什么查不到资源要返回 404 而不是 403?
|
||
|
||
返回 403 会暴露"资源存在但你没权限"这一信息,攻击者可以通过此响应确认某个 ID 对应的资源确实存在。统一返回 404,攻击者无法区分"资源不存在"和"无权访问"。
|
||
|
||
### Q: SUPER_ADMIN 需要跨用户查看数据怎么办?
|
||
|
||
在 Service 方法中通过角色判断跳过归属校验:
|
||
|
||
```typescript
|
||
async getSession(sessionId: string, userId: string, role: string) {
|
||
const where: any = { id: sessionId };
|
||
if (role !== 'SUPER_ADMIN') {
|
||
where.userId = userId;
|
||
}
|
||
return prisma.ssaSession.findFirst({ where });
|
||
}
|
||
```
|
||
|
||
### Q: 后台 Worker 没有用户上下文怎么校验归属?
|
||
|
||
Worker 内部调用使用 `xxxInternal` 方法,该方法不校验 userId。但必须确保 Worker 处理的数据 ID 本身来自已校验过归属的上游流程(如任务队列中的 taskId 是由已认证的用户创建的)。
|
||
|
||
### Q: 哪些数据应该做 PII 脱敏?
|
||
|
||
所有可直接或间接识别到自然人的信息:手机号、身份证号、姓名、邮箱、住院号、病历号、地址等。当前系统自动脱敏前四类,如业务中出现其他 PII 类型,需在 `PiiMaskingService` 中扩展规则。
|
||
|
||
---
|
||
|
||
**最后更新:** 2026-03-03(v1.1)
|
||
**维护人:** 技术架构师
|