增加非白名单的日志
This commit is contained in:
@@ -2,28 +2,30 @@
|
||||
|
||||
| 项 | 内容 |
|
||||
| --- | --- |
|
||||
| 最近更新 | 2026-09-06 |
|
||||
| 最近更新 | 2026-09-07 |
|
||||
| 当前分支 | `main` |
|
||||
| 当前阶段 | 对话、SuperAgent、空间 MCP、默认关闭的测试页面、无 Trace 兼容模式与测试环境容器部署基线已完成;7 个 MCP 工具的统一分页代码和自动化验证已完成,真实 PostGIS 与公网分页验收待完成 |
|
||||
| 当前重点 | 部署并验证 MCP 分页契约,再发布“小牟”消防 Profile 提示词,核对具体方案的多工具调用、分页提示和 WGS84 坐标输出 |
|
||||
| 当前阶段 | 对话、SuperAgent、空间 MCP、默认关闭的测试页面、无 Trace 兼容模式、测试环境容器部署基线与兼容 Chat 403 Origin 诊断日志已完成代码基线;7 个 MCP 工具的统一分页代码和自动化验证已完成,真实 PostGIS、公网分页与目标机 Origin 白名单验收待完成 |
|
||||
| 当前重点 | 在目标机用受限 403 诊断日志确认发送方精确 Origin 并完成白名单联调,再部署验证 MCP 分页契约、发布“小牟”消防 Profile,核对具体方案的多工具调用、分页提示和 WGS84 坐标输出 |
|
||||
|
||||
## 1. 当前 Checkpoint
|
||||
|
||||
- 名称:`fire-safety-ymd-mcp-pagination-v1`
|
||||
- 状态:Ready for Review(7 个工具的分页代码、自动化测试和文档已完成;分页版真实 PostGIS 查询、测试服务器部署和公网 SuperAgent 验收待执行)
|
||||
- 目标:为 7 个固定只读 MCP 工具提供兼容现有调用的有界 `limit/offset` 分页,返回过滤后 `total_count`、当前页 `result_count`、`has_more` 和 `next_offset`,确保 Agent 不再把默认前 10/20 条误报为全部结果。
|
||||
- 非目标:替用户提交或推送 Git、直接修改远程服务器、创建数据库容器、修改或迁移生产数据、实现任意 SQL/游标快照分页、改写 Chat 最终回答协议、实现真实用户认证、动态授权、会话持久化或生产审计。
|
||||
- 名称:`fire-safety-ymd-chat-origin-diagnostic-logging-v1`
|
||||
- 状态:代码、针对性自动化与本地全量验证已完成,待提交和测试服务器部署验收
|
||||
- 目标:在兼容 `completion` 返回 403 时提供可复制精确 Origin 的受限日志;普通拒绝记录有界 `origin`,预检拒绝再记录有界方法、请求头名称集合和稳定原因,同时不暴露任何 Secret、请求正文、会话或 Provider 数据。
|
||||
- 非目标:改变 Origin 白名单默认拒绝、放开 `*`、删除或改写 Origin、恢复旧 DashScope 全路径代理、修改 Nginx 保存 Secret、实现真实用户认证、动态授权、持久审计或直接修改远程服务器。
|
||||
|
||||
当前进展:
|
||||
|
||||
- 兼容 Chat 的普通 Origin 拒绝记录 `dashscope_chat_request result=forbidden_origin` 和经过引用/ASCII 转义的有界 `origin`;每个请求 Header 最多保留前 256 个输入字节,超长值追加 `[truncated]` 后再转义。CORS 预检拒绝记录 `result=preflight_forbidden`、有界 `origin`、`preflight_method`、`preflight_headers` 和 `reason`(`origin_missing`、`origin_not_allowed`、`method_not_allowed` 或 `headers_not_allowed`)。成功请求不记录 Origin,日志不记录 `xtoken`、`Authorization`、`Cookie`、prompt/body、会话或 Provider 数据。
|
||||
- 受限部署排障已补充:按 `request_id` 关联 403 响应和 Go 日志;从完整 Origin 确认后,以英文逗号分隔写入 `FIRE_SAFETY_CHAT_ALLOWED_ORIGINS`,每项规范形式为 `scheme://host[:port]`,不含非根路径、查询或片段,禁止 `*`;单个末尾 `/` 会被接受并规范化移除,配置时建议省略。修改代码需 build + recreate,修改环境值也需 recreate;目标服务器仍待实测。
|
||||
- 7 个 MCP 工具均已增加 `limit/offset` schema、服务端校验和稳定 SQL 分页;响应 `data` 仍为数组,metadata 已区分当前页数量与过滤后总量。自动化、竞态、vet 和 Linux 构建通过;开发机当前无法连接 `.env` 中的 PostGIS,分页版真实 SQL 尚未实库复验。
|
||||
- 用户报告测试页面已经能够正常输出内容,并确认对外身份应为“山东省烟台市牟平区森林防火平台 AI 助手小牟”。现有 Profile 更像泛化模板生成器,没有主动形成数据支撑的演练方案,也没有稳定展示工具坐标。仓库已新增可直接粘贴的消防 Profile 提示词 v2,明确“小牟”身份、“数据库事实 + 通用专业规划”的能力定位、坐标输出硬规则、纯文本结果格式、综合方案结构和能力边界;真实 Profile 尚未由用户发布和验收。
|
||||
- `Dockerfile` 使用显式 Go/Alpine 版本的多阶段构建,在构建阶段执行全部 Go 测试,最终镜像只包含静态服务二进制、CA 和时区数据,并以非 root 用户运行。
|
||||
- Docker build 支持通过 `FIRE_SAFETY_BUILD_GOPROXY` 选择目标机可达的可信 Go module proxy;默认仍为官方代理并保留 checksum database,`git` 只存在于 builder,构建代理配置在运行容器中强制清空。
|
||||
- `compose.yaml` 只运行一个 API 实例,从未提交的 `.env` 注入配置,强制清空一次性迁移 DSN,把容器内 8080 发布到宿主机 `127.0.0.1:16587`,并设置健康检查、只读文件系统、权限收紧和日志轮转。
|
||||
- 现有 PostgreSQL/PostGIS 不进入 Compose;同宿主机数据库需要使用容器可达的宿主机地址,且仍需受 `listen_addresses`、`pg_hba.conf` 和防火墙约束。
|
||||
- Nginx 示例增加 HTTP 到 HTTPS 跳转和 HTTP 429 JSON 限流响应,精确反代 `/chat`、`/chat/`、`/chat/app.css`、`/chat/app.js`、兼容 completion 和 `/mcp`,仍不比较、保存或注入 Chat、MCP、SuperAgent 或数据库 Secret。
|
||||
- 运维手册记录 Git 前置条件、服务器目录、Secret 权限、Compose/Nginx 启停、页面开关、Chat/MCP 冒烟、SuperAgent 回调、更新和回滚。
|
||||
- Nginx 示例增加 HTTP 到 HTTPS 跳转和 HTTP 429 JSON 限流响应,精确反代 `/chat`、`/chat/`、`/chat/app.css`、`/chat/app.js`、兼容 completion 和 `/mcp`,仍不比较、保存或注入 Chat、MCP、SuperAgent 或数据库 Secret;代理必须保留客户端 Origin 交给 Go。
|
||||
- 运维手册记录 Git 前置条件、服务器目录、Secret 权限、Compose/Nginx 启停、页面开关、Chat/MCP 冒烟、403 Origin 排障、SuperAgent 回调、更新和回滚。
|
||||
- SuperAgent 无法在其配置中指定 MCP 协议版本;同一 SuperAgent 中 `th-hotel-simple-superagent` 已稳定启用/调用,现场实现不读取 `initialize.params.protocolVersion`,不读取或校验 `MCP-Protocol-Version` Header,并固定返回 `2025-06-18`。消防 MCP 按该 proven profile 兼容:版本字段和 Header 不作为拒绝门禁,响应固定为 `2025-06-18`。这不表示支持任意其他版本,也不是追求最新协议;日志只使用 `direct_success`/`compatibility_success`,不记录原始版本值。
|
||||
- SuperAgent Open API 现在由 `FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE` 控制消息流查询参数,默认 false。无 Trace 严格成功要求最终 AI 消息 `finish_reason=stop`、非空顶层 `message.final` 和顶层 `end`;Trace=true 时仍要求 `run.completed(status=success)`。无 Trace 不返回公开工具/步骤轨迹,但不等于禁止 Agent 调 MCP。受控诊断中,同一 Key 的 `include_trace=true` 因应用策略返回 HTTP 403 `open_agent_trace_disabled`;改用 false 后,真实探针返回严格成功,兼容 Chat 本地 SSE 也完成 `null -> stop`。公网完整 MCP 对话仍待验证。
|
||||
|
||||
@@ -65,7 +67,7 @@
|
||||
- 服务默认监听 `:8080`;`GET /health` 仍只是 liveness,不访问外部依赖。
|
||||
- 用户对话 API、SuperAgent Open API Adapter、默认关闭的 `/chat` 测试页面和 MCP endpoint 都默认关闭;Chat Bearer、页面手动输入的 xtoken、Open API Key 和 MCP Token 属于三个独立信任方向并禁止复用。
|
||||
- `/api/chat` 已实现单进程内存会话映射、同会话并发 Run 冲突和严格 SSE 最终回答;模拟 Provider 端到端测试通过,真实 SuperAgent 尚未通过该入口联调。
|
||||
- 可选兼容入口已实现截图所示路径、`xtoken`、`input.prompt/session_id` 和 `event: result` 外形;正文仍只在严格成功的 `stop` 事件中出现,不是 DashScope 全量 API。
|
||||
- 可选兼容入口已实现截图所示路径、`xtoken`、`input.prompt/session_id` 和 `event: result` 外形;正文仍只在严格成功的 `stop` 事件中出现,不是 DashScope 全量 API。403 Origin/预检拒绝仅记录有界、引用/转义的诊断字段,不记录 Secret、正文或会话/Provider 数据。
|
||||
- 可选 `/chat` 测试页面已纳入 Go 路由边界:页面开关默认关闭,关闭时 `/chat`、`/chat/`、`/chat/app.css`、`/chat/app.js` 均直接 404;开启时 `/chat` 返回 308 到 `/chat/`,`/chat/` 与 `/chat/app.css`、`/chat/app.js` 提供同源页面资源。页面不嵌入或持久化 Token,用户手动输入 xtoken,页面仅在内存中复用 `session_id`。
|
||||
- `Dockerfile`、`.dockerignore` 和 `compose.yaml` 已建立测试部署基线;容器单实例运行,容器内 8080 仅发布到宿主机 `127.0.0.1:16587`,一次性迁移 DSN 在服务容器中强制为空。
|
||||
- `deploy/nginx/fire-safety-ymd.conf.example` 已将公网页面/资源、兼容对话和 MCP 调用指向宿主机 `127.0.0.1:16587`,再由 Docker 映射到容器 8080;配置不保存或注入任何 Provider/Chat/MCP Secret。目标机仅 `nginx -t` 语法检查已通过,reload、页面资源和 HTTPS 实际响应尚未验证。
|
||||
@@ -83,7 +85,7 @@
|
||||
- 除防火网格外 7 张表缺少 GiST 几何索引;当前 geography 距离表达式的生产索引方案需根据实库查询计划确认。
|
||||
- 水源/设施 `syzt`、水源 `hc_datetime` 等字段的枚举、单位、时区和更新责任人尚未确认。
|
||||
- SuperAgent MCP 的公网 URL 已有现场成功证据:Bearer、`Content-Type`、JSON-RPC 通过,随后记录了 `initialize`、`notifications/initialized`、`tools/list`,并于 2026-09-05 22:35 成功调用 `fire_safety_search_place_candidates`。同一 SuperAgent 的 th-hotel 服务已稳定调用,为 proven profile 参照;消防服务其余 6 个工具、完整多工具链、TLS、网络白名单和 Token 轮换仍待联调。是否发送协议 Header 不构成兼容阻塞。
|
||||
- `/api/chat` 静态 Bearer、兼容路径 `xtoken` 和 `/chat` 测试页面只适用于受控联调,浏览器用户可以看到手动输入的 Token;页面默认关闭,开启时要求 `FIRE_SAFETY_CHAT_ALLOWED_ORIGINS` 包含精确 `https://agent.nianxx.com`。真实用户身份、动态授权、生产速率限制和滥用防护尚未实现。Chat 短凭证仅可通过默认关闭的显式 legacy 开关在受控测试/迁移窗口使用,MCP Token 仍要求至少 32 个可打印 ASCII 字符,三种凭证必须不同。
|
||||
- `/api/chat` 静态 Bearer、兼容路径 `xtoken` 和 `/chat` 测试页面只适用于受控联调,浏览器用户可以看到手动输入的 Token;页面默认关闭,开启时要求 `FIRE_SAFETY_CHAT_ALLOWED_ORIGINS` 包含精确 `https://agent.nianxx.com`,复杂网络下其他浏览器来源须按 403 诊断日志和 Network 面板确认后追加。真实用户身份、动态授权、生产速率限制和滥用防护尚未实现。Chat 短凭证仅可通过默认关闭的显式 legacy 开关在受控测试/迁移窗口使用,MCP Token 仍要求至少 32 个可打印 ASCII 字符,三凭证必须不同。
|
||||
- SuperAgent Trace 策略当前不对本项目开放:同一 Key 请求 `include_trace=true` 返回 403 `open_agent_trace_disabled`;改为 `include_trace=false` 后真实探针已严格成功。无 Trace 只影响工具/步骤轨迹返回和成功判定,不禁止 Agent 调用 MCP;实际消防 Profile 的无 Trace 多工具对话和工具轨迹替代证据仍待公网验收。
|
||||
- 目标公网机器的 Docker/Compose 和 Nginx 版本、配置 include 层级、证书、DNS、安全组及 PostgreSQL 网络拓扑尚未完整验证;用户已开始远程部署,仓库资产与服务器现场配置仍需完成一致性核验。
|
||||
- 目标服务器此前连续两次访问 `proxy.golang.org:443` 均在约 91 秒后超时,随后已通过可达的构建路径完成 Docker 镜像构建;该事实不代表所有外部 HTTPS 都可达。
|
||||
@@ -92,7 +94,7 @@
|
||||
- 当前 `all`/`town_allowlist` 都是服务账号静态范围,不是最终用户级授权;`all` 会授权当前数据库中 MCP 固定查询表内所有镇街和镇街字段为空的记录,身份提供方、角色、租户和精确位置权限尚未确定。
|
||||
- 地名搜索是无索引的有界包含匹配;真实数据量下的耗时、重名率和名称字段质量尚未验证,生产优化可能需要标准地名表、别名词典或 `pg_trgm` 索引。
|
||||
- 镇街或村庄名称可能匹配多条资源/网格;线面只返回只读计算的代表点,不能直接作为真实演练点。
|
||||
- 只实现最小结构化运行日志,没有持久审计、指标、限流网关或数据源版本。
|
||||
- 仅实现结构化运行日志和兼容 Chat 拒绝场景的有界诊断字段,没有持久审计、指标、限流网关或数据源版本;403 日志不是最终审计方案。
|
||||
- 路线规划缺少路网拓扑、坡度、路面、宽度、车辆限制、封路、实时火场和天气数据。
|
||||
- 队伍集结缺少正式集结点、实时定位、战备状态、人员/车辆/装备和容量数据。
|
||||
- `fire-safety-ymd` 正式 module path 与 CI/部署 Go 版本仍待确认。
|
||||
@@ -109,6 +111,7 @@
|
||||
- 在目标机替换安全 App ID、执行 `nginx -t` 后 reload,并验证 TLS、HTTP 到 HTTPS 跳转、429 和未列出路径 404。
|
||||
- 验证页面开关关闭时 `/chat`、`/chat/`、`/chat/app.css` 和 `/chat/app.js` 均直接返回 404;开启并 recreate 后 `/chat` 返回 308、`/chat/` 返回 200,两个资源也返回 200;页面 Origin 白名单包含 `https://agent.nianxx.com`。
|
||||
- 使用无敏感信息的问题验证兼容首轮 `null -> stop`、后续 `session_id` 复用、错误 xtoken、断流和超时。
|
||||
- 验证兼容 Chat 403 Origin/预检日志:仅拒绝场景记录有界、引用/转义后的 Origin;预检附有界方法、请求头名称集合和原因;日志不出现 Token、Cookie、prompt/body、会话或 Provider 数据。
|
||||
- 使用浏览器测试页面手动输入静态测试 xtoken,确认页面不写入 Token、同源发送兼容 completion SSE,并在第二轮复用内存 `session_id`;页面不作为生产认证。
|
||||
- 配置 SuperAgent 对公网 `/mcp` 的独立 Bearer,验证真实工具调用、TLS 和 warning 保留。
|
||||
- 不在输出、命令历史、Nginx、镜像层或 Git 中记录任何 Secret。
|
||||
@@ -118,6 +121,8 @@
|
||||
- MCP 分页:`gofmt`、`GOCACHE=/private/tmp/fire-safety-ymd-pagination-go-cache go test -count=1 ./...`、`go vet ./...`、`go test -race -count=1 ./...` 和 Linux 静态服务构建通过;覆盖 7 个工具的 schema、默认值、边界校验、透传、当前页/总量元数据、末页、超尾页、稳定排序与只读 SQL 参数化。开发机使用现有 `.env` 尝试真实分页冒烟时无法连接已配置 PostGIS,因此分页版真实 SQL 和公网链路仍待测试服务器验证。
|
||||
- `gofmt -w ./cmd ./internal`:通过。
|
||||
- `GOCACHE=/private/tmp/fire-safety-ymd-go-cache go test -count=1 ./...`:通过;新增覆盖页面开关依赖、开启/关闭路由、HTML/静态资源、安全响应头和应用装配;原有 Chat、SuperAgent、MCP/PostGIS 覆盖继续通过。
|
||||
- `GOCACHE=/private/tmp/fire-safety-ymd-origin-log-go-cache go test -count=1 ./internal/handler -run 'TestDashScopeChatForbidden(OriginLogIsActionableAndSafe|PreflightLogIdentifiesCause)$'`:通过;覆盖 403 Origin 日志的引用/转义、前 256 个输入字节截断边界、恶意换行隔离、预检方法/请求头/原因和 Token/Cookie/prompt 不落日志。
|
||||
- 403 Origin 诊断日志全量回归:`go test -count=1 ./...`、`go vet ./...`、`go test -race -count=1 ./...`、`gofmt -l ./cmd ./internal`、Linux amd64 静态服务构建和 `git diff --check` 均通过;沙箱内完整测试因既有 `httptest` 无权绑定回环端口失败,获准在沙箱外重跑后通过。
|
||||
- `GOCACHE=/private/tmp/fire-safety-ymd-go-cache go vet ./...`:通过。
|
||||
- `GOCACHE=/private/tmp/fire-safety-ymd-go-cache go test -race -count=1 ./...`:通过。
|
||||
- SuperAgent 无 Trace:自动化覆盖 `include_trace=false` 请求参数、最终 AI 消息 `finish_reason=stop` + 非空顶层 `message.final` + 顶层 `end` 成功,以及 Trace=true 仍要求 `run.completed(status=success)`;真实同一 Key 诊断为 true=403 `open_agent_trace_disabled`,false 模式下 `cmd/superagent-probe` 严格成功并返回最终回答,本地兼容 Chat SSE 也完成 `finish_reason=null -> stop`,公网完整 MCP 对话仍待验证。
|
||||
@@ -154,3 +159,12 @@
|
||||
- 状态语义:仅 `total_count=0` 使用 `status=no_results`;总量大于零但请求超出末页时仍为 `status=ok`,返回空数组和 `has_more=false`。
|
||||
- Agent 规则:`has_more=true` 时说明当前展示数/总数;用户要求继续时使用上一页 `next_offset`;综合方案默认不自动拉取全部页。
|
||||
- 实现范围:Domain/Service/Handler/Repository 已统一分页;Repository 使用过滤后的窗口总量、稳定排序和参数化 `LIMIT/OFFSET`,超尾空页通过相同条件的只读首页探测恢复准确 `total_count`。同步更新了项目上下文、架构、空间 MCP Spec、SuperAgent 接入指南、演练证据流程、消防 Profile 提示词和本状态记录。
|
||||
|
||||
## 8. 2026-09-07 Chat 403 Origin 诊断日志 checkpoint
|
||||
|
||||
- 名称:`fire-safety-ymd-chat-origin-diagnostic-logging-v1`
|
||||
- 状态:Handler 实现、针对性自动化与本地全量回归已完成;提交、目标机重建和公网 Origin 白名单验收待完成。
|
||||
- 诊断契约:普通兼容 Chat POST 仅在非空 Origin 不被允许时记录 `result=forbidden_origin` 与有界 `origin`;预检拒绝记录 `result=preflight_forbidden`、`origin`、`preflight_method`、`preflight_headers` 和稳定 `reason`。每个请求 Header 最多保留前 256 个输入字节,超长值追加 `[truncated]` 后再引用/ASCII 转义,不能伪造额外日志行。
|
||||
- 安全边界:诊断字段仅用于定位来源,不改变授权;成功请求不记录 Origin,任何场景均不记录 `xtoken`、`Authorization`、`Cookie`、prompt/body、会话或 Provider 数据。日志无持久审计、保留周期和访问角色设计,生产前仍需另行审查。
|
||||
- 白名单契约:从完整、可信的浏览器 Network Origin 或未截断日志确认后,才把规范形式 `scheme://host[:port]` 写入 `FIRE_SAFETY_CHAT_ALLOWED_ORIGINS`;多个来源英文逗号分隔,不得带非根路径、查询或片段,也不得使用 `*`;单个末尾 `/` 会被接受并规范化移除,配置时建议省略。修改 `.env` 需 recreate;修改代码需 build + recreate;目标服务器真实日志、Nginx 链路和公网联调仍待验证。
|
||||
- 本地验证:目标 Handler 回归、全量测试、竞态测试、vet、格式检查、Linux amd64 静态服务构建和 diff 检查均通过;目标机 Docker build/recreate、Nginx reload、浏览器实际 Origin、复杂网络来源和公网 HTTPS 响应尚未形成证据。
|
||||
|
||||
@@ -143,6 +143,9 @@ FIRE_SAFETY_POSTGIS_EXPECTED_SRID=4326
|
||||
- FIRE_SAFETY_CHAT_PAGE_ENABLED 默认必须为 false。开启后 Go 提供 `/chat`、`/chat/` 以及同源的
|
||||
`/chat/app.css`、`/chat/app.js`;`/chat` 会 308 到 `/chat/`,页面和资源的最终可用性仍由 Go
|
||||
开关决定。页面只用于受控测试,不嵌入或持久化 Token。
|
||||
- `FIRE_SAFETY_CHAT_ALLOWED_ORIGINS` 是 Go 侧的精确 Origin 白名单,多个来源用英文逗号分隔;每项的规范形式是
|
||||
`scheme://host[:port]`,不能带非根路径、查询或片段,也禁止 `*`。单个末尾 `/` 会被接受并规范化移除,配置时建议省略。复杂网络环境不要猜测来源,先从
|
||||
兼容 Chat 的 403 诊断日志或对方浏览器 Network 面板确认完整 Origin,再修改该值。
|
||||
- FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE 默认必须为 false。无 Trace 的严格成功条件是最终 AI 消息
|
||||
`finish_reason=stop`、非空顶层 `event: message.final` 加顶层 `event: end`;设置为 true 时还要求
|
||||
`run.completed(status=success)`,且外部应用策略必须开启 `trace_policy.enabled=true`。无 Trace
|
||||
@@ -306,6 +309,60 @@ curl -N --fail \
|
||||
|
||||
至少验证:错误 xtoken 为 401;不存在的 app ID 和未列出的路径为 404。不要使用真实联系人、电话或精确敏感位置作为冒烟问题。
|
||||
|
||||
### 7.1 403 Origin 诊断与白名单调整
|
||||
|
||||
如果兼容 completion 返回 HTTP 403,先在响应中记录 `request_id`,再在服务器查看有限的 Go 日志:
|
||||
|
||||
~~~bash
|
||||
cd /home/firee-safety-ymd
|
||||
docker compose logs --no-color --since=10m --tail=200 api \
|
||||
| grep -E 'dashscope_chat_request.*result=(forbidden_origin|preflight_forbidden)'
|
||||
~~~
|
||||
|
||||
日志含义如下:
|
||||
|
||||
- `result=forbidden_origin`:普通 POST 的非空 `Origin` 不在白名单;日志中的 `origin` 最多保留前 256
|
||||
个输入字节,超长值追加 `[truncated]` 后再引用/ASCII 转义。
|
||||
- `result=preflight_forbidden`:CORS 预检被拒绝;除 `origin` 外还会记录有界的
|
||||
`preflight_method`、`preflight_headers` 和 `reason`。原因只可能是 `origin_missing`、
|
||||
`origin_not_allowed`、`method_not_allowed` 或 `headers_not_allowed`。
|
||||
- 出现 `[truncated]` 时日志不足以配置白名单,必须从对方浏览器 Network 面板确认完整 Origin;不要执行日志中的
|
||||
未可信内容或把它当作 shell 变量展开。
|
||||
|
||||
确认发送方后,才把精确来源写入服务器 `.env`,例如:
|
||||
|
||||
~~~text
|
||||
FIRE_SAFETY_CHAT_ALLOWED_ORIGINS=https://agent.nianxx.com,http://ui.example:9045
|
||||
~~~
|
||||
|
||||
示例中的第二个值只能在 Network 面板确认为 `http://ui.example:9045` 时使用。不要加入 API 路径、
|
||||
Nginx 上游地址、Token 或 `*`。这些拒绝日志不记录 `xtoken`、`Authorization`、`Cookie`、prompt/body、
|
||||
会话或 Provider 数据。
|
||||
|
||||
若 Go 日志没有对应的 `dashscope_chat_request`,再检查 Nginx 是否在到达 Go 前拒绝或关闭连接;只读取有限日志,
|
||||
不要使用会把旧配置 Secret 全部打印出来的命令:
|
||||
|
||||
~~~bash
|
||||
sudo tail -n 100 /var/log/nginx/error.log
|
||||
sudo tail -n 200 /var/log/nginx/access.log \
|
||||
| grep -E ' /api/v1/apps/[^ ]+/completion '
|
||||
~~~
|
||||
|
||||
修改 `.env` 后必须重新创建容器,单独 `restart` 不会读取新值:
|
||||
|
||||
~~~bash
|
||||
cd /home/firee-safety-ymd
|
||||
docker compose config --quiet
|
||||
docker compose up -d --force-recreate --no-build api
|
||||
~~~
|
||||
|
||||
如果同时更新了 Go 日志代码,必须重新构建并创建容器:
|
||||
|
||||
~~~bash
|
||||
docker compose build --pull
|
||||
docker compose up -d --force-recreate --remove-orphans
|
||||
~~~
|
||||
|
||||
完成 Chat 测试后清理当前 shell 中的临时变量:
|
||||
|
||||
~~~bash
|
||||
@@ -576,6 +633,8 @@ docker compose down
|
||||
- 目标机 nginx -t 和 reload 成功;443 证书、DNS、安全组及旧 location / 已确认不再生效。
|
||||
- 页面开关关闭时 `/chat`、`/chat/`、`/chat/app.css`、`/chat/app.js` 直接返回 404;开启时 `/chat` 返回 308、`/chat/` 返回 200 HTML,两个资源返回 200;`FIRE_SAFETY_CHAT_ALLOWED_ORIGINS` 包含 `https://agent.nianxx.com`。
|
||||
- Chat 首轮/多轮 SSE、错误凭证、未知 app/path 的实际 HTTPS 响应。
|
||||
- 兼容 Chat 的 403 Origin 排障:拒绝日志只在拒绝场景记录有界、引用/转义后的 Origin;预检拒绝还记录有界
|
||||
方法、请求头名称集合和稳定原因;日志中不得出现 Token、Cookie、prompt/body、会话或 Provider 数据。
|
||||
- 页面浏览器验收证明用户手动输入 xtoken、Token 不被页面持久化、兼容 completion SSE 及同页面 `session_id` 复用;这只是静态测试页面,不是生产认证。
|
||||
- 若为已交付旧客户端临时开启 Chat legacy 短凭证兼容,应记录受控迁移窗口,确认启动 warning 不含 Secret,并在凭证轮换后恢复 `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN=false`。
|
||||
- SuperAgent Open API 使用 `FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE=false` 时,验证最终 AI 消息
|
||||
|
||||
@@ -53,7 +53,8 @@ Nginx 不再调用 DashScope,也不保存或注入 SuperAgent Open API Key、C
|
||||
|
||||
页面默认关闭。测试开启时,Go 进程配置的 `FIRE_SAFETY_CHAT_ALLOWED_ORIGINS` 必须包含精确
|
||||
Origin `https://agent.nianxx.com`;该值是浏览器从页面同源发出 completion 请求时的来源。不要
|
||||
用 `*`,也不要把 Token 写入 Nginx。
|
||||
用 `*`,也不要把 Token 写入 Nginx。Nginx 必须保留客户端的 `Origin` Header 原样交给 Go,不要在
|
||||
代理层删除、改写或用固定值覆盖它;白名单判断和 403 诊断日志均由 Go 完成。
|
||||
|
||||
`limit_req_zone` 必须位于 Nginx `http` context,不能放进 `server` 或 `location`。示例文件假定它被 `conf.d/*.conf` 从 `http {}` 中 include;如果部署系统不是这样 include,应把两条 `limit_req_zone` 指令单独移到 `http {}`,并保留 `server`/`upstream` 在合法上下文。
|
||||
|
||||
@@ -118,7 +119,51 @@ FIRE_SAFETY_POSTGIS_DSN=<readonly-postgresql-dsn>
|
||||
|
||||
MCP 是普通 JSON 请求,不需要 SSE 的关闭响应缓冲设置;示例使用 30 秒读写超时,仍关闭上游自动重试。
|
||||
|
||||
## 5. 启用与检查
|
||||
## 5. 兼容 Chat 403 Origin 诊断
|
||||
|
||||
兼容 completion 返回 HTTP 403 时,先用响应中的 `request_id` 与 Go 日志关联。Go 只在拒绝场景记录
|
||||
受限诊断字段:
|
||||
|
||||
- 普通请求的非空 Origin 不在白名单时,记录 `result=forbidden_origin`;`origin` 最多保留前 256 个
|
||||
输入字节,超长值追加 `[truncated]` 后再引用/ASCII 转义;
|
||||
- CORS 预检拒绝时,记录 `result=preflight_forbidden`、同样有界的 `origin`、`preflight_method`、
|
||||
`preflight_headers` 和 `reason`;
|
||||
- `reason` 只表示 `origin_missing`、`origin_not_allowed`、`method_not_allowed` 或
|
||||
`headers_not_allowed`;成功请求不记录 Origin。
|
||||
|
||||
这些日志值是非可信请求输入,经过引用/转义且有长度边界;日志不记录 `xtoken`、`Authorization`、
|
||||
`Cookie`、prompt/body、会话或 Provider 数据。仅在受限运维终端读取有限日志,不要把完整日志粘贴到公开工单:
|
||||
|
||||
```bash
|
||||
cd /home/firee-safety-ymd
|
||||
docker compose logs --no-color --since=10m --tail=200 api \
|
||||
| grep -E 'dashscope_chat_request.*result=(forbidden_origin|preflight_forbidden)'
|
||||
```
|
||||
|
||||
如果日志值带 `[truncated]`,不能据此配置白名单;回到对方浏览器的 Network 面板读取完整 `Origin`。
|
||||
确认来源后,使用规范形式 `scheme://host[:port]`,不能带非根路径、查询或片段,多个来源用英文逗号分隔,
|
||||
禁止 `*`。单个末尾 `/` 会被接受并规范化移除,配置时建议省略。例如仅当 Network 面板确认来源确为
|
||||
`http://ui.example:9045` 时,才配置:
|
||||
|
||||
```text
|
||||
FIRE_SAFETY_CHAT_ALLOWED_ORIGINS=https://agent.nianxx.com,http://ui.example:9045
|
||||
```
|
||||
|
||||
修改 `.env` 后必须重新创建容器;只改配置时可不重建镜像:
|
||||
|
||||
```bash
|
||||
cd /home/firee-safety-ymd
|
||||
docker compose config --quiet
|
||||
docker compose up -d --force-recreate --no-build api
|
||||
```
|
||||
|
||||
若同时更新了 Go 代码,必须先拉取明确 revision,再执行 `docker compose build --pull` 和
|
||||
`docker compose up -d --force-recreate --remove-orphans`。修改 Nginx 配置本身时,仍须先执行
|
||||
`sudo nginx -t`,通过后再 reload。Nginx 层若返回 HTML 403 或连接被关闭,而 Go 没有对应
|
||||
`dashscope_chat_request` 日志,应检查实际生效的 server/location、旧配置和 Nginx error log,不能通过
|
||||
删除 `Origin` 或恢复旧的全路径 DashScope 代理绕过问题。
|
||||
|
||||
## 6. 启用与检查
|
||||
|
||||
启动 Go 服务前,在服务器的进程环境中加载 Secret。不要把 Secret 直接写进命令行或 shell 历史;可以在受保护的环境文件中加载后,再通过 Header 环境变量展开:
|
||||
|
||||
@@ -228,7 +273,7 @@ curl --fail \
|
||||
|
||||
预期 `initialize` 返回协议版本 `2025-06-18`,initialized notification 返回 HTTP 202,`tools/list` 返回 7 个固定工具。只收到 HTTP 200 但 JSON-RPC 中含 `error` 也属于失败,不能把它当作 MCP 已就绪。
|
||||
|
||||
## 6. 验收边界与回滚
|
||||
## 7. 验收边界与回滚
|
||||
|
||||
完成 `nginx -t` 和 reload 后,应至少验证:
|
||||
|
||||
@@ -246,7 +291,7 @@ curl --fail \
|
||||
路径均直接返回 404。这样不会自动关闭兼容 completion;如果也要关闭对话 API,再按 Chat 配置和对应回滚流程
|
||||
处理。页面开关和 Nginx 路由均应保留清晰的变更记录。
|
||||
|
||||
## 7. 未确认事项
|
||||
## 8. 未确认事项
|
||||
|
||||
- 目标公网机器的 Nginx 版本、include 层级、TLS 终止位置和证书续期方式;示例同时监听 80 做 HTTPS 跳转,安全组需按实际策略决定是否允许 80。
|
||||
- `agent.nianxx.com` 的 DNS、安全组、反向代理来源 IP 和 SuperAgent 对 MCP 回调的网络策略。
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
| 项 | 内容 |
|
||||
| --- | --- |
|
||||
| 项目 | `fire-safety-ymd` |
|
||||
| 状态 | SuperAgent 出站、用户对话 API v1 与空间只读 MCP 代码控制已实现;真实环境、最终用户授权和持久审计待完成 |
|
||||
| 最近更新 | 2026-09-05 |
|
||||
| 状态 | SuperAgent 出站、用户对话 API v1、兼容 Chat 403 Origin 诊断日志与空间只读 MCP 代码控制已实现;真实环境、最终用户授权和持久审计待完成 |
|
||||
| 最近更新 | 2026-09-07 |
|
||||
|
||||
## 1. 目的
|
||||
|
||||
@@ -63,7 +63,12 @@ MCP 第一阶段已按以下只读边界实现:
|
||||
- 权限不足与“没有数据”使用不同稳定状态,避免 Agent 猜测。
|
||||
- 调用记录至少关联 request ID、可信主体、工具名、授权范围、耗时、结果类别和数据源版本;审计日志不保存无必要的完整敏感正文。
|
||||
|
||||
当前日志已记录 request ID、操作、耗时和结果类别;可信主体、数据源版本和持久审计尚未实现,因此生产前仍需补齐。
|
||||
当前日志已记录 request ID、操作、耗时和结果类别;可信主体、数据源版本和持久审计尚未实现,因此生产前仍需补齐。兼容
|
||||
`completion` 的拒绝路径另有受限诊断字段:非预检请求仅在 Origin 被拒绝时记录
|
||||
`result=forbidden_origin` 和实际 Origin;CORS 预检拒绝记录
|
||||
`result=preflight_forbidden`、实际 Origin、请求方法、请求头名称集合和稳定 `reason`。
|
||||
每个请求 Header 最多保留前 256 个输入字节,超长值追加 `[truncated]` 后再引用/ASCII 转义;成功请求不记录
|
||||
Origin。日志绝不记录 `xtoken`、`Authorization`、`Cookie`、prompt/body、会话 ID 或 Provider 数据。
|
||||
|
||||
任何写工具、资源调度或状态变更都需要新的 Spec、幂等设计、人工确认边界、审计和安全 Review,不属于默认扩展。
|
||||
|
||||
@@ -109,10 +114,31 @@ MCP 第一阶段已按以下只读边界实现:
|
||||
| 精确设施或风险区域坐标 | 业务敏感 | 按权限最少披露,不记录完整结果集 |
|
||||
| 用户问题和对话 | 可能含敏感信息 | 默认不记录原文,使用摘要或分类字段 |
|
||||
| 工具名、耗时、结果类别 | 运行元数据 | 可记录,不附敏感 payload |
|
||||
| 兼容 Chat 被拒绝的 Origin 诊断字段 | 受限请求元数据 | 仅拒绝时记录引用/转义且有界的 Origin;预检附方法、请求头名称集合和原因;禁止 Secret 与请求正文 |
|
||||
|
||||
生产前需确定数据分类负责人、日志访问角色、保留周期、删除流程和安全事件响应方式。
|
||||
|
||||
当前 Chat 日志只记录 request ID、结果类别、是否复用和耗时,不记录消息、回答、对话 ID、Provider Session 或 Trace payload。`docs/import/db-samples/*.sql` 含真实联系人、电话和精确坐标,仅作为本地只读输入并由 Git 忽略;不得执行或进入版本历史。长期测试数据必须另做脱敏 fixture。
|
||||
当前 Chat 日志只记录 request ID、结果类别、是否复用和耗时,不记录消息、回答、对话 ID、Provider Session 或 Trace payload。
|
||||
兼容 `completion` 的 403 Origin 诊断是例外但仍受严格边界约束:`dashscope_chat_request` 在
|
||||
`result=forbidden_origin` 时记录有界的 `origin`;每个请求 Header 最多保留前 256 个输入字节,超长值追加
|
||||
`[truncated]` 后再引用/ASCII 转义。在
|
||||
`result=preflight_forbidden` 时再记录同样有界的 `preflight_method`、`preflight_headers` 和稳定
|
||||
`reason`(`origin_missing`、`origin_not_allowed`、`method_not_allowed` 或 `headers_not_allowed`)。
|
||||
这些字段只用于定位发送方的精确浏览器 Origin,不是授权凭证;不记录 `xtoken`、`Authorization`、`Cookie`、
|
||||
prompt/body、会话或 Provider 数据。`docs/import/db-samples/*.sql` 含真实联系人、电话和精确坐标,仅作为本地只读输入并由 Git 忽略;不得执行或进入版本历史。长期测试数据必须另做脱敏 fixture。
|
||||
|
||||
### 8.1 兼容 Chat 403 诊断与 Origin 白名单
|
||||
|
||||
本节只适用于 DashScope 风格兼容 `completion`。收到 HTTP 403 后,运维人员在受限日志中按 `request_id`
|
||||
关联响应;`result=forbidden_origin` 表示普通请求的 Origin 不在白名单,`result=preflight_forbidden`
|
||||
还需根据 `reason` 判断是来源、方法还是请求头名称集合不符合预检契约。日志中的值是经过引用/转义的非可信输入,
|
||||
不能当作 shell 代码执行;出现 `[truncated]` 时必须回到浏览器 Network 面板确认完整值。
|
||||
|
||||
确认发送方后,将精确的浏览器来源配置到 `FIRE_SAFETY_CHAT_ALLOWED_ORIGINS`:多个值用英文逗号分隔,
|
||||
每项的规范形式是 `scheme://host[:port]`,不能带非根路径、查询或片段,也禁止 `*`;单个末尾 `/` 会被接受并
|
||||
规范化移除,配置时建议省略。例如页面来源确实为 `http://example.test:9045` 时才加入该值;不要把 API
|
||||
路径、Nginx 上游地址或 `xtoken` 放入白名单。
|
||||
修改环境配置后必须重新 build(代码变更时)并 recreate 运行容器,不能依赖 `restart` 重新加载配置。
|
||||
|
||||
## 9. 应急场景安全
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
| 项 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | Implemented |
|
||||
| 日期 | 2026-09-05 |
|
||||
| 状态 | Implemented;已增加拒绝请求的 Origin 诊断日志 |
|
||||
| 日期 | 2026-09-07 |
|
||||
| 负责人 | fire-safety-ymd 后端 |
|
||||
| 需求来源 | 复用既有客户端的 `/api/v1/apps/{app_id}/completion`、`xtoken` 与 `event: result` 契约 |
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
| `FIRE_SAFETY_CHAT_COMPAT_APP_ID` | 空 | 非空时注册兼容路径;1 至 128 个 ASCII 字母、数字、下划线或连字符 |
|
||||
| `FIRE_SAFETY_CHAT_AUTH_TOKEN` | 空 | 兼容路径期望的 `xtoken`;默认至少 32 个可打印 ASCII 字符,兼容开关开启时允许已交付的旧短凭证 |
|
||||
| `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN` | `false` | 仅为已交付旧客户端短凭证的受控测试/迁移临时兼容开关;新环境不得开启 |
|
||||
| `FIRE_SAFETY_CHAT_ALLOWED_ORIGINS` | 空 | 逗号分隔的精确 HTTP(S) 浏览器 Origin;不允许 `*`、非根路径、查询或片段;单个末尾 `/` 会被规范化移除 |
|
||||
|
||||
兼容路径只有在 `FIRE_SAFETY_CHAT_ENABLED=true` 且 App ID 非空时注册。App ID 是用于路径匹配的公开标识,不是 Secret;不匹配的路径返回 404。
|
||||
|
||||
@@ -120,15 +121,34 @@ SSE 开始前使用 HTTP 状态和 DashScope 风格安全 JSON:
|
||||
SSE 开始后的失败发送 `event: error`,只包含稳定 code、通用 message、request ID 和本地 session ID;不发送 `finish_reason: "stop"`,也不返回任何已接收的部分回答。
|
||||
|
||||
沿用原生 Chat 的主要状态:400 输入错误、401 token 错误、403 Origin 错误、404 App/会话不存在、409 会话忙、503 容量不足,以及 502/504 上游失败或超时。
|
||||
兼容入口的 403 响应仍只返回稳定错误 JSON;服务端日志在拒绝时提供有限诊断信息,便于确认发送方应加入哪一个精确 Origin,
|
||||
但不会因为诊断而放宽鉴权或预检规则。
|
||||
|
||||
## 8. CORS、Nginx 与 Secret
|
||||
|
||||
- 浏览器 Origin 必须精确出现在 `FIRE_SAFETY_CHAT_ALLOWED_ORIGINS`;不允许 `*` 或 credentials。
|
||||
- 浏览器 Origin 必须精确出现在 `FIRE_SAFETY_CHAT_ALLOWED_ORIGINS`;多个值用英文逗号分隔,不允许 `*` 或 credentials。
|
||||
- 白名单值的规范形式是 `scheme://host[:port]`,不能带非根路径、查询或片段;单个末尾 `/` 会被接受并规范化移除,配置时建议省略。不要把 API URL、Nginx 上游地址或 Token 当作 Origin。
|
||||
- 预检只允许 `POST` 以及 `Content-Type`、`xtoken`、`X-DashScope-SSE`、`X-Request-ID`。
|
||||
- Nginx 示例只公开精确兼容路径、`/mcp` 和可选 `/health`,其余路径返回 404。
|
||||
- Nginx 不比较或注入 `xtoken`、SuperAgent Open API Key、MCP Bearer 或数据库凭证;Header 原样交给 Go 验证。
|
||||
- 对话 SSE 必须关闭代理缓冲、缓存、gzip 和上游自动重试,并让代理超时覆盖 Chat 总运行时限。
|
||||
|
||||
### 8.1 403 Origin 诊断日志
|
||||
|
||||
兼容入口只在拒绝场景记录诊断字段:普通 POST 的非空 Origin 不在白名单时记录
|
||||
`result=forbidden_origin` 与 `origin`;CORS 预检拒绝时记录 `result=preflight_forbidden`、
|
||||
`origin`、`preflight_method`、`preflight_headers` 和稳定 `reason`。`reason` 取值为
|
||||
`origin_missing`、`origin_not_allowed`、`method_not_allowed` 或 `headers_not_allowed`。
|
||||
|
||||
每个请求 Header 最多保留前 256 个输入字节,超长值追加 `[truncated]` 后再引用/ASCII 转义,
|
||||
以确保恶意换行不能伪造日志记录。成功请求不记录 Origin。日志禁止记录 `xtoken`、`Authorization`、`Cookie`、
|
||||
prompt/body、会话 ID、Provider Session、Provider payload 或其他 Secret。
|
||||
|
||||
运维人员只能把日志中的完整 `origin` 当作排障线索,不能直接执行或无审查复制;若出现 `[truncated]`,应从浏览器
|
||||
Network 面板确认完整 Origin。确认后再把 `scheme://host[:port]` 原样加入
|
||||
`FIRE_SAFETY_CHAT_ALLOWED_ORIGINS`,多个来源用英文逗号分隔;禁止 `*`。修改代码需要重新 build 并 recreate,
|
||||
仅修改环境配置也需要 recreate,单独 restart 不会让运行容器读取新值。
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
- Given App ID 未配置,When 请求兼容路径,Then 路由返回 404,原生 `/api/chat` 行为不变。
|
||||
@@ -139,6 +159,9 @@ SSE 开始后的失败发送 `event: error`,只包含稳定 code、通用 mess
|
||||
- Given 后续请求携带成功返回的 `session_id`,When 调用,Then 复用同一服务端会话映射。
|
||||
- Given Provider 流失败,When SSE 已开始,Then 收到安全 `error`,不收到部分正文或 `stop`。
|
||||
- Given Nginx 配置生效,When 请求未列出的路径,Then 不会转发到 Go 或外部 DashScope。
|
||||
- Given 普通请求的 Origin 不在白名单,When 兼容入口返回 403,Then 日志记录有界、引用/转义后的 `origin`,但不记录 Token、Cookie、prompt/body、会话或 Provider 数据。
|
||||
- Given CORS 预检因来源、方法或请求头名称集合被拒绝,When 返回 403,Then 日志记录有界、引用/转义后的 `origin`、`preflight_method`、`preflight_headers` 和稳定 `reason`,且恶意换行不能增加日志行数。
|
||||
- Given Origin 白名单值被更新,When 服务重新加载配置,Then 精确 `scheme://host[:port]` 值生效,单个末尾 `/` 被规范化移除,而 `*`、非根路径、查询和片段不得被接受。
|
||||
|
||||
## 10. 协议来源
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -105,9 +106,10 @@ func (h *DashScopeChatHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
h.logResult(requestID, "method_not_allowed", false, started)
|
||||
return
|
||||
}
|
||||
if !h.authorizeOrigin(w, r.Header.Get("Origin")) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if !h.authorizeOrigin(w, origin) {
|
||||
h.writeError(w, http.StatusForbidden, requestID, "CHAT_ORIGIN_FORBIDDEN", "Chat browser origin is not allowed.")
|
||||
h.logResult(requestID, "forbidden_origin", false, started)
|
||||
h.logForbiddenOrigin(requestID, origin, started)
|
||||
return
|
||||
}
|
||||
if !h.validToken(r.Header.Get("xtoken")) {
|
||||
@@ -346,10 +348,22 @@ type dashScopeStreamError struct {
|
||||
|
||||
func (h *DashScopeChatHandler) handlePreflight(w http.ResponseWriter, r *http.Request, requestID string, started time.Time) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" || !h.authorizeOrigin(w, origin) || r.Header.Get("Access-Control-Request-Method") != http.MethodPost ||
|
||||
!validDashScopePreflightHeaders(r.Header.Get("Access-Control-Request-Headers")) {
|
||||
requestedMethod := r.Header.Get("Access-Control-Request-Method")
|
||||
requestedHeaders := r.Header.Get("Access-Control-Request-Headers")
|
||||
forbiddenReason := ""
|
||||
switch {
|
||||
case origin == "":
|
||||
forbiddenReason = "origin_missing"
|
||||
case !h.authorizeOrigin(w, origin):
|
||||
forbiddenReason = "origin_not_allowed"
|
||||
case requestedMethod != http.MethodPost:
|
||||
forbiddenReason = "method_not_allowed"
|
||||
case !validDashScopePreflightHeaders(requestedHeaders):
|
||||
forbiddenReason = "headers_not_allowed"
|
||||
}
|
||||
if forbiddenReason != "" {
|
||||
h.writeError(w, http.StatusForbidden, requestID, "CHAT_ORIGIN_FORBIDDEN", "Chat browser origin or preflight request is not allowed.")
|
||||
h.logResult(requestID, "preflight_forbidden", false, started)
|
||||
h.logForbiddenPreflight(requestID, origin, requestedMethod, requestedHeaders, forbiddenReason, started)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Methods", http.MethodPost)
|
||||
@@ -391,6 +405,35 @@ func (h *DashScopeChatHandler) logResult(requestID, result string, reused bool,
|
||||
h.logger.Printf("dashscope_chat_request request_id=%s result=%s reused=%t duration_ms=%d", requestID, result, reused, time.Since(started).Milliseconds())
|
||||
}
|
||||
|
||||
func (h *DashScopeChatHandler) logForbiddenOrigin(requestID, origin string, started time.Time) {
|
||||
h.logger.Printf(
|
||||
"dashscope_chat_request request_id=%s result=forbidden_origin reused=false duration_ms=%d origin=%s",
|
||||
requestID,
|
||||
time.Since(started).Milliseconds(),
|
||||
quotedBoundedLogHeader(origin),
|
||||
)
|
||||
}
|
||||
|
||||
func (h *DashScopeChatHandler) logForbiddenPreflight(requestID, origin, requestedMethod, requestedHeaders, reason string, started time.Time) {
|
||||
h.logger.Printf(
|
||||
"dashscope_chat_request request_id=%s result=preflight_forbidden reused=false duration_ms=%d origin=%s preflight_method=%s preflight_headers=%s reason=%s",
|
||||
requestID,
|
||||
time.Since(started).Milliseconds(),
|
||||
quotedBoundedLogHeader(origin),
|
||||
quotedBoundedLogHeader(requestedMethod),
|
||||
quotedBoundedLogHeader(requestedHeaders),
|
||||
strconv.Quote(reason),
|
||||
)
|
||||
}
|
||||
|
||||
func quotedBoundedLogHeader(value string) string {
|
||||
const maximumLoggedHeaderBytes = 256
|
||||
if len(value) > maximumLoggedHeaderBytes {
|
||||
value = value[:maximumLoggedHeaderBytes] + "...[truncated]"
|
||||
}
|
||||
return strconv.QuoteToASCII(value)
|
||||
}
|
||||
|
||||
func writeDashScopeSSE(w io.Writer, flusher http.Flusher, id int, event string, payload any) error {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -123,6 +124,9 @@ func TestDashScopeChatStreamsCompatibleResultAfterStrictSuccess(t *testing.T) {
|
||||
t.Fatalf("logs contain sensitive value %q: %s", sensitive, logs.String())
|
||||
}
|
||||
}
|
||||
if strings.Contains(logs.String(), "https://allowed.example") {
|
||||
t.Fatalf("successful request log contains origin: %s", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashScopeChatMapsSessionIDToLocalConversation(t *testing.T) {
|
||||
@@ -217,6 +221,96 @@ func TestDashScopeChatCORSPreflight(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashScopeChatForbiddenOriginLogIsActionableAndSafe(t *testing.T) {
|
||||
var logs bytes.Buffer
|
||||
handler := newTestDashScopeChatHandler(t, &fakeChatUseCase{}, []string{"https://allowed.example"}, log.New(&logs, "", 0))
|
||||
request := authenticatedDashScopeRequest(http.MethodPost, `{"input":{"prompt":"secret prompt must not be logged"}}`)
|
||||
request.Header.Set("Origin", "https://evil.example/\nforged="+strings.Repeat("a", 400))
|
||||
request.Header.Set("Authorization", "Bearer provider-secret")
|
||||
request.Header.Set("Cookie", "session=secret-cookie")
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
logged := logs.String()
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if !strings.Contains(logged, `result=forbidden_origin`) ||
|
||||
!strings.Contains(logged, `origin="https://evil.example/\nforged=`) ||
|
||||
!strings.Contains(logged, `[truncated]"`) {
|
||||
t.Fatalf("forbidden origin log is not actionable and bounded: %q", logged)
|
||||
}
|
||||
if strings.Count(logged, "\n") != 1 {
|
||||
t.Fatalf("untrusted origin injected a log line: %q", logged)
|
||||
}
|
||||
for _, sensitive := range []string{testChatToken, "provider-secret", "secret-cookie", "secret prompt"} {
|
||||
if strings.Contains(logged, sensitive) {
|
||||
t.Fatalf("forbidden origin log contains sensitive value %q: %q", sensitive, logged)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashScopeChatForbiddenPreflightLogIdentifiesCause(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
origin string
|
||||
method string
|
||||
headers string
|
||||
wantReason string
|
||||
}{
|
||||
{name: "missing origin", method: http.MethodPost, headers: "content-type", wantReason: "origin_missing"},
|
||||
{name: "forbidden origin", origin: "https://evil.example", method: http.MethodPost, headers: "content-type", wantReason: "origin_not_allowed"},
|
||||
{name: "wrong method", origin: "https://allowed.example", method: http.MethodDelete, headers: "content-type", wantReason: "method_not_allowed"},
|
||||
{name: "forbidden headers", origin: "https://allowed.example", method: http.MethodPost, headers: "authorization\nforged=" + strings.Repeat("b", 400), wantReason: "headers_not_allowed"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var logs bytes.Buffer
|
||||
handler := newTestDashScopeChatHandler(t, &fakeChatUseCase{}, []string{"https://allowed.example"}, log.New(&logs, "", 0))
|
||||
request := authenticatedDashScopeRequest(http.MethodOptions, "")
|
||||
if tt.origin != "" {
|
||||
request.Header.Set("Origin", tt.origin)
|
||||
}
|
||||
request.Header.Set("Access-Control-Request-Method", tt.method)
|
||||
request.Header.Set("Access-Control-Request-Headers", tt.headers)
|
||||
request.Header.Set("Authorization", "Bearer provider-secret")
|
||||
request.Header.Set("Cookie", "session=secret-cookie")
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
logged := logs.String()
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
for _, want := range []string{
|
||||
`result=preflight_forbidden`,
|
||||
`origin=` + strconv.Quote(tt.origin),
|
||||
`preflight_method=` + strconv.Quote(tt.method),
|
||||
`reason="` + tt.wantReason + `"`,
|
||||
} {
|
||||
if !strings.Contains(logged, want) {
|
||||
t.Fatalf("preflight log missing %q: %q", want, logged)
|
||||
}
|
||||
}
|
||||
if tt.wantReason == "headers_not_allowed" {
|
||||
if !strings.Contains(logged, `preflight_headers="authorization\nforged=`) || !strings.Contains(logged, `[truncated]"`) {
|
||||
t.Fatalf("preflight headers are not safely bounded: %q", logged)
|
||||
}
|
||||
}
|
||||
if strings.Count(logged, "\n") != 1 {
|
||||
t.Fatalf("untrusted preflight header injected a log line: %q", logged)
|
||||
}
|
||||
for _, sensitive := range []string{testChatToken, "provider-secret", "secret-cookie"} {
|
||||
if strings.Contains(logged, sensitive) {
|
||||
t.Fatalf("preflight log contains sensitive value %q: %q", sensitive, logged)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashScopeChatRejectsNonCompatibleJSON(t *testing.T) {
|
||||
tests := []string{
|
||||
``,
|
||||
|
||||
Reference in New Issue
Block a user