信任边界与安全模型
Sub2API 同时面向浏览器用户、API 客户端、管理员、支付平台和多个 AI 上游。它们使用的凭证、路由和信任假设完全不同,不能用一个“鉴权成功”概括。
信任区域
不可信客户端
├─ 浏览器 ── JWT / Cookie ───────▶ /api/v1/*
├─ AI 客户端 ─ Sub2API API Key ─▶ /v1/*、/v1beta/*
└─ 支付平台 ─ Webhook 签名 ─────▶ /api/v1/payment/webhook/*
│
▼
Sub2API 进程边界
Handler / Service / Repository
│ │
PostgreSQL/Redis 出站 HTTP
│ │
可信基础设施 AI/支付/邮件上游最重要的隔离是:客户端永远不应该直接拿到上游账号凭证;上游 API Key 也不能替代 Sub2API 用户 Key 访问网关。
四种入口身份
| 入口 | 身份材料 | 主要权限 | 服务端最终校验 |
|---|---|---|---|
| 用户/管理员 API | JWT access token,refresh token 辅助续期 | 用户资料或管理控制面 | 签名、token version、用户状态、角色 |
| AI 网关 | Sub2API API Key | 指定 Group/模型和用户权益 | Key、用户、Group、ACL、配额、并发 |
| 支付 Webhook | Provider 请求签名与通知字段 | 只能推进匹配订单 | Provider 验签、订单、渠道、金额、状态 |
| 首次设置 | 本地安装状态 | 创建基础配置与管理员 | NeedsSetup、安装锁、输入校验 |
前端路由守卫只是用户体验层。任何管理员权限、支付开关、后台模式限制都必须在后端中间件或 Service 再校验。
管理 API 与网关 API 不共用凭证
/api/v1/* 的 JWT 表示一个登录用户;/v1/* 和 /v1beta/* 的 API Key 表示一次模型调用资格。
这两类 Token 即使都放在 Authorization: Bearer 中,语义仍不同:
- JWT 可以访问用户资料、Key 管理和管理员页面。
- Sub2API API Key 只能调用允许的模型网关能力。
- 上游 OpenAI/Anthropic/Gemini Key 只保存在 Account 中,用于服务端出站请求。
新增路由时必须先确定它属于控制面还是数据面,再选择 JWT、AdminAuth 或 APIKeyAuth,不能因为 Header 形式相同就复用错误的中间件。
后端中间件边界
全局请求先经过 Recovery、访问日志、CORS 和安全响应头。具体路由再叠加:
JWTAuth:验证登录身份和用户状态。AdminAuth:验证管理员角色、token version 和相关后台策略。APIKeyAuth:解析 Key、用户、Group、ACL 和网关身份快照。BackendMode*Guard:限制后台模式下的用户能力。AdminComplianceGuard:要求管理员完成合规确认。- Request body limit、错误采样和网关 preflight。
权限检查尽量靠近路由注册,领域不变量仍应在 Service 中保留。这样即使 Service 以后被后台任务或另一个 Handler 调用,也不会绕过关键约束。
上游凭证隔离
Account 可能保存:
- API Key。
- OAuth access/refresh token。
- Setup Token。
- AWS/Google 云凭证。
- 自定义 Base URL、Header 和 Proxy 关系。
设计上的要求是:
- API 响应与后台列表只返回掩码或必要状态。
- 日志不得输出完整 Authorization、Cookie、refresh token 或支付 Secret。
- 导出、备份和数据库快照按 Secret 级别加密管理。
- OAuth 刷新使用跨实例锁,避免多个副本同时 rotation。
- 修改凭证后失效 token、调度和连接相关缓存。
“数据库是事实来源”不代表数据库内容天然安全。能够读取生产 PostgreSQL 备份的人,原则上也能接触大量上游权限。
自定义 Base URL 与 SSRF
第三方兼容上游允许管理员填写 Base URL,这是必要扩展点,也是明显的服务端请求伪造入口。
防护由 security.url_allowlist 控制:
- 限制上游、定价和 CRS 的 hostname。
- 生产关闭私网地址访问,除非明确需要内部上游。
- 生产关闭不安全 HTTP。
- URL 必须是绝对 HTTP(S) 地址。
- Proxy 初始化失败时,关键 AI 网关不能静默回退直连。
需要添加新上游时,应只放行准确 hostname。不要为了临时兼容关闭整个 allowlist,也不要允许用户级输入直接覆盖 Account Base URL。
还要防范 DNS rebinding、重定向到私网、IPv6/十进制 IP 表达和端口绕过。新增 URL 获取功能时应复用现有 URL 校验和安全 transport,而不是只用 url.Parse。
反向代理与真实 IP
客户端 IP 参与 API Key ACL、限流、审计和风控。Gin 只有在 server.trusted_proxies 明确配置后才信任代理转发链;为空时会禁用代理信任并在 release 模式告警。
安全配置原则:
- 只填实际 Nginx、Ingress 或负载均衡器 CIDR。
- 不使用
0.0.0.0/0。 - 公网不能直接绕过反向代理访问应用端口。
- API Key ACL 是否使用 forwarded IP 还有独立动态设置。
- 部署后用外部请求验证记录的 IP,而不是只看配置文件。
错误的 trusted proxy 比“不识别真实 IP”更危险,因为攻击者可能伪造 X-Forwarded-For 绕过 ACL。
浏览器边界
嵌入式前端与 API 默认同源,降低 CORS 复杂度。服务端还设置 CSP、frame ancestors、base URI 等安全头,并根据后台自定义页面设置动态刷新 frame-src。
前端侧仍要注意:
- access token 当前保存在浏览器 localStorage,XSS 会直接威胁会话。
- 自定义 Markdown/HTML 必须经 DOMPurify 等既有路径处理。
- 不把管理员 Secret 注入
window.__APP_CONFIG__。 - 公共设置注入只包含可公开字段。
- 第三方支付 iframe、脚本或自定义页面需要最小 CSP 放行。
因此 CSP 与输入净化是纵深防御,不能用“只有管理员能配置”作为跳过校验的理由。
支付 Webhook 边界
Webhook 路由不能使用用户 JWT,因为调用者是支付平台。安全性来自 Provider 实现的签名验证,以及业务层的二次校验:
- 使用订单绑定或快照对应的 Provider 验签。
- 根据
out_trade_no查订单。 - 校验 Provider、metadata 和支付金额。
- 使用条件更新把允许状态推进到
paid。 - 重复通知进入幂等处理,不重复充值或开通订阅。
- 全过程写支付审计记录。
反向代理不得缓存 Webhook 响应,也不能改变签名依赖的原始 body。日志可记录订单号和 Provider request ID,但不能记录完整签名 Secret 或支付账户配置。
失败时应该开放还是关闭
| 依赖/检查 | 推荐语义 | 原因 |
|---|---|---|
| JWT/API Key 无法验证 | fail closed | 不能绕过身份 |
| 用户权益/余额无法确认 | fail closed | 避免未授权消费 |
| 并发锁无法可靠获取 | 通常 fail closed | 避免失去容量约束 |
| 只读展示缓存失败 | DB 回源或降级 | 不直接扩大权限 |
| Proxy 初始化失败 | 关键网关 fail closed | 防止真实出口 IP 泄露 |
| 取消订单限流统计失败 | 当前实现 fail open | 不应因审计计数故障阻塞取消,但要告警 |
| Webhook 找不到本环境订单 | 业务终止并避免无限重试 | 可能是跨环境配置错误,需要审计 |
新增依赖时必须明确这张表中的位置。未经设计的 if err != nil { return nil } 可能变成权限绕过,未经设计的全部 fail closed 也可能放大基础设施故障。
日志与错误响应
安全日志要同时满足可定位和不泄密:
- 用 Request ID、user ID、account ID、order ID 关联,不依赖完整请求体。
- 上游错误 body 只在显式开关下截断记录。
- Header allowlist 优于 denylist;强制移除敏感响应头。
- 客户端错误不回显内部 DSN、SQL、Proxy 密码和上游凭证。
- Ops 详情页也应应用脱敏,而不是因为只有管理员可见就输出 Secret。
新功能安全检查
- 入口调用者是谁,使用哪类凭证?
- 前端守卫之外,后端哪一层强制权限?
- 请求能否让服务端访问任意 URL、文件或 Proxy?
- 输入是否会进入日志、模板、Markdown、SQL 或 Header?
- Secret 保存、展示、备份和轮换方式是什么?
- 多实例锁和幂等 key 的 owner、TTL、释放方式是否安全?
- 依赖失败时是 fail-open、fail-closed 还是只读降级?
- 是否有审计事件能说明谁在何时改变了高风险状态?