认证只能确认访问者身份,却无法保证其提交的数据、触发的工具以及模型返回的内容足够安全。对于 AI Agent,输入还会继续流向 Prompt、RAG 索引、Markdown 渲染器和外部工具,因此需要在每次数据跨越边界时设置明确约束。下面将围绕校验、执行、限流、渲染与流式错误处理展开具体实现。
认证解决的是“谁可以访问”,但不能回答“允许提交什么”“一次能提交多少”“模型输出能否直接进入 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 | 仅允许 chain 和 agent |
用户名先进行 NFKC 归一化,减少外观相同但编码不同的字符造成账号和限流规则不一致。昵称和标题使用 NFC,保留正常显示形式。
密码校验有一个容易忽略的细节:密码不会调用 strip()。如果静默删除首尾空格,就等于服务端替用户修改了密码内容,后续登录时容易产生难以解释的不一致。
校验失败统一抛出带稳定错误码和字段信息的异常,例如:
{
"code": "CHAT_MESSAGE_LENGTH_INVALID",
"message": "聊天内容长度必须为 1-4000 个字符",
"request_id": "...",
"details": {
"field": "message"
}
}
数据库字段长度仍然保留,但它只负责数据结构约束,不能代替 API 入口校验。
上传文档需要依次经过:
检查请求体总大小
│
▼
检查文件扩展名和安全文件名
│
▼
检查 MIME 类型
│
▼
读取不超过上限的字节
│
▼
检查 UTF-8 和非空正文
│
▼
限制 Front Matter 规模与层级
│
▼
执行 Markdown 切片和索引刷新
允许的 MIME 包括 text/markdown、text/x-markdown、text/plain,以及部分浏览器上传 .md 时使用的 application/octet-stream。允许通用二进制 MIME 不等于完全信任文件,后续仍会检查扩展名、大小、UTF-8、Front Matter 和正文。
文档元数据限制包括:
这些限制不仅防止异常文档占用内存,也避免极深 YAML、超大数组或超长元数据进入 MySQL、Chroma 和前端展示链路。
旧式计算工具常见写法是:
eval(expression)
这意味着输入不仅能表示算术,还可能访问对象属性、调用函数、导入模块或执行代码。即使做字符串替换,也很难覆盖 Python 语法的全部绕过方式。
新实现先解析表达式:
tree = ast.parse(expression, mode="eval")
然后只接受明确列入白名单的节点:
名称、属性访问、函数调用、列表、字典、推导式和导入语句都不会进入执行分支。
同时增加资源边界:
| 项目 | 限制 |
|---|---|
| 表达式长度 | 128 字符 |
| AST 节点数 | 64 |
| 整数位数 | 4096 bit |
| 指数绝对值 | 10 |
| 浮点结果 | 必须有限且绝对值不超过 1e100 |
因此安全不仅是防止代码执行,还包括防止极端指数和复杂表达式消耗 CPU 或内存。
模型输出、历史消息和 RAG 文档不能直接通过 innerHTML 渲染。前端现在采用 Markdown-It 加 DOMPurify:
模型或文档文本
│
▼
Markdown-It 解析,禁止原始 HTML
│
▼
链接协议第一层白名单
│
▼
DOMPurify 清洗完整 HTML
│
▼
对最终链接再次检查协议
│
▼
添加 noopener 和 noreferrer
│
▼
渲染到页面
允许的链接协议只有:
http
https
mailto
明确拒绝:
javascript:。data:。原始 HTML 被 Markdown 解析器转义,DOMPurify 再对最终结果执行第二次清洗。即使未来有人修改 Markdown-It 配置,完整 HTML 仍会经过清洗层。
新窗口链接统一增加:
target="_blank" rel="noopener noreferrer"
避免新页面通过 window.opener 控制原页面,并减少来源信息泄露。
项目没有给所有接口套用同一个粗粒度额度,而是根据风险与成本分层:
| 接口类别 | 默认规则 | 限流身份 |
|---|---|---|
| 注册 | 5 次/小时 | 来源 IP |
| 登录 | 20 次/分钟 | 来源 IP |
| 登录 | 5 次/分钟 | 账号摘要 |
| 聊天 | 12 次/分钟 | 登录用户 |
| 管理读取 | 120 次/分钟 | 管理员用户 |
| 管理写入 | 6 次/分钟 | 登录用户 |
登录使用两层限制:
登录请求
│
▼
检查来源 IP 总量
├─→ 超限:返回 429
└─→ 未超限:继续
│
▼
检查账号摘要额度
├─→ 超限:返回 429
└─→ 未超限:校验密码
账号先进行 NFKC、去空格和小写归一化,再计算 SHA-256 摘要。Redis 中不会出现明文用户名。
聊天入口共享 chat-stream scope,因此在恋爱大师和超级智能体之间切换不会得到两份额度。管理员上传、删除、重建和邀请码兑换共享 admin-write scope。
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 次 | 200 | 1 | 1 |
| 第 2 次 | 200 | 2 | 0 |
| 第 3 次 | 429 | 3 | 0 |
第三次请求先被记录,再判断超限,因此攻击请求本身也会进入计数。
更完整的 Redis 存储、装饰器顺序和运维命令见项目总结文档《Redis 差异化限流与生产运行基线》。
流式响应一旦开始发送,后端通常不能再把状态码改成普通 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 相关测试覆盖:
前端使用 Vitest 与 jsdom 执行 6 项 Markdown 安全测试;后端完整回归共 36 项,全部通过。
阶段 0.3 的核心不是在几个输入框上添加 maxlength,而是让不可信数据在每一次跨越边界时都受到约束:
输入有长度和结构上限
工具只执行白名单语法
高风险接口有差异化频率上限
模型输出经过解析和清洗
流式异常不泄露内部细节
浏览器再由安全响应头限制执行能力
这套边界为后续异步任务、多 Agent 调度和更多工具接入提供了统一安全基础。