阶段 0.3:为 AI Agent 建立输入、工具、限流与前端渲染安全边界

阶段 0.3:为 AI Agent 建立输入、工具、限流与前端渲染安全边界

认证解决的是"谁可以访问",但不能回答"允许提交什么""一次能提交多少""模型输出能否直接进入 DOM""工具是否会执行任意代码"。

AI Agent 项目比普通 CRUD 系统多了几条特殊输入链路:用户输入会进入 Prompt,模型输出会进入 Markdown 渲染器,工具参数可能进入计算或外部调用,上传文档还会进入解析、切片和向量索引。如果这些边界只依赖数据库字段长度或前端表单属性,就不足以形成真正的安全控制。

本阶段建立了以下完整链路:

text 复制代码
浏览器输入或文件
      │
      ▼
请求体和频率限制
      │
      ▼
后端类型、长度、格式校验
      │
      ▼
安全工具执行或业务处理
      │
      ▼
稳定 JSON 或 SSE 输出
      │
      ▼
前端 Markdown 解析与 DOM 清洗
      │
      ▼
浏览器安全响应头约束

一、统一输入边界

项目新增统一校验模块,不再让各路由分别写零散的 if not value。

当前主要限制为:

输入 限制
用户名 3 到 50 个字符,只允许中英文、数字、点、横线和下划线
昵称 1 到 50 个字符,拒绝控制字符
注册密码 8 到 128 个字符,拒绝空字符
邀请码 最多 256 个字符,拒绝控制字符
会话标题 1 到 100 个字符,拒绝控制字符
聊天消息 1 到 4000 个字符,允许正常换行但拒绝危险控制字符
Chat Type 仅允许 chain 和 agent

用户名先进行 NFKC 归一化,减少外观相同但编码不同的字符造成账号和限流规则不一致。昵称和标题使用 NFC,保留正常显示形式。

密码校验有一个容易忽略的细节:密码不会调用 strip()。如果静默删除首尾空格,就等于服务端替用户修改了密码内容,后续登录时容易产生难以解释的不一致。

校验失败统一抛出带稳定错误码和字段信息的异常,例如:

json 复制代码
{
  "code": "CHAT_MESSAGE_LENGTH_INVALID",
  "message": "聊天内容长度必须为 1-4000 个字符",
  "request_id": "...",
  "details": {
    "field": "message"
  }
}

数据库字段长度仍然保留,但它只负责数据结构约束,不能代替 API 入口校验。

二、RAG 文件和元数据边界

上传文档需要依次经过:

text 复制代码
检查请求体总大小
      │
      ▼
检查文件扩展名和安全文件名
      │
      ▼
检查 MIME 类型
      │
      ▼
读取不超过上限的字节
      │
      ▼
检查 UTF-8 和非空正文
      │
      ▼
限制 Front Matter 规模与层级
      │
      ▼
执行 Markdown 切片和索引刷新

允许的 MIME 包括 text/markdown、text/x-markdown、text/plain,以及部分浏览器上传 .md 时使用的 application/octet-stream。允许通用二进制 MIME 不等于完全信任文件,后续仍会检查扩展名、大小、UTF-8、Front Matter 和正文。

文档元数据限制包括:

  • 最多 32 个顶层字段。
  • Key 最长 64 个字符。
  • 单个标量值最长 1000 个字符。
  • 整体最多遍历 128 个项目。
  • 最大嵌套深度为 4。
  • Front Matter 原文最多 16 KiB。

这些限制不仅防止异常文档占用内存,也避免极深 YAML、超大数组或超长元数据进入 MySQL、Chroma 和前端展示链路。

三、用 AST 白名单替代 eval

旧式计算工具常见写法是:

python 复制代码
eval(expression)

这意味着输入不仅能表示算术,还可能访问对象属性、调用函数、导入模块或执行代码。即使做字符串替换,也很难覆盖 Python 语法的全部绕过方式。

新实现先解析表达式:

python 复制代码
tree = ast.parse(expression, mode="eval")

然后只接受明确列入白名单的节点:

  • 数字常量。
  • 一元正号和负号。
  • 加、减、乘、除。
  • 整除、取模和幂运算。
  • 由这些节点组成的括号表达式。

名称、属性访问、函数调用、列表、字典、推导式和导入语句都不会进入执行分支。

同时增加资源边界:

项目 限制
表达式长度 128 字符
AST 节点数 64
整数位数 4096 bit
指数绝对值 10
浮点结果 必须有限且绝对值不超过 1e100

因此安全不仅是防止代码执行,还包括防止极端指数和复杂表达式消耗 CPU 或内存。

四、Markdown 与 XSS 双层防线

模型输出、历史消息和 RAG 文档不能直接通过 innerHTML 渲染。前端现在采用 Markdown-It 加 DOMPurify:

text 复制代码
模型或文档文本
      │
      ▼
Markdown-It 解析,禁止原始 HTML
      │
      ▼
链接协议第一层白名单
      │
      ▼
DOMPurify 清洗完整 HTML
      │
      ▼
对最终链接再次检查协议
      │
      ▼
添加 noopener 和 noreferrer
      │
      ▼
渲染到页面

允许的链接协议只有:

text 复制代码
http
https
mailto

明确拒绝:

  • javascript:。
  • HTML 实体混淆后的危险协议。
  • data:。
  • 相对地址。
  • 无法正确解析的 URL。

原始 HTML 被 Markdown 解析器转义,DOMPurify 再对最终结果执行第二次清洗。即使未来有人修改 Markdown-It 配置,完整 HTML 仍会经过清洗层。

新窗口链接统一增加:

html 复制代码
target="_blank" rel="noopener noreferrer"

避免新页面通过 window.opener 控制原页面,并减少来源信息泄露。

五、Redis 差异化限流

项目没有给所有接口套用同一个粗粒度额度,而是根据风险与成本分层:

接口类别 默认规则 限流身份
注册 5 次/小时 来源 IP
登录 20 次/分钟 来源 IP
登录 5 次/分钟 账号摘要
聊天 12 次/分钟 登录用户
管理读取 120 次/分钟 管理员用户
管理写入 6 次/分钟 登录用户

登录使用两层限制:

text 复制代码
登录请求
      │
      ▼
检查来源 IP 总量
  ├─→ 超限:返回 429
  └─→ 未超限:继续
               │
               ▼
         检查账号摘要额度
           ├─→ 超限:返回 429
           └─→ 未超限:校验密码

账号先进行 NFKC、去空格和小写归一化,再计算 SHA-256 摘要。Redis 中不会出现明文用户名。

聊天入口共享 chat-stream scope,因此在恋爱大师和超级智能体之间切换不会得到两份额度。管理员上传、删除、重建和邀请码兑换共享 admin-write scope。

六、Redis 使用 String 计数器,而不是 Hash

Redis 整体是 K-V 数据库,但当前固定窗口的 Value 类型是 String:

text 复制代码
Key:限流身份、scope、额度和窗口组成的字符串
Value:整数计数器
TTL:该 Key 独立的过期时间

例如:

text 复制代码
Key:
LIMITS:LIMITER/zzx:ratelimit/user:9/admin-write/6/1/minute

Value:
3

TTL:
42 秒

这里的 3 就是 Value。对外类型为 String,但合法整数可以直接使用 Redis 的原子递增能力。TTL 是 Key 的独立过期元数据,不包含在字符串 "3" 中。

它不是一个包含大量 Field 的 Redis Hash。使用独立 String Key,可以让每个用户、scope 和窗口拥有自己的 TTL。

一次实际的"每分钟最多 2 次"测试结果为:

请求 HTTP 状态 Redis 计数 剩余额度
第 1 次 200 1 1
第 2 次 200 2 0
第 3 次 429 3 0

第三次请求先被记录,再判断超限,因此攻击请求本身也会进入计数。

更完整的 Redis 存储、装饰器顺序和运维命令见项目总结文档《Redis 差异化限流与生产运行基线》。

七、稳定 SSE 错误事件

流式响应一旦开始发送,后端通常不能再把状态码改成普通 JSON 500。旧实现如果把原始异常文本或 Agent Thought 直接推给浏览器,可能泄露内部工具参数、数据库信息或模型执行细节。

新 SSE 流程是:

text 复制代码
发送 THINKING 心跳
      │
      ▼
逐块发送模型内容
      │
      ▼
生成过程是否异常
  ├─→ 否:发送 DONE
  └─→ 是:记录服务端堆栈
             │
             ▼
       发送稳定 error 事件

浏览器只收到:

json 复制代码
{
  "code": "CHAT_STREAM_FAILED",
  "message": "生成过程中出现异常,请稍后重试",
  "request_id": "...",
  "details": null
}

详细堆栈只写服务端日志,并通过同一个 request_id 与用户反馈关联。

八、安全响应头

Flask API 统一增加:

  • Content-Security-Policy。
  • X-Content-Type-Options: nosniff。
  • Referrer-Policy。
  • X-Frame-Options: DENY。
  • Permissions-Policy。

Nginx 静态页面使用单独的 CSP,因为只给 API 响应增加 CSP 并不能保护真正加载 Vue、CSS 和字体的 HTML 页面。

当前前端 CSP 允许同源脚本、样式、图片和 API 连接,并只为现有字体域名保留必要白名单。未来增加独立 API 域名、图片 CDN 或 WebSocket 时,需要同步调整 CSP,而不是临时改成宽泛的 *。

九、安全回归测试

阶段 0.3 相关测试覆盖:

  • 用户名和聊天内容边界。
  • Markdown MIME 与元数据规模。
  • 合法算术和恶意代码表达式。
  • 超大数、超复杂 AST 和危险指数。
  • JavaScript、Data URL、实体混淆和相对链接。
  • 原始 HTML 转义。
  • Redis 真实计数、账号归一化、按用户隔离和共享 scope。
  • RAG 危险 MIME 和超大文件。
  • SSE 稳定错误事件和 request_id。
  • 日志正文与异常堆栈脱敏。

前端使用 Vitest 与 jsdom 执行 6 项 Markdown 安全测试;后端完整回归共 36 项,全部通过。

十、阶段结论

阶段 0.3 的核心不是在几个输入框上添加 maxlength,而是让不可信数据在每一次跨越边界时都受到约束:

text 复制代码
输入有长度和结构上限
工具只执行白名单语法
高风险接口有差异化频率上限
模型输出经过解析和清洗
流式异常不泄露内部细节
浏览器再由安全响应头限制执行能力

这套边界为后续异步任务、多 Agent 调度和更多工具接入提供了统一安全基础。

相关推荐
洋就在江州1 分钟前
gitlab-cicd 离线集成——springboot-cicd (非docker形式,shell形式)
java·spring boot·后端·ci/cd·gitlab·gitlab-runner
根目录下的猫22 分钟前
虚拟机复制过来运行时:“虚拟机使用的此版本,VMware Workstation 不支持的硬件版本。”错误解决办法,亲测可用
linux·运维·服务器·后端
蜗牛互联网34 分钟前
Java Agent 工具调用的 allowlist、参数校验与调用预算
java·开发语言·人工智能·后端·oracle
用户8132679332535 分钟前
行情数据晚到几秒,会让量化策略失去优势吗?从信号时间到回测偏差
后端·github·api
九零HTTP35 分钟前
一次 TCP 连接的一生:从三次握手到四次挥手
后端
ZOnePieceC39 分钟前
消息队列之Kafka
后端
yunwei3740 分钟前
eBPF 入门实践教程十七:编写 eBPF 程序统计随机/顺序磁盘 I/O
linux·后端·性能优化
花间相见41 分钟前
【计算基础|网络07】HTTPS(下):ECDHE 握手与优化
后端
QuantiCore_IO41 分钟前
从请求风暴到可维护的数据管道:量化系统为什么需要批量接口?
后端·github·api
1360967572342 分钟前
.env 的三个必查项
后端