Skip to content

Commit 6d04509

Browse files
garethxclaude
andcommitted
test: verify a real Stripe webhook, and record what Stripe actually sends
The last thing synthesis could not reach. The automated suite builds a signature from Stripe's documented example, which is `t=` and `v1=`. A real delivery carries three schemes: t=1786615…,v1=e798bfb…,v0=6260368… Hookdeck verified it regardless, so its parser handles the multi-scheme form — worth knowing before anyone is tempted to verify a Stripe signature by hand somewhere. The run produced its own control group, unplanned and better than the one I would have designed: two requests arrived before the source was armed and came in VERIFICATION_FAILED, then three arrived a minute later on the same endpoint and verified cleanly. Same sender, same payload shape, the only difference being whether the secret was on the source — which is about as direct a demonstration that verification is enforced as this could produce. scripts/e2e-live-stripe.mjs drives it in phases, because ordering is the whole trap: the secret must be on the source BEFORE the event is triggered, since verification happens at ingest. It also reads headers from GET /requests/{id} rather than the list endpoint, which omits them. The source is created with no connection, so nothing is delivered anywhere and it cannot disturb a real project; `cleanup` removes it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent a226fe0 commit 6d04509

3 files changed

Lines changed: 194 additions & 0 deletions

File tree

.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,13 @@ HOOKDECK_TEST_API_KEY=
3333
# set both of these. Without them the run is read-only by construction.
3434
# AGENT_TEST_ALLOW_MUTATIONS=1
3535
# AGENT_TEST_MUTATION_CONNECTION=my-test-connection
36+
37+
# Optional: for scripts/e2e-live-stripe.mjs, which verifies a REAL Stripe
38+
# webhook end to end. Run the script's `setup` step first — it prints a source
39+
# URL to point Stripe at — then paste the signing secret Stripe gives you here
40+
# and run `arm` BEFORE triggering an event: verification happens at ingest, so
41+
# a secret set afterwards proves nothing.
42+
#
43+
# With `stripe listen --forward-to <url>` the CLI mints a fresh secret per
44+
# session, so re-arm whenever you restart it.
45+
# STRIPE_WEBHOOK_SECRET=whsec_...

docs/security.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,19 @@ A valid signature authenticates the sender, not the content. That matters more h
99
- Verify provider signatures at the Hookdeck source (`config.auth_type`), so a payload is attributable before it ever reaches OpenClaw. Verification failure rejects at the request layer — no event is created, so nothing reaches your agent.
1010

1111
Signature headers and resolved secrets are redacted from logs.
12+
13+
## Provider verification, proven against a real provider
14+
15+
Two suites cover this, because neither is sufficient alone.
16+
17+
`npm run test:e2e:verification` is fully automated. It provisions a source **through the plugin's own config**, so it tests the shape this codebase sends, then posts a correctly signed payload and a forged one. The forged case is the assertion that matters: a source holding a provider secret is byte-identical over the API to one without it, since the secret is never returned, so the only proof verification is switched on is that an invalid signature is refused — `verified: false`, `rejection_cause: VERIFICATION_FAILED`, and no event created.
18+
19+
`scripts/e2e-live-stripe.mjs` closes what synthesis cannot reach: a real webhook from Stripe. It found that a real `Stripe-Signature` carries three schemes, not the two in the documented example —
20+
21+
```
22+
t=1786615…,v1=e798bfb…,v0=6260368…
23+
```
24+
25+
— and Hookdeck verified it regardless. The run also produced its own control group: requests that arrived before the source was armed came in `VERIFICATION_FAILED`, and the same endpoint verified cleanly a minute later once the secret was set.
26+
27+
> **Read `verified` and `rejection_cause`, never the status code.** Hookdeck answers **200 at the edge** while refusing a request. A test that checks the status will report verification as working when it is switched off.

scripts/e2e-live-stripe.mjs

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
/**
2+
* A real Stripe webhook, verified by Hookdeck.
3+
*
4+
* The synthetic verification suite proves Hookdeck refuses a forged signature,
5+
* using the documented single-scheme header. This one closed the remaining
6+
* gap: what Stripe actually sends in the wild.
7+
*
8+
* Measured, rather than assumed. A real delivery carries THREE schemes:
9+
*
10+
* t=1786615…,v1=e798bfb…,v0=6260368…
11+
*
12+
* The `v0` is not in the documented example a synthetic signature is built
13+
* from, and Hookdeck verified the request regardless — so its parser handles
14+
* the multi-scheme form. Worth knowing before anyone is tempted to verify a
15+
* Stripe signature by hand.
16+
*
17+
* Idempotent, and run in three passes:
18+
*
19+
* 1. `setup` creates the source and prints the URL to give Stripe.
20+
* 2. `arm` puts the signing secret on the source (needs
21+
* STRIPE_WEBHOOK_SECRET in .env.local). Verification happens at
22+
* ingest, so this must be done BEFORE the event is triggered.
23+
* 3. `watch` polls for the delivery and reports what arrived.
24+
*
25+
* `cleanup` removes the source when you are done with it.
26+
*/
27+
import { readFileSync } from "node:fs";
28+
29+
const REPO = process.argv[2] ?? ".";
30+
const MODE = process.argv[3] ?? "watch";
31+
const NAME = "openclaw-e2e-stripe-live";
32+
const env = readFileSync(`${REPO}/.env.local`, "utf8");
33+
const KEY = /^HOOKDECK_TEST_API_KEY=(.+)$/m.exec(env)[1].trim();
34+
const STRIPE_SECRET = /^STRIPE_WEBHOOK_SECRET=(.+)$/m.exec(env)?.[1]?.trim();
35+
36+
const api = async (method, path, body) => {
37+
const r = await fetch(`https://api.hookdeck.com/2025-07-01${path}`, {
38+
method,
39+
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
40+
...(body ? { body: JSON.stringify(body) } : {}),
41+
});
42+
const t = await r.text();
43+
return { status: r.status, body: t ? JSON.parse(t) : {} };
44+
};
45+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
46+
47+
const findSource = async () =>
48+
(await api("GET", `/sources?name=${NAME}`)).body.models?.[0];
49+
50+
if (MODE === "cleanup") {
51+
for (const kind of ["connections", "sources", "destinations"]) {
52+
const list = (await api("GET", `/${kind}?limit=255`)).body.models ?? [];
53+
for (const m of list.filter((x) => String(x.name).startsWith("openclaw-e2e-stripe-live"))) {
54+
const d = await api("DELETE", `/${kind}/${m.id}`);
55+
console.log(`deleted ${kind}/${m.id} (${m.name}) ${d.status}`);
56+
}
57+
}
58+
process.exit(0);
59+
}
60+
61+
// A source with no connection: nothing is delivered anywhere, so this cannot
62+
// disturb anything. Verification happens at ingest, which is all we need.
63+
let source = await findSource();
64+
if (source === undefined) {
65+
const created = await api("PUT", "/sources", { name: NAME, type: "STRIPE" });
66+
if (!created.body.id) {
67+
console.log("could not create the source:", JSON.stringify(created.body).slice(0, 200));
68+
process.exit(1);
69+
}
70+
source = created.body;
71+
}
72+
73+
if (MODE === "setup") {
74+
console.log(`\nSource ready.\n\n URL: ${source.url}\n`);
75+
console.log("Point Stripe at that URL, then put the signing secret Stripe gives you into");
76+
console.log(".env.local as:\n\n STRIPE_WEBHOOK_SECRET=whsec_...\n");
77+
console.log("Then run: node scripts/e2e-live-stripe.mjs . arm");
78+
process.exit(0);
79+
}
80+
81+
if (MODE === "arm") {
82+
if (STRIPE_SECRET === undefined) {
83+
console.log("STRIPE_WEBHOOK_SECRET is not in .env.local; nothing to arm.");
84+
process.exit(1);
85+
}
86+
const armed = await api("PUT", "/sources", {
87+
name: NAME,
88+
type: "STRIPE",
89+
config: { auth: { webhook_secret_key: STRIPE_SECRET } },
90+
});
91+
// A source whose TYPE is STRIPE carries `auth` with no `auth_type` — the
92+
// type names the provider. A generic WEBHOOK source uses `auth_type` instead,
93+
// which is the shape the plugin provisions. Both enable verification.
94+
const cfg = armed.body.config ?? {};
95+
const ok =
96+
cfg.auth_type === "STRIPE" ||
97+
typeof cfg.auth?.webhook_secret_key === "string";
98+
99+
// Never print the config: it echoes the signing secret back.
100+
console.log(
101+
ok
102+
? `Armed. Verification is on for ${source.url}\n\nTrigger a Stripe event, then run:\n node scripts/e2e-live-stripe.mjs . watch`
103+
: `The source did not accept the secret. type=${armed.body.type} keys=[${Object.keys(cfg).join(", ")}]`,
104+
);
105+
process.exit(ok ? 0 : 1);
106+
}
107+
108+
// ------------------------------------------------------------------- watch
109+
const armedCfg = (await findSource())?.config ?? {};
110+
const armedNow =
111+
armedCfg.auth_type === "STRIPE" ||
112+
typeof armedCfg.auth?.webhook_secret_key === "string";
113+
console.log(`watching ${source.url}`);
114+
console.log(`verification is ${armedNow ? "ON" : "OFF — run `arm` first"}\n`);
115+
116+
const started = Date.now();
117+
let seen;
118+
for (let i = 0; i < 60 && seen === undefined; i += 1) {
119+
const reqs = (await api("GET", `/requests?source_id=${source.id}&limit=5`)).body.models ?? [];
120+
seen = reqs.find((r) => r.ingested_at > new Date(started - 120_000).toISOString());
121+
if (seen === undefined) {
122+
process.stdout.write(".");
123+
await sleep(5000);
124+
}
125+
}
126+
console.log();
127+
128+
if (seen === undefined) {
129+
console.log("No request arrived in five minutes. Trigger a Stripe event and run watch again.");
130+
process.exit(1);
131+
}
132+
133+
// The list omits headers; only the detail endpoint returns them.
134+
const detail = (await api("GET", `/requests/${seen.id}`)).body;
135+
const headers = detail.data?.headers ?? {};
136+
const parsed = typeof headers === "string" ? JSON.parse(headers) : headers;
137+
const sig = parsed["stripe-signature"] ?? parsed["Stripe-Signature"];
138+
139+
const results = [];
140+
const record = (name, pass, detail_) => {
141+
results.push({ name, pass });
142+
console.log(`${pass ? "PASS" : "FAIL"} ${name}${detail_ ? ` — ${detail_}` : ""}`);
143+
};
144+
145+
record("a real Stripe webhook reached the source", true, `request ${seen.id}`);
146+
record(
147+
"it carried a Stripe-Signature header",
148+
typeof sig === "string" && sig.length > 0,
149+
sig ? `${String(sig).slice(0, 60)}…` : "absent",
150+
);
151+
record(
152+
"Hookdeck verified it",
153+
seen.verified === true,
154+
`verified=${seen.verified} rejection=${seen.rejection_cause ?? "none"}`,
155+
);
156+
157+
// What the wild actually looks like, versus the documented single scheme.
158+
const schemes = String(sig ?? "")
159+
.split(",")
160+
.map((p) => p.split("=")[0]?.trim())
161+
.filter(Boolean);
162+
console.log(`\n signature schemes present: [${[...new Set(schemes)].join(", ")}]`);
163+
console.log(` event type: ${detail.data?.body ? JSON.parse(detail.data.body)?.type ?? "?" : "?"}`);
164+
165+
console.log("\n=============================================");
166+
console.log(`${results.filter((r) => r.pass).length}/${results.length} checks passed`);
167+
console.log("\nWhen you are done: node scripts/e2e-live-stripe.mjs . cleanup");
168+
process.exit(results.every((r) => r.pass) ? 0 : 1);

0 commit comments

Comments
 (0)