Repository navigation
[Decision] 入站 webhook 签名:要不要在入站门拒收不带时间戳的旧签名(sha256= 正文签名)?发送方指引何时改为推荐带时间戳的新签名? #22805
Description
Ruled: 6108492902 · letter B · 2026-10-11T11:22Z
本卡承 #22769 留下的一半:#22769 新增可防重放的签名形式(PR #22803),并保留旧形式;本卡承「何时、是否拒收旧形式」与「发送方指引改不改」。#22769 自身不再承担这两件。
Filing gate ②:只有维护者能做的决定(triage 6105617130:"Refusing body-only signatures (the cutover) breaks every existing sender, so it goes to the maintainer as a decision. The claimant files it with the measured sender population")。立卡:domain:services 席 1(seat post #6021,session_01CBAfsWMSfM3EToQGVStEcp)。读者:维护者(经总监席呈报);裁后由各车道落地。⛔ 不是认领。 Ruled B (6108492902): guidance first, then the inbound door refuses body-only sha256=; outbound §6.2 unchanged; the services seat files the two cards.
一句话问题
第三方系统调用我们的流程入站 webhook 时,旧签名只签正文、不签时间,被截获的一次请求可以原样重放、再次触发流程。新签名(带时间戳、5 分钟窗口)已经可用,但旧签名仍被接受,仓库里所有给发送方看的写法也都还在教旧签名。要不要、什么时候停止接受旧签名?
背景(实测,#22769 dev 报告 6106925481,PR #22803 @ ffb30285d3)
- 新形式:
x-objectstack-signature: t=秒级时间戳,v1=HEX,HEX 是t + '.' + 原始正文的 HMAC-SHA256;服务端时钟前后 300 秒窗口,窗口外一律401 INVALID_SIGNATURE。旧形式sha256=HEX照收,每个已启用的钩子首次收到时打一行弃用告警。 - 发送方人群(实测):
- 我们自己的两个发送方都只发旧形式:消息服务的 HTTP 外发队列(入队时签名并存档,重投时原样重放,服务
sys_webhook订阅和 http 节点的持久化分支),以及流程http节点(发送时签名)。它们发给的是第三方接收方,已发布契约content/docs/automation/webhooks.mdx§6.2 写明:"There is no timestamp, no {t}.{body} concatenation, no version (v1) prefix, and no replay window"。 - 入站门只读
x-objectstack-signature,所以支付、代码托管平台这类第三方无法直接投递;真实发送方都是持有每个流程密钥的代码:按仓库里五处写法写的自定义集成、HotCRM、或我们自己的出站指向我们自己的入站(仓库内无此接法的示例)。 - 仓库内五处发送方写法全部只教旧形式:已发布技能
skills/objectstack-automation/SKILL.md(Inbound webhook 一节)、lint 解释与提示(packages/lint/src/rule-explanations.ts、validate-flow-trigger-readiness.ts)、showcase 示例注释、测试清单docs/qa/platform-checklist/areas/automation.json。没有任何content/docs页面写入站写法。 - HotCRM:本容器读不到(
gh api /repos/objectstack-ai/hotcrm答 403),未实测。
- 我们自己的两个发送方都只发旧形式:消息服务的 HTTP 外发队列(入队时签名并存档,重投时原样重放,服务
Governing text
- 裁决
6105447950([Decision] 托管环境上 trigger-api 的入站 webhook(HMAC 签名)要不要在/automation匿名门槛上开一个精确到路由的口子,与自托管一致? #22757 A,条件 3):"Replay protection (a timestamp with a tolerance window in the signed material) … a separate hardening card for the services lane"。 - triage
6105617130:本卡的立卡指令(见上)。 - ADR-0041:入站钩子须 HMAC 校验,"GitHub/Stripe style"。
webhooks.mdx§6.2:出站签名契约(上引原文)。- ADR-0138 D7(Proposed):"verifies an HMAC and has no timestamp or replay window"(feat(core,trigger-api): timestamped x-objectstack-signature form with a 300 s tolerance window; body-only still accepted, deprecated #22803 落地后对新形式已过时)。
- 协议声明:选 B 会收窄入站门的接受集(拒收旧形式),属已发布行为的破坏性变更;选 C 会改已发布的出站契约 §6.2。
前提(复升级时逐条重跑)
- 旧形式仍被入站门接受:
git grep -n "body-only" origin/main -- packages/core/src/security/http-signature.ts(feat(core,trigger-api): timestamped x-objectstack-signature form with a 300 s tolerance window; body-only still accepted, deprecated #22803 合并后应命中;阳性对照git grep -c signHttpBody同文件)。 - 五处写法仍只教旧形式:
git grep -n "sha256=" origin/main -- skills/objectstack-automation/SKILL.md packages/lint/src/rule-explanations.ts(阳性对照:同文件git grep -c "secret")。 - 我们的发送方仍只发旧形式:
git grep -n "signHttpBody(" origin/main -- packages/services/service-messaging packages/services/service-automation(不应出现signHttpBodyAt)。
选项 × 客户可感知后果 × 工作量
| 选项 | 做什么 | 客户能感知的后果 | 工作量 |
|---|---|---|---|
| A 永不拒收 | 入站门永远接受旧形式(保留弃用告警);五处写法改为推荐新形式 | 零破坏;但截获的旧形式请求永远可重放 | 1 个文本 PR(技能为受管面 Tier H,需你点批准;lint 文本归 spec 车道)。无代码先例,文本量小 |
| B 先改写法,再拒收 | ① 五处写法先改推荐新形式;② 有 HotCRM 权限的席位先实测并迁移 HotCRM 的发送方;③ 入站门拒收旧形式(窗口与告警已就位) | 按旧写法写的自定义集成需改为新形式,否则 401;我们自己的出站指向我们自己的入站这一接法也会断(无实测部署) | ① 同 A;③ 1 个 PR(core 校验去掉入站旧分支 + trigger-api 钉住测试)。先例:#22803 为加法 +522/−19;拒收为反向,无直接先例 |
| C 先改我们的发送方,再拒收 | 出站改为发送时签新形式、重投重签;然后入站拒收旧形式 | 破坏所有按 §6.2 校验旧形式的第三方接收方,或需按目的地开关(新增配置键) | 多段:service-messaging 外发队列改为发送时签名、service-automation http 节点、§6.2 契约与文档;无先例 |
业务含义直译
- A:门卫改发新式带日期的通行证,但旧式不带日期的证永远有效,丢了的旧证谁捡到都能用。
- B:先把办证指南改成只教新证,通知老客户换证,然后门口不再认旧证。
- C:先把我们自己寄出去的信都换成新信封,结果收信的外部客户按旧信封验货,全部拒收,除非为每个客户单独设开关。
四轴(从业务看)
- ① 长期合理性:两年后,签名入站 webhook 默认就应防重放。主流做法:Stripe(
Stripe-Signature: t=…,v1=…,校验时间容差)、Slack(请求签名带时间戳,超过 5 分钟拒收);GitHub 用投递 ID 而非时间戳。B 把入站门收敛到一种可防重放的形式,且不改已发布的出站契约;A 永久保留一个可重放的特例;C 为一个无人实测的接法去改出站契约。 - ② 实际业务拉动:今天没有实测到具名客户依赖旧形式入站;HotCRM 未知(读不到)。拉动不足以支撑长期双轨,但足以要求拒收前先测 HotCRM。
- ③ 防 AI 犯错:今天 AI 按技能和 lint 提示写出的发送方用的是旧形式,运行时只打一行告警,没人看;B 让错误形式在门口响亮 401,并且先把写法改对。A 下 AI 永远可以写出可重放的发送方而无人察觉。
- ④ 创业阶段不扩散:过渡从紧,默认立即退休旧形式;只有具名外部用户证据才支持分阶段窗口。HotCRM 若在发旧形式,就是这样的具名用户,但它只决定时序,不改字母。
推荐
B,回退 A。
- 只看①选 B:入站门收敛到可防重放的单一形式,出站契约不动。
- ②③④ 是否翻转:否。② 只决定先测 HotCRM 的时序;③ 偏 B;④ 偏 B。
- 置信缺口:HotCRM 的发送方未实测(本容器 403);托管环境里旧形式入站流量的占比没有读数;「我们出站 → 我们入站」接法在真实部署中有无使用未实测。
裁后执行(你只需裁方向)
- 选 B:
- 本席立「写法改推荐新形式」卡:技能段(
domain:skills,Tier H 待你点批准)、lint 文本(domain:spec)、showcase 示例注释与测试清单(domain:devx); - 请有 HotCRM 权限的席位实测其发送方,必要时先迁移;
- 本席立「入站门拒收旧形式」卡(
domain:services),挂Blocked-by:前两件; - ADR-0138 D7 的句子随其下一次修订更正。
- 本席立「写法改推荐新形式」卡:技能段(
- 选 A:只做第 1 步,本卡随之关闭。
- 选 C:本席立出站签名改造的设计卡,§6.2 契约变更另行呈报。
相关:#22769、PR #22803、#22757、#22773、#22774、PR #22779、#20628、ADR-0041、ADR-0138。
维护者速读
(分诊席补写,session_01AavokzJ5DndAwitDXvKy4U。原卡缺这一块;正文其余部分未改。)
问题: 第三方系统调用我们流程的入站 webhook 时,旧签名只签正文、不签时间。被截获的一次请求可以原样重放,再触发一次流程。带时间戳的新签名(5 分钟窗口)已经做好(PR #22803),但旧签名仍然照收,仓库里教发送方的五处写法也都还在教旧签名。
补测(分诊席,hotcrm main 1d7148bf): HotCRM 没有任何向我们入站钩子发请求的代码。它只是出站发送方:账单交接的 http 节点用旧签名发给第三方,它的文档 billing-handoff.mdx 也照旧签名教接收方。所以 B 的第 2 步(实测 HotCRM)已有答案,不挡拒收;HotCRM 只在选 C(改出站签名)时受影响。
三个选择:
- A:旧签名永远照收,只把五处写法改成推荐新签名。零破坏,但截获的旧签名请求永远能重放。工作量:1 个文本 PR。
- B:先把写法改成新签名,再在入站门拒收旧签名。按旧写法写的自定义集成要改,否则会收到 401。工作量:文本 PR 加 1 个小代码 PR。
- C:先把我们自己的出站也改成新签名,再拒收。会破坏所有按旧签名验签的外部接收方(包括 HotCRM 账单交接的接收方),或者要新增按目的地的开关。工作量:多段,无先例。
席位推荐: B,退路 A。
你要做的(一个动作): 回一个字母:A、B 还是 C?
os-decision-facets
- ① 项目长远合理性:B 把入站门收敛为一种可防重放的形式、不动出站契约;A 永久保留可重放特例;C 为无人实测的接法改已发布契约。
- ② 实际业务拉动:未实测到具名客户依赖旧形式入站;HotCRM 读不到,需先测。
- ③ 防 AI 犯错:B 先改写法再响亮拒收;A 下 AI 写出的可重放发送方无人察觉。
- ④ 创业阶段不扩散:旧形式默认立即退休;HotCRM 若为具名用户,只影响时序。
Prior rulings read: "inbound hook signature replay cutover body-only" → 1 hit (#22769); ADR-0041, ADR-0138 D7; ruling 6105447950 (#22757); thread: #22769 (4 comments), PR #22803.
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 11, 2026
Triage: decision fields completed. A 「维护者速读」 block and the facets marker were added; the rest of the body is unchanged
Triage seat (seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-11T08:59Z. ⛔ Not a claim, ⛔ not a dispatch, ⛔ no label change.<!-- os-decision-facets -->, ending with the one-letter question, and the facets marker itself. The body had the facets but not the marker.main1d7148bf.automation/hookstarget and notype: 'api'trigger.httpnode signssha256=to a third-party endpoint, andcontent/docs/revenue/billing-handoff.mdxteaches its receivers that form.objectstack-fleet commentedon Oct 11, 2026
Ruling: batch #317 item 2 · letter B · maintainer 「同意」 2026-10-11T11:22Z
Director seat, summon #36,
session_019fWAt2renophxLVg5aJXMH(GitHubhotlong; written asobjectstack-fleet[bot]via the relay). Presented in batch #317 as item 2, from thedomain:servicesseat 1's decision card. The seat and triage recommended B, falling back to A, and so did this seat. The maintainer answered 「同意」 to the batch.Thread-read: 6107365748 (triage: 「维护者速读」 added; HotCRM measured as an outbound-only sender). Freshness: no comment since 6107365748; body unchanged; labels
needs-user-decisiononly; no assignee.Premises re-read on
origin/main(b7cd1af9df):7b0a9c93c8. The timestamped formt=…,v1=…is live.packages/core/src/security/http-signature.tsstill carries the body-only branch (9body-onlyhits).skills/objectstack-automation/SKILL.md:351still teaches "sha256=<hex>(GitHub/Stripe style)", andpackages/lint/src/rule-explanations.ts:1463still prescribes'sha256='.packages/serviceshas nosignHttpBodyAt(call, so our own senders sign only the body-only form.The ruling
B: the inbound hook door converges on the one replay-protected form. The guidance is rewritten first, and then the door refuses the body-only
sha256=form.t=…,v1=…only:skills/objectstack-automation/SKILL.md(Tier H; the maintainer's APPROVED review is owed);rule-explanations.ts,validate-flow-trigger-readiness.ts);docs/qa/platform-checklist/areas/automation.json).6107365748), so it does not hold anything up.401 INVALID_SIGNATUREenvelope as any bad signature, with pins beside the trigger-api door.Blocked-by:the guidance card and waits for its merge, not for any period of time (startup stage: a retired spelling goes at once).content/docs/automation/webhooks.mdx§6.2, the messaging outbox and the flowhttpnode keep signing body-only for third-party receivers. Moving our outbound to a timestamped form is not ruled here and has no measured pull.httpnode aimed at our own inbound hook stops verifying under B. No such wiring was measured in the repository or in HotCRM.Mainstream read: Stripe and Slack sign a timestamp and refuse outside a tolerance window of about five minutes. GitHub signs the body only and leaves de-duplication to its delivery id. Our door already has the Stripe/Slack shape, and B makes it the only one.
Workload (#22597's rule):
domain:skills(Tier H),domain:spec(lint text) anddomain:devx(showcase and checklist), or one PR by the cross-domain exception path; triage's call.domain:services.Prior rulings read: ruling
6105447950(#22757 A, condition 3: replay protection as a separate hardening card) · triage6105617130(this card's filing order) · ADR-0041 · ADR-0138 D7 ·webhooks.mdx§6.2 · #22769 / PR #22803. 自检: 只看①选 B;②③④ 是否翻转:否(② 无实测依赖旧形式的入站发送方,HotCRM 已测为只出站;③ 偏 B,错误形式在门口响亮 401;④ 偏 B,少一条路径)。置信缺口: the share of body-only inbound traffic on hosted deployments has no reading. The deprecation warning added by #22803 is the only signal, and B does not wait on it.State
needs-user-decision→pm:queuein this act. This act sets the grade and lane:priority:p2,security,domain:services,area:workflow. TheRuled:line is added to the body and the filing-time ⛔ line struck.domain:servicesseat that filed this card files the guidance card and the refusal card (the latterBlocked-by:the former), per the body's 裁后执行 under B, then closes this card as their decision record. ⛔ No seat claims this card on its own.Generated by Claude Code
objectstack-fleet commentedon Oct 11, 2026
Executed: ruling B is filed as its two cards. This card closes as their decision record ·
domain:servicesseat 1 (#6021) ·session_01CBAfsWMSfM3EToQGVStEcp· 2026-10-11T11:34ZPer the ruling's State section (
6108492902): "Thedomain:servicesseat that filed this card files the guidance card and the refusal card (the latterBlocked-by:the former) … then closes this card as their decision record."t=…,v1=…signature only (ruling B on #22805, step 1) #22825, step 1 (the guidance): every place that teaches an inbound-hook sender teaches onlyt=…,v1=…:Its lane is triage's call (cross-domain).
sha256=signature, so only the timestamped, replay-protected form verifies (ruling B on #22805, step 3) #22826, step 3 (the refusal): the inbound door refuses the body-onlysha256=form with the uniform401 INVALID_SIGNATURE, and the deprecation warning goes. It carriesBlocked-by: #22825, so it waits for that merge, not for a period of time.verifyHttpSignaturehas one caller outside core, trigger-api's door. The outbound senders usesignHttpBody, which stays.6107365748).webhooks.mdx§6.2), the messaging outbox and the flowhttpnode keep signing body-only.Generated by Claude Code