@openwop/openwop-conformance 2.32.0 → 2.33.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +5 -4
- package/coverage.md +2 -1
- package/dist/cli.js +4 -2
- package/dist/lib/certification-bundle-v3.js +12 -2
- package/dist/lib/v2-profiles.js +66 -0
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +58 -14
- package/scenario-majors.json +5 -2
- package/schemas/CORPUS-STAMP.json +8 -8
- package/src/cli.ts +4 -2
- package/src/lib/a2a-fake-peer.ts +16 -3
- package/src/lib/certification-bundle-v3.ts +11 -2
- package/src/lib/durability-watch.ts +53 -0
- package/src/lib/mcp-fake-server.ts +13 -0
- package/src/lib/v2-profiles.ts +66 -0
- package/src/lib/webhook-receiver.ts +24 -5
- package/src/scenarios/a2a-1-0-agent-card.test.ts +1 -1
- package/src/scenarios/a2a-peer-authority.test.ts +1 -1
- package/src/scenarios/a2a-task-roundtrip.test.ts +1 -1
- package/src/scenarios/a2a-version-negotiation.test.ts +3 -3
- package/src/scenarios/mcp-extension-opacity.test.ts +1 -1
- package/src/scenarios/mcp-mrtr-roundtrip.test.ts +1 -1
- package/src/scenarios/mcp-tool-roundtrip.test.ts +1 -1
- package/src/scenarios/mcp-version-negotiation.test.ts +3 -3
- package/src/scenarios/v2-durability-recovery.test.ts +13 -21
- package/src/scenarios/v2-minimum-version-refused.test.ts +2 -2
- package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +1 -1
- package/src/scenarios/v2-negotiation-authenticated.test.ts +2 -2
- package/src/scenarios/v2-negotiation-decided-emitted.test.ts +3 -3
- package/src/scenarios/v2-relaxation-recorded.test.ts +37 -2
- package/src/scenarios/v2-webhook-delivery-shape.test.ts +45 -8
- package/src/scenarios/v2-webhook-egress-refusal.test.ts +115 -0
|
@@ -115,9 +115,32 @@ async function register(url: string, major: 1 | 2): Promise<string | null> {
|
|
|
115
115
|
}
|
|
116
116
|
expect(reg.status, req(ID, 'webhooks.md §Surfaces', 'POST /webhooks MUST answer 201 { webhookId }')).toBe(201);
|
|
117
117
|
const id = (reg.json as { webhookId?: unknown } | null)?.webhookId;
|
|
118
|
+
if (typeof id === 'string') registered.push({ id, major });
|
|
118
119
|
return typeof id === 'string' ? id : null;
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Every subscription this file registers, unregistered after the leg that made
|
|
124
|
+
* it. Until 2.33.1 NOTHING here was ever unregistered. On loopback that was
|
|
125
|
+
* invisible: each leg's receiver bound its own ephemeral port, so a leftover
|
|
126
|
+
* subscription delivered to a dead address. Behind a public front every leg
|
|
127
|
+
* shares ONE URL on ONE pinned port - so leg 1's still-live MAJOR-2
|
|
128
|
+
* subscription delivered its v2 rendering into leg 2, and the major-1 leg read
|
|
129
|
+
* it and failed a host that had rendered both contracts correctly ("a major-1
|
|
130
|
+
* run.started payload MUST validate against the V1 definition … engineVersion
|
|
131
|
+
* must be string"). Found on the v2 reference host's first relaxation-free cut;
|
|
132
|
+
* reproduced with no ingress at all by pinning OPENWOP_WEBHOOK_RECEIVER_PORT on
|
|
133
|
+
* a loopback run. It also left a live subscription on every host this file ever
|
|
134
|
+
* ran against.
|
|
135
|
+
*/
|
|
136
|
+
const registered: Array<{ id: string; major: 1 | 2 }> = [];
|
|
137
|
+
async function unregisterAll(): Promise<void> {
|
|
138
|
+
for (const r of registered.splice(0)) {
|
|
139
|
+
const path = `${r.major === 2 ? '' : '/v1'}/webhooks/${encodeURIComponent(r.id)}`;
|
|
140
|
+
try { await driver.delete(path, { headers: { 'OpenWOP-Version': r.major === 2 ? '2.0' : '1.0' } }); } catch { /* best effort: the leg's verdict is already recorded */ }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
121
144
|
async function driveRun(): Promise<string> {
|
|
122
145
|
const create = await driver.post('/runs', { workflowId: FIXTURE });
|
|
123
146
|
expect(create.status, req(ID, 'runs.md §Create', 'POST /runs MUST answer 201 for the noop fixture')).toBe(201);
|
|
@@ -135,10 +158,21 @@ async function waitFor<T>(fn: () => T | undefined, ms: number): Promise<T | unde
|
|
|
135
158
|
* receive the bare opaque id (versioning.md §5), so the match is on the segment
|
|
136
159
|
* both spellings share.
|
|
137
160
|
*/
|
|
138
|
-
|
|
161
|
+
/** The subscription a delivery says it belongs to, from either header family; undefined when it carries neither. */
|
|
162
|
+
function subscriptionOf(d: Delivery): string | undefined {
|
|
163
|
+
const h = d.headers['openwop-webhook-id'] ?? d.headers['x-openwop-webhook-id'];
|
|
164
|
+
return typeof h === 'string' ? h : Array.isArray(h) ? h[0] : undefined;
|
|
165
|
+
}
|
|
166
|
+
const bare = (id: string): string => (id.includes('/') ? id.slice(id.indexOf('/') + 1) : id);
|
|
167
|
+
|
|
168
|
+
function deliveryFor(deliveries: Delivery[], runId: string, webhookId?: string): { event: Record<string, unknown>; envelope: Record<string, unknown> } | undefined {
|
|
139
169
|
const opaque = runId.includes('/') ? runId.slice(runId.indexOf('/') + 1) : runId;
|
|
140
170
|
for (const d of deliveries) {
|
|
141
171
|
if (!d.body.includes(opaque)) continue;
|
|
172
|
+
// Belt and braces beside unregisterAll(): a delivery that NAMES another
|
|
173
|
+
// subscription is not this leg's, whatever URL it arrived on.
|
|
174
|
+
const sub = subscriptionOf(d);
|
|
175
|
+
if (webhookId !== undefined && sub !== undefined && bare(sub) !== bare(webhookId)) continue;
|
|
142
176
|
let parsed: unknown;
|
|
143
177
|
try { parsed = JSON.parse(d.body); } catch { continue; }
|
|
144
178
|
if (parsed === null || typeof parsed !== 'object') continue;
|
|
@@ -181,15 +215,16 @@ const V1 = 'https://openwop.dev/spec/v1/';
|
|
|
181
215
|
|
|
182
216
|
describe('webhook delivery shape is per-contract (webhooks.md §Delivery, versioning.md §1.2)', () => {
|
|
183
217
|
let active: Server | null = null;
|
|
184
|
-
afterEach(async () => { const s = active; active = null; if (s) await new Promise<void>((r) => s.close(() => r())); });
|
|
218
|
+
afterEach(async () => { await unregisterAll(); const s = active; active = null; if (s) await new Promise<void>((r) => s.close(() => r())); });
|
|
185
219
|
|
|
186
220
|
it('a major-2 subscriber receives the v2 rendering: the delivery validates, and run.started.owner carries subject, never principal', async () => {
|
|
187
221
|
if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable');
|
|
188
222
|
if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
|
|
189
223
|
const receiver = await startReceiver(); active = receiver.server;
|
|
190
|
-
|
|
224
|
+
const webhookId = await register(receiver.url, 2);
|
|
225
|
+
if (webhookId === null) return softSkip('blocked', 'registration refused (reason recorded above)');
|
|
191
226
|
const runId = await driveRun();
|
|
192
|
-
const d = await waitFor(() => deliveryFor(receiver.deliveries, runId), 15_000);
|
|
227
|
+
const d = await waitFor(() => deliveryFor(receiver.deliveries, runId, webhookId), 15_000);
|
|
193
228
|
if (!d) return softSkip('blocked', `no ${EVENT_TYPE} delivery for this run arrived inside 15s — durability is v2-webhook-durable-delivery's claim, not this file's`);
|
|
194
229
|
const v2 = validators(2);
|
|
195
230
|
const envelope = v2.ref(`${V2}webhook-delivery.schema.json`)(d.envelope);
|
|
@@ -211,9 +246,10 @@ describe('webhook delivery shape is per-contract (webhooks.md §Delivery, versio
|
|
|
211
246
|
const versions = Array.isArray(disc?.['protocolVersions']) ? (disc?.['protocolVersions'] as unknown[]).map(String) : [];
|
|
212
247
|
if (!versions.some((v) => v.startsWith('1.'))) return softSkip('inapplicable', `host advertises [${versions.join(', ') || 'no protocolVersions'}] — no 1.x member, so there is no v1 wire to keep still`);
|
|
213
248
|
const receiver = await startReceiver(); active = receiver.server;
|
|
214
|
-
|
|
249
|
+
const webhookId = await register(receiver.url, 1);
|
|
250
|
+
if (webhookId === null) return softSkip('blocked', 'registration refused or inapplicable (disposition recorded above)');
|
|
215
251
|
const runId = await driveRun();
|
|
216
|
-
const d = await waitFor(() => deliveryFor(receiver.deliveries, runId), 15_000);
|
|
252
|
+
const d = await waitFor(() => deliveryFor(receiver.deliveries, runId, webhookId), 15_000);
|
|
217
253
|
if (!d) return softSkip('blocked', `no ${EVENT_TYPE} delivery for this run arrived inside 15s`);
|
|
218
254
|
// The v1 definition is the discriminator, not the owner's keys: v1's owner admits `subject` (RFC 0165
|
|
219
255
|
// §B, echoed verbatim when present) alongside `principal`, so a v2 owner is ALSO a valid v1 owner.
|
|
@@ -234,11 +270,12 @@ describe('webhook delivery shape is per-contract (webhooks.md §Delivery, versio
|
|
|
234
270
|
const gate = era2Gate(disc);
|
|
235
271
|
if (gate !== null && !gate.ok) return softSkip(gate.kind, gate.reason);
|
|
236
272
|
const receiver = await startReceiver(); active = receiver.server;
|
|
237
|
-
|
|
273
|
+
const webhookId = await register(receiver.url, 2);
|
|
274
|
+
if (webhookId === null) return softSkip('blocked', 'registration refused (reason recorded above)');
|
|
238
275
|
const log = await seedEra2Log(v1FixtureLog(FIXTURE), 'completed');
|
|
239
276
|
if (!log.ok) return softSkip(log.kind, log.reason);
|
|
240
277
|
const runId = log.runId;
|
|
241
|
-
const d = await waitFor(() => deliveryFor(receiver.deliveries, runId), 15_000);
|
|
278
|
+
const d = await waitFor(() => deliveryFor(receiver.deliveries, runId, webhookId), 15_000);
|
|
242
279
|
// The seam appends HISTORY — rows that already happened — and a host MAY not fan out history (the
|
|
243
280
|
// reference host's seam appends with fan-out suppressed by design). No delivery inside 15s means the
|
|
244
281
|
// era-2 fan-out branch is unobservable on this host, not that a measurement failed: inapplicable.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2-webhook-egress-refusal — the webhook egress guard REFUSES, at major 2.
|
|
3
|
+
*
|
|
4
|
+
* `spec/v2/core/webhooks.md` §SSRF: "At registration a host MUST reject (`400
|
|
5
|
+
* webhook_url_rejected`) non-`https://` URLs, RFC 1918 and loopback and
|
|
6
|
+
* link-local ranges, IPv6 ULA, cloud metadata hosts, and `localhost`."
|
|
7
|
+
*
|
|
8
|
+
* ── Why this file exists ─────────────────────────────────────────────────────
|
|
9
|
+
* Until 2.33.0 NO major-2 row asserted that sentence. The scenarios that do
|
|
10
|
+
* (`http-client-ssrf`, `webhook-negative`) are registered at major 1 only, and
|
|
11
|
+
* the three major-2 files that name `webhook_url_rejected` name it only as the
|
|
12
|
+
* soft-skip taken when a host refuses the suite's own receiver. So a v2 bundle
|
|
13
|
+
* could certify `openwop-core-standard` — the profile built on `webhooks` — on a
|
|
14
|
+
* host whose guard refused nothing at all.
|
|
15
|
+
*
|
|
16
|
+
* It was found the expensive way: the steward's own reference host had been
|
|
17
|
+
* cutting its certified bundles with the guard open (`OPENWOP_WEBHOOK_ALLOW_PRIVATE`,
|
|
18
|
+
* because every suite fixture was a loopback listener) and recording no
|
|
19
|
+
* relaxation, which `security-defaults.md` §Relaxations forbids. Nothing in the
|
|
20
|
+
* suite could see it. `conformance/README.md` even said so: "the suite cannot
|
|
21
|
+
* detect an undeclared one." For THIS relaxation that was never true — a host
|
|
22
|
+
* that accepts `https://127.0.0.1/…` has shown on the wire that its guard is open.
|
|
23
|
+
*
|
|
24
|
+
* ── The three outcomes ───────────────────────────────────────────────────────
|
|
25
|
+
* every probe refused → executed-pass. The guard holds.
|
|
26
|
+
* a probe ACCEPTED, relaxation
|
|
27
|
+
* DECLARED (OPENWOP_HOST_RELAXATIONS
|
|
28
|
+
* names a `webhooks.*` obligation) → inapplicable, with the declaration
|
|
29
|
+
* quoted. The bundle carries
|
|
30
|
+
* `host.relaxations[]` and the owning
|
|
31
|
+
* profile is denied by that (RFC 0173 §A.2).
|
|
32
|
+
* a probe ACCEPTED, nothing declared → executed-FAIL — and the emitter and
|
|
33
|
+
* verifier deny every profile that owns
|
|
34
|
+
* `webhooks` (`lib/v2-profiles.ts`
|
|
35
|
+
* `profilesDeniedByObservedRelaxation`).
|
|
36
|
+
*
|
|
37
|
+
* Registration-time only, by design: every probe is a destination the suite
|
|
38
|
+
* does not own, so nothing is ever delivered and an accepted registration is
|
|
39
|
+
* deleted at once. Delivery-time re-resolution is a separate MUST that needs a
|
|
40
|
+
* resolver the suite controls; it is not claimed here.
|
|
41
|
+
*
|
|
42
|
+
* @see spec/v2/core/webhooks.md §SSRF
|
|
43
|
+
* @see spec/v2/core/security-defaults.md §Relaxations
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { describe, expect, it } from 'vitest';
|
|
47
|
+
import { driver } from '../lib/driver.js';
|
|
48
|
+
import { gateFamily, v2Discovery } from '../lib/v2.js';
|
|
49
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
50
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
51
|
+
import { req } from '../lib/requirement-ids.js';
|
|
52
|
+
|
|
53
|
+
const ID = 'openwop.requirement.0171.webhook-egress-refused';
|
|
54
|
+
const DOC = 'spec/v2/core/webhooks.md §SSRF';
|
|
55
|
+
const EVENTS = ['run.completed'];
|
|
56
|
+
|
|
57
|
+
/** One destination per class the sentence names. None is ever contacted. */
|
|
58
|
+
const PROBES: ReadonlyArray<{ readonly url: string; readonly cls: string }> = [
|
|
59
|
+
{ url: 'http://webhook-egress-probe.example.com/hook', cls: 'non-https scheme' },
|
|
60
|
+
{ url: 'https://127.0.0.1/openwop-egress-probe', cls: 'loopback (IPv4)' },
|
|
61
|
+
{ url: 'https://[::1]/openwop-egress-probe', cls: 'loopback (IPv6)' },
|
|
62
|
+
{ url: 'https://localhost/openwop-egress-probe', cls: 'localhost' },
|
|
63
|
+
{ url: 'https://10.255.255.1/openwop-egress-probe', cls: 'RFC 1918 (10/8)' },
|
|
64
|
+
{ url: 'https://192.168.255.1/openwop-egress-probe', cls: 'RFC 1918 (192.168/16)' },
|
|
65
|
+
{ url: 'https://169.254.169.254/latest/meta-data/', cls: 'link-local / cloud metadata' },
|
|
66
|
+
{ url: 'https://[fd00::1]/openwop-egress-probe', cls: 'IPv6 ULA' },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/** The `webhooks.*` obligations the operator declared relaxed, if any. */
|
|
70
|
+
function declaredWebhookRelaxations(): string[] {
|
|
71
|
+
const raw = process.env['OPENWOP_HOST_RELAXATIONS'];
|
|
72
|
+
if (!raw) return [];
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(raw) as Array<{ obligation?: unknown }>;
|
|
75
|
+
return (Array.isArray(parsed) ? parsed : []).map((r) => String(r.obligation ?? '')).filter((o) => o.split('.')[0] === 'webhooks');
|
|
76
|
+
} catch {
|
|
77
|
+
return []; // the emitter rejects unparseable JSON loudly; this file does not second-guess it
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe('v2-webhook-egress-refusal (webhooks.md §SSRF — the guard refuses, at major 2)', () => {
|
|
82
|
+
it('a webhook registration naming a destination the guard MUST refuse is refused 400 webhook_url_rejected', async () => {
|
|
83
|
+
const doc = await v2Discovery();
|
|
84
|
+
if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
|
|
85
|
+
if (!(await gateFamily('webhooks'))) return softSkip('inapplicable', 'webhooks family not advertised (gate recorded under openwop.family.webhooks)');
|
|
86
|
+
|
|
87
|
+
const accepted: string[] = [];
|
|
88
|
+
const wrongCode: string[] = [];
|
|
89
|
+
for (const probe of PROBES) {
|
|
90
|
+
const reg = await driver.post('/webhooks', { url: probe.url, events: EVENTS });
|
|
91
|
+
if (reg.status === 201) {
|
|
92
|
+
accepted.push(`${probe.cls} (${probe.url})`);
|
|
93
|
+
const webhookId = (reg.json as { webhookId?: unknown } | null)?.webhookId;
|
|
94
|
+
if (typeof webhookId === 'string') await driver.delete(`/webhooks/${encodeURIComponent(webhookId)}`);
|
|
95
|
+
} else if (!(reg.status === 400 && readErrorCode(reg.json) === 'webhook_url_rejected')) {
|
|
96
|
+
wrongCode.push(`${probe.cls}: ${reg.status} ${String(readErrorCode(reg.json))}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (accepted.length > 0) {
|
|
101
|
+
const declared = declaredWebhookRelaxations();
|
|
102
|
+
if (declared.length > 0) {
|
|
103
|
+
return softSkip('inapplicable', `the egress guard is relaxed and the operator DECLARED it (OPENWOP_HOST_RELAXATIONS: ${declared.join(', ')}) — the host accepted ${accepted.length} of ${PROBES.length} destinations the guard MUST refuse (${accepted.join('; ')}). The refusal cannot be witnessed on a host running relaxed; the bundle carries host.relaxations[] and the profile that owns \`webhooks\` does not certify (security-defaults.md §Relaxations)`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
expect(
|
|
107
|
+
accepted,
|
|
108
|
+
req(ID, DOC, `a host MUST reject (400 webhook_url_rejected) non-https URLs, RFC 1918, loopback, link-local, IPv6 ULA, cloud-metadata hosts and localhost at registration — this host ACCEPTED ${accepted.length} of ${PROBES.length} (${accepted.join('; ')}) and no \`webhooks.*\` relaxation is declared in OPENWOP_HOST_RELAXATIONS. An open guard nobody declared is an UNDECLARED RELAXATION: security-defaults.md §Relaxations requires it in host.relaxations[], and no profile built on \`webhooks\` certifies either way`),
|
|
109
|
+
).toEqual([]);
|
|
110
|
+
expect(
|
|
111
|
+
wrongCode,
|
|
112
|
+
req(ID, DOC, `the refusal MUST be 400 webhook_url_rejected (spec/v2/errors.json) — other answers: ${wrongCode.join('; ')}`),
|
|
113
|
+
).toEqual([]);
|
|
114
|
+
}, 60_000);
|
|
115
|
+
});
|