Station: verified on station-v0.97 (current). File: workbench/primitives/discord_api.py::_req — the error strings at line 153 (raise DiscordError(f"HTTP {e.code} on {method} {url}: {err_body!r}")) and 155 (raise DiscordError(f"network failure on {method} {url}: {e!r}")), where url = self.hook_url + "?wait=true" (post, 115) / self.hook_url + f"/messages/{message_id}" (delete, 125) and self.hook_url = f"{base}/api/webhooks/{self.hook_id}/{self.hook_token}" (105) — the webhook token is the last path segment. Sink: studio_integration_send.py::approve — err = repr(e)[:200] (247) → receipt["error"] = err (261) → receipt["signature"] = signing.sign_block(rint) (274). Separately, the same send's integration_audit row (301-305) asserts "secret_value_logged": False — a distinct record from the receipt, yet the paired receipt holds the token. Class: insertion of a secret (bearer-equivalent capability token) into a signed log/receipt (CWE-532 / CWE-201).
The guarantee
A Discord webhook URL is a bearer-equivalent capability: whoever holds https://discord.com/api/webhooks/<id>/<token> can post, edit, and delete messages in that channel with no other auth. The RailCall receipt is the artifact meant to be safe to persist and share — _preview() exists specifically to "strip private blobs from anything surfaced to the UI/receipt", and every integration-audit row asserts "secret_value_logged": False. So a secret must never end up in cleartext inside the signed receipt or the airlock's HTTP response. (There is also a second durable sink: when the failing send runs inside the Saga, sagalog.step records step_record["error"] = repr(e) and step_record["traceback"] = traceback.format_exc() — the full, un-truncated exception — so the token is persisted there too, not even bounded by the receipt's [:200] slice.)
The break — the token-bearing URL is formatted into the error, which becomes the signed receipt's error field
# discord_api.py — the URL carries the token
url = self.hook_url + "?wait=true" # hook_url = .../api/webhooks/{id}/{TOKEN}
...
raise DiscordError(f"HTTP {e.code} on {method} {url}: {err_body!r}") # 153
raise DiscordError(f"network failure on {method} {url}: {e!r}") # 155
# studio_integration_send.approve — the error string is signed into the receipt
except Exception as e:
err = repr(e)[:200] # 247 — token is ~offset 40-125, well inside 200
...
receipt = { ..., "error": err, ... } # 261
receipt["signature"] = signing.sign_block(rint) # 274 — the leak is now SIGNED
Contrast the sibling telegram_api._req (line 122/124), whose token is also in the URL path but is deliberately omitted: raise TelegramError(f"HTTP {e.code} on {method}: {err_body!r}") — method only, no URL. Slack/Stripe/GitHub format only the non-secret {path}. Discord is the one client that inlines the credential-bearing URL.
Proof (container, REAL discord_api.DiscordClient, v0.97; loopback-closed port so no egress)
A live-shaped webhook URL whose token is a 60-char secret; post_message fails on a network error and the token appears in the exception that approve() stores as repr(e)[:200]:
DISCORD raised: DiscordError("network failure on POST http://127.0.0.1:9/api/webhooks/123456789012345678/aB3dEf…(60-char token)…?wait=true: URLError…
TOKEN in error string : True
TOKEN survives repr()[:200] : True # → lands in receipt["error"], which is then signed
The full webhook token sits at offset ~57 in the string, inside the [:200] slice that approve() writes to the signed receipt. The identical scenario against TelegramClient (whose token is also in the URL) leaks nothing — its error omits the URL. In production the live client is built from the vault's real webhook URL (integration_registry _LIVE_BUILD["discord"] → DiscordClient(hook_url=<vault hook>, live=True)), so the token in the error is the operator's real credential.
Impact
Any routine failure on a live Discord send — HTTP 429 (rate limit), 404 (rotated/deleted webhook), 401, a timeout, or a transient network drop, all normal operational events requiring no attacker input — writes the channel's bearer-equivalent webhook token in cleartext into (a) the signed, persisted apply receipt (receipts/…/discord__live_*.json) and (b) the airlock approve() HTTP response returned to the caller. The receipt is the object RailCall encourages operators to keep and share as tamper-evident proof, and the audit row next to it certifies secret_value_logged: False — so the one artifact that promises "no secret was logged" is exactly where the secret lands. Anyone who can read the receipt file, the API response, or a shipped copy of the "shareable receipt" recovers a credential that lets them post/edit/delete in that Discord channel.
Honest scope
- The trigger is an error on the outbound request, not attacker-forced — the harm is that a live send which errors self-discloses its credential into durable signed state. (An attacker who can cause errors, e.g. by exhausting the webhook's rate limit, can turn this into an on-demand disclosure, but that is not required.)
- It is a disclosure of an existing credential, not privilege escalation or RCE. Its blast radius is whoever can read the receipt / audit log / API response (operators, log shippers, and anyone the "signed, shareable receipt" is shared with).
- I exercised
_req's error formatting directly (loopback-closed port, no real egress); the string that reachesreceipt["error"]is byte-identical to what a real 429/network failure produces on a vault-built live client.
Distinctness
Distinct from the posted workflow_http URL-secret leak: different file (primitives/discord_api.py vs the workflow HTTP node), different secret class (a Discord webhook capability token vs an HTTP-node URL query param), and a different sink (a provider-client exception → the signed saga receipt + airlock approve() response vs a workflow-node output). Not the webhook-bus/external_post SSRF findings (those concern the outbound destination, not a credential-in-error disclosure). I did not find a community thread about discord_api leaking the webhook token into the receipt.
Fix
Mirror telegram_api: format only the non-secret parts into DiscordError — the method and, if useful, the path without the /webhooks/{id}/{token} segment (or a redacted …/webhooks/{id}/***). More defensively, approve() should scrub known secret shapes from err before signing it into receipt["error"], so a future client that leaks a credential into an exception cannot land it in the signed receipt that certifies secret_value_logged: False.
Reviewed adversarially against the source before posting.