Skip to content

Commit bf1ea92

Browse files
os-zhuangclaude
andauthored
fix(plugin-webhooks): park a subscription whose stored header map resolves to nothing (#8558) (#8565)
`resolveWebhookHeaders` answered `undefined` for two different facts — "the author configured no custom headers" and "a map IS stored and did not come back as one" — and `AutoEnqueuer.attachHeaders` acts on the first reading. So the second became the first: the subscription armed and every delivery went out missing its entire authored header map, the ordinary place an `Authorization` goes, while the row kept reading `active: true` with `headers_secret` masked. Measured end to end: the delivery SUCCEEDED carrying a byte-correct `X-Objectstack-Signature`, which is the worst available combination — the signature tells the receiver the request is genuinely ours while it no longer matches the configuration its operator wrote. Sibling of #8542 on the same seam's other credential, but WIDER rather than symmetric: a signing secret is an opaque scalar so only `''` collapsed, while a header map's content decides and every string that is not a flat JSON object of string values collapses — through the ordinary data API, on a field whose own description tells the admin to type JSON into it. Fixed at the seam, so no caller re-derives the rule: stored headers that do not come back as a map now raise `WebhookHeadersUnresolvableError`, reaching `attachHeaders`' existing `catch` exactly the way a throwing resolver already did. The park (#8069), the durable `sys_http_delivery` record and the say-once ADR-0112 `error` all apply unchanged; `attachHeaders` needed no new branch. Claude-Session: https://claude.ai/code/session_01ARidKDYSCD56LaygrvDPnk Co-authored-by: Claude <noreply@anthropic.com>
1 parent a98085f commit bf1ea92

4 files changed

Lines changed: 525 additions & 14 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
---
2+
"@objectstack/plugin-webhooks": patch
3+
---
4+
5+
fix(plugin-webhooks): a stored header map that cannot be recovered parks the subscription instead of arming it and delivering the headers MISSING (#8558)
6+
7+
A webhook whose `sys_webhook.headers_secret` held a value that did not come back
8+
as a header map was treated as **authored without custom headers**. The
9+
subscription armed, every matching record change was delivered, and the entire
10+
authored map — the ordinary place an `Authorization: Bearer …` goes — was
11+
silently absent. Nothing logged, nothing dropped, and
12+
`GET /api/v1/data/sys_webhook` kept reporting `active: true` with the header
13+
column masked, so both the operator and the Setup UI still read "custom headers
14+
are configured".
15+
16+
Measured end to end against a real engine, what reached the receiver was worse
17+
than "a delivery with something missing": the request SUCCEEDED
18+
(`sys_http_delivery.status = 'success'`) carrying a byte-correct
19+
`X-Objectstack-Signature`. The signature is the receiver's proof the request is
20+
genuinely ours, so a receiver that authenticates by signature had every reason
21+
to accept a request that no longer matched the configuration its operator wrote.
22+
Against an endpoint that requires the credential the result is a 401 nobody
23+
attributes correctly; against one that does not — a routing `X-Tenant-Id`, an
24+
`X-Environment: staging` — the delivery is simply wrong and nobody finds out.
25+
26+
The cause was one return value carrying two facts. `resolveWebhookHeaders`
27+
answered `undefined` both for *"the author configured no custom headers"*
28+
legitimate, `headers` is optional on the envelope — and for *"a map is stored
29+
and did not come back as one"*. Its caller acts on the first reading, so the
30+
second became the first. This is the sibling of the signing-secret collapse
31+
(#8542) on the same seam's other credential, and the file's own header comment
32+
already promised the opposite: *"It does not deliver partially. A row whose
33+
stored headers cannot be resolved DROPS the subscription."*
34+
35+
**This path is wider than the signing-secret one, not symmetric to it.** A
36+
signing secret is an opaque scalar, so any non-empty answer is a usable key and
37+
only the empty string collapsed. A header map's CONTENT decides, and
38+
`parseStoredHeaders` answers `undefined` — correctly, for its own job — for every
39+
string that is not a flat JSON object of string values. Four states reach the
40+
seam, all confirmed against a real engine:
41+
42+
- the `sys_webhook` row is deleted between the enqueuer's cache read and the
43+
per-row dereference;
44+
- the column holds something that is not a `secret:` ref — reachable only
45+
through a write that bypasses the engine (a column edited in SQL, a dump
46+
restored without its `sys_secret` rows, a seed script writing at driver level);
47+
- the stored value decrypts to an **empty string**;
48+
- the stored value decrypts to a perfectly readable string that is **not a flat
49+
string map**`{}`, `[]`, `{"X-Count": 5}`, a nested object, or any typo.
50+
This is the widest road rather than an exotic one: `headers_secret` is an
51+
admin-authorable field whose own description instructs the author to type a
52+
JSON object into it, and every one of these spellings is accepted by the
53+
ordinary data API, encrypted like any other value, and left behind a
54+
perfectly valid ref that reads back as the mask.
55+
56+
The fix is at the seam, so no consumer has to re-derive the rule: presence is
57+
already decidable there (`headers_secret` is a map only in the plaintext — at
58+
the storage layer it is an ordinary scalar `secret` column, so a set map comes
59+
back from the generic read path as the engine's mask and an unset one as `null`),
60+
and stored headers that do not come back as a map now raise rather than
61+
answering `undefined`. They therefore reach `AutoEnqueuer.attachHeaders` exactly
62+
the way a throwing resolver already did — the subscription is parked, the
63+
discarded event lands in `sys_http_delivery` with a cause (#8069), and the
64+
operator gets the existing remedy-bearing say-once `error` carrying
65+
`INTERNAL_ERROR` / `500` (ADR-0112) and naming `headers_secret`, so it cannot be
66+
confused with the signing secret's identical-looking drop.
67+
68+
**Unchanged:** a webhook authored with no custom headers at all still arms and
69+
delivers — that is a legitimate authored configuration, and it is pinned as the
70+
control for this change, as is a webhook whose stored map resolves normally and
71+
still delivers every header including the credential entry.
72+
73+
**What an operator sees after upgrading.** A webhook that was quietly delivering
74+
without its headers stops delivering and starts reporting. Re-save the headers
75+
as a flat JSON object of string values so the column holds a fresh ref, or
76+
**clear** the field to `null` if the webhook is meant to send no custom headers
77+
— an empty or unparseable header map is not the same thing as no header map,
78+
and only the second one means "send nothing extra".

packages/plugins/plugin-webhooks/src/auto-enqueuer.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,18 @@ export class AutoEnqueuer {
550550
* from the configuration the author wrote, and nothing anywhere records
551551
* that it went out incomplete. A subscription that stops is visible; a
552552
* delivery that arrives subtly wrong is not.
553+
*
554+
* [#8558] And that is what this method used to do, for the same reason its
555+
* signing sibling did (#8542): `resolveWebhookHeaders` answered `undefined`
556+
* for BOTH "no headers are stored" and "a map is stored and did not come
557+
* back as one", so this method read the second as the first and armed the
558+
* subscription — the paragraph above failing OPEN. Measured, the delivery
559+
* then went out SUCCESSFULLY and correctly SIGNED with the whole authored
560+
* map missing, which is the worst available combination: the signature
561+
* tells the receiver the request is genuinely ours. Nothing here changed:
562+
* the seam now raises, so it lands in the `catch` below exactly the way a
563+
* throwing resolver already did, and the drop, the say-once `error` and the
564+
* #8069 park all apply to it unchanged.
553565
*/
554566
private async attachHeaders(sub: CachedSubscription, row: any): Promise<boolean> {
555567
try {

packages/plugins/plugin-webhooks/src/webhook-headers.ts

Lines changed: 160 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,21 @@
6666
* - It does not deliver partially. A row whose stored headers cannot be
6767
* resolved DROPS the subscription rather than delivering it with the headers
6868
* missing — see {@link resolveWebhookHeaders}.
69+
*
70+
* [#8558] That last line was a statement of intent this file did not keep. Only
71+
* a THROWING resolver reached the caller's `catch`; a resolver that answered
72+
* `null` — or handed back a value that was not a flat string map — folded onto
73+
* the `undefined` this seam uses for "no headers stored", and the subscription
74+
* armed and delivered without them. {@link WebhookHeadersUnresolvableError} is
75+
* what makes the sentence true.
6976
*/
7077

7178
import type { IDataEngine } from '@objectstack/spec/contracts';
72-
import { isOpaqueSecretForm } from './webhook-secret.js';
79+
import {
80+
WEBHOOK_SECRET_REFUSAL_CODE,
81+
WEBHOOK_SECRET_REFUSAL_STATUS,
82+
isOpaqueSecretForm,
83+
} from './webhook-secret.js';
7384

7485
/** Column on `sys_webhook` holding the encrypted custom-header map. */
7586
export const WEBHOOK_HEADERS_FIELD = 'headers_secret';
@@ -151,13 +162,131 @@ export function readLegacyHeaders(definitionJson: unknown): WebhookHeaders | und
151162
}
152163

153164
/**
154-
* Recover a row's custom headers. Returns `undefined` when the row stores none
155-
* — which is not an error: `headers` is optional on the authoring envelope.
165+
* [#8558] A header map IS stored on the row and did not come back as one.
166+
*
167+
* ## Why this is an error and not an `undefined`
168+
* `resolveWebhookHeaders` used to return `undefined` for two different facts —
169+
* *"the author configured this webhook with no custom headers"* and *"a map is
170+
* stored and did not come back"* — and its caller acts on the first reading,
171+
* which is the legitimate one. So the second silently became the first: the
172+
* subscription ARMED and every delivery went out missing the entire authored
173+
* header map, while `sys_webhook` kept reading `active: true` with
174+
* `headers_secret` masked, i.e. still reporting "custom headers are
175+
* configured". Measured end to end, what reached the receiver was a delivery
176+
* that SUCCEEDED, carrying a byte-correct `X-Objectstack-Signature`, with the
177+
* `Authorization` the author declared simply absent — and nothing logged.
178+
*
179+
* That the signature is VALID is what makes the direction so bad. It tells the
180+
* receiver the request is genuinely ours, so a receiver that authenticates by
181+
* signature has every reason to accept a request that no longer matches the
182+
* configuration its operator wrote. Against an endpoint that does not require
183+
* the header at all — a routing `X-Tenant-Id`, an `X-Environment: staging` —
184+
* the delivery is simply wrong and nobody finds out.
185+
*
186+
* Presence is decidable even when the value is not, and this is the one place
187+
* worth stating plainly because the field LOOKS like it should behave
188+
* differently: `headers_secret` is a map only in the plaintext. At the storage
189+
* layer it is an ordinary scalar `secret` column holding the serialized map, so
190+
* the generic read path returns the engine's mask for a set map and `null` for
191+
* an unset one — the same decidable signal `signing_secret` gives, for the same
192+
* reason. The "it is a map, not a scalar" worry does not survive measurement.
193+
*
194+
* Carries the ADR-0112 pair as fields so a consumer branches on `code`/`status`
195+
* rather than on message text — the same pair `attachHeaders`' drop report and
196+
* the signing seam's refusal already carry for the same class of cause.
197+
*
198+
* ## Why ONE error class for two conditions
199+
* A stored map reaches this seam and fails in two distinguishable ways: it
200+
* could not be RECOVERED (nothing came back), or it was recovered fine and is
201+
* not a usable header map. They deserve different remedies and get different
202+
* messages. They do not deserve different types: every consumer of this seam
203+
* branches on the ADR-0112 pair and the disposition, both identical — park the
204+
* subscription, record the discarded event, say it once. A second class with no
205+
* consumer would be a distinction the tree cannot act on.
206+
*/
207+
export class WebhookHeadersUnresolvableError extends Error {
208+
readonly code = WEBHOOK_SECRET_REFUSAL_CODE;
209+
readonly status = WEBHOOK_SECRET_REFUSAL_STATUS;
210+
constructor(message: string) {
211+
super(message);
212+
this.name = 'WebhookHeadersUnresolvableError';
213+
}
214+
}
215+
216+
/** The remedy clause both refusals end with — one wording, stated once. */
217+
const HEADERS_REMEDY =
218+
'Fix: re-save the webhook headers as a flat JSON object of string values so the column holds a '
219+
+ 'fresh ref, or CLEAR the field to null if this webhook is meant to send no custom headers — an '
220+
+ 'empty or unparseable header map is not the same thing as no header map, and only the second '
221+
+ 'one means "send nothing extra".';
222+
223+
/**
224+
* Parse a recovered value into the map, or refuse.
225+
*
226+
* {@link parseStoredHeaders} answers `undefined` for every string that is not a
227+
* flat `Record` of strings, which is right for its own job and wrong as an
228+
* answer to *"what are this webhook's headers?"* once a value is known to be
229+
* stored. This is the narrow wrapper that turns the second reading into a
230+
* refusal, so the rule lives at the seam and no caller re-derives it.
231+
*/
232+
function requireHeaderMap(
233+
recovered: unknown,
234+
row: { id: string; [k: string]: unknown },
235+
where: string,
236+
): WebhookHeaders {
237+
const parsed = parseStoredHeaders(recovered);
238+
if (parsed) return parsed;
239+
240+
throw new WebhookHeadersUnresolvableError(
241+
`Webhook "${String(row.name ?? row.id)}" stores custom headers in ${where} that came back but are `
242+
+ 'not a flat JSON object of string values, so there is no header map to send. A value IS stored '
243+
+ '— the read path returns the engine mask for it — so this is NOT a webhook authored without '
244+
+ 'headers, and delivering it without them would silently drop whatever the author put in that '
245+
+ 'map, including an Authorization credential, on a delivery that is otherwise correctly signed '
246+
+ 'and therefore looks genuine to the receiver (#7986, #8558). Causes, in the order worth '
247+
+ 'checking: the value was typed into the Custom Headers field and is not valid JSON; it parses '
248+
+ 'but is an array, an empty object, or has a non-string value ({"X-Count": 5}); or it is a '
249+
+ `nested object where the wire format allows only strings. ${HEADERS_REMEDY}`,
250+
);
251+
}
252+
253+
/**
254+
* Recover a row's custom headers. Returns `undefined` for EXACTLY one fact —
255+
* the row stores no headers — which is not an error: `headers` is optional on
256+
* the authoring envelope, and a webhook with no custom headers is a legitimate
257+
* authored configuration.
258+
*
259+
* Throws {@link WebhookHeadersUnresolvableError} when a map IS stored and does
260+
* not come back as one. Callers must treat that as "drop this subscription",
261+
* never as "deliver without them" — see `AutoEnqueuer.attachCredentials` for
262+
* why partial delivery is the invisible failure and a stopped subscription is
263+
* the visible one.
264+
*
265+
* ## [#8558] Why "did not come back" is not spelled `undefined`
266+
* This is the sibling of #8542 on `webhook-secret.ts`, and the measurement that
267+
* produced it found the header path is WIDER than the signing path rather than
268+
* symmetric to it. A signing secret is an opaque scalar: any non-empty answer
269+
* is a usable key, so only the empty string collapses. A header map's CONTENT
270+
* decides, so every one of these reaches this function as a stored-but-unusable
271+
* value, all confirmed against a real engine:
156272
*
157-
* Throws when headers ARE stored but cannot be dereferenced. Callers must treat
158-
* that as "drop this subscription", never as "deliver without them" — see
159-
* `AutoEnqueuer.attachCredentials` for why partial delivery is the invisible
160-
* failure and a stopped subscription is the visible one.
273+
* 1. the `sys_webhook` row is deleted between the enqueuer's cache read and
274+
* this dereference (`resolveSecretField` opens `if (!row) return null`);
275+
* 2. the column holds something that is not a `secret:` ref — reachable only
276+
* through a write that BYPASSES the engine (a hand-edited column, a dump
277+
* restored without its `sys_secret` rows, a seed script writing at driver
278+
* level). The engine's own write path defends both obvious routes: an
279+
* echoed mask is dropped and cleartext is re-encrypted;
280+
* 3. the ciphertext decrypts to the empty string;
281+
* 4. ⭐ the ciphertext decrypts to a perfectly readable string that is not a
282+
* flat string map — `{}`, `[]`, `{"X-Count": 5}`, a nested object, or any
283+
* typo. Reachable through the ORDINARY data API with no privileged access,
284+
* and it is the WIDEST road here rather than an exotic one:
285+
* `sys_webhook.headers_secret` is an admin-authorable field whose own
286+
* description instructs the author to type a JSON object into it.
287+
*
288+
* In all four the row still advertises stored headers on every read path, so
289+
* returning `undefined` told the caller the opposite of what the row says.
161290
*/
162291
export async function resolveWebhookHeaders(
163292
engine: IDataEngine,
@@ -167,24 +296,42 @@ export async function resolveWebhookHeaders(
167296
const stored = row[WEBHOOK_HEADERS_FIELD];
168297
// Unset / cleared. On the generic read path a set secret comes back as the
169298
// engine's mask (a non-empty string) and an unset one as `null`, so presence
170-
// is decidable here WITHOUT the value ever being readable.
299+
// is decidable here WITHOUT the value ever being readable. Everything below
300+
// this line therefore runs with "headers ARE stored" already established —
301+
// which is the knowledge the old `undefined` return threw away.
171302
if (stored == null || stored === '') return undefined;
172303

173304
const resolver = engine as SecretResolvingEngine;
174305
if (typeof resolver.resolveSecretField !== 'function') {
175306
// An engine with no encrypted-field channel stored verbatim what the seeder
176307
// handed it, so the column IS the serialized map — reading it is correct,
177-
// not a fallback. The refusal below is for the narrow case where the value
178-
// is one of objectql's opaque forms and there is no way to invert it.
179-
if (!isOpaqueSecretForm(stored)) return parseStoredHeaders(stored);
180-
throw new Error(
308+
// not a fallback. It can still fail to parse, and that arm used to answer
309+
// `undefined` too; it is refused here for the same reason as everything
310+
// else on this seam.
311+
if (!isOpaqueSecretForm(stored)) {
312+
return requireHeaderMap(stored, row, `${object}.${WEBHOOK_HEADERS_FIELD}`);
313+
}
314+
throw new WebhookHeadersUnresolvableError(
181315
`Webhook "${String(row.name ?? row.id)}" stores encrypted custom headers, but this data engine `
182316
+ 'does not implement resolveSecretField() — they cannot be recovered, so the subscription is '
183317
+ 'dropped rather than delivered without the headers it was authored with (#7986).',
184318
);
185319
}
186320
const plain = await resolver.resolveSecretField(object, String(row.id), WEBHOOK_HEADERS_FIELD);
187-
return parseStoredHeaders(plain);
321+
if (plain == null || plain === '') {
322+
throw new WebhookHeadersUnresolvableError(
323+
`Webhook "${String(row.name ?? row.id)}" stores custom headers in `
324+
+ `${object}.${WEBHOOK_HEADERS_FIELD} that resolved to nothing. A value IS stored — the read `
325+
+ 'path returns the engine mask for it — so this is NOT a webhook authored without headers, '
326+
+ 'and delivering it without them would silently drop whatever the author put in that map, '
327+
+ 'including an Authorization credential, on a delivery that is otherwise correctly signed and '
328+
+ 'therefore looks genuine to the receiver (#7986, #8558). Causes, in the order worth checking: '
329+
+ 'the row was deleted while this refresh was reading it; the column holds something that is '
330+
+ 'not a secret: ref (a hand-edited column, or a dump restored without its sys_secret rows); '
331+
+ `or the stored value decrypts to an empty string. ${HEADERS_REMEDY}`,
332+
);
333+
}
334+
return requireHeaderMap(plain, row, `${object}.${WEBHOOK_HEADERS_FIELD}`);
188335
}
189336

190337
/**

0 commit comments

Comments
 (0)