阶段 0.3:构建 AI Agent 的输入、工具、限流及前端渲染安全边界

作者:袖梨 2026-09-19

认证只能确认访问者身份,却无法保证其提交的数据、触发的工具以及模型返回的内容足够安全。对于 AI Agent,输入还会继续流向 Prompt、RAG 索引、Markdown 渲染器和外部工具,因此需要在每次数据跨越边界时设置明确约束。下面将围绕校验、执行、限流、渲染与流式错误处理展开具体实现。

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

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

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

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

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

一、统一输入边界

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

当前主要限制为:

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

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

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

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

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

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

二、RAG 文件和元数据边界

上传文档需要依次经过:

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

允许的 MIME 包括 text/markdowntext/x-markdowntext/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

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

eval(expression)

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

新实现先解析表达式:

tree = ast.parse(expression, mode="eval")

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

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

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

同时增加资源边界:

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

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

四、Markdown 与 XSS 双层防线

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

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

允许的链接协议只有:

http
https
mailto

明确拒绝:

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

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

新窗口链接统一增加:

target="_blank" rel="noopener noreferrer"

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

五、Redis 差异化限流

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

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

登录使用两层限制:

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

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

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

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

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

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

例如:

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 次20011
第 2 次20020
第 3 次42930

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

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

七、稳定 SSE 错误事件

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

新 SSE 流程是:

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

浏览器只收到:

{
  "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,而是让不可信数据在每一次跨越边界时都受到约束:

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

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

相关文章

精彩推荐