Files
AIclinicalresearch/docs/04-开发规范/12-安全开发规范.md
HaHafeng 0677d42345 docs(security): 安全评估报告 + IDOR修复方案 + 安全开发规范v1.1
- 新增安全评估报告与P0修复方案(IDOR水平越权~29接口 + 全局LLM脱敏)
- 新增安全开发规范v1.1(9大安全规范 + Code Review检查清单)
- 采纳架构师审查意见补充OSS签名URL、导出审计、接口限流、依赖安全
- 更新开发规范README索引

Made-with: Cursor
2026-03-03 22:20:03 +08:00

21 KiB
Raw Permalink Blame History

安全开发规范

版本: v1.1
创建日期: 2026-03-02
最后更新: 2026-03-03v1.1:采纳架构师审查意见,补充 OSS 签名 URL、导出审计、接口限流、依赖安全
强制等级: P0 必须遵守
适用范围: 前端React/TypeScript+ 后端Node.js/TypeScript+ 部署Docker/SAE
背景: 本规范基于 2026 年 3 月安全评估中发现的实际漏洞总结而成,所有条目均对应真实代码问题


目录

  1. API 访问控制规范(防 IDOR
  2. 数据库查询安全规范(防 SQL 注入)
  3. LLM 调用安全规范PII 脱敏)
  4. 认证与授权规范
  5. 敏感信息管理规范(防泄露)
  6. 前端安全规范
  7. 日志与错误处理安全规范
  8. 部署与网络安全规范
  9. Code Review 安全检查清单

1. API 访问控制规范(防 IDOR

IDORInsecure Direct Object Reference不安全的直接对象引用是本系统曾出现的最大面积漏洞影响 ~29 个接口。以下规范强制执行

1.1 核心原则

任何通过 ID 访问资源的 API必须在查询条件中包含归属校验。

  • 个人资源(会话、文档、任务):必须加 userId
  • 租户资源(项目、团队数据):必须加 tenantId
  • 禁止仅凭资源 ID 查询/修改/删除

1.2 正确写法

// ✅ 正确:查询条件包含 userId
const session = await prisma.ssaSession.findFirst({
  where: { id: sessionId, userId },
});
if (!session) {
  return reply.status(404).send({ error: '资源不存在' });
}
// ✅ 正确:通过关联关系校验归属
const result = await prisma.aslExtractionResult.findFirst({
  where: {
    id: resultId,
    task: { userId },
  },
});
// ✅ 正确:租户资源按 tenantId 过滤
const project = await prisma.iitProject.findFirst({
  where: { id: projectId, tenantId, deletedAt: null },
});

1.3 错误写法(禁止)

// ❌ 禁止:仅凭 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

// 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 已有的正确实现(参考模板)

新模块开发时,参考以下已正确实现归属校验的模块:

  • PKBdocumentService.getDocumentById(userId, id)where: { id, userId }
  • RVWreviewService.getTaskDetail(userId, taskId)where: { id: taskId, userId }
  • AIAconversationService.getConversationById(userId, id)where: { id, userId }

1.7 OSS 签名 URL 安全要求

文件下载是 IDOR 的重灾区。所有通过 OSS 签名 URL 提供文件下载的接口,必须遵守:

归属校验(与 1.1 一致):生成签名 URL 前,必须先校验文件/会话/任务归属当前用户。

// ✅ 正确:先校验归属,再生成签名 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 });
// ❌ 禁止:不校验归属直接生成签名 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 处理数据时使用,非用户直接访问
// ✅ 正确:显式设置短有效期
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 查询(推荐)

// ✅ 正确:使用 Prisma 标准 API自动参数化
const records = await prisma.qcRecord.findMany({
  where: { projectId, status },
  skip: offset,
  take: pageSize,
});

2.3 原始 SQL 查询

当必须使用原始 SQL 时(如复杂聚合、向量搜索),使用 Prisma 的参数化原始查询:

// ✅ 正确:$queryRaw 使用模板字面量(自动参数化)
const results = await prisma.$queryRaw`
  SELECT * FROM iit_qc_records
  WHERE project_id = ${projectId}
    AND status = ${status}
  LIMIT ${pageSize} OFFSET ${offset}
`;
// ✅ 正确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 禁止写法

// ❌ 致命:$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 向量搜索场景

// ✅ 正确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 13800138000138****8000
身份证号 保留前 6 后 4 330102199001011234330102********1234
患者姓名 保留姓氏 患者张三丰患者张**
邮箱地址 保留首字符和域名 zhangsan@example.comz***@example.com

3.4 开发时注意事项

  • 不要在业务代码中重复实现脱敏:全局脱敏由 MaskedLLMAdapter 自动完成
  • System Prompt 不会被脱敏:只有 userassistant 角色的消息会被脱敏
  • 如需关闭脱敏(如调试):设置环境变量 PII_MASKING_ENABLED=false
  • 如需扩展脱敏规则:修改 backend/src/common/services/piiMaskingService.ts

3.5 使用通用脱敏服务

当业务代码需要在非 LLM 场景下脱敏如日志、API 响应):

import { PiiMaskingService } from '@/common/services/piiMaskingService';

// 脱敏单段文本
const masked = PiiMaskingService.maskText(rawText);

// 脱敏 LLM 消息数组
const maskedMessages = PiiMaskingService.maskMessages(messages);

4. 认证与授权规范

4.1 路由注册规范

所有 API 路由必须挂载 authenticate 中间件,除非有明确的豁免理由。

// ✅ 正确:路由级别挂载认证
app.register(async (protectedApp) => {
  protectedApp.addHook('preHandler', authenticate);
  protectedApp.register(myRoutes, { prefix: '/api/v2/my-module' });
});
// ❌ 禁止:未挂载认证的业务路由
app.register(myRoutes, { prefix: '/api/v1/my-module' });

4.2 角色与模块权限

// 需要特定角色
app.get('/admin/users', {
  preHandler: [authenticate, requireRoles(['SUPER_ADMIN', 'ORG_ADMIN'])],
}, handler);

// 需要模块订阅
app.get('/ssa/sessions', {
  preHandler: [authenticate, requireModule('SSA')],
}, handler);

4.3 测试/调试端点

// ✅ 正确:生产环境禁用测试端点
if (process.env.NODE_ENV !== 'production') {
  app.register(testRoutes, { prefix: '/test' });
}
// ❌ 禁止:测试端点无条件注册且无认证
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 正确写法

// ✅ 正确:从环境变量读取
const apiKey = process.env.DEEPSEEK_API_KEY;
if (!apiKey) throw new Error('DEEPSEEK_API_KEY is required');
// ❌ 禁止:硬编码密钥
const apiKey = 'sk-7f8cc37a79fa4799860b38fc7ba2e150';

5.3 .env.example 规范

# ✅ 正确
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 防护

// ✅ 正确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 请求

// ✅ 正确:使用统一的 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 日志脱敏

// ✅ 正确:日志中脱敏敏感信息
logger.info('验证码已发送', { phone: phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') });

// ❌ 禁止明文记录验证码、密码、Token
logger.info('验证码已生成', { phone, code });
console.log(`验证码: ${code}`);

7.2 错误响应脱敏

// ✅ 正确:生产环境返回通用错误信息
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 管理与高危操作审计日志

以下两类操作必须记录审计日志:

管理员增删改操作

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() 记录导出日志。这是发生数据泄露时的唯一溯源手段。

// ✅ 正确:导出接口记录审计日志
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 安全

# ✅ 正确:使用非 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 安全头

# 必须配置的安全响应头
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 配置

// ✅ 正确:生产环境使用白名单
app.register(cors, {
  origin: process.env.NODE_ENV === 'production'
    ? ['https://iit.xunzhengyixue.com']
    : true,
  credentials: true,
});

// ❌ 禁止:生产环境允许所有来源
app.register(cors, { origin: true });

8.4 数据库连接

# ✅ 正确:启用 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 中注册):

import rateLimit from '@fastify/rate-limit';

await app.register(rateLimit, {
  max: 100,
  timeWindow: '1 minute',
});

敏感接口独立限流(更严格):

// 登录接口:单 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
  • 锁定计数存入 cachePostgresCacheAdapter),成功登录后重置

8.6 依赖安全审查

每次发版前必须检查 npm 依赖的已知漏洞。

本地检查

# 检查后端依赖漏洞
cd backend && npm audit

# 检查前端依赖漏洞
cd frontend-v2 && npm audit

CI/CD 集成(搭建流水线时必须配置):

# GitHub Actions 示例
- name: Security audit
  run: npm audit --audit-level=high
  # high/critical 级别漏洞必须修复后才能上线

package.json 中添加审查脚本

{
  "scripts": {
    "audit": "npm audit --audit-level=high",
    "audit:fix": "npm audit fix"
  }
}

9. Code Review 安全检查清单

每次 Code Review 时,以下安全项必须检查

9.1 访问控制

  • 新增/修改的 API 是否挂载了 authenticate 中间件?
  • 通过 ID 查询的接口是否在 where 条件中包含 userIdtenantId
  • 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.logadminOperationLog

9.7 限流与防刷

  • 暴露到公网的查询、登录接口是否配置了 Rate Limit

常见问题 FAQ

Q: 为什么查不到资源要返回 404 而不是 403

返回 403 会暴露"资源存在但你没权限"这一信息,攻击者可以通过此响应确认某个 ID 对应的资源确实存在。统一返回 404攻击者无法区分"资源不存在"和"无权访问"。

Q: SUPER_ADMIN 需要跨用户查看数据怎么办?

在 Service 方法中通过角色判断跳过归属校验:

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-03v1.1
维护人: 技术架构师