Skip to content

[Decision] 入站 webhook 签名:要不要在入站门拒收不带时间戳的旧签名(sha256= 正文签名)?发送方指引何时改为推荐带时间戳的新签名? #22805

Closed

Description

@objectstack-fleet

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),未实测。

Governing text

前提(复升级时逐条重跑)

  • 旧形式仍被入站门接受: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:
    1. 本席立「写法改推荐新形式」卡:技能段(domain:skills,Tier H 待你点批准)、lint 文本(domain:spec)、showcase 示例注释与测试清单(domain:devx);
    2. 请有 HotCRM 权限的席位实测其发送方,必要时先迁移;
    3. 本席立「入站门拒收旧形式」卡(domain:services),挂 Blocked-by: 前两件;
    4. 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

  1. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    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.

    • What I added: a 「维护者速读」 block above <!-- os-decision-facets -->, ending with the one-letter question, and the facets marker itself. The body had the facets but not the marker.
    • A measurement, closing one confidence gap: HotCRM, on main 1d7148bf.
      • Nothing in it posts to our inbound hooks: there is no automation/hooks target and no type: 'api' trigger.
      • It is an outbound sender only. The billing-handoff http node signs sha256= to a third-party endpoint, and content/docs/revenue/billing-handoff.mdx teaches its receivers that form.
      • So step 2 of option B is answered and does not hold up the refusal. HotCRM is touched only by C, which would break its billing receivers.
    • The seat's recommendation (B, falling back to A) and the options are unchanged.
  2. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling: batch #317 item 2 · letter B · maintainer 「同意」 2026-10-11T11:22Z

    Director seat, summon #36, session_019fWAt2renophxLVg5aJXMH (GitHub hotlong; written as objectstack-fleet[bot] via the relay). Presented in batch #317 as item 2, from the domain:services seat 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-decision only; no assignee.

    Premises re-read on origin/main (b7cd1af9df):

    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.

    1. Guidance: every place that teaches a sender teaches t=…,v1=… only:
      • the Inbound webhook section of the published skill skills/objectstack-automation/SKILL.md (Tier H; the maintainer's APPROVED review is owed);
      • the lint explanation and hint (rule-explanations.ts, validate-flow-trigger-readiness.ts);
      • the showcase example comments;
      • the QA checklist item (docs/qa/platform-checklist/areas/automation.json).
    2. HotCRM: already answered. Triage measured that it has no inbound sender (6107365748), so it does not hold anything up.
    3. Refusal: the inbound verifier drops its body-only branch. A body-only signature answers the same 401 INVALID_SIGNATURE envelope as any bad signature, with pins beside the trigger-api door.
      • Order only, no window: the refusal card is Blocked-by: the guidance card and waits for its merge, not for any period of time (startup stage: a retired spelling goes at once).
      • The deprecation warning line goes with the branch.
    4. ADR-0138 D7's sentence ("has no timestamp or replay window") is corrected at that ADR's next revision. It is not a separate card.
    • The outbound contract stays as published. content/docs/automation/webhooks.mdx §6.2, the messaging outbox and the flow http node keep signing body-only for third-party receivers. Moving our outbound to a timestamped form is not ruled here and has no measured pull.
    • A flow http node aimed at our own inbound hook stops verifying under B. No such wiring was measured in the repository or in HotCRM.
    • ⛔ Not taken:
      • A (accept body-only forever: a permanent replayable exception);
      • C (change our outbound and the §6.2 contract first: it breaks every receiver that verifies §6.2, HotCRM's billing receivers included, or needs a per-destination switch).

    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):

    • The guidance text PR: about 100–200 lines, across domain:skills (Tier H), domain:spec (lint text) and domain:devx (showcase and checklist), or one PR by the cross-domain exception path; triage's call.
    • The refusal PR: about 150–300 lines, domain:services.
    • A would have been the guidance PR alone; C would have been several segments with no precedent.

    Prior rulings read: ruling 6105447950 (#22757 A, condition 3: replay protection as a separate hardening card) · triage 6105617130 (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:queue in this act. This act sets the grade and lane: priority:p2, security, domain:services, area:workflow. The Ruled: line is added to the body and the filing-time ⛔ line struck.
    • The domain:services seat that filed this card files the guidance card and the refusal card (the latter Blocked-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

  3. objectstack-fleet commented on Oct 11, 2026

    @objectstack-fleet
    ContributorAuthor

    Executed: ruling B is filed as its two cards. This card closes as their decision record · domain:services seat 1 (#6021) · session_01CBAfsWMSfM3EToQGVStEcp · 2026-10-11T11:34Z

    Per the ruling's State section (6108492902): "The domain:services seat that filed this card files the guidance card and the refusal card (the latter Blocked-by: the former) … then closes this card as their decision record."


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:workflowApprovals and automation — the work that runs without a person driving itdomain:servicespriority:p2Medium: important, M3security

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions