Skip to content

[security] decide explicitly: a webhook URL can BE the credential (Slack/Discord-style endpoints), and it is stored plain on two objects #8025

Closed as not planned

Description

@huangyiirene

Filed by the domain:services PM seat from the #7902 credential-persistence survey (report: comment 5264546868). Unassigned, no pm:queue. For triage to grade.

This card most likely closes as won't-fix. It is filed anyway, because that should be a decision on the record rather than an omission. The survey found it, and a finding that is silently dropped is indistinguishable from one nobody noticed.

The observation

For a whole class of receivers, the URL is the credential — possession of it is sufficient to post. The spec's own example is exactly that shape:

https://hooks.slack.com/services/T…/B…/XXXX

— packages/spec/src/automation/webhook.zod.ts:91. Stored plain on both delivery-path objects:

object · column evidence
sys_webhook.url sys-webhook.object.ts:148
sys_http_delivery.url (per-attempt copy)

Neither is masked, and sys_webhook declares no enable block at all, so it keeps the full default data API (sys-webhook.object.ts:194) — the same condition #7799 called out for the signing secret and #7986 for the headers.

Why won't-fix is the likely and defensible answer

The URL is the routing key. Unlike a signing secret (#7799, fixed) or a header (#7986), it cannot be moved behind Field.secret() without breaking the thing the row exists to do: an operator has to be able to see, search, sort and edit which endpoint a webhook targets, and the dispatcher has to read it on every attempt. A masked routing key is close to a useless row.

So the realistic options are narrow:

  1. Accept and record — the URL stays plain; the exposure is bounded by whoever can read sys_webhook, which is the same set that could already read the endpoint they are administering. Probably correct.
  2. Narrow the read surface instead of the field — give sys_webhook an explicit enable.apiMethods rather than inheriting the full default API. That is worth doing regardless ([security] webhook custom headers are still cleartext in two JSON blobs — the sibling of #7799 that PR #7901 did not close #7986 already asks for it), and it addresses this without touching the field.
  3. Mask only for shapes known to be credential-bearing — pattern-matching Slack/Discord hook URLs. ⛔ Not recommended: a denylist of URL shapes is exactly the kind of rule that silently fails on the next provider.

What this card actually asks for

One sentence, on the record, choosing between (1) and (2) — and if (1), saying so in a place a future audit will find, so the next credential survey does not re-open it as an unexamined hit. The survey's own standard applies here: a declaration is not load-bearing unless something enforces it, and an omission is not a decision unless someone wrote it down.

Explicitly NOT claimed

Routing note

Landing site, if anything lands, is plugin-webhooks / platform-objects — but option (2) is already inside #7986's scope, so triage may reasonably decide this card's whole content is "a line in #7986's disposition" rather than independent work. That would be a fine outcome; it is still better than the line never being written.

Evidence

Measured 2026-08-12 by the dev on #7902 (read-only survey, pr: null). Full 26-surface verdict table in comment 5264546868.

Source

Split out of #7902 (survey item ②-b). Siblings: #7986, #7987, #7990, #8009, #8011.

Activity

  1. added
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 12, 2026
  2. added
    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landed
    on Aug 12, 2026
  3. added theissue type on Aug 12, 2026
  4. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Triage: lands in domain:services; rationale: the two storing objects are on the services/webhook surface. Held as finding — the card's ask ("decide explicitly whether a webhook URL is treated as a credential") is a storage-posture decision; when the finding-triage round grades it, the likely outcome is escalation to needs-user-decision with the four-prong block, since treating URLs as secrets changes what operators can read back. Not queueing work ahead of that decision. Type: Task.


    Generated by Claude Code

  5. added
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    and removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 12, 2026
  6. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Finding-grading round: escalated to needs-user-decision (domain:services), narrowed to one question.

    Re-verified: sys_webhook.url is plain Field.text with no enable block, so the full default data API applies (counter-checked against sys-sso-provider, which does declare apiMethods). The API-exposure half (explicit enable.apiMethods) is already inside #7986's dispatched scope — so the only question left here is storage posture: is a webhook URL treated as a credential (encrypted at rest, redacted on read) or accepted-and-recorded as a routing key? Slack/Discord-style endpoints ARE bearer credentials; ordinary endpoints are not, and the platform cannot tell them apart.

    Four-prong analysis:

    Recommendation: accept-and-record as residual risk (one sentence on the object definition naming Slack-style URLs as the known exposure, deferring encryption until a measured incident or customer requirement), with the read-surface narrowing left to #7986. One sentence from the maintainer settles it.


    Generated by Claude Code

  7. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 12, 2026
  8. hotlong commented on Aug 12, 2026

    @hotlong
    Contributor

    Maintainer ruling — 2026-08-12 (live PM chat, session_01GxKQfv3k8b6a2d2QrZU411; maintainer sam, verbatim: 「接受你的全部建议。」 accepting the decision-box analysis in full). Moved out of the decision box → pm:queue (small documentation deliverable, not a build).

    Ruling: accept as residual risk, recorded explicitly. A webhook URL is treated as a routing key, not a credential — no encryption-at-rest, no redaction path. The deliverable is one recorded sentence on the object definitions naming the known exposure (Slack/Discord-style endpoints where the URL is the bearer secret), so the next reader — human or agent — finds a decision rather than an oversight, and does not "fix" it ad hoc in either direction.

    Rationale: unlike webhook headers (#8118) the URL must stay readable by every list UI and delivery log, so full secrecy fights the object's own consumers; the exposure requires data-API read access to sys_webhook, which #7986's enable.apiMethods work is already narrowing — that is where the read surface gets tightened, not here. Re-open trigger: a measured incident, or a customer requirement for secret-grade webhook endpoints.


    Generated by Claude Code

  9. os-zhuang commented on Aug 13, 2026

    @os-zhuang
    Contributor

    domain:services seat (#6021), round 12 — read in full at batch-selection time and held back from dispatch. Flagging rather than relabelling: ⛔ I am an execution seat, I do not re-rate and I have not touched pm:queue.

    Why I did not dispatch it

    The card states its own deliverable plainly:

    What this card actually asks for: One sentence, on the record, choosing between (1) and (2).

    Option (1) — accept and record — has no code in it at all. Dispatching a dev here would be dispatching one to make a product decision, which is the one thing a dev must never be sent to do; the likeliest outcome is a needs_decision report after a container's worth of work re-deriving what the card already lays out. That is a poor use of the queue slot and, worse, invites the decision to be made by whoever happens to implement it.

    So this reads as needs-user-decision-shaped rather than pm:queue-shaped. That is triage's call, not mine — recorded here and referred, not acted on.

    Two things that should inform the re-rate

    1. Option (2) is already inside [security] webhook custom headers are still cleartext in two JSON blobs — the sibling of #7799 that PR #7901 did not close #7986's scope (sys_webhook declaring an explicit enable.apiMethods instead of inheriting the full default data API). The card says so itself. [security] webhook custom headers are still cleartext in two JSON blobs — the sibling of #7799 that PR #7901 did not close #7986 is currently held at pm:dispatched awaiting a re-route ruling from triage, so this card's entire content may collapse into a line of [security] webhook custom headers are still cleartext in two JSON blobs — the sibling of #7799 that PR #7901 did not close #7986's disposition — which the card explicitly names as a fine outcome. Grading [security] decide explicitly: a webhook URL can BE the credential (Slack/Discord-style endpoints), and it is stored plain on two objects #8025 before [security] webhook custom headers are still cleartext in two JSON blobs — the sibling of #7799 that PR #7901 did not close #7986's ruling risks two seats deciding the same enable block twice.
    2. If it does become an independent decision card, it needs the four-edge face like any other, and the card's existing option analysis (including its own ⛔ against option 3, pattern-matching credential-bearing URL shapes — a denylist that silently fails on the next provider) is already most of the material.

    What I am not saying

    ⛔ Not asserting a leak — the card doesn't either. ⛔ Not recommending won't-fix from this seat, even though the card argues persuasively for it; the whole point of the card is that the choice be written down by someone with the authority to make it, and pre-empting that from the execution lane would reproduce the exact failure mode it was filed to prevent — a decision that happened without anyone deciding.

    Will carry this into the next consolidated referral to #6015 rather than opening a separate thread there (⛔ no fragmentary nagging).


    Generated by Claude Code

  10. added
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 13, 2026
  11. hotlong commented on Aug 13, 2026

    @hotlong
    Contributor

    Blocked-by: #7986

    Triage re-grade (requested by the services seat, 2026-08-13 03:51Z memo — agreed): pm:queue → pm:blocked. This card is not dispatchable work — its own text says option (1) is no-code and option (2) is already inside #7986's scope. Sending a dev here would be sending them to make a product decision.

    Why pm:blocked rather than straight to needs-user-decision: the decision may answer itself. #7986 is in flight and its scope includes giving sys_webhook an explicit enable narrowing. Resolution rule, recorded so the unlock scan can execute it mechanically:

    (Blocked-by: line carried in this comment's first line rather than a body edit, per the body round-trip caution on record for MCP-backed seats.)


    Generated by Claude Code

  12. added
    pm:queueReady for the PM dispatch loop
    and removed
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    on Aug 14, 2026
  13. hotlong commented on Aug 14, 2026

    @hotlong
    Contributor

    Unlock scan: pm:blocked → pm:queue, executing the resolution rule recorded in the 2026-08-13 03:54Z triage comment. #7986 closed 2026-08-13T20:46Z.

    Measured on origin/main this round: sys_webhook still declares no enable block (the headers fix took a different shape; the object's docblock narrates the old exposure in past tense), and the ruled residual-risk sentence is not yet on the object (no routing-key/residual-risk language on the url field).

    The rule's second branch ("promote to needs-user-decision") is deliberately not taken: the residual question it names — accept URL-as-credential as recorded residual risk? — was already ruled by the maintainer on this card, 2026-08-12T18:26Z (「接受你的全部建议。」, accept-and-record). Re-asking would re-litigate a settled ruling; the blocked re-grade postdated the seat memo but the ruling itself was never rescinded.

    What remains is exactly that ruling's outstanding deliverable, which is dispatchable no-question work: one recorded sentence on sys_webhook.url and sys_http_delivery.url naming Slack/Discord-style endpoints as the known, accepted exposure (routing key, not credential; re-open trigger = a measured incident or a customer requirement for secret-grade endpoints). Docs-only diff, skip-changeset candidate. At claim time also read #7986's closing disposition and cite in the sentence how the read surface was actually dispositioned there, so the two records cross-reference.

    Size/model suggestion: S, sonnet.

    本评论来自分诊座位 Routine。


    Generated by Claude Code

  14. added
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 15, 2026
  15. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 15, 2026
  16. os-project-manager commented on Aug 15, 2026

    @os-project-manager
    Collaborator

    State repair — this card was ruled on 2026-08-12; it does not belong in the decision inbox

    needs-user-decision → pm:queue. The standing maintainer ruling of 2026-08-12T18:26Z (this thread: accept-and-record; URL is a routing key, not a credential) was never rescinded, and the 2026-08-14 unlock scan already reduced the card to its outstanding deliverable. The label regressed to needs-user-decision afterwards without any comment recording why — that is the label drifting from the state machine, not a new question arriving.

    For the avoidance of doubt: the maintainer re-reviewed this card today in the 18-card decision-box pass (live PM chat 2026-08-15, session session_01LXPH2ApQmyHYeZGHfYv7Xs, 「接受你的所有建议。」) and the accepted recommendation is identical to the standing ruling — accept-and-record, read-surface narrowing belonging to #7986's disposition. No new ruling is created; the 2026-08-12 record governs.

    Deliverable, unchanged from the unlock scan's reduction: one recorded sentence on sys_webhook.url and sys_http_delivery.url naming Slack/Discord-style endpoints as the known, accepted exposure (routing key, not credential; re-open trigger = a measured incident or a customer requirement for secret-grade endpoints), citing #7986's actual closing disposition for how the read surface was handled. Docs-only diff, skip-changeset candidate. Size/model: S, sonnet.


    Generated by Claude Code

  17. os-project-manager commented on Aug 18, 2026

    @os-project-manager
    Collaborator

    PM ruling — (1) accept and record. The field stays plain. The read-surface narrowing is #7986's, not this card's.

    PM dispatch seat, session session_01Y26DJEHSBhhAQ6wwfsHNza. domain:services is this seat's lane. The filer asked for "one sentence, on the record"; here it is, with the reasoning attached so a future survey does not re-open it as an unexamined hit.

    Ruled: the URL stays plain on sys_webhook.url and sys_http_delivery.url

    ⭐ The deciding argument is the filer's own, and it is an engineering fact rather than a risk appetite: the URL is the routing key. Unlike the signing secret (#7799, fixed) or the headers map (#7986), it cannot move behind Field.secret() without breaking what the row exists to do — an operator must be able to see, search, sort and edit which endpoint a webhook targets, and the dispatcher reads it on every attempt. ⇒ A masked routing key is close to a useless row, and the masking would buy nothing: the exposure is bounded by whoever can read sys_webhook, which is the same set that could already read the endpoint they administer.

    ⛔ Option (3) — pattern-matching known credential-bearing URL shapes — is rejected, and the filer's reason is the right one: a denylist of URL shapes is exactly the rule that silently fails on the next provider. It would also be worse than doing nothing, because a partial mask advertises that masking is happening. ⚠️ That is the same failure family as everything else this seat has worked today: a protection that is narrower than it appears is more dangerous than an absent one, because it reads as coverage.

    ⛔ Option (2) is real work, but it is NOT this card's

    sys_webhook declares no enable block at all, so it inherits the full default data API (sys-webhook.object.ts:194). Giving it an explicit enable.apiMethods is worth doing — and #7986 already asks for it. ⇒ Recording it there rather than duplicating it here; two cards racing to narrow the same object's API surface is how conflicting shapes get landed for one problem.

    This card closes as not_planned once #7986 carries that line, ⛔ not before — the disposition must exist somewhere a future credential survey will find it, and this card's whole purpose is that the decision be written rather than omitted.

    On the filing itself

    ⭐ Worth saying plainly, because it shaped the outcome: this card was filed expecting to be closed, and said so in its first paragraph — "a finding that is silently dropped is indistinguishable from one nobody noticed." That is correct and it is why the ruling took ten minutes instead of a survey: the analysis was already done, the untenable options were already eliminated with reasons, and what was left was a choice between two defensible dispositions.

    ⚠️ I also record a correction of my own: I had this queued as a maintainer decision on the grounds that it was a "security/product call about whether a webhook URL should be treated as a credential." That overstated it. The filer had already settled the only genuinely contested part on engineering grounds; what remained was a bounded disposition inside my own lane. Escalating it would have cost the maintainer a decision they did not need to make.


    Generated by Claude Code

  18. os-project-manager commented on Aug 18, 2026

    @os-project-manager
    Collaborator

    Closing won't-fix on the field question — and correcting the condition I attached to it, which had already resolved the wrong way

    PM seat os-project-manager, session session_01Y26DJEHSBhhAQ6wwfsHNza, domain:services lane.

    The ruling, restated for the record — this is what the card asked for

    Option (1): the url stays plain. It is the routing key. Unlike a signing secret (#7799, fixed) or a header (#7986), it cannot go behind Field.secret() without breaking what the row exists to do — an operator has to see, search, sort and edit which endpoint a webhook targets, and the dispatcher reads it on every attempt. A masked routing key is close to a useless row.

    ⛔ Option (3) — pattern-matching Slack/Discord-shaped URLs — is refused, for the reason the card itself gives: a denylist of URL shapes silently fails on the next provider, and a detector that quietly stops detecting is worse than none.

    The exposure is bounded by whoever can read sys_webhook, which is broadly the set already administering those endpoints. ⛔ No live leak asserted; none was.

    That is the sentence this card existed to get written down. It is now written down.

    ⚠️ The correction: my close condition was "once #7986 carries the line." It does not, and cannot.

    I ruled this card earlier today and deferred its option (2) — narrowing sys_webhook's read surface with an explicit enable.apiMethods — on the card's own reasoning that #7986 already asks for it. Before closing I checked whether that had happened. It has not:

    $ grep -n -A8 '^\s*enable:' packages/plugins/plugin-webhooks/src/sys-webhook.object.ts
    (no output)
    

    sys_webhook declares no enable block at all on main today, so it still keeps the full default data API. And #7986 closed completed on 2026-08-13 — five days before I leaned on it.

    ⇒ My deferral pointed at a card that had already closed without delivering the thing I was deferring. Closing this one silently on that basis would have retired both halves while only one was decided — which is precisely the failure mode this card was filed to prevent: "an omission is not a decision unless someone wrote it down."

    ⭐ And the pattern is worth naming, because three cards did the same thing in sequence: #7799 noticed sys_webhook keeps the full default API and fixed the secret; #7986 repeated the observation verbatim and fixed the headers question it was scoped to; this card named it as option (2) and pointed back at #7986. Every card that saw it pointed at the next one, and the last one closed. Nobody ever decided sys_webhook should keep the full default data API — it kept it because each card correctly stayed in its scope.

    Filed as #9756, unassigned, with the measurement and with the census (who reads that object today) named as the first deliverable rather than the edit — narrowing an object currently serving the full default API is a behaviour change for real callers.

    Disposition

    Closing not_planned: the field question is decided, and the read-surface half now has an owner-shaped card of its own instead of a pointer to a closed one. ⛔ Reopen if a deployment is found putting credentials in a webhook URL and the read surface turns out to be wider than the administering set — that combination would change the grade, and neither half is measured today.


    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

    domain:servicesRetired 2026-10-22, alias of domain:server (services surface); in-flight cards keep it until landedpm:queueReady for the PM dispatch loopsecurity

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions