From 801c0af692ae17d38512a30bcc5dc3cbc196c7c5 Mon Sep 17 00:00:00 2001 From: andy Date: Sat, 5 Sep 2026 17:36:23 +0800 Subject: [PATCH] =?UTF-8?q?=E9=97=A8=E7=A6=81=E5=85=BC=E5=AE=B9=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 10 ++- CONTEXT.md | 8 +- PROJECT_STATE.md | 21 +++--- docs/architecture/chat-api-v1.md | 2 + .../integrations/superagent-openapi.md | 5 +- .../operations/docker-test-deployment.md | 6 ++ docs/project/operations/nginx-public-entry.md | 7 +- .../security-access-control-boundary.md | 4 +- docs/specs/fire-safety-ymd-chat-api-v1.md | 11 ++- ...safety-ymd-dashscope-compatible-chat-v1.md | 7 +- internal/app/app.go | 27 ++++--- internal/app/app_test.go | 34 ++++++--- internal/config/config.go | 75 ++++++++++++------- internal/config/config_test.go | 58 ++++++++++++++ internal/handler/chat.go | 19 ++--- internal/handler/chat_test.go | 16 ++++ internal/handler/dashscope_chat.go | 17 +++-- internal/handler/dashscope_chat_test.go | 17 +++++ 18 files changed, 257 insertions(+), 87 deletions(-) diff --git a/.env.example b/.env.example index ad00afb..6c6924f 100644 --- a/.env.example +++ b/.env.example @@ -25,9 +25,16 @@ FIRE_SAFETY_SUPERAGENT_PROBE_TIMEOUT=10m # User-facing chat API (disabled by default; requires SuperAgent above) FIRE_SAFETY_CHAT_ENABLED=false -# Test-stage static Bearer only. Generate a distinct high-entropy value of at least 32 printable ASCII characters. +# Test-stage static Bearer only. By default, generate a distinct high-entropy +# value of at least 32 printable ASCII characters. # Never reuse FIRE_SAFETY_SUPERAGENT_OPEN_API_KEY or FIRE_SAFETY_MCP_AUTH_TOKEN. FIRE_SAFETY_CHAT_AUTH_TOKEN= +# Compatibility exception for an already-issued legacy client credential that +# is shorter than 32 characters. Keep false for new environments. Set true +# only for a controlled test/migration window; even then the value must be +# non-empty, at most 4096 bytes, and contain only ASCII 0x21-0x7e (no spaces, +# controls, or Unicode). Rotate the credential, then restore false. +FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN=false # Server-controlled test subject. This is not final end-user identity or authorization. FIRE_SAFETY_CHAT_SUBJECT_ID=fire-safety-ymd-chat-test-subject # Optional legacy-client compatibility route: @@ -46,6 +53,7 @@ FIRE_SAFETY_CHAT_MAX_SESSIONS=1000 # Inbound MCP for SuperAgent (disabled by default) FIRE_SAFETY_MCP_ENABLED=false # Required when enabled. Use a new per-environment high-entropy token (at least 32 printable ASCII characters). +# FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN never relaxes this MCP requirement. # Never reuse FIRE_SAFETY_SUPERAGENT_OPEN_API_KEY. FIRE_SAFETY_MCP_AUTH_TOKEN= # Trusted server-side data scope: town_allowlist (default) or all. diff --git a/CONTEXT.md b/CONTEXT.md index 1a990c7..f665a70 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -6,13 +6,15 @@ 项目不自行训练或实现通用 Agent,也不允许大模型直接访问数据库。 +用户对话的 Chat 凭证默认要求至少 32 个可打印 ASCII 字符。若已经交付的旧客户端只能继续发送短凭证,必须在受控测试/迁移窗口显式开启 `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN`;该开关默认关闭,新环境不得开启,轮换后恢复关闭。无论开关如何,Chat 凭证仍须非空、不超过 4096 字节且仅含 ASCII `0x21-0x7e`(无空格、控制字符或 Unicode);该兼容范围只适用于 Chat 原生 Bearer 与兼容入口 `xtoken`,MCP Token 仍要求至少 32 个字符,三种凭证必须不同。 + ## 2. 当前系统组成 | 路径或系统 | 当前职责 | 当前状态 | | --- | --- | --- | | `cmd/server` | Go 服务进程入口 | 已建立 | | `internal/app` | 应用装配、readiness 和 HTTP 生命周期 | 已建立;按开关装配原生/兼容 Chat、SuperAgent 和 MCP/PostGIS | -| `internal/config` | 环境配置入口 | 已包含 HTTP、SuperAgent、Chat 兼容 App ID、MCP 与 PostGIS 配置校验和凭证分离门禁 | +| `internal/config` | 环境配置入口 | 已包含 HTTP、SuperAgent、Chat 兼容 App ID/legacy 短凭证开关、MCP 与 PostGIS 配置校验和凭证分离门禁 | | `internal/handler` | HTTP/MCP 入站协议层 | `GET /health` 已启用;默认关闭的原生 `/api/chat`、可选 DashScope 风格 `completion` 和 `/mcp` 已实现 | | `internal/service` | 业务用例编排 | 已实现单进程聊天会话/并发 Run 控制,以及地名候选、有界空间查询、可信数据库全范围/镇街白名单和结果语义 | | `internal/domain` | 森林防火领域模型与规则 | 已包含点位、水源、候选设施、通道、队伍和风险区模型 | @@ -36,7 +38,7 @@ - HTTP:Go 标准库 `net/http`。 - 测试:Go 标准库 `testing`、`httptest`。 - 配置:环境变量;支持 HTTP、SuperAgent、Chat、MCP 与 PostGIS 配置,并默认关闭 Chat 及两个外部方向。 -- Chat API:标准库 HTTP/SSE;原生 `/api/chat` 使用静态联调 Bearer,可选 `completion` 兼容入口使用同一信任方向的 `xtoken`;两者共享精确 Origin、严格 JSON、总超时、有界单进程会话和同会话并发冲突。兼容入口只在严格成功后发送正文。 +- Chat API:标准库 HTTP/SSE;原生 `/api/chat` 使用静态联调 Bearer,可选 `completion` 兼容入口使用同一信任方向的 `xtoken`;默认要求 Chat 凭证至少 32 个可打印 ASCII 字符,受控迁移时可通过默认关闭的 `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN` 兼容已交付短凭证;两者共享精确 Origin、严格 JSON、总超时、有界单进程会话和同会话并发冲突。兼容入口只在严格成功后发送正文。 - SuperAgent:标准库 HTTP/SSE 客户端,分离 Session 创建和消息发送,支持严格完成判定与既有 Run 断流恢复。 - MCP:标准库 HTTP/JSON-RPC,协议基线 `2025-06-18`,同步 JSON 响应,独立 Bearer 和 7 个只读工具;地名工具只搜索现有业务记录并要求用户确认候选。 - PostgreSQL:`github.com/jackc/pgx/v5 v5.10.0` 原生连接池;连接默认只读并设置 statement timeout。 @@ -48,7 +50,7 @@ - PostgreSQL/PostGIS 的适用索引和生产查询计划验证。 - SuperAgent 到 `/mcp` 的真实网络、TLS、Header 与 Token 联调。 - 任意地址/山名的外部地理编码、别名词典和大数据量地名索引。 -- 用户聊天的真实身份认证、动态授权、共享/持久会话、主动取消和限流策略;首版默认关闭的静态 Bearer + 内存会话 API 已实现。 +- 用户聊天的真实身份认证、动态授权、共享/持久会话、主动取消和限流策略;首版默认关闭的静态 Bearer + 内存会话 API 已实现。legacy 短凭证仅限受控测试/迁移窗口,轮换后须关闭兼容开关。 - 最终用户鉴权、动态角色/区域或租户隔离、持久审计与完整可观测性方案。 - 正式前端身份接入;现有客户端仅通过受限 DashScope 风格协议适配。 diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 3d6f593..2ad1144 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -4,14 +4,14 @@ | --- | --- | | 最近更新 | 2026-09-05 | | 当前分支 | `main` | -| 当前阶段 | 对话、SuperAgent、空间 MCP 与测试环境容器部署基线已完成;目标机首次构建排障中 | -| 当前重点 | 解决目标服务器访问 Go module proxy 超时,完成容器启动后再验证公网链路 | +| 当前阶段 | 对话、SuperAgent、空间 MCP 与测试环境容器部署基线已完成;目标机镜像构建成功,Chat 短凭证兼容方案已完成并待部署 | +| 当前重点 | 部署默认关闭的 Chat 短凭证兼容开关,完成容器启动后再验证 health 与公网链路 | ## 1. 当前 Checkpoint - 名称:`fire-safety-ymd-test-server-build-network-recovery` - 状态:In Progress -- 目标:在不关闭 Go module 校验的前提下,让 Docker 构建可显式选择目标服务器可达且经认可的模块代理,完成 `/home/firee-safety-ymd` 首次镜像构建与容器启动。 +- 目标:在不关闭 Go module 校验的前提下,让 Docker 构建可显式选择目标服务器可达且经认可的模块代理,并处理已交付旧 Chat 短凭证的受控兼容,完成 `/home/firee-safety-ymd` 容器启动。 - 非目标:替用户提交或推送 Git、直接修改远程服务器、创建数据库容器、迁移生产数据、签发证书、改变 DNS/安全组、实现真实用户认证、动态授权、会话持久化或生产审计。 当前进展: @@ -29,13 +29,13 @@ - Compose 配置不包含明文 Secret,且没有数据库容器、数据卷或迁移命令;现有业务数据库不会被部署动作重建。 - Nginx 上游固定为宿主机回环地址,兼容 Chat SSE 禁用缓冲和自动重试,未列出路径固定 404。 - 目标机执行步骤包含不渲染 `.env` 内容的 Compose 检查、`nginx -t` 前置门禁和可恢复的配置替换。 -- 目标服务器已开始部署:Nginx 配置语法检查通过,但 Docker 构建在 `go mod download` 阶段因访问 `proxy.golang.org:443` 超时而停止,应用尚未监听 16587。 +- 目标服务器已开始部署:Nginx 配置语法检查通过,Docker 镜像已构建成功;容器启动日志显示 `FIRE_SAFETY_CHAT_AUTH_TOKEN` 未通过默认至少 32 字符门禁而反复重启,`16587/health` 尚未验证。 ## 2. 当前优先级 -1. 为 Docker build 配置目标服务器实际可达、经运维认可的 Go module proxy,保留 `go.sum`/checksum database 校验并重新构建。 +1. 由用户审查、提交并部署显式 legacy 兼容开关;仅在已交付旧 Chat 短凭证的受控测试/迁移窗口开启,并确保轮换后恢复关闭。 2. 由用户审查本 checkpoint 变更后提交并推送 `origin/main`;服务器只部署明确提交的 revision。 -3. 在 `/home/firee-safety-ymd` 构建并启动 Compose,确认宿主机 16587 只绑定回环地址、容器内 8080 健康可达且数据库连接正常。 +3. 在 `/home/firee-safety-ymd` recreate Compose,确认宿主机 16587 只绑定回环地址、容器内 8080 健康可达且数据库连接正常。 4. reload 已通过语法检查的 Nginx 配置,只公开 HTTPS 兼容路径、`/mcp` 和可选 `/health`。 5. 使用项目专属测试 Key 和已发布消防 Profile 做兼容 `completion` 首轮/多轮真实冒烟,核对最终回答、用量和会话复用。 6. 为所有查询表补齐适用 GiST 索引并验证查询计划;当前小数据可做联调,但生产前必须完成索引与并发验证。 @@ -72,9 +72,10 @@ - 除防火网格外 7 张表缺少 GiST 几何索引;当前 geography 距离表达式的生产索引方案需根据实库查询计划确认。 - 水源/设施 `syzt`、水源 `hc_datetime` 等字段的枚举、单位、时区和更新责任人尚未确认。 - SuperAgent MCP 的公网/内网 URL、TLS、网络白名单、Header 行为、Token 注入和轮换尚未联调。 -- `/api/chat` 静态 Bearer 和兼容路径 `xtoken` 只适用于受控联调,浏览器用户可以看到它;真实用户身份、动态授权、生产速率限制和滥用防护尚未实现。 +- `/api/chat` 静态 Bearer 和兼容路径 `xtoken` 只适用于受控联调,浏览器用户可以看到它;真实用户身份、动态授权、生产速率限制和滥用防护尚未实现。Chat 短凭证仅可通过默认关闭的显式 legacy 开关在受控测试/迁移窗口使用,MCP Token 仍要求至少 32 个可打印 ASCII 字符,三种凭证必须不同。 - 目标公网机器的 Docker/Compose 和 Nginx 版本、配置 include 层级、证书、DNS、安全组及 PostgreSQL 网络拓扑尚未完整验证;用户已开始远程部署,仓库资产与服务器现场配置仍需完成一致性核验。 -- 目标服务器可以拉取 Docker 基础镜像元数据,但连续两次访问 `proxy.golang.org:443` 均在约 91 秒后超时;当前不能据此判断替代代理、GitHub direct 或所有外部 HTTPS 都不可达。 +- 目标服务器此前连续两次访问 `proxy.golang.org:443` 均在约 91 秒后超时,随后已通过可达的构建路径完成 Docker 镜像构建;该事实不代表所有外部 HTTPS 都可达。 +- 目标机当前容器因 `FIRE_SAFETY_CHAT_AUTH_TOKEN` 为已交付短凭证而未通过默认配置门禁,反复重启;`curl http://127.0.0.1:16587/health` 因服务未稳定监听而返回 connection refused。仓库已加入 `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN` 显式兼容方案,待用户提交并部署;公网 HTTPS、reload 和健康检查均尚未验证。 - Chat 会话只在单个 Go 进程内存中保存;重启或多实例切换会丢失上下文,且当前没有历史查询、持久审计或主动取消 Provider Run。 - 当前 `all`/`town_allowlist` 都是服务账号静态范围,不是最终用户级授权;`all` 会授权当前数据库中 MCP 固定查询表内所有镇街和镇街字段为空的记录,身份提供方、角色、租户和精确位置权限尚未确定。 - 地名搜索是无索引的有界包含匹配;真实数据量下的耗时、重名率和名称字段质量尚未验证,生产优化可能需要标准地名表、别名词典或 `pg_trgm` 索引。 @@ -103,12 +104,14 @@ - `GOCACHE=/private/tmp/fire-safety-go-cache go test -count=1 ./...`:通过;新增覆盖兼容 App ID、`xtoken`、CORS、严格请求子集、`null -> stop` SSE、会话复用映射、用量/模型名、错误脱敏和路由/应用装配;原有 Chat、SuperAgent、MCP/PostGIS 覆盖继续通过。 - `GOCACHE=/private/tmp/fire-safety-go-cache go vet ./...`:通过。 - `GOCACHE=/private/tmp/fire-safety-go-cache go test -race -count=1 ./...`:通过。 +- Chat legacy 短凭证回归:默认仍拒绝少于 32 个字符的 Chat Token;仅在 `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN=true` 时接受非空、最多 4096 字节且仅含 ASCII `0x21-0x7e` 的短值;原生与兼容入口均通过,MCP 至少 32 字符和三凭证分离门禁保持不变。 - 模拟 SuperAgent Chat 端到端:原生与兼容入口均通过;应用创建 Provider Session、发送消息、解析严格完成事件,原生返回 `conversation/message/done`,兼容入口返回 `result` 且最终 `finish_reason=stop`。 - Nginx:配置已完成静态检查且未包含真实 Secret;开发机未安装 Nginx。据用户截图,目标机 `nginx -t` 已通过,reload 和 HTTPS 实际响应尚未验证。 -- Docker/Compose:部署文件已通过 YAML/静态安全断言;开发机未安装 Docker。目标机已发起真实构建但未生成镜像,`docker compose config --quiet`、健康状态和运行容器仍待现场确认。 +- Docker/Compose:部署文件已通过 YAML/静态安全断言;开发机未安装 Docker。目标机后续 Docker 镜像构建已成功,但容器因 Chat 短凭证默认门禁反复重启;`docker compose config --quiet`、稳定健康状态和公网运行容器仍待现场确认。 - 构建代理回归检查:修复前静态反馈命令返回 `RED: Docker build has no configurable GOPROXY`;修复后确认 Compose build arg、Dockerfile `GOPROXY` 和运行容器清空边界,返回 GREEN。 - `CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -trimpath ./cmd/server`:通过,生成 Linux 静态服务二进制;不替代目标机真实 Docker build。 - 目标机首次 Docker 构建:失败;`go mod download` 获取 `github.com/jackc/pgpassfile@v1.0.0` 时连接 `proxy.golang.org:443` 超时,镜像/容器未生成,随后 `curl http://127.0.0.1:16587/health` 得到 connection refused,符合前置构建失败。 +- 目标机后续 Docker 构建:成功;但 `docker compose logs --tail=100 api` 报 `FIRE_SAFETY_CHAT_AUTH_TOKEN must contain at least 32 printable ASCII characters`,容器因配置校验失败重启,故 `curl http://127.0.0.1:16587/health` 仍未形成通过证据。 - 目标机 Nginx:用户截图显示 `nginx -t` 配置语法检查成功;是否已 reload 以及 HTTPS 实际响应仍待确认。 - SRID 迁移预检:通过;使用临时 `admin` 连接确认 4,048 条候选、8 表 UPDATE 权限和 7 表 ALTER 权限,未输出 DSN 或业务记录。 - SRID 数据迁移:通过;事务更新 4,048 条非空几何,7 张二维表改为 `geometry(Geometry,4326)`,防火通道保持裸 `geometry`,迁移前后几何载荷指纹与维度一致。 diff --git a/docs/architecture/chat-api-v1.md b/docs/architecture/chat-api-v1.md index 394c0d0..52fbf14 100644 --- a/docs/architecture/chat-api-v1.md +++ b/docs/architecture/chat-api-v1.md @@ -66,6 +66,8 @@ busy - 进度只允许安全字符组成的 `run.*` / `tool.*` 事件,以及工具名和状态;消息 delta、Provider ID、工具参数和结果不向客户端透传。 - 错误映射为稳定代码,不回显 Provider 响应正文、URL、Session、堆栈或 Secret。 - API 默认关闭;启用必须同时具备有效 SuperAgent 配置和独立 Chat Bearer。 +- Chat 凭证默认至少 32 个可打印 ASCII 字符。为兼容已经交付且无法立即更换的旧客户端短凭证,只有在受控测试/迁移窗口显式设置 `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN=true` 才允许短值;无论开关如何,凭证都必须非空、不超过 4096 字节且仅含 ASCII `0x21-0x7e`(无空格、控制字符或 Unicode)。该开关同时适用于原生 Bearer 和兼容 `xtoken`,不影响 MCP 的至少 32 字符门禁;三种凭证必须不同。轮换后恢复 `false`,新环境不得开启。 +- 开关启用时只记录不含 Secret 的安全 warning,提醒运维在迁移完成后关闭兼容。 ## 5. 伸缩与后续替换点 diff --git a/docs/project/integrations/superagent-openapi.md b/docs/project/integrations/superagent-openapi.md index 953b263..8f16804 100644 --- a/docs/project/integrations/superagent-openapi.md +++ b/docs/project/integrations/superagent-openapi.md @@ -74,6 +74,9 @@ FIRE_SAFETY_SUPERAGENT_OPEN_API_KEY= FIRE_SAFETY_CHAT_ENABLED=true FIRE_SAFETY_CHAT_AUTH_TOKEN= +# Keep false for new environments. Only set true during a controlled migration +# when an already-issued legacy Chat credential is shorter than 32 characters. +FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN=false FIRE_SAFETY_CHAT_ALLOWED_ORIGINS=http://localhost:5173 ``` @@ -105,7 +108,7 @@ go run ./cmd/superagent-probe - [`../../workflows/user-chat.md`](../../workflows/user-chat.md) - [`../../specs/fire-safety-ymd-chat-api-v1.md`](../../specs/fire-safety-ymd-chat-api-v1.md) -`FIRE_SAFETY_CHAT_AUTH_TOKEN` 是独立的首版联调凭证:原生接口将其作为 Bearer,兼容入口将其作为 `xtoken`。它不是最终用户登录;浏览器会暴露静态 Token,因此公网真实用户入口仍必须接入身份提供方、动态授权、限流和审计。 +`FIRE_SAFETY_CHAT_AUTH_TOKEN` 是独立的首版联调凭证:原生接口将其作为 Bearer,兼容入口将其作为 `xtoken`。默认要求至少 32 个字符;如果旧客户端已经拿到且无法立即更换的凭证较短,只能在受控测试/迁移窗口显式设置 `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN=true`。此时仍要求凭证非空、不超过 4096 字节,并且仅含 ASCII `0x21-0x7e`(无空格、控制字符或 Unicode);该开关不影响 MCP Token 的至少 32 个字符门禁,三种凭证仍必须不同。轮换后恢复 `false`,新环境不得开启;服务会记录不含 Secret 的安全 warning 提醒清理。它不是最终用户登录;浏览器会暴露静态 Token,因此公网真实用户入口仍必须接入身份提供方、动态授权、限流和审计。 ## 8. 与 MCP 的关系 diff --git a/docs/project/operations/docker-test-deployment.md b/docs/project/operations/docker-test-deployment.md index 0f801bf..0798958 100644 --- a/docs/project/operations/docker-test-deployment.md +++ b/docs/project/operations/docker-test-deployment.md @@ -112,6 +112,9 @@ FIRE_SAFETY_SUPERAGENT_OPEN_API_KEY= FIRE_SAFETY_CHAT_ENABLED=true FIRE_SAFETY_CHAT_AUTH_TOKEN=<独立的高熵xtoken> +# 新环境必须保持 false;仅当已交付旧客户端的短凭证无法立即更换时, +# 才能在受控测试/迁移窗口临时设置 true,轮换后恢复 false。 +FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN=false FIRE_SAFETY_CHAT_SUBJECT_ID=fire-safety-ymd-chat-test-subject FIRE_SAFETY_CHAT_COMPAT_APP_ID=<与Nginx路径完全一致的公开app-id> # 仅浏览器实际 Origin;CLI/服务端调用可以留空 @@ -131,11 +134,13 @@ FIRE_SAFETY_POSTGIS_EXPECTED_SRID=4326 注意: - FIRE_SAFETY_CHAT_AUTH_TOKEN 只用于兼容对话入口的 xtoken;FIRE_SAFETY_MCP_AUTH_TOKEN 只用于 SuperAgent 调用 /mcp;FIRE_SAFETY_SUPERAGENT_OPEN_API_KEY 只用于 Go 服务访问 SuperAgent。三者必须不同。 +- Chat Token 默认至少 32 个可打印 ASCII 字符。如果已交付的旧客户端凭证较短且无法立即更换,只能临时设置 `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN=true`;此时仍要求 Token 非空、不超过 4096 字节,并且每个字符都在 ASCII `0x21-0x7e` 范围内(无空格、控制字符或 Unicode)。该开关只影响 Chat 原生 Bearer 和兼容 `xtoken`,不放宽 MCP Token 至少 32 个可打印 ASCII 字符的要求。新环境不得开启,凭证轮换完成后必须恢复 `false`;开关启用时启动日志会记录不含 Secret 的安全 warning。 - FIRE_SAFETY_CHAT_COMPAT_APP_ID 是公开路径标识,不是 Secret;它必须与 Nginx 的精确 location = /api/v1/apps//completion 完全一致。 - 使用 FIRE_SAFETY_MCP_SCOPE_MODE=all 时,FIRE_SAFETY_MCP_ALLOWED_TOWNS 必须保持为空。all 是服务账号级的固定查询表范围,不是最终用户级授权。 - FIRE_SAFETY_POSTGIS_MIGRATION_DSN 不应配置给运行服务;迁移凭证只供一次性迁移命令使用,完成后应移除。 - FIRE_SAFETY_HTTP_ADDR 在容器内应为 :8080,安全边界由 Compose 的 127.0.0.1:16587:8080 和宿主机 Nginx 提供;不要在 Compose 场景设为容器内的 127.0.0.1:8080。 - 不要用没有 --quiet 的 docker compose config 或 docker inspect 把完整环境渲染到终端、CI 日志或工单;检查 .env 权限仍为 600。 +- 修改 `.env` 后必须重新创建容器(例如 `docker compose up -d --force-recreate --no-build`)才能加载新的 Chat 兼容开关;单独 `docker compose restart` 不会重新读取容器环境。旧短凭证仅用于受控测试/迁移,完成轮换后把开关改回 `false` 并再次 recreate。 ## 4. 容器访问现有 PostgreSQL/PostGIS @@ -402,6 +407,7 @@ docker compose down - 目标机 Compose 配置校验、build、up、健康检查和无 Secret 的有限日志结果。 - 目标机 nginx -t 和 reload 成功;443 证书、DNS、安全组及旧 location / 已确认不再生效。 - Chat 首轮/多轮 SSE、错误凭证、未知 app/path 的实际 HTTPS 响应。 +- 若为已交付旧客户端临时开启 Chat legacy 短凭证兼容,应记录受控迁移窗口,确认启动 warning 不含 Secret,并在凭证轮换后恢复 `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN=false`。 - SuperAgent -> /mcp 的独立 Bearer、TLS/网络白名单、工具发现和至少一个受控只读工具调用。 - 数据库未公开 5432,运行账号保持只读,PostGIS readiness warning 和 35 条无效面几何缺口已记录。 diff --git a/docs/project/operations/nginx-public-entry.md b/docs/project/operations/nginx-public-entry.md index 8bdb1fe..8c51768 100644 --- a/docs/project/operations/nginx-public-entry.md +++ b/docs/project/operations/nginx-public-entry.md @@ -55,7 +55,10 @@ FIRE_SAFETY_SUPERAGENT_BASE_URL=https:// FIRE_SAFETY_SUPERAGENT_OPEN_API_KEY= FIRE_SAFETY_CHAT_ENABLED=true -FIRE_SAFETY_CHAT_AUTH_TOKEN= +FIRE_SAFETY_CHAT_AUTH_TOKEN= +# Keep false for new environments. Only set true in a controlled migration +# when an already-issued legacy Chat credential is shorter than 32 characters. +FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN=false FIRE_SAFETY_CHAT_COMPAT_APP_ID= FIRE_SAFETY_MCP_ENABLED=true @@ -67,9 +70,11 @@ FIRE_SAFETY_POSTGIS_DSN= 当前仍是受控联调静态门禁: - `FIRE_SAFETY_CHAT_AUTH_TOKEN` 由用户请求的 `xtoken` Header 携带,必须与 SuperAgent Open API Key、MCP Token 使用不同值。 +- 默认要求 Chat Token 至少 32 个可打印 ASCII 字符;若已交付的旧客户端短凭证无法立即更换,只能在 Go 服务环境中显式设置 `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN=true` 做受控测试/迁移。无论开关如何,Token 都必须非空、不超过 4096 字节,并且只含 ASCII `0x21-0x7e`(无空格、控制字符或 Unicode);轮换完成后恢复 `false`,新环境不得开启。该开关同时适用于原生 Bearer 和兼容 `xtoken`,不放宽 MCP Token 的至少 32 字符门禁。 - `FIRE_SAFETY_MCP_AUTH_TOKEN` 只用于 SuperAgent -> Go `/mcp`,不应复用 Chat Token。 - Chat、SuperAgent、MCP 和 PostGIS 的完整配置校验以 `.env.example` 和对应项目文档为准。 - 浏览器会看到 `xtoken`;它不能代表最终用户身份、角色、租户或数据授权。公网真实用户入口仍需身份提供方、动态授权、限流、Secret 轮换和持久审计。 +- Nginx 不读取、比较或存储 Chat Token,也不需要为 legacy 开关增加配置;请求 Header 原样转发给 Go 校验。开关启用时 Go 仅记录不含 Secret 的安全 warning,便于迁移完成后清理。 测试服务器使用 Docker Compose 的完整目录、启动、更新和回滚步骤见 [`docker-test-deployment.md`](docker-test-deployment.md)。 diff --git a/docs/project/security-access-control-boundary.md b/docs/project/security-access-control-boundary.md index 3dfc74d..18a4118 100644 --- a/docs/project/security-access-control-boundary.md +++ b/docs/project/security-access-control-boundary.md @@ -15,7 +15,7 @@ | 能力 | 状态 | 当前含义 | | --- | --- | --- | | `GET /health` | 已实现 | 公开的进程存活响应,不访问业务数据 | -| 用户认证 | 仅首版联调门禁 | `/api/chat` 使用独立静态 Bearer;可选兼容路径用同一信任方向的 `xtoken`;它们不是最终用户身份,身份提供方和正式 Token 格式待确认 | +| 用户认证 | 仅首版联调门禁 | `/api/chat` 使用独立静态 Bearer;可选兼容路径用同一信任方向的 `xtoken`;默认要求至少 32 个可打印 ASCII 字符;已交付旧短凭证仅可通过默认关闭的显式 legacy 开关在受控测试/迁移窗口暂时兼容;它们不是最终用户身份,身份提供方和正式 Token 格式待确认 | | 角色/区域/租户授权 | 部分实现 | MCP 使用显式服务端静态范围:默认镇街白名单,或经授权的数据库全范围;最终用户动态身份/角色/租户仍未实现 | | SuperAgent Open API Adapter | 已实现、模拟测试通过 | 默认关闭;Secret 仅由项目环境变量注入;真实环境未联调 | | 用户聊天与会话映射 | 已实现、默认关闭 | 原生 `POST /api/chat` 和可选 DashScope 风格 `completion` 路径、严格 SSE 最终回答、单进程内存映射和同会话并发冲突;无持久化、跨实例恢复或最终用户授权 | @@ -48,6 +48,7 @@ - 联系人、电话和精确敏感位置按字段级权限控制,不因同一记录的普通字段可见而自动可见。 - `FIRE_SAFETY_MCP_SCOPE_MODE=all` 是 MCP 固定查询表的数据库全范围授权,只能在该 MCP 凭证确实获准读取这些表全部记录时显式使用;它不会从业务数据自动推导授权。 - `all` 与镇街白名单不能同时配置,避免操作者误以为白名单仍限制结果;两种模式都不能由模型或工具参数修改。 +- `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN` 默认必须为 `false`。只有已交付旧客户端的 Chat 凭证较短且无法立即更换时,才允许在受控测试/迁移窗口显式设为 `true`;无论开关如何,Chat 凭证必须非空、不超过 4096 字节且仅含 ASCII `0x21-0x7e`(不得包含空格、控制字符或 Unicode)。该开关只影响 Chat 原生 Bearer 和兼容入口 `xtoken`,不放宽 MCP Token 至少 32 个可打印 ASCII 字符的要求;Chat、SuperAgent Open API 和 MCP 三种凭证必须不同。轮换完成后必须恢复 `false`,新环境不得开启。启用期间仅记录不含 Secret 的安全 warning,以提示后续清理。 - 地名搜索只读取已允许的名称、镇街、村庄和几何字段,仍应用相同服务端数据范围;不读取联系人、电话或任意表。 - 地名匹配输出属于候选。线和面的代表点只用于帮助用户识别记录,未经用户确认不得作为演练点继续查询或形成距离结论。 @@ -93,6 +94,7 @@ MCP 第一阶段已按以下只读边界实现: - `/api/chat` 不转发消息 delta;只有 Adapter 的严格成功条件全部满足后才发送最终 `message` 和 `done`。公开进度只保留安全的 `run.*` / `tool.*` 事件、工具名和状态。 - 兼容 `completion` 路径同样不转发消息 delta;它先返回无正文的 `finish_reason="null"`,严格成功后才发送 `finish_reason="stop"` 和最终正文。URL App ID 只是公开路由标识,不是授权依据。 - 兼容路径的 `xtoken` 复用 Chat 入站凭证而不是 SuperAgent Key。Nginx 不保存、比较或注入任何应用 Secret,只把 Header 交给 Go 校验。 +- `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN` 是 Go 服务侧的配置,不由 Nginx 参与;Nginx 不读取、比较或存储 Chat Token,短凭证兼容规则和 warning 均由 Go 执行。 - 同一内存会话只允许一个活动 Run。流失败或结果不确定时删除本地映射,避免继续复用可能仍在运行的 Provider Session。 - 当前会话只在单进程内存中保存且有数量/TTL 上限;不保存消息历史。重启或多实例切换会导致旧对话不可用,不得向用户承诺持久会话。 diff --git a/docs/specs/fire-safety-ymd-chat-api-v1.md b/docs/specs/fire-safety-ymd-chat-api-v1.md index 4f9042a..8f3323a 100644 --- a/docs/specs/fire-safety-ymd-chat-api-v1.md +++ b/docs/specs/fire-safety-ymd-chat-api-v1.md @@ -17,7 +17,7 @@ ## 2. 目标 - 新增默认关闭的 `POST /api/chat`。 -- 使用独立静态 Bearer Token 保护首版测试入口,不复用 SuperAgent Key 或 MCP Token。 +- 使用独立静态 Bearer Token 保护首版测试入口,不复用 SuperAgent Key 或 MCP Token;默认要求至少 32 个可打印 ASCII 字符。 - 首轮创建随机本地 `conversation_id` 和 SuperAgent Session;后续轮次复用映射。 - 同一 `conversation_id` 同时只允许一个活动 Run,冲突时返回 `409`。 - 通过 SSE 返回对话 ID、安全进度、严格完成后的最终回答和完成事件。 @@ -119,7 +119,8 @@ SSE 开始后的 Provider 失败使用终止 `error` 事件,不返回部分回 | 环境变量 | 默认值 | 说明 | | --- | --- | --- | | `FIRE_SAFETY_CHAT_ENABLED` | `false` | 对话 API 总开关;启用时要求 SuperAgent 同时启用 | -| `FIRE_SAFETY_CHAT_AUTH_TOKEN` | 空 | 独立高熵静态 Bearer,至少 32 个可打印 ASCII 字符 | +| `FIRE_SAFETY_CHAT_AUTH_TOKEN` | 空 | 独立静态 Bearer;默认至少 32 个可打印 ASCII 字符,兼容开关开启时允许已交付的旧短凭证 | +| `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN` | `false` | 仅为已交付旧客户端短凭证的受控测试/迁移临时兼容开关;新环境不得开启 | | `FIRE_SAFETY_CHAT_SUBJECT_ID` | `fire-safety-ymd-chat-test-subject` | 服务端固定测试主体,不使用真实用户标识 | | `FIRE_SAFETY_CHAT_ALLOWED_ORIGINS` | 空 | 逗号分隔的精确 HTTP(S) Origin;空值只允许无 Origin 的服务端/CLI 调用 | | `FIRE_SAFETY_CHAT_MAX_BODY_BYTES` | `131072` | HTTP JSON 请求体上限,最大 1 MiB | @@ -129,13 +130,15 @@ SSE 开始后的 Provider 失败使用终止 `error` 事件,不返回部分回 `FIRE_SAFETY_CHAT_AUTH_TOKEN` 必须分别不同于 `FIRE_SAFETY_SUPERAGENT_OPEN_API_KEY` 和 `FIRE_SAFETY_MCP_AUTH_TOKEN`。 +`FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN` 默认为 `false`。只有在旧客户端已经拿到、且无法立即更换的短凭证时,才可在受控测试或迁移窗口显式设置为 `true`。无论开关取值如何,`FIRE_SAFETY_CHAT_AUTH_TOKEN` 都必须非空、不超过 4096 字节,并且每个字符都在 ASCII `0x21-0x7e` 范围内(不得包含空格、控制字符或 Unicode);开关只影响 Chat 原生 Bearer 和兼容入口的 `xtoken`,不放宽 MCP Token 至少 32 个可打印 ASCII 字符的门禁。三种凭证仍必须使用不同值。凭证轮换完成后必须恢复 `false`;新环境不得以该开关绕过默认门禁。开关启用时,服务启动日志会记录不含 Secret 的安全 warning,便于后续清理。 + ## 7. CORS 与鉴权边界 - 无 `Origin` 的 CLI/服务端请求可以进入 Bearer 校验。 - 带 `Origin` 的浏览器请求必须精确匹配配置列表;不支持 `*`。 - 预检只允许 `POST` 以及 `Authorization`、`Content-Type` Header。 - 不启用 Cookie 身份或 CORS credentials。 -- 静态 Bearer 只适合首版受控联调;公网最终用户入口必须接入真实身份认证、速率限制和动态授权。 +- 静态 Bearer 只适合首版受控联调;legacy 短凭证开关只适合明确的迁移窗口,公网最终用户入口必须接入真实身份认证、速率限制和动态授权。 ## 8. 日志与数据边界 @@ -153,6 +156,8 @@ SSE 开始后的 Provider 失败使用终止 `error` 事件,不返回部分回 - Given 同一会话已有活动 Run,When 再次发送,Then 返回 `409` 且不发第二个上游请求。 - Given Provider 流不完整或 Run 失败,When 处理结束,Then 不发送 `message` 或 `done`,只返回安全错误。 - Given 服务重启、会话过期或随机 ID 不存在,When 继续对话,Then 返回 `404` 而不把客户端值当作 Provider Session。 +- Given `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN` 未开启,When Chat Token 少于 32 个字符,Then 服务启动配置校验失败。 +- Given `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN=true`,When 旧短凭证用于受控测试/迁移,Then 原生 Bearer 和兼容 `xtoken` 均可校验,但非空、4096 字节上限和 ASCII `0x21-0x7e` 约束仍生效,且启动日志只记录不含 Secret 的安全 warning。 - Given 无真实网络和 Secret,When 执行自动化测试,Then 使用本地 fake/模拟 Provider 并全部通过。 ## 10. Definition of Done diff --git a/docs/specs/fire-safety-ymd-dashscope-compatible-chat-v1.md b/docs/specs/fire-safety-ymd-dashscope-compatible-chat-v1.md index 2dffc2d..95a298e 100644 --- a/docs/specs/fire-safety-ymd-dashscope-compatible-chat-v1.md +++ b/docs/specs/fire-safety-ymd-dashscope-compatible-chat-v1.md @@ -36,10 +36,13 @@ | 环境变量 | 默认值 | 说明 | | --- | --- | --- | | `FIRE_SAFETY_CHAT_COMPAT_APP_ID` | 空 | 非空时注册兼容路径;1 至 128 个 ASCII 字母、数字、下划线或连字符 | -| `FIRE_SAFETY_CHAT_AUTH_TOKEN` | 空 | 兼容路径期望的 `xtoken`,仍必须与 SuperAgent Key、MCP Token 不同 | +| `FIRE_SAFETY_CHAT_AUTH_TOKEN` | 空 | 兼容路径期望的 `xtoken`;默认至少 32 个可打印 ASCII 字符,兼容开关开启时允许已交付的旧短凭证 | +| `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN` | `false` | 仅为已交付旧客户端短凭证的受控测试/迁移临时兼容开关;新环境不得开启 | 兼容路径只有在 `FIRE_SAFETY_CHAT_ENABLED=true` 且 App ID 非空时注册。App ID 是用于路径匹配的公开标识,不是 Secret;不匹配的路径返回 404。 +`FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN` 默认为 `false`。只有在旧客户端已经拿到、且无法立即更换的短 `xtoken` 时,才可在受控测试或迁移窗口显式设置为 `true`。无论开关取值如何,Chat 凭证都必须非空、不超过 4096 字节,并且每个字符都在 ASCII `0x21-0x7e` 范围内(不得包含空格、控制字符或 Unicode);开关同时适用于原生 `/api/chat` 的 Bearer 和本兼容路径的 `xtoken`,不放宽 MCP Token 至少 32 个可打印 ASCII 字符的门禁。Chat、SuperAgent Open API 和 MCP 三种凭证仍必须使用不同值。凭证轮换完成后必须恢复 `false`,新环境不得开启该开关。开关启用时,服务启动日志会记录不含 Secret 的安全 warning,便于后续清理。 + ## 5. 请求契约 ```http @@ -130,6 +133,8 @@ SSE 开始后的失败发送 `event: error`,只包含稳定 code、通用 mess - Given App ID 未配置,When 请求兼容路径,Then 路由返回 404,原生 `/api/chat` 行为不变。 - Given App ID 或 `xtoken` 错误,When 请求,Then 在读取问题和调用 SuperAgent 前拒绝。 +- Given `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN` 未开启,When 已交付的旧 `xtoken` 少于 32 个字符,Then 服务启动配置校验失败。 +- Given `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN=true`,When 旧短 `xtoken` 用于受控测试/迁移,Then 兼容入口允许该凭证但仍执行非空、4096 字节上限和 ASCII `0x21-0x7e` 校验,并记录不含 Secret 的安全 warning;MCP Token 的至少 32 字符门禁不变。 - Given 首轮请求成功,When 读取 SSE,Then 先收到字符串 `"null"` 会话事件,再收到同会话 `"stop"` 最终正文。 - Given 后续请求携带成功返回的 `session_id`,When 调用,Then 复用同一服务端会话映射。 - Given Provider 流失败,When SSE 已开始,Then 收到安全 `error`,不收到部分正文或 `stop`。 diff --git a/internal/app/app.go b/internal/app/app.go index 3ba0b84..c3ec66c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -41,6 +41,9 @@ func New(ctx context.Context, cfg config.Config) (*Application, error) { if !cfg.SuperAgent.Enabled { return nil, fmt.Errorf("initialize chat: SuperAgent must be enabled") } + if cfg.Chat.AllowLegacyShortToken { + log.Printf("security warning: legacy short chat auth token compatibility is enabled") + } superAgentClient, err := superagent.NewHTTPClient(superagent.Config{ Enabled: cfg.SuperAgent.Enabled, BaseURL: cfg.SuperAgent.BaseURL, @@ -67,11 +70,12 @@ func New(ctx context.Context, cfg config.Config) (*Application, error) { return nil, fmt.Errorf("initialize chat service: %w", err) } chatHandler, err := handler.NewChatHandler(chatService, handler.ChatOptions{ - AuthToken: cfg.Chat.AuthToken, - AllowedOrigins: cfg.Chat.AllowedOrigins, - MaxBodyBytes: cfg.Chat.MaxBodyBytes, - RunTimeout: cfg.Chat.RunTimeout, - Logger: log.Default(), + AuthToken: cfg.Chat.AuthToken, + AllowLegacyShortToken: cfg.Chat.AllowLegacyShortToken, + AllowedOrigins: cfg.Chat.AllowedOrigins, + MaxBodyBytes: cfg.Chat.MaxBodyBytes, + RunTimeout: cfg.Chat.RunTimeout, + Logger: log.Default(), }) if err != nil { return nil, fmt.Errorf("initialize chat handler: %w", err) @@ -79,12 +83,13 @@ func New(ctx context.Context, cfg config.Config) (*Application, error) { routerOptions.Chat = chatHandler if cfg.Chat.CompatAppID != "" { dashScopeChatHandler, err := handler.NewDashScopeChatHandler(chatService, handler.DashScopeChatOptions{ - AppID: cfg.Chat.CompatAppID, - AuthToken: cfg.Chat.AuthToken, - AllowedOrigins: cfg.Chat.AllowedOrigins, - MaxBodyBytes: cfg.Chat.MaxBodyBytes, - RunTimeout: cfg.Chat.RunTimeout, - Logger: log.Default(), + AppID: cfg.Chat.CompatAppID, + AuthToken: cfg.Chat.AuthToken, + AllowLegacyShortToken: cfg.Chat.AllowLegacyShortToken, + AllowedOrigins: cfg.Chat.AllowedOrigins, + MaxBodyBytes: cfg.Chat.MaxBodyBytes, + RunTimeout: cfg.Chat.RunTimeout, + Logger: log.Default(), }) if err != nil { return nil, fmt.Errorf("initialize DashScope-compatible chat handler: %w", err) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 82f8cc4..d8b5b17 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -1,8 +1,10 @@ package app import ( + "bytes" "context" "fmt" + "log" "net/http" "net/http/httptest" "strings" @@ -43,6 +45,11 @@ func TestNewKeepsExternalIntegrationsDisabledByDefault(t *testing.T) { } func TestNewWiresChatAPIToSuperAgent(t *testing.T) { + var applicationLogs bytes.Buffer + originalLogOutput := log.Writer() + log.SetOutput(&applicationLogs) + t.Cleanup(func() { log.SetOutput(originalLogOutput) }) + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "Bearer provider-open-api-key" { t.Errorf("provider Authorization = %q", r.Header.Get("Authorization")) @@ -77,14 +84,15 @@ func TestNewWiresChatAPIToSuperAgent(t *testing.T) { MaxMessageBytes: 4096, }, Chat: config.ChatConfig{ - Enabled: true, - AuthToken: "0123456789abcdef0123456789abcdef", - SubjectID: "app-chat-test-subject", - CompatAppID: "fire-safety-app", - MaxBodyBytes: 4096, - RunTimeout: time.Minute, - SessionTTL: time.Minute, - MaxSessions: 10, + Enabled: true, + AuthToken: "delivered-key", + AllowLegacyShortToken: true, + SubjectID: "app-chat-test-subject", + CompatAppID: "fire-safety-app", + MaxBodyBytes: 4096, + RunTimeout: time.Minute, + SessionTTL: time.Minute, + MaxSessions: 10, }, }) if err != nil { @@ -93,7 +101,7 @@ func TestNewWiresChatAPIToSuperAgent(t *testing.T) { defer application.close() request := httptest.NewRequest(http.MethodPost, "/api/chat", strings.NewReader(`{"message":"你好"}`)) - request.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef") + request.Header.Set("Authorization", "Bearer delivered-key") request.Header.Set("Content-Type", "application/json") request.Header.Set("Accept", "text/event-stream") response := httptest.NewRecorder() @@ -105,7 +113,7 @@ func TestNewWiresChatAPIToSuperAgent(t *testing.T) { } compatRequest := httptest.NewRequest(http.MethodPost, "/api/v1/apps/fire-safety-app/completion", strings.NewReader(`{"input":{"prompt":"你好"},"parameters":{}}`)) - compatRequest.Header.Set("xtoken", "0123456789abcdef0123456789abcdef") + compatRequest.Header.Set("xtoken", "delivered-key") compatRequest.Header.Set("Content-Type", "application/json") compatResponse := httptest.NewRecorder() application.server.Handler.ServeHTTP(compatResponse, compatRequest) @@ -113,4 +121,10 @@ func TestNewWiresChatAPIToSuperAgent(t *testing.T) { !strings.Contains(compatResponse.Body.String(), `"finish_reason":"stop"`) || !strings.Contains(compatResponse.Body.String(), "测试回答") { t.Fatalf("compat chat status=%d body=%s", compatResponse.Code, compatResponse.Body.String()) } + if !strings.Contains(applicationLogs.String(), "legacy short chat auth token compatibility is enabled") { + t.Fatalf("application logs did not contain legacy compatibility warning: %s", applicationLogs.String()) + } + if strings.Contains(applicationLogs.String(), "delivered-key") { + t.Fatal("application logs leaked the chat auth token") + } } diff --git a/internal/config/config.go b/internal/config/config.go index 16cf10b..0082d4f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,15 +25,16 @@ const ( SuperAgentProbeSubjectIDEnv = "FIRE_SAFETY_SUPERAGENT_PROBE_SUBJECT_ID" SuperAgentProbeTimeoutEnv = "FIRE_SAFETY_SUPERAGENT_PROBE_TIMEOUT" - ChatEnabledEnv = "FIRE_SAFETY_CHAT_ENABLED" - ChatAuthTokenEnv = "FIRE_SAFETY_CHAT_AUTH_TOKEN" - ChatSubjectIDEnv = "FIRE_SAFETY_CHAT_SUBJECT_ID" - ChatAllowedOriginsEnv = "FIRE_SAFETY_CHAT_ALLOWED_ORIGINS" - ChatMaxBodyBytesEnv = "FIRE_SAFETY_CHAT_MAX_BODY_BYTES" - ChatRunTimeoutEnv = "FIRE_SAFETY_CHAT_RUN_TIMEOUT" - ChatSessionTTLEnv = "FIRE_SAFETY_CHAT_SESSION_TTL" - ChatMaxSessionsEnv = "FIRE_SAFETY_CHAT_MAX_SESSIONS" - ChatCompatAppIDEnv = "FIRE_SAFETY_CHAT_COMPAT_APP_ID" + ChatEnabledEnv = "FIRE_SAFETY_CHAT_ENABLED" + ChatAuthTokenEnv = "FIRE_SAFETY_CHAT_AUTH_TOKEN" + ChatAllowLegacyShortTokenEnv = "FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN" + ChatSubjectIDEnv = "FIRE_SAFETY_CHAT_SUBJECT_ID" + ChatAllowedOriginsEnv = "FIRE_SAFETY_CHAT_ALLOWED_ORIGINS" + ChatMaxBodyBytesEnv = "FIRE_SAFETY_CHAT_MAX_BODY_BYTES" + ChatRunTimeoutEnv = "FIRE_SAFETY_CHAT_RUN_TIMEOUT" + ChatSessionTTLEnv = "FIRE_SAFETY_CHAT_SESSION_TTL" + ChatMaxSessionsEnv = "FIRE_SAFETY_CHAT_MAX_SESSIONS" + ChatCompatAppIDEnv = "FIRE_SAFETY_CHAT_COMPAT_APP_ID" MCPEnabledEnv = "FIRE_SAFETY_MCP_ENABLED" MCPAuthTokenEnv = "FIRE_SAFETY_MCP_AUTH_TOKEN" @@ -117,15 +118,16 @@ type SuperAgentConfig struct { // conversation settings. The static credential is a test-stage boundary, not // final end-user authentication. type ChatConfig struct { - Enabled bool - AuthToken string - SubjectID string - CompatAppID string - AllowedOrigins []string - MaxBodyBytes int64 - RunTimeout time.Duration - SessionTTL time.Duration - MaxSessions int + Enabled bool + AuthToken string + AllowLegacyShortToken bool + SubjectID string + CompatAppID string + AllowedOrigins []string + MaxBodyBytes int64 + RunTimeout time.Duration + SessionTTL time.Duration + MaxSessions int } // MCPConfig contains inbound SuperAgent MCP settings. @@ -229,6 +231,10 @@ func loadChatConfig() (ChatConfig, error) { if err != nil { return ChatConfig{}, err } + allowLegacyShortToken, err := parseBool(ChatAllowLegacyShortTokenEnv, false) + if err != nil { + return ChatConfig{}, err + } maxBodyBytes, err := parseInt64(ChatMaxBodyBytesEnv, defaultChatMaxBodyBytes) if err != nil { return ChatConfig{}, err @@ -251,15 +257,16 @@ func loadChatConfig() (ChatConfig, error) { } cfg := ChatConfig{ - Enabled: enabled, - AuthToken: strings.TrimSpace(os.Getenv(ChatAuthTokenEnv)), - SubjectID: valueOrDefault(ChatSubjectIDEnv, defaultChatSubjectID), - CompatAppID: strings.TrimSpace(os.Getenv(ChatCompatAppIDEnv)), - AllowedOrigins: allowedOrigins, - MaxBodyBytes: maxBodyBytes, - RunTimeout: runTimeout, - SessionTTL: sessionTTL, - MaxSessions: int(maxSessions), + Enabled: enabled, + AuthToken: os.Getenv(ChatAuthTokenEnv), + AllowLegacyShortToken: allowLegacyShortToken, + SubjectID: valueOrDefault(ChatSubjectIDEnv, defaultChatSubjectID), + CompatAppID: strings.TrimSpace(os.Getenv(ChatCompatAppIDEnv)), + AllowedOrigins: allowedOrigins, + MaxBodyBytes: maxBodyBytes, + RunTimeout: runTimeout, + SessionTTL: sessionTTL, + MaxSessions: int(maxSessions), } if cfg.MaxBodyBytes <= 0 || cfg.MaxBodyBytes > maximumChatBodyBytes { return ChatConfig{}, fmt.Errorf("%s must be between 1 and %d", ChatMaxBodyBytesEnv, maximumChatBodyBytes) @@ -279,12 +286,22 @@ func loadChatConfig() (ChatConfig, error) { if cfg.CompatAppID != "" && !validChatCompatAppID(cfg.CompatAppID) { return ChatConfig{}, fmt.Errorf("%s must contain 1 to 128 ASCII letters, digits, underscores, or hyphens", ChatCompatAppIDEnv) } - if cfg.Enabled && (len(cfg.AuthToken) < 32 || !validSecretHeaderValue(cfg.AuthToken)) { - return ChatConfig{}, fmt.Errorf("%s must contain at least 32 printable ASCII characters", ChatAuthTokenEnv) + if cfg.Enabled && !validChatAuthToken(cfg.AuthToken, cfg.AllowLegacyShortToken) { + if cfg.AllowLegacyShortToken { + return ChatConfig{}, fmt.Errorf("%s must contain 1 to 4096 printable ASCII characters when %s is true", ChatAuthTokenEnv, ChatAllowLegacyShortTokenEnv) + } + return ChatConfig{}, fmt.Errorf("%s must contain 32 to 4096 printable ASCII characters", ChatAuthTokenEnv) } return cfg, nil } +func validChatAuthToken(value string, allowLegacyShortToken bool) bool { + if !validSecretHeaderValue(value) { + return false + } + return allowLegacyShortToken || len(value) >= 32 +} + func validChatCompatAppID(value string) bool { if value == "" || len(value) > 128 { return false diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b262278..5b82783 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,6 +1,7 @@ package config import ( + "strconv" "strings" "testing" "time" @@ -40,6 +41,9 @@ func TestLoadDefaults(t *testing.T) { if cfg.Chat.Enabled { t.Fatal("Chat.Enabled = true, want false") } + if cfg.Chat.AllowLegacyShortToken { + t.Fatal("Chat.AllowLegacyShortToken = true, want false") + } if cfg.Chat.SubjectID != "fire-safety-ymd-chat-test-subject" || cfg.Chat.MaxBodyBytes != 128*1024 || cfg.Chat.CompatAppID != "" || cfg.Chat.RunTimeout != 10*time.Minute || cfg.Chat.SessionTTL != 30*time.Minute || cfg.Chat.MaxSessions != 1000 { t.Fatalf("unexpected Chat defaults: %#v", cfg.Chat) @@ -209,6 +213,7 @@ func TestLoadRejectsInvalidValues(t *testing.T) { {name: "probe timeout syntax", key: SuperAgentProbeTimeoutEnv, value: "forever"}, {name: "probe timeout non-positive", key: SuperAgentProbeTimeoutEnv, value: "0s"}, {name: "Chat boolean", key: ChatEnabledEnv, value: "sometimes"}, + {name: "Chat legacy short token boolean", key: ChatAllowLegacyShortTokenEnv, value: "sometimes"}, {name: "Chat body syntax", key: ChatMaxBodyBytesEnv, value: "large"}, {name: "Chat body non-positive", key: ChatMaxBodyBytesEnv, value: "0"}, {name: "Chat body too large", key: ChatMaxBodyBytesEnv, value: "1048577"}, @@ -300,6 +305,57 @@ func TestLoadRequiresEnabledChatSettings(t *testing.T) { } } +func TestLoadChatLegacyShortTokenPolicy(t *testing.T) { + configureChat := func(t *testing.T, token string, allowLegacy bool) { + t.Helper() + t.Setenv(SuperAgentEnabledEnv, "true") + t.Setenv(SuperAgentBaseURLEnv, "https://superagent.example.test") + t.Setenv(SuperAgentOpenAPIKeyEnv, "different-superagent-open-api-key") + t.Setenv(ChatEnabledEnv, "true") + t.Setenv(ChatAuthTokenEnv, token) + t.Setenv(ChatAllowLegacyShortTokenEnv, strconv.FormatBool(allowLegacy)) + } + + t.Run("default rejects short token", func(t *testing.T) { + clearEnvironment(t) + configureChat(t, "delivered-key", false) + if _, err := Load(); err == nil || !strings.Contains(err.Error(), ChatAuthTokenEnv) { + t.Fatalf("Load() error = %v, want short token rejection", err) + } + }) + + t.Run("explicit compatibility allows short token", func(t *testing.T) { + clearEnvironment(t) + configureChat(t, "delivered-key", true) + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if !cfg.Chat.AllowLegacyShortToken || cfg.Chat.AuthToken != "delivered-key" { + t.Fatalf("unexpected Chat config: %#v", cfg.Chat) + } + }) + + for _, tt := range []struct { + name string + token string + }{ + {name: "empty", token: ""}, + {name: "space", token: "delivered key"}, + {name: "control character", token: "delivered\tkey"}, + {name: "Unicode", token: "delivered密钥"}, + {name: "oversized", token: strings.Repeat("a", 4097)}, + } { + t.Run("compatibility rejects "+tt.name, func(t *testing.T) { + clearEnvironment(t) + configureChat(t, tt.token, true) + if _, err := Load(); err == nil || !strings.Contains(err.Error(), ChatAuthTokenEnv) { + t.Fatalf("Load() error = %v, want unsafe token rejection", err) + } + }) + } +} + func TestLoadRequiresEnabledMCPSettings(t *testing.T) { validToken := "0123456789abcdef0123456789abcdef" validDSN := "postgresql://fire:secret@127.0.0.1:5432/fire_safety?sslmode=disable" @@ -318,6 +374,7 @@ func TestLoadRequiresEnabledMCPSettings(t *testing.T) { { name: "short token", configure: func(t *testing.T) { + t.Setenv(ChatAllowLegacyShortTokenEnv, "true") t.Setenv(MCPAuthTokenEnv, "short-token") t.Setenv(MCPAllowedTownsEnv, "莒格庄镇") }, @@ -465,6 +522,7 @@ func clearEnvironment(t *testing.T) { SuperAgentProbeTimeoutEnv, ChatEnabledEnv, ChatAuthTokenEnv, + ChatAllowLegacyShortTokenEnv, ChatSubjectIDEnv, ChatAllowedOriginsEnv, ChatMaxBodyBytesEnv, diff --git a/internal/handler/chat.go b/internal/handler/chat.go index 3f56838..7a434ce 100644 --- a/internal/handler/chat.go +++ b/internal/handler/chat.go @@ -32,11 +32,12 @@ type ChatUseCase interface { // ChatOptions contains the independently authenticated user-facing transport settings. type ChatOptions struct { - AuthToken string - AllowedOrigins []string - MaxBodyBytes int64 - RunTimeout time.Duration - Logger *log.Logger + AuthToken string + AllowLegacyShortToken bool + AllowedOrigins []string + MaxBodyBytes int64 + RunTimeout time.Duration + Logger *log.Logger } // ChatHandler exposes a bounded SSE chat API without exposing provider credentials or sessions. @@ -54,8 +55,8 @@ func NewChatHandler(chat ChatUseCase, options ChatOptions) (*ChatHandler, error) if chat == nil { return nil, errors.New("chat service is required") } - if !validChatToken(options.AuthToken) { - return nil, errors.New("chat auth token must contain at least 32 printable ASCII characters") + if !validChatToken(options.AuthToken, options.AllowLegacyShortToken) { + return nil, errors.New("chat auth token is outside the configured printable ASCII length policy") } if options.MaxBodyBytes <= 0 || options.MaxBodyBytes > maximumChatBodyBytes { return nil, errors.New("chat max body bytes is outside the supported range") @@ -389,8 +390,8 @@ func publicChatError(err error) (int, string, string) { } } -func validChatToken(value string) bool { - if len(value) < 32 || len(value) > 4096 { +func validChatToken(value string, allowLegacyShortToken bool) bool { + if value == "" || len(value) > 4096 || (!allowLegacyShortToken && len(value) < 32) { return false } for _, character := range value { diff --git a/internal/handler/chat_test.go b/internal/handler/chat_test.go index 5809c8c..e0794c2 100644 --- a/internal/handler/chat_test.go +++ b/internal/handler/chat_test.go @@ -19,6 +19,10 @@ const testChatToken = "abcdef0123456789abcdef0123456789" func TestNewChatHandlerRejectsUnsafeOptions(t *testing.T) { tests := []ChatOptions{ {AuthToken: "short", MaxBodyBytes: 1, RunTimeout: time.Second}, + {AuthToken: "", AllowLegacyShortToken: true, MaxBodyBytes: 1, RunTimeout: time.Second}, + {AuthToken: "delivered\tkey", AllowLegacyShortToken: true, MaxBodyBytes: 1, RunTimeout: time.Second}, + {AuthToken: "delivered密钥", AllowLegacyShortToken: true, MaxBodyBytes: 1, RunTimeout: time.Second}, + {AuthToken: strings.Repeat("a", 4097), AllowLegacyShortToken: true, MaxBodyBytes: 1, RunTimeout: time.Second}, {AuthToken: testChatToken, MaxBodyBytes: maximumChatBodyBytes + 1, RunTimeout: time.Second}, {AuthToken: testChatToken, MaxBodyBytes: 1, RunTimeout: maximumChatRunTime + time.Second}, {AuthToken: testChatToken, AllowedOrigins: []string{"*"}, MaxBodyBytes: 1, RunTimeout: time.Second}, @@ -31,6 +35,18 @@ func TestNewChatHandlerRejectsUnsafeOptions(t *testing.T) { } } +func TestNewChatHandlerAllowsExplicitLegacyShortToken(t *testing.T) { + _, err := NewChatHandler(&fakeChatUseCase{}, ChatOptions{ + AuthToken: "delivered-key", + AllowLegacyShortToken: true, + MaxBodyBytes: 1, + RunTimeout: time.Second, + }) + if err != nil { + t.Fatalf("NewChatHandler() error = %v", err) + } +} + func TestChatTransportGuardsRunBeforeService(t *testing.T) { tests := []struct { name string diff --git a/internal/handler/dashscope_chat.go b/internal/handler/dashscope_chat.go index 920744e..d37391e 100644 --- a/internal/handler/dashscope_chat.go +++ b/internal/handler/dashscope_chat.go @@ -20,12 +20,13 @@ import ( // transport. Its token authenticates callers of this service and is never a // provider credential. type DashScopeChatOptions struct { - AppID string - AuthToken string - AllowedOrigins []string - MaxBodyBytes int64 - RunTimeout time.Duration - Logger *log.Logger + AppID string + AuthToken string + AllowLegacyShortToken bool + AllowedOrigins []string + MaxBodyBytes int64 + RunTimeout time.Duration + Logger *log.Logger } // DashScopeChatHandler adapts the provider-neutral ChatUseCase to the narrow @@ -48,8 +49,8 @@ func NewDashScopeChatHandler(chat ChatUseCase, options DashScopeChatOptions) (*D if !validDashScopeAppID(options.AppID) { return nil, errors.New("DashScope-compatible app ID is invalid") } - if !validChatToken(options.AuthToken) { - return nil, errors.New("DashScope-compatible auth token must contain at least 32 printable ASCII characters") + if !validChatToken(options.AuthToken, options.AllowLegacyShortToken) { + return nil, errors.New("DashScope-compatible auth token is outside the configured printable ASCII length policy") } if options.MaxBodyBytes <= 0 || options.MaxBodyBytes > maximumChatBodyBytes { return nil, errors.New("DashScope-compatible max body bytes is outside the supported range") diff --git a/internal/handler/dashscope_chat_test.go b/internal/handler/dashscope_chat_test.go index e5541aa..d910572 100644 --- a/internal/handler/dashscope_chat_test.go +++ b/internal/handler/dashscope_chat_test.go @@ -22,6 +22,10 @@ func TestNewDashScopeChatHandlerRejectsUnsafeOptions(t *testing.T) { {AppID: "app/other", AuthToken: testChatToken, MaxBodyBytes: 1, RunTimeout: time.Second}, {AppID: "app.other", AuthToken: testChatToken, MaxBodyBytes: 1, RunTimeout: time.Second}, {AppID: testDashScopeAppID, AuthToken: "short", MaxBodyBytes: 1, RunTimeout: time.Second}, + {AppID: testDashScopeAppID, AuthToken: "", AllowLegacyShortToken: true, MaxBodyBytes: 1, RunTimeout: time.Second}, + {AppID: testDashScopeAppID, AuthToken: "delivered\tkey", AllowLegacyShortToken: true, MaxBodyBytes: 1, RunTimeout: time.Second}, + {AppID: testDashScopeAppID, AuthToken: "delivered密钥", AllowLegacyShortToken: true, MaxBodyBytes: 1, RunTimeout: time.Second}, + {AppID: testDashScopeAppID, AuthToken: strings.Repeat("a", 4097), AllowLegacyShortToken: true, MaxBodyBytes: 1, RunTimeout: time.Second}, {AppID: testDashScopeAppID, AuthToken: testChatToken, MaxBodyBytes: maximumChatBodyBytes + 1, RunTimeout: time.Second}, {AppID: testDashScopeAppID, AuthToken: testChatToken, MaxBodyBytes: 1, RunTimeout: maximumChatRunTime + time.Second}, {AppID: testDashScopeAppID, AuthToken: testChatToken, AllowedOrigins: []string{"*"}, MaxBodyBytes: 1, RunTimeout: time.Second}, @@ -38,6 +42,19 @@ func TestNewDashScopeChatHandlerRejectsUnsafeOptions(t *testing.T) { } } +func TestNewDashScopeChatHandlerAllowsExplicitLegacyShortToken(t *testing.T) { + _, err := NewDashScopeChatHandler(&fakeChatUseCase{}, DashScopeChatOptions{ + AppID: testDashScopeAppID, + AuthToken: "delivered-key", + AllowLegacyShortToken: true, + MaxBodyBytes: 1, + RunTimeout: time.Second, + }) + if err != nil { + t.Fatalf("NewDashScopeChatHandler() error = %v", err) + } +} + func TestDashScopeChatStreamsCompatibleResultAfterStrictSuccess(t *testing.T) { turn := &fakeChatTurn{ conversationID: "conv_0123456789abcdef01234567",