Repository navigation
Conversation
…er-owned idempotency
ExternalTriggersController resolved IExternalTriggerIngress directly, so the anonymous delivery path reached the application without passing any MediatR pipeline behavior — the same bypass the run-ledger SSE stream made. Dispatch a marker-less ReceiveExternalTriggerCommand instead, mirroring the anonymous webhook ingress. The command declares no authorization marker, so no authorization behavior selects it, exactly like ReceiveWebhookCommand; the endpoint stays anonymous at [AllowAnonymous]. The outcome type lives in CodeSpace.Messages so the Messages assembly keeps no reference to CodeSpace.Core: ExternalTriggerDeliveryOutcome mirrors the ingress's own outcome, and the handler maps between the two. The HTTP contract is unchanged — the bounded body and its 413, the Bearer -> X-Api-Key -> ?key= precedence, the whitespace-only Idempotency-Key normalization, and the 404/401/400/200/202 outcome mapping all behave as before. ReceiveExternalTriggerCommand joins AnonymousByDesign in the authorization inventory, the same list ReceiveWebhookCommand already sits in.
DeleteAsync soft-deleted the workflow and its activations but left workflow_external_trigger live. Reveal, rotate and rename resolve a slug through that table alone, so after a delete any team member who knew the slug could still read the plaintext bearer key, and the slug stayed reserved for a workflow nobody could list. Soft-delete rather than shred: it matches how ExternalTriggerDefinitionSync already retires a trigger whose node left the definition (ciphertext retained, partial unique index uq_wet_slug freed because it is filtered on deleted_date IS NULL), it is the convention every other retired row in this repo follows, and it preserves the audit history of which key belonged to which endpoint. The row is now marked deleted in the same SaveChangesAsync as the workflow and its activations, so the delete stays atomic.
The confirm panel interpolated the derived slug unconditionally, so opening the rename confirmation and then clearing or mangling the draft left the user reading "Rename the live endpoint to ?" with no reason and no way forward. Reuse the guidance the slug field already shows for that case.
ppXD
requested changes
Oct 21, 2026
Owner
There was a problem hiding this comment.
感謝這個 PR 的細緻工作。key 的處理、tenancy、資料庫層的冪等保證和測試深度都很扎實;重用 IRunStarter / RunSourceEnvelope / ActivationAuthoritySnapshot 也是正確的選擇。
這次標成 Request changes,原因有兩個:
- 有幾項屬於對外公開的契約。一旦有外部呼叫方接入,就再也收不回來。
- 匿名入口有幾處防線比現有的 webhook 和 callback 路徑弱。
合併前:對外契約(現在改成本低,之後改不了)
?key=查詢參數形式的憑證- 位置:
ExternalTriggersController.cs:32,40;編輯器範例在external-trigger-inspector.tsx:47。 - 問題:#2113 的日誌遮罩只涵蓋名為
token的路徑參數,?key=不在範圍內。任何一行帶?key=的 access log,都等於一個長期有效的觸發能力。 - 修法:只接受
Authorization: Bearer與X-Api-Key,curl 範例也一併改掉。
- 位置:
- endpoint 身分
- 問題:目前是人工命名、全域唯一、可改名的 slug,加上單一 key 欄位。實際後果:
- 跨 team 建立同名 slug 會回 409,形成存在性 oracle;
- 401 與 404 的差異讓 endpoint 可被列舉;
- 改名會讓既有呼叫方立即失效;
- rotate 沒有重疊期。
- 修法:URL 改用不可猜的 opaque public id,並在路由加上長度限制;名稱降為可自由修改的 label。憑證改成 current/previous 兩個 slot,並帶到期時間。
- 問題:目前是人工命名、全域唯一、可改名的 slug,加上單一 key 欄位。實際後果:
- 拒絕語意
- 問題:authority 被拒與 completion admission 被拒都回
202 {runId:null}。稽核的 DedupKey 是rejected:{slug}:{idempotencyKey}:不帶 Idempotency-Key 時只留一筆就失明,帶了則每次投遞都新增一筆、無上限。 - 修法:對已通過驗證的呼叫方回傳穩定的非 2xx 錯誤碼。稽核列改用 (endpoint, reason, day) 為鍵,與
PullRequestTriggerAdmission的做法一致。
- 問題:authority 被拒與 completion admission 被拒都回
- 冪等範圍
- 問題:範圍綁在
activation.Id,而ReplaceActivationsAsync每次存檔都會重建 activation。所以「投遞、存檔、再投遞同一個 key」會產生第二個 run。 - 修法:改以 trigger 列 id 為範圍,並存 body hash;同一個 key 搭配不同 body 時回 422。
- 問題:範圍綁在
- 路由遮蔽
- 問題:新增的
GET /api/workflows/external-triggers會遮蔽 slug 剛好為external-triggers的既有 workflow,而這個名稱不在ReservedSlugs(WorkflowService.cs:2236)裡。 - 修法:改成以 id 為鍵的子路由;或者把它加入保留字,並為既有資料重新產生 slug。
- 問題:新增的
合併前:匿名入口加固
- 先讀 body 才驗證
- 問題:controller 在查 slug、驗 key 之前,就先讀入最多 25 MiB 的 body(
MaxDeliveryBodyBytes,ExternalTriggersController.cs:34)。實測對不存在的 slug 送一個 24 MiB、未帶憑證的 POST,配置了約 177–341 MiB。對照之下,不存在的 webhook id 會先回 404,配置不到 1 MiB。 - 修法:先解析 slug、驗證 key,再用每個 trigger 自己的小上限讀 body。
- 問題:controller 在查 slug、驗 key 之前,就先讀入最多 25 MiB 的 body(
- 比對 key 前做了太多工作
- 問題:
ExternalTriggerIngress.cs:36-63在比對 key 之前,先做了 3 次查詢並反序列化整份 definition。 - 修法:查到 slug 列後立即比對 digest;不存在的 slug 與錯誤的 key 回應要一致。input schema 存到 trigger 列上,不要每次請求都解析整份定義。
- 問題:
- 限流
- 問題:
{slug}沒有長度限制,partition key 的大小由攻擊者決定。失敗的認證也會消耗合法呼叫方的額度:打 2000 次錯 key 之後,真 key 會拿到 429。 - 修法:失敗只計入位址桶;429 回應補上
Retry-After。
- 問題:
- 輸入校驗器沒有上限
- 問題:
ExternalTriggerPayloadValidator不設上限。3.8 MiB 的1,會產生約 200 萬筆錯誤、配置約 0.8 GiB;超長的整數指數會走進BigInteger.Parse,耗掉數秒 CPU。另外pattern、minimum、oneOf、$ref,甚至拼錯的type,目前都被靜默接受。 - 修法:錯誤數設上限(約 20 筆),不回顯原值;拒絕超長的數字 token;存檔時拒絕不支援的 schema 關鍵字。
- 問題:
- jsonb 存不下的合法 JSON 會回 500
- 問題:
\u0000、孤立 surrogate、1e200000都能通過解析,但存進 jsonb 時失敗,回 500。 - 修法:在入口直接回 400。
- 問題:
- 沒有 per-trigger 的 run 速率或併發上限
- 問題:一把外洩的 key,每分鐘可以用發布者的 authority 起約 2000 個 run。
- 修法:在
RunStarter之前加一個 admission 步驟,使用 committed 預設值。
後續 PR
- 分層與規範:Rule 16/18.1:Reveal 與 List handler 直接注入
CodeSpaceDbContext;Core 的 outcome 型別重複了 Messages 的 DTO,還需要一個 mapper。Rule 3/4:拆分ReceiveAsync(約 90 行)。Rule 1:建構子參數過多。 - reveal、rotate、rename:reveal 改成 POST,並加
Cache-Control: private, no-store。rotate、rename 要寫稽核 log,並加上並行控制。 - 三份真相:node、前端派生的 activation、trigger 列三者各自存在。透過 API 建立、卻沒有 activation 列的 workflow 會永遠 404。改用 node-save hook,也能解除
WorkflowService對trigger.external的耦合。 - payload 的信任邊界:發送端的 payload 原樣存入,Viewer 可讀。可考慮對選定欄位 seal、設定
SourceInstanceId/ExternalEventId,並在 goal template 中把這些內容標記為不受信任。 - 測試:補上 payload → run → engine 的端到端覆蓋,以及真 HTTP 的並行同 key、投遞/存檔/再投遞、
/api/ext的 429 情境。本機 mutation 測試中,E2E 層 15 個 mutant 一個都沒殺掉。
長期方向
改成以 registry 為基礎的 inbound endpoint:authenticator、body decoder、admission、responder 各自是 sibling interface,做法與既有的 webhook verifier/normalizer registry 相同。這樣 HMAC 發送端和其他 body 形式就能直接接入,不用改 controller。這部分可以等上面的契約定案後再做。
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
工作流新增 External API 触发器,让外部系统可以通过独立端点和节点专属 key 投递 JSON 来启动运行。实现覆盖凭据的创建、读取、轮换与退役,以及入站鉴权、输入契约校验、幂等、审计和编辑器管理入口。
触发器保存时把节点声明的
inputSchema投影为冻结的 workflow inputs,使入站校验与下游{{input.*}}使用同一份契约。请求只接受符合声明的 JSON 对象;Idempotency-Key可选,携带时按 activation 隔离并并发安全地去重。端点退役、节点移除或工作流删除后,旧 key 立即失效且密文不会继续保留。Related
Closes #2137
Changes
trigger.external节点、实例内唯一的 slug 与节点级 key,以及独立的匿名运行端点。Authorization: Bearer、X-Api-Key和查询参数三种 key 形态,统一做定时安全摘要比较。required、properties、additionalProperties: false、enum、maxLength、数组items、整数语义与类型联合。Idempotency-Key,按 trigger activation 隔离,缺失时不自动按 body 去重。workflows.write门控的 key reveal、rotate、rename 管理接口,以及供检查器读取 live slug 的公开元数据接口。slug|remoteIp。type: [...]联合类型的支持,并让空类型约束返回可定位的错误。Risk / Impact
workflows.write的团队成员可以读取该团队外部触发器的明文 key。config.slug只是草稿,保存工作流不会隐式改名。key_ciphertext。Test plan
自动化
手工验证
errcode: 0。