GoatFlow 0.10.0:accept[:16] 越界 panic、PostgreSQL 卡在 migration 5、token 无 session 无法注销
2026 年 10 月 4 日
项目信息
- GitHub:goatkit/goatflow
- 完整 changelog:CHANGELOG.md
- Helm chart:
oci://ghcr.io/goatkit/charts/goatflow - Demo:https://try.goatflow.io
- 项目站点:https://goatflow.io
- Apache 2.0,由 Gibbsoft Ltd. 开发。
0.9.0 解决的是能装起来;0.10.0 处理的是运行后状态是否真实。93 commits,263 条 changelog entries。
登录与自助服务
LDAP / Active Directory 登录可用。ldap provider 以前返回 not yet implemented。现在会搜索目录、以用户身份 bind 检查密码,并在 StartTLS/LDAPS 上验证服务器证书。
代理和客户的忘记密码重置,以及客户自助注册。重置链接和密码重试有速率限制。
Passkey 管理与恢复码。
现在每次登录都有 session。OIDC、SAML 和 POST /api/v1/auth/login 以前发出的 token 背后没有 session,所以 “log out” 和 Admin → Sessions → kill 不会撤销任何东西。Token 现在带 session id(sid),session 一消失就被拒绝。
login -> JWT login -> session row -> JWT{sid}
logout -> cookie cleared; logout -> session deleted; JWT still valid old -> JWT{sid} refused on every route
这个变化有一个代价:升级后所有人需要重新登录一次。0.9.0 的 token 没有 sid,会被拒绝。
PostgreSQL,真的能用了
0.9.0 在 PostgreSQL 上根本装不上:golang-migrate 停在 migration 5,报 duplicate migration file。0.10.0 在 MariaDB 和 PostgreSQL 上跑完整套件,SQL 可移植性 lint(cmd/gk-lint)现在在 pre-commit 里用两个数据库的 schema 检查每条查询。
- Fresh PostgreSQL install: 0.9.0 fails at migration 5 -> 0.10.0 works
- MySQL-only SQL in Go code: caught in review -> caught by gk-lint
- Unknown table/column in a query: runtime error -> lint error
- Upserts: hand-written per driver ->
database.ConvertUpsert
如果你试过在 PostgreSQL 上跑 0.9.0,安装 0.10.0 时要用全新安装。没有 0.9.0 的 PostgreSQL 数据库可以升级。
兼容 OTRS 的存储
附件和原始 article body 现在可以放到 OTRS 保存它们的位置,goatflow-storage 是一个可用的 CLI,用于在 database 和 filesystem storage 之间移动。
插件平台开始校验输入
权限被强制执行。每个 HostAPI 调用和插件运行的每条 SQL 语句,都会对照它声明的 resources.permissions 检查。gRPC 插件以前会绕过沙箱;现在也走沙箱。
Org 作用域生效。以前插件查询只要提到 org_id,就可能跳过组织过滤。
插件以调用者身份运行。Routes、widgets、插件 UI 和 MCP 调用会携带用户的 identity、organisation 和 language。
新 HostAPI:CreateArticle、article attachments、RenderMarkdownToPdf with branding、共享 Tiptap editor partial。
能记住版本的市场
Marketplace index 以前每个插件只描述一个版本。现在每个条目可以带 release history:
{"name":"goat-kb","latest_version":"0.2.0","min_host_version":"0.10.0","versions":[{"version":"0.2.0","min_host_version":"0.10.0"},{"version":"0.1.1","min_host_version":"0.8.0"},{"version":"0.1.0","min_host_version":"0.8.0"}]}
GoatFlow 会选择它能实际运行的最新版本。
补了三个缺口:update 现在检查 min_host_version;更新失败不再删除插件(下载并在 staging 验证签名,然后替换);插件现在拿到真实 host 版本,而不是硬编码 0.6.4。
两个插件发布:goat-kb 0.2.0 和新的 goat-kanban 0.1.0。
以前只会说 Yes 的接口
- 工单通知:保存了,从不发送 -> 发送,收件人遵循 OTRS 规则
- SLA escalation:时间从不计算 -> 首次响应 / 更新 / 解决事件被触发
PUT /api/tickets/:id:返回 200,什么都没保存 -> 保存,记录历史- Zinc / Elasticsearch:reindex 返回 501 -> 可用,遵守 queue 权限
- Dashboard widgets:出错时显示 0 -> 显示数据或说明无法显示
make test-unit:几乎没跑也能通过 -> 某个 package 加载失败就失败
运维
GET /health 执行真实检查;/metrics 暴露 Prometheus 指标;结构化日志由 LOG_FORMAT / LOG_OUTPUT 控制。
优雅关闭带 connection draining,大小适配 stop grace period。
Scheduled jobs 和 startup migrations 在多个副本间只跑一次,带锁。失败的 migration 不再被标记为已应用。
Helm chart 开箱即用:runner Deployment、持久化文件存储、可用 probes、Valkey 和 Ingress。air-gapped bundle 包含 chart 需要的所有镜像。
升级会安装 bundled plugins 的新版本,同时保留管理员改过的文件。
Compose 用户:deploy/docker-compose.yml 现在要求在 .env 中设置 GOATFLOW_SECURE_KEY(64 hex characters,openssl rand -hex 32)和 SMTP_HOST。key 必须在 app、customer portal 和 runner 中一致,所以 compose 缺它会拒绝启动。
安全清理
- 客户工单列表 order 参数中的 SQL 注入。
- 工单视图 description card 中的存储 XSS。
POST /api/v1/auth/login忽略第二因子。- SSO 登录 CSRF:OIDC
state和 SAMLRelayState现在绑定到发起登录的浏览器。 - Identity-provider 和 webhook URL 可能访问内部地址。
- Identity-provider secrets 和 webhook headers 静态加密。
- 数据库错误文本不再返回客户端。
版本与统计
93 commits;263 条 changelog entries(30 added, 22 changed, 154 fixed, 32 security, 22 removed);33 个 migrations(0.9.0 是 26 个),在 MariaDB 和 PostgreSQL 上一致;Go 1.25.12,grpc v1.83.2,goldmark v1.7.17,golang-jwt v5.3.1。
附加:Accept 的十六个字符
每个 release 总有一个值得复述的 bug。0.10.0 的这个由 demo deploy smoke test 发现,而且就在保护 demo 的代码里。
func wantsJSON(c *gin.Context) bool {
accept := c.GetHeader("Accept")
return accept == "application/json" ||
c.GetHeader("X-Requested-With") == "XMLHttpRequest" ||
c.ContentType() == "application/json" ||
len(accept) > 0 && accept[:16] == "application/json"
}
len(accept) > 0 本来想守住 slice。它什么都没守住:accept[:16] 需要 16 字节,不是 1 字节。curl 发送 Accept: */*,三个字节。每个打到受保护页面的 curl 请求都会 panic,报 slice bounds out of range [:16] with length 3,并以 500 返回。
浏览器从没触发它,因为浏览器的 Accept header 很长。所以 demo 对浏览器用户看起来正常,却被第一个礼貌请求的脚本打崩。
修复:
func wantsJSON(c *gin.Context) bool {
return strings.HasPrefix(c.GetHeader("Accept"), "application/json") ||
c.GetHeader("X-Requested-With") == "XMLHttpRequest" ||
c.ContentType() == "application/json"
}
同一个 smoke test 发现这个 guard 的第二个问题:没有 Referer 时,浏览器重定向把页面指回它自己。现在回退到 dashboard。两处都有 regression tests,能在旧代码上失败。
升级注意:0.9.0 的 token 没有 sid,升级后会被拒绝,所有人要重新登录一次;PostgreSQL 用户需要全新安装 0.10.0,没有 0.9.0 的 PostgreSQL 数据库可升级。