|
| 1 | +--- |
| 2 | +'@objectstack/plugin-webhooks': minor |
| 3 | +--- |
| 4 | + |
| 5 | +fix(plugin-webhooks): a webhook credential stored as cleartext inside `sys_webhook.definition_json` is refused, at the delivery path and at the write door (#10164) |
| 6 | + |
| 7 | +Clause-②: no (narrowing) |
| 8 | + |
| 9 | +**BREAKING** — shipped as `minor` under the launch-window convention |
| 10 | +(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by |
| 11 | +this banner and the ADR-0087 disposition below, never by the level). |
| 12 | + |
| 13 | +**Webhooks that still carry the legacy cleartext shape STOP DELIVERING.** A |
| 14 | +`sys_webhook` row whose signing secret or custom header map exists only as a |
| 15 | +`secret` / `headers` key inside `definition_json` — with nothing stored in the |
| 16 | +encrypted `signing_secret` / `headers_secret` column — used to be delivered from |
| 17 | +that cleartext with a `warn`. It is now refused, dated `2026-09-23`: |
| 18 | + |
| 19 | +- **Delivery path.** The subscription is PARKED, the same fail-closed shape as an |
| 20 | + encrypted credential that cannot be recovered: nothing is sent, and every |
| 21 | + matching record change is recorded in `sys_http_delivery` as a `dead` row with |
| 22 | + 0 attempts, no signature and no headers. Its `error` names the refusal as |
| 23 | + `[VALIDATION_ERROR/400]`, and the drop is reported once at `error`, with |
| 24 | + `code: 'VALIDATION_ERROR'`, `status: 400`, `field: 'definition_json'` and the |
| 25 | + refused `keys` in the log meta. |
| 26 | +- **Write door** (when `WebhookOutboxPlugin` is mounted, the standard mount). A |
| 27 | + `sys_webhook` insert or update whose `definition_json` carries a `secret` or |
| 28 | + `headers` key, whatever its value, is refused before anything is stored |
| 29 | + (`VALIDATION_ERROR` / `400`, with `object`, `field` and `keys` on the error). |
| 30 | + That covers a raw `PATCH /api/v1/data/sys_webhook`. It also covers a Setup-form |
| 31 | + save that echoes back a legacy blob unchanged. Omitting `definition_json`, or |
| 32 | + writing one without those keys, is unaffected. The plugin binds this refusal |
| 33 | + itself, and it is not exported from the package entry: a host that composes |
| 34 | + `AutoEnqueuer` on its own still gets the delivery-path refusal above, but not |
| 35 | + this write-door refusal. |
| 36 | + |
| 37 | +**Fix.** Both remedies need a registered `CryptoProvider` |
| 38 | +(`engine.setCryptoProvider` — `LocalCryptoProvider` in dev, KMS/Vault in |
| 39 | +production), because writing a `secret`-typed column is itself refused without |
| 40 | +one. With a provider registered, either: |
| 41 | + |
| 42 | +- restart, and the boot sweep `migrateLegacyWebhookSecrets` moves both values into |
| 43 | + their encrypted columns and strips them from `definition_json` in one update. The |
| 44 | + subscription re-arms at the next refresh. |
| 45 | +- or re-author the webhook yourself. Write the key into `signing_secret` and the |
| 46 | + header map into `headers_secret` (a JSON object of string values), then remove |
| 47 | + both keys from `definition_json`. |
| 48 | + |
| 49 | +There is no transition path for a deployment that runs with no `CryptoProvider`. |
| 50 | + |
| 51 | +Unchanged: authoring. `defineWebhook({ secret, headers })` is written exactly as |
| 52 | +before, and the boot materializer still routes each value to its encrypted column. |
| 53 | +A row whose encrypted columns are set delivers exactly as before. |
| 54 | + |
| 55 | +<!-- adr-0087: not-required (no-migration-prescription) Nothing authored moves: `packages/spec` is untouched, `WebhookSchema` still declares `secret` and `headers`, and the materializer still routes them to their encrypted columns, so `objectstack migrate meta` has nothing to rewrite and the ledger has no row to gain. What is refused is a stored DATA-row shape on `sys_webhook`, and its converter already ships and runs at every boot: `migrateLegacyWebhookSecrets`, named in the refusal and in the Fix paragraph above. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers a data-row sweep (not `registered` / `already-registered`); and the change is runtime behaviour, not a TypeScript declaration (not `runtime-interface-only` / `type-surface-only`). --> |
0 commit comments