openvibers/openvibe.watch Commits
A webhook watch can be signed by Slack or Stripe, and Telegram's header works (agent plan A3) (#194)
Changes
@@@ -200,8 +200,20 @@ product (`server/webhook-sources.js`, `server/carriers/webhook.js`).(`whk_…`) is never shown again; `POST /api/v1/watches/:id/webhook-secret` replaces it (the old one stops working atonce). It is kept in full because checking a signature needs it (`webhook_secrets`), and it goes with its watch:deleting the watch or the account removes it.--- **Signing.** `X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of the exact body with the secret>` (GitHub's form), or-- `Authorization: Bearer <secret>` for a sender that cannot sign. Compared in constant time.++- **Signing.** `X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of the exact body with the secret>` (GitHub's form),++ `Authorization: Bearer <secret>` for a sender that cannot sign, or Telegram's `X-Telegram-Bot-Api-Secret-Token`++ (give the secret as `setWebhook`'s `secret_token`). Compared in constant time.++- **A sender's own secret (agent plan A3).** Slack and Stripe sign with a secret they make. Paste it on the watch's++ page ("Signed by Slack or Stripe?") or send `POST /api/v1/watches/:id/webhook-secret` with++ `{ "provider": "slack" | "stripe", "secret": "…" }`: that scheme then checks every delivery, and the secret is kept++ (never shown again) instead of Watch's own. A bad provider or secret answers 422 and changes nothing; a new secret of++ Watch's own (the same call with no body) replaces it.++ - `slack`: `X-Slack-Signature: v0=<hex HMAC-SHA256 of "v0:<X-Slack-Request-Timestamp>:<body>">`, the timestamp within++ five minutes. Slack's `url_verification` is answered with its `challenge`, and a retry (`X-Slack-Retry-Num`) is++ acknowledged without checking twice.++ - `stripe`: `Stripe-Signature: t=<time>,v1=<hex HMAC-SHA256 of "<time>.<body>">` (any of several `v1`), the time++ within five minutes.++ - Cases: `test/webhook-providers.test.js`.- **What the extraction reads.** A JSON delivery is parsed and becomes the document (`value_path: "order.total"`); anyother is read as text (`text`, `regex`). A delivery that says JSON but is not records `parse_error`.- **Answers.** 200 `{ watch_id, state, triggered }` after the check. 401 for a missing or wrong signature. 404 for annnew file mode 100644@@@ -0,0 +1,6 @@++-- phase: expand++-- OpenVibe.Watch: a webhook watch may be signed by the sender's own secret (agent plan A3, triggers v1). Slack signs++-- with the app's signing secret and Stripe with the endpoint's secret, both made by them, not by Watch: the person++-- pastes it, and `provider` says which scheme checks it (server/webhook-sources.js). NULL is Watch's own whk_ secret,++-- as before. The secret is needed in full to check a signature, so it is stored as Watch's own is.++ALTER TABLE webhook_secrets ADD COLUMN IF NOT EXISTS provider text;@@@ -100,7 +100,11 @@ function watchesRouter({ registry, auth, limits, webhookSources = null }) {if (!webhookSources || !view.source || view.source.kind !== 'webhook') {return res.status(409).type('application/problem+json').send(JSON.stringify({ type: 'about:blank', title: 'watch.not_webhook', status: 409, code: 'watch.not_webhook', detail: 'only a webhook watch has a secret' }));}-- res.json({ watch_id: row.id, webhook: await webhookSources.issue(row.id) });++ // { provider, secret }: the sender's own signing secret (Slack, Stripe) instead of a new one of Watch's.++ const b = req.body && typeof req.body === 'object' ? req.body : {};++ const hook = await webhookSources.issue(row.id, { provider: b.provider || null, secret: b.secret || null });++ if (hook.error) return res.status(422).type('application/problem+json').send(JSON.stringify({ type: 'about:blank', title: 'watch.invalid', status: 422, code: 'watch.invalid', detail: hook.error }));++ res.json({ watch_id: row.id, webhook: hook });} catch (err) { fail(res, req, err); }});@@@ -825,6 +825,7 @@ ${watchForm({ template, fields, errors, action: '/watches/new', submit: CREATE_Sconst obs = await observations.list(row.id, { before: ridBefore(req.query.obs_before), limit: obsLimit(req.query.obs_limit) });const runs = await check.list(row.id, { before: ridBefore(req.query.checks_before), limit: obsLimit(req.query.checks_limit) });const acts = actions ? await actions.recent(row.id, 10) : [];++ const hookProvider = v.source.kind === 'webhook' && ctx.webhookSources && ctx.webhookSources.providerOf ? await ctx.webhookSources.providerOf(row.id) : null;const configuredActions = (v.action || []).map((a) => a.kind === 'actor-agent'? html`<div>Run agent <a href="https://openvibe.actor/me/agents/${a.agent_id}"><code>${a.agent_id}</code></a>${a.note ? html` — ${a.note}` : ''}</div>`: a.kind === 'codes-run'@@@ -891,7 +892,9 @@ ${testRun ? testCheckNotice(testRun, testObs, v) : ''}<dl class="facts"><dt>Status</dt><dd>${badge(unarmed ? 'Not armed yet' : W.statusWords(v.status), unarmed ? 'warn' : W.statusKind(v.status))}</dd>${v.source.kind === 'webhook'-- ? html`<dt>Webhook address</dt><dd><code>${config.baseUrl}/hooks/${v.id}</code> <form class="inline" method="post" action="/watches/${v.id}/webhook-secret"><button class="btn" type="submit">Make a new secret</button></form><br><span class="muted small">The secret was shown once, when the watch was made. A new one replaces it at once.</span></dd>`++ ? html`<dt>Webhook address</dt><dd><code>${config.baseUrl}/hooks/${v.id}</code> <form class="inline" method="post" action="/watches/${v.id}/webhook-secret"><button class="btn" type="submit">Make a new secret</button></form><br><span class="muted small">${hookProvider ? `Signed by ${hookProvider === 'slack' ? 'Slack' : 'Stripe'} with the signing secret you gave. A new secret of Watch's own replaces it.` : 'The secret was shown once, when the watch was made. A new one replaces it at once.'}</span>++<details class="hook-provider"><summary>Signed by Slack or Stripe?</summary><p class="muted small">They sign with a secret of their own: paste it here and Watch checks their signature instead of its own. It is kept to check each delivery and never shown again.</p>++<form method="post" action="/watches/${v.id}/webhook-secret"><label>Sender <select name="provider"><option value="slack">Slack (signing secret)</option><option value="stripe">Stripe (endpoint secret, whsec_…)</option></select></label> <label>Its signing secret <input name="secret" type="password" autocomplete="off" required maxlength="220"></label> <button class="btn" type="submit">Use it</button></form></details></dd>`: html`<dt>Source</dt><dd><code>${v.source.kind}</code> · <code>${v.source.url || (v.source.pattern ? v.source.pattern : '(none)')}</code></dd>`}<dt>Cadence</dt><dd>${v.cadence ? W.everyWords(v.cadence.every_sec) : (v.source.kind === 'webhook' ? 'when a delivery arrives' : (v.source.kind === 'event' ? 'when its event arrives' : 'not polled'))}</dd><dt>Actions</dt><dd>${configuredActions}</dd>@@@ -983,7 +986,11 @@ ${table(['When', 'State', 'HTTP', 'Detail', 'Read', 'Fired'], runRows, { empty:if (!row) return notFoundPage(req, res);const v = registry.view(row);if (v.source.kind !== 'webhook' || !ctx.webhookSources) return detailNotice(req, res, row, 409, 'Only a webhook watch has a secret.');-- return secretPage(req, res, v, await ctx.webhookSources.issue(row.id), 200);++ // A sender's own signing secret (Slack, Stripe), or a new secret of Watch's own.++ const b = req.body || {};++ const hook = await ctx.webhookSources.issue(row.id, { provider: b.provider || null, secret: b.secret || null });++ if (hook.error) return detailNotice(req, res, row, 422, `Not changed: ${hook.error}.`);++ return secretPage(req, res, v, hook, 200);});/** The address and secret of a webhook watch, shown once: no cache, no index, and never again after this page. */@@@ -992,7 +999,18 @@ ${table(['When', 'State', 'HTTP', 'Detail', 'Read', 'Fired'], runRows, { empty:res.set('Referrer-Policy', 'no-referrer');return page(req, res, {title: `${v.name}: webhook address`, private: true, crumbs: [{ label: 'Your watches', href: '/watches' }, { label: v.name, href: `/watches/${v.id}` }],-- body: html`<h1>Your webhook is ready</h1>++ body: hook.provider ? html`<h1>Your webhook is ready</h1>++${notice(`Deliveries to this address are checked with the ${hook.provider === 'slack' ? 'Slack' : 'Stripe'} signing secret you gave. It is not shown again.`, 'ok')}++<dl class="facts">++<dt>Address</dt><dd><code class="copyable">${hook.url}</code></dd>++<dt>Signed by</dt><dd>${hook.signature}</dd>++</dl>++<h2>In ${hook.provider === 'slack' ? 'Slack' : 'Stripe'}</h2>++<ul class="facts">${hook.provider === 'slack'++ ? html`<li>In your app's <strong>Event Subscriptions</strong> (or Interactivity, or a slash command), paste the address as the Request URL. Slack sends a challenge first; Watch answers it.</li><li>A retry Slack sends because the first took more than three seconds is acknowledged without checking twice.</li>`++ : html`<li>In <strong>Developers → Webhooks</strong>, add the address as an endpoint and choose its events. The endpoint's signing secret is the one you pasted here.</li>`}++<li>Deliveries older than five minutes are refused, so a copied request cannot be replayed later.</li></ul>++<p><a class="btn" href="/watches/${v.id}">Back to the watch</a></p>` : html`<h1>Your webhook is ready</h1>${notice('Copy the secret now: it is not shown again. Making a new one replaces it.', 'warn')}<dl class="facts"><dt>Address</dt><dd><code class="copyable">${hook.url}</code></dd>@@@ -9,7 +9,16 @@* POST /api/v1/watches/:id/webhook-secret (which replaces it). It is needed in full to check a* signature, so it is stored as it is (webhook_secrets), and it goes with its watch.* Signing X-Hub-Signature-256: sha256=<hex HMAC-SHA256(secret, raw body)> (GitHub's form; many senders use-- * it), or Authorization: Bearer <secret> for a sender that cannot sign. Compared in constant time.++ * it), Authorization: Bearer <secret> for a sender that cannot sign, or Telegram's++ * X-Telegram-Bot-Api-Secret-Token (setWebhook's secret_token: the watch's secret is a valid one).++ * Compared in constant time.++ * A sender's own secret (agent plan A3, triggers v1): Slack and Stripe sign with a secret they make, so the person++ * pastes it (POST …/webhook-secret { provider, secret }) and that scheme checks the delivery instead:++ * slack X-Slack-Signature: v0=<hex HMAC-SHA256(secret, "v0:" + X-Slack-Request-Timestamp + ":" +++ * body)>, the timestamp within 5 minutes; Slack's url_verification is answered with its++ * challenge, and a retry (X-Slack-Retry-Num) is acknowledged without a second check.++ * stripe Stripe-Signature: t=<time>,v1=<hex HMAC-SHA256(secret, time + "." + body)> (any v1 of++ * several), the time within 5 minutes.* Answers 200 { state, triggered } after the check; 404 for an unknown id or a watch that is not a webhook* watch; 401 for a missing or wrong signature (the same answer either way); 409 for a paused watch;* 429 over the watch's hourly budget (WATCH_WEBHOOK_MAX_PER_HOUR, default 60) with Retry-After;@@@ -18,6 +27,12 @@const crypto = require('crypto');const SECRET_RE = /^whk_[A-Za-z0-9_-]{43}$/;++const TOLERANCE_SEC = 300;++// A sender's own secret: what it looks like, and how it is described where the person pastes it.++const PROVIDERS = Object.freeze({++ slack: { name: 'Slack', secret: /^[0-9a-f]{32}$/, how: 'Slack signs each request with your app\'s signing secret (X-Slack-Signature, v0).' },++ stripe: { name: 'Stripe', secret: /^whsec_[A-Za-z0-9+/=_-]{16,200}$/, how: 'Stripe signs each event with the endpoint\'s signing secret (Stripe-Signature, v1).' },++});const MAX_BODY = 256 * 1024;const newSecret = () => `whk_${crypto.randomBytes(32).toString('base64url')}`;@@@ -29,9 +44,37 @@ function safeEqual(a, b) {return x.length === y.length && crypto.timingSafeEqual(x, y);}--/** Does the delivery carry the watch's secret: a matching body signature, or the secret as a bearer token? */--function verified(secret, raw, headers) {++const hmac = (secret, data) => crypto.createHmac('sha256', secret).update(data).digest('hex');++const fresh = (ts, nowMs) => /^\d{1,12}$/.test(String(ts)) && Math.abs(Math.floor(nowMs / 1000) - Number(ts)) <= TOLERANCE_SEC;++++/** Slack's request signing: v0 over "v0:<timestamp>:<body>", the timestamp within five minutes. */++function slackVerified(secret, raw, headers, nowMs) {++ const ts = String(headers['x-slack-request-timestamp'] || '');++ const sig = String(headers['x-slack-signature'] || '');++ if (!fresh(ts, nowMs) || !sig.startsWith('v0=')) return false;++ return safeEqual(sig, `v0=${hmac(secret, Buffer.concat([Buffer.from(`v0:${ts}:`), raw]))}`);++}++++/** Stripe's webhook signing: v1 over "<t>.<body>" (any of several v1), t within five minutes. */++function stripeVerified(secret, raw, headers, nowMs) {++ const parts = String(headers['stripe-signature'] || '').split(',').map((x) => x.trim().split('='));++ const t = (parts.find(([k]) => k === 't') || [])[1];++ if (!fresh(t, nowMs)) return false;++ const want = hmac(secret, Buffer.concat([Buffer.from(`${t}.`), raw]));++ return parts.filter(([k]) => k === 'v1').some(([, v]) => safeEqual(v || '', want));++}++++/**++ * Does the delivery carry the watch's secret? With a sender's own secret (`provider`), only that sender's scheme++ * counts; with Watch's own, a matching body signature, the secret as a bearer token, or Telegram's secret token.++ */++function verified(secret, raw, headers, { provider = null, now = Date.now() } = {}) {if (!secret) return false;++ if (provider === 'slack') return slackVerified(secret, raw, headers, now);++ if (provider === 'stripe') return stripeVerified(secret, raw, headers, now);++ if (provider) return false;++ const tg = headers['x-telegram-bot-api-secret-token'];++ if (tg != null && safeEqual(String(tg), secret)) return true;const sig = String(headers['x-hub-signature-256'] || '');if (sig.startsWith('sha256=')) {const want = `sha256=${crypto.createHmac('sha256', secret).update(raw).digest('hex')}`;@@@ -47,20 +90,35 @@ function createWebhookSources({ db, registry, check, config, now = () => Date.no? config.webhookSources.maxPerWatchPerHour : 60;const counts = new Map(); // watch id → { hour, n }const st = {-- set: db.prepare(`INSERT INTO webhook_secrets (watch_id, secret, created_at) VALUES (?, ?, ?)-- ON CONFLICT (watch_id) DO UPDATE SET secret = excluded.secret, created_at = excluded.created_at`),-- get: db.prepare('SELECT secret FROM webhook_secrets WHERE watch_id = ?'),++ set: db.prepare(`INSERT INTO webhook_secrets (watch_id, secret, created_at, provider) VALUES (?, ?, ?, ?)++ ON CONFLICT (watch_id) DO UPDATE SET secret = excluded.secret, created_at = excluded.created_at, provider = excluded.provider`),++ get: db.prepare('SELECT secret, provider FROM webhook_secrets WHERE watch_id = ?'),drop: db.prepare('DELETE FROM webhook_secrets WHERE watch_id = ?'),watch: db.prepare('SELECT * FROM watches WHERE id = ? AND deleted_at IS NULL'),};-- /** The public address and a fresh secret for a webhook watch (replacing any it had). Shown once. */-- async function issue(watchId) {++ /**++ * The public address and a fresh secret for a webhook watch (replacing any it had), shown once. With a sender's own++ * secret ({ provider: 'slack' | 'stripe', secret }), that secret is kept instead and never echoed back. A bad++ * provider or secret answers { error } and changes nothing.++ */++ async function issue(watchId, { provider = null, secret: theirs = null } = {}) {++ const url = `${config.baseUrl}/hooks/${watchId}`;++ if (provider != null && provider !== '') {++ const p = PROVIDERS[provider];++ if (!p) return { error: `provider is one of ${Object.keys(PROVIDERS).join(', ')}` };++ const value = String(theirs || '').trim();++ if (!p.secret.test(value)) return { error: `that does not look like a ${p.name} signing secret` };++ await st.set.run(watchId, value, now(), provider);++ return { url, provider, signature: p.how };++ }const secret = newSecret();-- await st.set.run(watchId, secret, now());-- return { url: `${config.baseUrl}/hooks/${watchId}`, secret, signature: 'X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of the body with the secret>, or Authorization: Bearer <secret>' };++ await st.set.run(watchId, secret, now(), null);++ return { url, secret, signature: 'X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of the body with the secret>, Authorization: Bearer <secret>, or Telegram\'s X-Telegram-Bot-Api-Secret-Token' };}async function forget(watchId) { await st.drop.run(watchId); }++ /** Whose secret signs this watch's deliveries: null for Watch's own, else 'slack' or 'stripe'. */++ async function providerOf(watchId) { return ((await st.get.get(watchId)) || {}).provider || null; }function retryAfter() { return 3600 - Math.floor((now() / 1000) % 3600); }function underBudget(id) {@@@ -96,8 +154,18 @@ function createWebhookSources({ db, registry, check, config, now = () => Date.noconst row = /^wch_[0-9A-HJKMNP-TV-Z]{26}$/.test(id) ? await st.watch.get(id) : null;const source = row ? (typeof row.source === 'string' ? JSON.parse(row.source) : row.source) : null;if (!row || !source || source.kind !== 'webhook') return problem(res, 404, 'watch.not_found', 'no webhook watch at this address');-- const secret = (await st.get.get(id) || {}).secret || null;-- if (!verified(secret, raw, req.headers)) return problem(res, 401, 'watch.bad_signature', 'the delivery is not signed with this watch\'s secret');++ const kept = await st.get.get(id) || {};++ const provider = kept.provider || null;++ if (!verified(kept.secret || null, raw, req.headers, { provider, now: now() })) return problem(res, 401, 'watch.bad_signature', 'the delivery is not signed with this watch\'s secret');++ if (provider === 'slack') {++ // Slack checks a new address with a challenge it wants back, and resends an event it did not see answered++ // within three seconds: the first delivery is the one that runs the check.++ let msg = null;++ try { msg = JSON.parse(raw.toString('utf8')); } catch { /* a form post or not JSON: a delivery like any other */ }++ res.setHeader('Cache-Control', 'no-store');++ if (msg && msg.type === 'url_verification' && typeof msg.challenge === 'string') return res.json({ challenge: msg.challenge.slice(0, 200) });++ if (req.headers['x-slack-retry-num'] != null) return res.json({ watch_id: id, retry: true, state: null, triggered: false });++ }if (row.status !== 'active') return problem(res, 409, 'watch.disabled', `the watch is ${row.status}`);if (!underBudget(id)) return problem(res, 429, 'watch.rate_limited', `at most ${maxPerHour} deliveries an hour wake one watch`, { 'Retry-After': String(retryAfter()) });let out;@@@ -112,7 +180,7 @@ function createWebhookSources({ db, registry, check, config, now = () => Date.noreturn res.json({ watch_id: id, state: out && out.run ? out.run.state : null, triggered: !!(out && out.met) });}-- return { issue, forget, receive, endpointFor, verified, SECRET_RE };++ return { issue, forget, providerOf, receive, endpointFor, verified, SECRET_RE, PROVIDERS };}--module.exports = { createWebhookSources, verified, endpointFor, newSecret };++module.exports = { createWebhookSources, verified, endpointFor, newSecret, PROVIDERS };nnew file mode 100644@@@ -0,0 +1,106 @@++'use strict';++/**++ * A webhook watch signed by the sender's own secret (agent plan A3, triggers v1). Slack and Stripe sign with a secret++ * they make, not Watch's: the person pastes it (POST …/webhook-secret { provider, secret }) and that scheme checks each++ * delivery. Slack's url_verification gets its challenge back and a retry is acknowledged without a second check; a++ * delivery older than five minutes is refused; Telegram's secret-token header works with Watch's own secret.++ */++const assert = require('assert');++const crypto = require('crypto');++const { boot, request, userToken, suite, newUser } = require('./helpers');++const { verified, PROVIDERS } = require('../server/webhook-sources');++++const t = suite('webhook-providers');++let svc;++const me = newUser();++const ME = userToken(me);++const OTHER = userToken(newUser());++const api = (method, p, opts = {}) => request(svc.base, method, p, opts);++const hmac = (secret, data) => crypto.createHmac('sha256', secret).update(data).digest('hex');++const nowSec = () => Math.floor(Date.now() / 1000);++const deliver = (id, body, headers = {}) => fetch(`${svc.base}/hooks/${id}`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...headers } });++const runs = async (id) => await svc.db.prepare('SELECT * FROM check_runs WHERE watch_id = ? ORDER BY rid').all(id);++const def = (name) => ({ name, source: { kind: 'webhook', endpoint: '/internal/hooks/x' }, cadence: null, extraction: { kind: 'json', value_path: 'event.type' }, condition: { op: 'contains', value: 'app_mention' }, action: [{ kind: 'notification' }] });++++const SLACK = 'a'.repeat(16) + '0123456789abcdef';++const STRIPE = 'whsec_' + 'Zx9'.repeat(10);++let slackWatch, stripeWatch;++++t('boot', async () => { svc = await boot(); });++++t('the helper: Slack v0, Stripe v1 (any of several), each in time; Telegram with Watch\'s own secret', () => {++ const body = Buffer.from('{"event":{"type":"app_mention"}}');++ const ts = String(nowSec());++ const slack = { 'x-slack-request-timestamp': ts, 'x-slack-signature': `v0=${hmac(SLACK, `v0:${ts}:${body}`)}` };++ assert.strictEqual(verified(SLACK, body, slack, { provider: 'slack' }), true);++ assert.strictEqual(verified(SLACK, body, { ...slack, 'x-slack-request-timestamp': String(nowSec() - 301) }, { provider: 'slack' }), false, 'older than five minutes');++ assert.strictEqual(verified(SLACK, Buffer.from(`${body} `), slack, { provider: 'slack' }), false, 'the exact bytes');++ assert.strictEqual(verified(SLACK, body, { authorization: `Bearer ${SLACK}` }, { provider: 'slack' }), false, 'a Slack watch takes only Slack\'s signature');++ const stripe = { 'stripe-signature': `t=${ts},v1=deadbeef,v1=${hmac(STRIPE, `${ts}.${body}`)}` };++ assert.strictEqual(verified(STRIPE, body, stripe, { provider: 'stripe' }), true);++ assert.strictEqual(verified(STRIPE, body, { 'stripe-signature': `t=${nowSec() - 400},v1=${hmac(STRIPE, `${nowSec() - 400}.${body}`)}` }, { provider: 'stripe' }), false);++ assert.strictEqual(verified('whk_x', body, { 'x-telegram-bot-api-secret-token': 'whk_x' }), true, 'Telegram\'s secret_token');++ assert.strictEqual(verified('whk_x', body, { 'x-telegram-bot-api-secret-token': 'whk_y' }), false);++ assert.strictEqual(verified(SLACK, body, slack, { provider: 'github' }), false, 'an unknown provider checks nothing');++ assert.deepStrictEqual(Object.keys(PROVIDERS), ['slack', 'stripe']);++});++++t('the owner pastes Slack\'s signing secret; it is kept, never echoed; a bad one changes nothing', async () => {++ const made = await api('POST', '/api/v1/watches', { token: ME, body: def('slack mentions') });++ assert.strictEqual(made.status, 201, JSON.stringify(made.body));++ slackWatch = made.body.watch;++ const own = made.body.webhook.secret;++ const bad = await api('POST', `/api/v1/watches/${slackWatch.id}/webhook-secret`, { token: ME, body: { provider: 'slack', secret: 'not-hex' } });++ assert.strictEqual(bad.status, 422);++ assert.strictEqual((await api('POST', `/api/v1/watches/${slackWatch.id}/webhook-secret`, { token: ME, body: { provider: 'fax', secret: SLACK } })).status, 422);++ assert.strictEqual((await svc.db.prepare('SELECT secret FROM webhook_secrets WHERE watch_id = ?').get(slackWatch.id)).secret, own, 'unchanged');++ assert.strictEqual((await api('POST', `/api/v1/watches/${slackWatch.id}/webhook-secret`, { token: OTHER, body: { provider: 'slack', secret: SLACK } })).status, 404);++ const set = await api('POST', `/api/v1/watches/${slackWatch.id}/webhook-secret`, { token: ME, body: { provider: 'slack', secret: SLACK } });++ assert.strictEqual(set.status, 200, JSON.stringify(set.body));++ assert.strictEqual(set.body.webhook.provider, 'slack');++ assert.strictEqual(set.body.webhook.url, `${svc.config.baseUrl}/hooks/${slackWatch.id}`);++ assert.ok(!JSON.stringify(set.body).includes(SLACK), 'their secret is never echoed');++ assert.strictEqual(await svc.webhookSources.providerOf(slackWatch.id), 'slack');++});++++t('Slack: the challenge comes back, a signed event runs the check once, a retry is acknowledged, an old one is refused', async () => {++ const ts = String(nowSec());++ const sign = (body, at = ts) => ({ 'X-Slack-Request-Timestamp': at, 'X-Slack-Signature': `v0=${hmac(SLACK, `v0:${at}:${body}`)}` });++ const challenge = JSON.stringify({ type: 'url_verification', challenge: 'c4ll3ng3', token: 'old' });++ const c = await deliver(slackWatch.id, challenge, sign(challenge));++ assert.strictEqual(c.status, 200);++ assert.deepStrictEqual(await c.json(), { challenge: 'c4ll3ng3' });++ assert.strictEqual((await runs(slackWatch.id)).length, 0, 'the challenge runs no check');++ const ev = JSON.stringify({ type: 'event_callback', event: { type: 'app_mention', text: 'hey' } });++ const r = await deliver(slackWatch.id, ev, sign(ev));++ assert.deepStrictEqual(await r.json(), { watch_id: slackWatch.id, state: 'condition_met', triggered: true });++ const retry = await deliver(slackWatch.id, ev, { ...sign(ev), 'X-Slack-Retry-Num': '1', 'X-Slack-Retry-Reason': 'http_timeout' });++ assert.strictEqual((await retry.json()).retry, true);++ assert.strictEqual((await runs(slackWatch.id)).length, 1, 'the retry did not check again');++ const old = String(nowSec() - 600);++ assert.strictEqual((await deliver(slackWatch.id, ev, sign(ev, old))).status, 401, 'a replayed request');++ assert.strictEqual((await deliver(slackWatch.id, challenge, { Authorization: `Bearer ${SLACK}` })).status, 401, 'no challenge for an unsigned caller');++});++++t('Stripe: a signed event runs the check; the wrong secret is refused', async () => {++ const made = await api('POST', '/api/v1/watches', { token: ME, body: { ...def('stripe payments'), extraction: { kind: 'json', value_path: 'type' }, condition: { op: 'contains', value: 'payment_intent.succeeded' } } });++ stripeWatch = made.body.watch;++ assert.strictEqual((await api('POST', `/api/v1/watches/${stripeWatch.id}/webhook-secret`, { token: ME, body: { provider: 'stripe', secret: STRIPE } })).status, 200);++ const body = JSON.stringify({ id: 'evt_1', type: 'payment_intent.succeeded' });++ const ts = String(nowSec());++ const ok = await deliver(stripeWatch.id, body, { 'Stripe-Signature': `t=${ts},v1=${hmac(STRIPE, `${ts}.${body}`)}` });++ assert.deepStrictEqual(await ok.json(), { watch_id: stripeWatch.id, state: 'condition_met', triggered: true });++ assert.strictEqual((await deliver(stripeWatch.id, body, { 'Stripe-Signature': `t=${ts},v1=${hmac('whsec_other', `${ts}.${body}`)}` })).status, 401);++});++++t('a new secret of Watch\'s own replaces a provider\'s, and its forms work again', async () => {++ const rotated = await api('POST', `/api/v1/watches/${stripeWatch.id}/webhook-secret`, { token: ME });++ assert.match(rotated.body.webhook.secret, /^whk_/);++ assert.strictEqual(await svc.webhookSources.providerOf(stripeWatch.id), null);++ const body = JSON.stringify({ type: 'payment_intent.succeeded' });++ assert.strictEqual((await deliver(stripeWatch.id, body, { 'X-Telegram-Bot-Api-Secret-Token': rotated.body.webhook.secret })).status, 200, 'Telegram\'s header');++});++++t('stop', async () => { await svc.stop(); });++++t.run();
@@ -200,8 +200,20 @@ product (`server/webhook-sources.js`, `server/carriers/webhook.js`).
@@ -200,8 +200,20 @@ product (`server/webhook-sources.js`, `server/carriers/webhook.js`).
(`whk_…`) is never shown again; `POST /api/v1/watches/:id/webhook-secret` replaces it (the old one stops working at
(`whk_…`) is never shown again; `POST /api/v1/watches/:id/webhook-secret` replaces it (the old one stops working at
once). It is kept in full because checking a signature needs it (`webhook_secrets`), and it goes with its watch:
once). It is kept in full because checking a signature needs it (`webhook_secrets`), and it goes with its watch:
deleting the watch or the account removes it.
deleting the watch or the account removes it.
-- **Signing.** `X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of the exact body with the secret>` (GitHub's form), or
- `Authorization: Bearer <secret>` for a sender that cannot sign. Compared in constant time.
+- **Signing.** `X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of the exact body with the secret>` (GitHub's form),
+ `Authorization: Bearer <secret>` for a sender that cannot sign, or Telegram's `X-Telegram-Bot-Api-Secret-Token`
+ (give the secret as `setWebhook`'s `secret_token`). Compared in constant time.
+- **A sender's own secret (agent plan A3).** Slack and Stripe sign with a secret they make. Paste it on the watch's
+ page ("Signed by Slack or Stripe?") or send `POST /api/v1/watches/:id/webhook-secret` with
+ `{ "provider": "slack" | "stripe", "secret": "…" }`: that scheme then checks every delivery, and the secret is kept
+ (never shown again) instead of Watch's own. A bad provider or secret answers 422 and changes nothing; a new secret of
+ Watch's own (the same call with no body) replaces it.
+ - `slack`: `X-Slack-Signature: v0=<hex HMAC-SHA256 of "v0:<X-Slack-Request-Timestamp>:<body>">`, the timestamp within
+ five minutes. Slack's `url_verification` is answered with its `challenge`, and a retry (`X-Slack-Retry-Num`) is
+ acknowledged without checking twice.
+ - `stripe`: `Stripe-Signature: t=<time>,v1=<hex HMAC-SHA256 of "<time>.<body>">` (any of several `v1`), the time
+ within five minutes.
+ - Cases: `test/webhook-providers.test.js`.
- **What the extraction reads.** A JSON delivery is parsed and becomes the document (`value_path: "order.total"`); any
- **What the extraction reads.** A JSON delivery is parsed and becomes the document (`value_path: "order.total"`); any
other is read as text (`text`, `regex`). A delivery that says JSON but is not records `parse_error`.
other is read as text (`text`, `regex`). A delivery that says JSON but is not records `parse_error`.
- **Answers.** 200 `{ watch_id, state, triggered }` after the check. 401 for a missing or wrong signature. 404 for an
- **Answers.** 200 `{ watch_id, state, triggered }` after the check. 401 for a missing or wrong signature. 404 for an
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
+-- phase: expand
+-- OpenVibe.Watch: a webhook watch may be signed by the sender's own secret (agent plan A3, triggers v1). Slack signs
+-- with the app's signing secret and Stripe with the endpoint's secret, both made by them, not by Watch: the person
+-- pastes it, and `provider` says which scheme checks it (server/webhook-sources.js). NULL is Watch's own whk_ secret,
+-- as before. The secret is needed in full to check a signature, so it is stored as Watch's own is.
+ALTER TABLE webhook_secrets ADD COLUMN IF NOT EXISTS provider text;
@@ -100,7 +100,11 @@ function watchesRouter({ registry, auth, limits, webhookSources = null }) {
@@ -100,7 +100,11 @@ function watchesRouter({ registry, auth, limits, webhookSources = null }) {
if (!webhookSources || !view.source || view.source.kind !== 'webhook') {
if (!webhookSources || !view.source || view.source.kind !== 'webhook') {
return res.status(409).type('application/problem+json').send(JSON.stringify({ type: 'about:blank', title: 'watch.not_webhook', status: 409, code: 'watch.not_webhook', detail: 'only a webhook watch has a secret' }));
return res.status(409).type('application/problem+json').send(JSON.stringify({ type: 'about:blank', title: 'watch.not_webhook', status: 409, code: 'watch.not_webhook', detail: 'only a webhook watch has a secret' }));
}
}
- res.json({ watch_id: row.id, webhook: await webhookSources.issue(row.id) });
+ // { provider, secret }: the sender's own signing secret (Slack, Stripe) instead of a new one of Watch's.
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
+ const hook = await webhookSources.issue(row.id, { provider: b.provider || null, secret: b.secret || null });
+ if (hook.error) return res.status(422).type('application/problem+json').send(JSON.stringify({ type: 'about:blank', title: 'watch.invalid', status: 422, code: 'watch.invalid', detail: hook.error }));
+ res.json({ watch_id: row.id, webhook: hook });
} catch (err) { fail(res, req, err); }
} catch (err) { fail(res, req, err); }
});
});
@@ -825,6 +825,7 @@ ${watchForm({ template, fields, errors, action: '/watches/new', submit: CREATE_S
@@ -825,6 +825,7 @@ ${watchForm({ template, fields, errors, action: '/watches/new', submit: CREATE_S
const obs = await observations.list(row.id, { before: ridBefore(req.query.obs_before), limit: obsLimit(req.query.obs_limit) });
const obs = await observations.list(row.id, { before: ridBefore(req.query.obs_before), limit: obsLimit(req.query.obs_limit) });
const runs = await check.list(row.id, { before: ridBefore(req.query.checks_before), limit: obsLimit(req.query.checks_limit) });
const runs = await check.list(row.id, { before: ridBefore(req.query.checks_before), limit: obsLimit(req.query.checks_limit) });
const acts = actions ? await actions.recent(row.id, 10) : [];
const acts = actions ? await actions.recent(row.id, 10) : [];
+ const hookProvider = v.source.kind === 'webhook' && ctx.webhookSources && ctx.webhookSources.providerOf ? await ctx.webhookSources.providerOf(row.id) : null;
const configuredActions = (v.action || []).map((a) => a.kind === 'actor-agent'
const configuredActions = (v.action || []).map((a) => a.kind === 'actor-agent'
? html`<div>Run agent <a href="https://openvibe.actor/me/agents/${a.agent_id}"><code>${a.agent_id}</code></a>${a.note ? html` — ${a.note}` : ''}</div>`
? html`<div>Run agent <a href="https://openvibe.actor/me/agents/${a.agent_id}"><code>${a.agent_id}</code></a>${a.note ? html` — ${a.note}` : ''}</div>`
: a.kind === 'codes-run'
: a.kind === 'codes-run'
@@ -891,7 +892,9 @@ ${testRun ? testCheckNotice(testRun, testObs, v) : ''}
@@ -891,7 +892,9 @@ ${testRun ? testCheckNotice(testRun, testObs, v) : ''}
<dl class="facts">
<dl class="facts">
<dt>Status</dt><dd>${badge(unarmed ? 'Not armed yet' : W.statusWords(v.status), unarmed ? 'warn' : W.statusKind(v.status))}</dd>
<dt>Status</dt><dd>${badge(unarmed ? 'Not armed yet' : W.statusWords(v.status), unarmed ? 'warn' : W.statusKind(v.status))}</dd>
${v.source.kind === 'webhook'
${v.source.kind === 'webhook'
- ? html`<dt>Webhook address</dt><dd><code>${config.baseUrl}/hooks/${v.id}</code> <form class="inline" method="post" action="/watches/${v.id}/webhook-secret"><button class="btn" type="submit">Make a new secret</button></form><br><span class="muted small">The secret was shown once, when the watch was made. A new one replaces it at once.</span></dd>`
+ ? html`<dt>Webhook address</dt><dd><code>${config.baseUrl}/hooks/${v.id}</code> <form class="inline" method="post" action="/watches/${v.id}/webhook-secret"><button class="btn" type="submit">Make a new secret</button></form><br><span class="muted small">${hookProvider ? `Signed by ${hookProvider === 'slack' ? 'Slack' : 'Stripe'} with the signing secret you gave. A new secret of Watch's own replaces it.` : 'The secret was shown once, when the watch was made. A new one replaces it at once.'}</span>
+<details class="hook-provider"><summary>Signed by Slack or Stripe?</summary><p class="muted small">They sign with a secret of their own: paste it here and Watch checks their signature instead of its own. It is kept to check each delivery and never shown again.</p>
+<form method="post" action="/watches/${v.id}/webhook-secret"><label>Sender <select name="provider"><option value="slack">Slack (signing secret)</option><option value="stripe">Stripe (endpoint secret, whsec_…)</option></select></label> <label>Its signing secret <input name="secret" type="password" autocomplete="off" required maxlength="220"></label> <button class="btn" type="submit">Use it</button></form></details></dd>`
: html`<dt>Source</dt><dd><code>${v.source.kind}</code> · <code>${v.source.url || (v.source.pattern ? v.source.pattern : '(none)')}</code></dd>`}
: html`<dt>Source</dt><dd><code>${v.source.kind}</code> · <code>${v.source.url || (v.source.pattern ? v.source.pattern : '(none)')}</code></dd>`}
<dt>Cadence</dt><dd>${v.cadence ? W.everyWords(v.cadence.every_sec) : (v.source.kind === 'webhook' ? 'when a delivery arrives' : (v.source.kind === 'event' ? 'when its event arrives' : 'not polled'))}</dd>
<dt>Cadence</dt><dd>${v.cadence ? W.everyWords(v.cadence.every_sec) : (v.source.kind === 'webhook' ? 'when a delivery arrives' : (v.source.kind === 'event' ? 'when its event arrives' : 'not polled'))}</dd>
<dt>Actions</dt><dd>${configuredActions}</dd>
<dt>Actions</dt><dd>${configuredActions}</dd>
@@ -983,7 +986,11 @@ ${table(['When', 'State', 'HTTP', 'Detail', 'Read', 'Fired'], runRows, { empty:
@@ -983,7 +986,11 @@ ${table(['When', 'State', 'HTTP', 'Detail', 'Read', 'Fired'], runRows, { empty:
if (!row) return notFoundPage(req, res);
if (!row) return notFoundPage(req, res);
const v = registry.view(row);
const v = registry.view(row);
if (v.source.kind !== 'webhook' || !ctx.webhookSources) return detailNotice(req, res, row, 409, 'Only a webhook watch has a secret.');
if (v.source.kind !== 'webhook' || !ctx.webhookSources) return detailNotice(req, res, row, 409, 'Only a webhook watch has a secret.');
- return secretPage(req, res, v, await ctx.webhookSources.issue(row.id), 200);
+ // A sender's own signing secret (Slack, Stripe), or a new secret of Watch's own.
+ const b = req.body || {};
+ const hook = await ctx.webhookSources.issue(row.id, { provider: b.provider || null, secret: b.secret || null });
+ if (hook.error) return detailNotice(req, res, row, 422, `Not changed: ${hook.error}.`);
+ return secretPage(req, res, v, hook, 200);
});
});
/** The address and secret of a webhook watch, shown once: no cache, no index, and never again after this page. */
/** The address and secret of a webhook watch, shown once: no cache, no index, and never again after this page. */
@@ -992,7 +999,18 @@ ${table(['When', 'State', 'HTTP', 'Detail', 'Read', 'Fired'], runRows, { empty:
@@ -992,7 +999,18 @@ ${table(['When', 'State', 'HTTP', 'Detail', 'Read', 'Fired'], runRows, { empty:
res.set('Referrer-Policy', 'no-referrer');
res.set('Referrer-Policy', 'no-referrer');
return page(req, res, {
return page(req, res, {
title: `${v.name}: webhook address`, private: true, crumbs: [{ label: 'Your watches', href: '/watches' }, { label: v.name, href: `/watches/${v.id}` }],
title: `${v.name}: webhook address`, private: true, crumbs: [{ label: 'Your watches', href: '/watches' }, { label: v.name, href: `/watches/${v.id}` }],
- body: html`<h1>Your webhook is ready</h1>
+ body: hook.provider ? html`<h1>Your webhook is ready</h1>
+${notice(`Deliveries to this address are checked with the ${hook.provider === 'slack' ? 'Slack' : 'Stripe'} signing secret you gave. It is not shown again.`, 'ok')}
+<dl class="facts">
+<dt>Address</dt><dd><code class="copyable">${hook.url}</code></dd>
+<dt>Signed by</dt><dd>${hook.signature}</dd>
+</dl>
+<h2>In ${hook.provider === 'slack' ? 'Slack' : 'Stripe'}</h2>
+<ul class="facts">${hook.provider === 'slack'
+ ? html`<li>In your app's <strong>Event Subscriptions</strong> (or Interactivity, or a slash command), paste the address as the Request URL. Slack sends a challenge first; Watch answers it.</li><li>A retry Slack sends because the first took more than three seconds is acknowledged without checking twice.</li>`
+ : html`<li>In <strong>Developers → Webhooks</strong>, add the address as an endpoint and choose its events. The endpoint's signing secret is the one you pasted here.</li>`}
+<li>Deliveries older than five minutes are refused, so a copied request cannot be replayed later.</li></ul>
+<p><a class="btn" href="/watches/${v.id}">Back to the watch</a></p>` : html`<h1>Your webhook is ready</h1>
${notice('Copy the secret now: it is not shown again. Making a new one replaces it.', 'warn')}
${notice('Copy the secret now: it is not shown again. Making a new one replaces it.', 'warn')}
<dl class="facts">
<dl class="facts">
<dt>Address</dt><dd><code class="copyable">${hook.url}</code></dd>
<dt>Address</dt><dd><code class="copyable">${hook.url}</code></dd>
@@ -9,7 +9,16 @@
@@ -9,7 +9,16 @@
* POST /api/v1/watches/:id/webhook-secret (which replaces it). It is needed in full to check a
* POST /api/v1/watches/:id/webhook-secret (which replaces it). It is needed in full to check a
* signature, so it is stored as it is (webhook_secrets), and it goes with its watch.
* signature, so it is stored as it is (webhook_secrets), and it goes with its watch.
* Signing X-Hub-Signature-256: sha256=<hex HMAC-SHA256(secret, raw body)> (GitHub's form; many senders use
* Signing X-Hub-Signature-256: sha256=<hex HMAC-SHA256(secret, raw body)> (GitHub's form; many senders use
- * it), or Authorization: Bearer <secret> for a sender that cannot sign. Compared in constant time.
+ * it), Authorization: Bearer <secret> for a sender that cannot sign, or Telegram's
+ * X-Telegram-Bot-Api-Secret-Token (setWebhook's secret_token: the watch's secret is a valid one).
+ * Compared in constant time.
+ * A sender's own secret (agent plan A3, triggers v1): Slack and Stripe sign with a secret they make, so the person
+ * pastes it (POST …/webhook-secret { provider, secret }) and that scheme checks the delivery instead:
+ * slack X-Slack-Signature: v0=<hex HMAC-SHA256(secret, "v0:" + X-Slack-Request-Timestamp + ":" +
+ * body)>, the timestamp within 5 minutes; Slack's url_verification is answered with its
+ * challenge, and a retry (X-Slack-Retry-Num) is acknowledged without a second check.
+ * stripe Stripe-Signature: t=<time>,v1=<hex HMAC-SHA256(secret, time + "." + body)> (any v1 of
+ * several), the time within 5 minutes.
* Answers 200 { state, triggered } after the check; 404 for an unknown id or a watch that is not a webhook
* Answers 200 { state, triggered } after the check; 404 for an unknown id or a watch that is not a webhook
* watch; 401 for a missing or wrong signature (the same answer either way); 409 for a paused watch;
* watch; 401 for a missing or wrong signature (the same answer either way); 409 for a paused watch;
* 429 over the watch's hourly budget (WATCH_WEBHOOK_MAX_PER_HOUR, default 60) with Retry-After;
* 429 over the watch's hourly budget (WATCH_WEBHOOK_MAX_PER_HOUR, default 60) with Retry-After;
@@ -18,6 +27,12 @@
@@ -18,6 +27,12 @@
const crypto = require('crypto');
const crypto = require('crypto');
const SECRET_RE = /^whk_[A-Za-z0-9_-]{43}$/;
const SECRET_RE = /^whk_[A-Za-z0-9_-]{43}$/;
+const TOLERANCE_SEC = 300;
+// A sender's own secret: what it looks like, and how it is described where the person pastes it.
+const PROVIDERS = Object.freeze({
+ slack: { name: 'Slack', secret: /^[0-9a-f]{32}$/, how: 'Slack signs each request with your app\'s signing secret (X-Slack-Signature, v0).' },
+ stripe: { name: 'Stripe', secret: /^whsec_[A-Za-z0-9+/=_-]{16,200}$/, how: 'Stripe signs each event with the endpoint\'s signing secret (Stripe-Signature, v1).' },
+});
const MAX_BODY = 256 * 1024;
const MAX_BODY = 256 * 1024;
const newSecret = () => `whk_${crypto.randomBytes(32).toString('base64url')}`;
const newSecret = () => `whk_${crypto.randomBytes(32).toString('base64url')}`;
@@ -29,9 +44,37 @@ function safeEqual(a, b) {
@@ -29,9 +44,37 @@ function safeEqual(a, b) {
return x.length === y.length && crypto.timingSafeEqual(x, y);
return x.length === y.length && crypto.timingSafeEqual(x, y);
}
}
-/** Does the delivery carry the watch's secret: a matching body signature, or the secret as a bearer token? */
-function verified(secret, raw, headers) {
+const hmac = (secret, data) => crypto.createHmac('sha256', secret).update(data).digest('hex');
+const fresh = (ts, nowMs) => /^\d{1,12}$/.test(String(ts)) && Math.abs(Math.floor(nowMs / 1000) - Number(ts)) <= TOLERANCE_SEC;
+
+/** Slack's request signing: v0 over "v0:<timestamp>:<body>", the timestamp within five minutes. */
+function slackVerified(secret, raw, headers, nowMs) {
+ const ts = String(headers['x-slack-request-timestamp'] || '');
+ const sig = String(headers['x-slack-signature'] || '');
+ if (!fresh(ts, nowMs) || !sig.startsWith('v0=')) return false;
+ return safeEqual(sig, `v0=${hmac(secret, Buffer.concat([Buffer.from(`v0:${ts}:`), raw]))}`);
+}
+
+/** Stripe's webhook signing: v1 over "<t>.<body>" (any of several v1), t within five minutes. */
+function stripeVerified(secret, raw, headers, nowMs) {
+ const parts = String(headers['stripe-signature'] || '').split(',').map((x) => x.trim().split('='));
+ const t = (parts.find(([k]) => k === 't') || [])[1];
+ if (!fresh(t, nowMs)) return false;
+ const want = hmac(secret, Buffer.concat([Buffer.from(`${t}.`), raw]));
+ return parts.filter(([k]) => k === 'v1').some(([, v]) => safeEqual(v || '', want));
+}
+
+/**
+ * Does the delivery carry the watch's secret? With a sender's own secret (`provider`), only that sender's scheme
+ * counts; with Watch's own, a matching body signature, the secret as a bearer token, or Telegram's secret token.
+ */
+function verified(secret, raw, headers, { provider = null, now = Date.now() } = {}) {
if (!secret) return false;
if (!secret) return false;
+ if (provider === 'slack') return slackVerified(secret, raw, headers, now);
+ if (provider === 'stripe') return stripeVerified(secret, raw, headers, now);
+ if (provider) return false;
+ const tg = headers['x-telegram-bot-api-secret-token'];
+ if (tg != null && safeEqual(String(tg), secret)) return true;
const sig = String(headers['x-hub-signature-256'] || '');
const sig = String(headers['x-hub-signature-256'] || '');
if (sig.startsWith('sha256=')) {
if (sig.startsWith('sha256=')) {
const want = `sha256=${crypto.createHmac('sha256', secret).update(raw).digest('hex')}`;
const want = `sha256=${crypto.createHmac('sha256', secret).update(raw).digest('hex')}`;
@@ -47,20 +90,35 @@ function createWebhookSources({ db, registry, check, config, now = () => Date.no
@@ -47,20 +90,35 @@ function createWebhookSources({ db, registry, check, config, now = () => Date.no
? config.webhookSources.maxPerWatchPerHour : 60;
? config.webhookSources.maxPerWatchPerHour : 60;
const counts = new Map(); // watch id → { hour, n }
const counts = new Map(); // watch id → { hour, n }
const st = {
const st = {
- set: db.prepare(`INSERT INTO webhook_secrets (watch_id, secret, created_at) VALUES (?, ?, ?)
- ON CONFLICT (watch_id) DO UPDATE SET secret = excluded.secret, created_at = excluded.created_at`),
- get: db.prepare('SELECT secret FROM webhook_secrets WHERE watch_id = ?'),
+ set: db.prepare(`INSERT INTO webhook_secrets (watch_id, secret, created_at, provider) VALUES (?, ?, ?, ?)
+ ON CONFLICT (watch_id) DO UPDATE SET secret = excluded.secret, created_at = excluded.created_at, provider = excluded.provider`),
+ get: db.prepare('SELECT secret, provider FROM webhook_secrets WHERE watch_id = ?'),
drop: db.prepare('DELETE FROM webhook_secrets WHERE watch_id = ?'),
drop: db.prepare('DELETE FROM webhook_secrets WHERE watch_id = ?'),
watch: db.prepare('SELECT * FROM watches WHERE id = ? AND deleted_at IS NULL'),
watch: db.prepare('SELECT * FROM watches WHERE id = ? AND deleted_at IS NULL'),
};
};
- /** The public address and a fresh secret for a webhook watch (replacing any it had). Shown once. */
- async function issue(watchId) {
+ /**
+ * The public address and a fresh secret for a webhook watch (replacing any it had), shown once. With a sender's own
+ * secret ({ provider: 'slack' | 'stripe', secret }), that secret is kept instead and never echoed back. A bad
+ * provider or secret answers { error } and changes nothing.
+ */
+ async function issue(watchId, { provider = null, secret: theirs = null } = {}) {
+ const url = `${config.baseUrl}/hooks/${watchId}`;
+ if (provider != null && provider !== '') {
+ const p = PROVIDERS[provider];
+ if (!p) return { error: `provider is one of ${Object.keys(PROVIDERS).join(', ')}` };
+ const value = String(theirs || '').trim();
+ if (!p.secret.test(value)) return { error: `that does not look like a ${p.name} signing secret` };
+ await st.set.run(watchId, value, now(), provider);
+ return { url, provider, signature: p.how };
+ }
const secret = newSecret();
const secret = newSecret();
- await st.set.run(watchId, secret, now());
- return { url: `${config.baseUrl}/hooks/${watchId}`, secret, signature: 'X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of the body with the secret>, or Authorization: Bearer <secret>' };
+ await st.set.run(watchId, secret, now(), null);
+ return { url, secret, signature: 'X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of the body with the secret>, Authorization: Bearer <secret>, or Telegram\'s X-Telegram-Bot-Api-Secret-Token' };
}
}
async function forget(watchId) { await st.drop.run(watchId); }
async function forget(watchId) { await st.drop.run(watchId); }
+ /** Whose secret signs this watch's deliveries: null for Watch's own, else 'slack' or 'stripe'. */
+ async function providerOf(watchId) { return ((await st.get.get(watchId)) || {}).provider || null; }
function retryAfter() { return 3600 - Math.floor((now() / 1000) % 3600); }
function retryAfter() { return 3600 - Math.floor((now() / 1000) % 3600); }
function underBudget(id) {
function underBudget(id) {
@@ -96,8 +154,18 @@ function createWebhookSources({ db, registry, check, config, now = () => Date.no
@@ -96,8 +154,18 @@ function createWebhookSources({ db, registry, check, config, now = () => Date.no
const row = /^wch_[0-9A-HJKMNP-TV-Z]{26}$/.test(id) ? await st.watch.get(id) : null;
const row = /^wch_[0-9A-HJKMNP-TV-Z]{26}$/.test(id) ? await st.watch.get(id) : null;
const source = row ? (typeof row.source === 'string' ? JSON.parse(row.source) : row.source) : null;
const source = row ? (typeof row.source === 'string' ? JSON.parse(row.source) : row.source) : null;
if (!row || !source || source.kind !== 'webhook') return problem(res, 404, 'watch.not_found', 'no webhook watch at this address');
if (!row || !source || source.kind !== 'webhook') return problem(res, 404, 'watch.not_found', 'no webhook watch at this address');
- const secret = (await st.get.get(id) || {}).secret || null;
- if (!verified(secret, raw, req.headers)) return problem(res, 401, 'watch.bad_signature', 'the delivery is not signed with this watch\'s secret');
+ const kept = await st.get.get(id) || {};
+ const provider = kept.provider || null;
+ if (!verified(kept.secret || null, raw, req.headers, { provider, now: now() })) return problem(res, 401, 'watch.bad_signature', 'the delivery is not signed with this watch\'s secret');
+ if (provider === 'slack') {
+ // Slack checks a new address with a challenge it wants back, and resends an event it did not see answered
+ // within three seconds: the first delivery is the one that runs the check.
+ let msg = null;
+ try { msg = JSON.parse(raw.toString('utf8')); } catch { /* a form post or not JSON: a delivery like any other */ }
+ res.setHeader('Cache-Control', 'no-store');
+ if (msg && msg.type === 'url_verification' && typeof msg.challenge === 'string') return res.json({ challenge: msg.challenge.slice(0, 200) });
+ if (req.headers['x-slack-retry-num'] != null) return res.json({ watch_id: id, retry: true, state: null, triggered: false });
+ }
if (row.status !== 'active') return problem(res, 409, 'watch.disabled', `the watch is ${row.status}`);
if (row.status !== 'active') return problem(res, 409, 'watch.disabled', `the watch is ${row.status}`);
if (!underBudget(id)) return problem(res, 429, 'watch.rate_limited', `at most ${maxPerHour} deliveries an hour wake one watch`, { 'Retry-After': String(retryAfter()) });
if (!underBudget(id)) return problem(res, 429, 'watch.rate_limited', `at most ${maxPerHour} deliveries an hour wake one watch`, { 'Retry-After': String(retryAfter()) });
let out;
let out;
@@ -112,7 +180,7 @@ function createWebhookSources({ db, registry, check, config, now = () => Date.no
@@ -112,7 +180,7 @@ function createWebhookSources({ db, registry, check, config, now = () => Date.no
return res.json({ watch_id: id, state: out && out.run ? out.run.state : null, triggered: !!(out && out.met) });
return res.json({ watch_id: id, state: out && out.run ? out.run.state : null, triggered: !!(out && out.met) });
}
}
- return { issue, forget, receive, endpointFor, verified, SECRET_RE };
+ return { issue, forget, providerOf, receive, endpointFor, verified, SECRET_RE, PROVIDERS };
}
}
-module.exports = { createWebhookSources, verified, endpointFor, newSecret };
+module.exports = { createWebhookSources, verified, endpointFor, newSecret, PROVIDERS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,106 @@
@@ -0,0 +1,106 @@
+'use strict';
+/**
+ * A webhook watch signed by the sender's own secret (agent plan A3, triggers v1). Slack and Stripe sign with a secret
+ * they make, not Watch's: the person pastes it (POST …/webhook-secret { provider, secret }) and that scheme checks each
+ * delivery. Slack's url_verification gets its challenge back and a retry is acknowledged without a second check; a
+ * delivery older than five minutes is refused; Telegram's secret-token header works with Watch's own secret.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { boot, request, userToken, suite, newUser } = require('./helpers');
+const { verified, PROVIDERS } = require('../server/webhook-sources');
+
+const t = suite('webhook-providers');
+let svc;
+const me = newUser();
+const ME = userToken(me);
+const OTHER = userToken(newUser());
+const api = (method, p, opts = {}) => request(svc.base, method, p, opts);
+const hmac = (secret, data) => crypto.createHmac('sha256', secret).update(data).digest('hex');
+const nowSec = () => Math.floor(Date.now() / 1000);
+const deliver = (id, body, headers = {}) => fetch(`${svc.base}/hooks/${id}`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...headers } });
+const runs = async (id) => await svc.db.prepare('SELECT * FROM check_runs WHERE watch_id = ? ORDER BY rid').all(id);
+const def = (name) => ({ name, source: { kind: 'webhook', endpoint: '/internal/hooks/x' }, cadence: null, extraction: { kind: 'json', value_path: 'event.type' }, condition: { op: 'contains', value: 'app_mention' }, action: [{ kind: 'notification' }] });
+
+const SLACK = 'a'.repeat(16) + '0123456789abcdef';
+const STRIPE = 'whsec_' + 'Zx9'.repeat(10);
+let slackWatch, stripeWatch;
+
+t('boot', async () => { svc = await boot(); });
+
+t('the helper: Slack v0, Stripe v1 (any of several), each in time; Telegram with Watch\'s own secret', () => {
+ const body = Buffer.from('{"event":{"type":"app_mention"}}');
+ const ts = String(nowSec());
+ const slack = { 'x-slack-request-timestamp': ts, 'x-slack-signature': `v0=${hmac(SLACK, `v0:${ts}:${body}`)}` };
+ assert.strictEqual(verified(SLACK, body, slack, { provider: 'slack' }), true);
+ assert.strictEqual(verified(SLACK, body, { ...slack, 'x-slack-request-timestamp': String(nowSec() - 301) }, { provider: 'slack' }), false, 'older than five minutes');
+ assert.strictEqual(verified(SLACK, Buffer.from(`${body} `), slack, { provider: 'slack' }), false, 'the exact bytes');
+ assert.strictEqual(verified(SLACK, body, { authorization: `Bearer ${SLACK}` }, { provider: 'slack' }), false, 'a Slack watch takes only Slack\'s signature');
+ const stripe = { 'stripe-signature': `t=${ts},v1=deadbeef,v1=${hmac(STRIPE, `${ts}.${body}`)}` };
+ assert.strictEqual(verified(STRIPE, body, stripe, { provider: 'stripe' }), true);
+ assert.strictEqual(verified(STRIPE, body, { 'stripe-signature': `t=${nowSec() - 400},v1=${hmac(STRIPE, `${nowSec() - 400}.${body}`)}` }, { provider: 'stripe' }), false);
+ assert.strictEqual(verified('whk_x', body, { 'x-telegram-bot-api-secret-token': 'whk_x' }), true, 'Telegram\'s secret_token');
+ assert.strictEqual(verified('whk_x', body, { 'x-telegram-bot-api-secret-token': 'whk_y' }), false);
+ assert.strictEqual(verified(SLACK, body, slack, { provider: 'github' }), false, 'an unknown provider checks nothing');
+ assert.deepStrictEqual(Object.keys(PROVIDERS), ['slack', 'stripe']);
+});
+
+t('the owner pastes Slack\'s signing secret; it is kept, never echoed; a bad one changes nothing', async () => {
+ const made = await api('POST', '/api/v1/watches', { token: ME, body: def('slack mentions') });
+ assert.strictEqual(made.status, 201, JSON.stringify(made.body));
+ slackWatch = made.body.watch;
+ const own = made.body.webhook.secret;
+ const bad = await api('POST', `/api/v1/watches/${slackWatch.id}/webhook-secret`, { token: ME, body: { provider: 'slack', secret: 'not-hex' } });
+ assert.strictEqual(bad.status, 422);
+ assert.strictEqual((await api('POST', `/api/v1/watches/${slackWatch.id}/webhook-secret`, { token: ME, body: { provider: 'fax', secret: SLACK } })).status, 422);
+ assert.strictEqual((await svc.db.prepare('SELECT secret FROM webhook_secrets WHERE watch_id = ?').get(slackWatch.id)).secret, own, 'unchanged');
+ assert.strictEqual((await api('POST', `/api/v1/watches/${slackWatch.id}/webhook-secret`, { token: OTHER, body: { provider: 'slack', secret: SLACK } })).status, 404);
+ const set = await api('POST', `/api/v1/watches/${slackWatch.id}/webhook-secret`, { token: ME, body: { provider: 'slack', secret: SLACK } });
+ assert.strictEqual(set.status, 200, JSON.stringify(set.body));
+ assert.strictEqual(set.body.webhook.provider, 'slack');
+ assert.strictEqual(set.body.webhook.url, `${svc.config.baseUrl}/hooks/${slackWatch.id}`);
+ assert.ok(!JSON.stringify(set.body).includes(SLACK), 'their secret is never echoed');
+ assert.strictEqual(await svc.webhookSources.providerOf(slackWatch.id), 'slack');
+});
+
+t('Slack: the challenge comes back, a signed event runs the check once, a retry is acknowledged, an old one is refused', async () => {
+ const ts = String(nowSec());
+ const sign = (body, at = ts) => ({ 'X-Slack-Request-Timestamp': at, 'X-Slack-Signature': `v0=${hmac(SLACK, `v0:${at}:${body}`)}` });
+ const challenge = JSON.stringify({ type: 'url_verification', challenge: 'c4ll3ng3', token: 'old' });
+ const c = await deliver(slackWatch.id, challenge, sign(challenge));
+ assert.strictEqual(c.status, 200);
+ assert.deepStrictEqual(await c.json(), { challenge: 'c4ll3ng3' });
+ assert.strictEqual((await runs(slackWatch.id)).length, 0, 'the challenge runs no check');
+ const ev = JSON.stringify({ type: 'event_callback', event: { type: 'app_mention', text: 'hey' } });
+ const r = await deliver(slackWatch.id, ev, sign(ev));
+ assert.deepStrictEqual(await r.json(), { watch_id: slackWatch.id, state: 'condition_met', triggered: true });
+ const retry = await deliver(slackWatch.id, ev, { ...sign(ev), 'X-Slack-Retry-Num': '1', 'X-Slack-Retry-Reason': 'http_timeout' });
+ assert.strictEqual((await retry.json()).retry, true);
+ assert.strictEqual((await runs(slackWatch.id)).length, 1, 'the retry did not check again');
+ const old = String(nowSec() - 600);
+ assert.strictEqual((await deliver(slackWatch.id, ev, sign(ev, old))).status, 401, 'a replayed request');
+ assert.strictEqual((await deliver(slackWatch.id, challenge, { Authorization: `Bearer ${SLACK}` })).status, 401, 'no challenge for an unsigned caller');
+});
+
+t('Stripe: a signed event runs the check; the wrong secret is refused', async () => {
+ const made = await api('POST', '/api/v1/watches', { token: ME, body: { ...def('stripe payments'), extraction: { kind: 'json', value_path: 'type' }, condition: { op: 'contains', value: 'payment_intent.succeeded' } } });
+ stripeWatch = made.body.watch;
+ assert.strictEqual((await api('POST', `/api/v1/watches/${stripeWatch.id}/webhook-secret`, { token: ME, body: { provider: 'stripe', secret: STRIPE } })).status, 200);
+ const body = JSON.stringify({ id: 'evt_1', type: 'payment_intent.succeeded' });
+ const ts = String(nowSec());
+ const ok = await deliver(stripeWatch.id, body, { 'Stripe-Signature': `t=${ts},v1=${hmac(STRIPE, `${ts}.${body}`)}` });
+ assert.deepStrictEqual(await ok.json(), { watch_id: stripeWatch.id, state: 'condition_met', triggered: true });
+ assert.strictEqual((await deliver(stripeWatch.id, body, { 'Stripe-Signature': `t=${ts},v1=${hmac('whsec_other', `${ts}.${body}`)}` })).status, 401);
+});
+
+t('a new secret of Watch\'s own replaces a provider\'s, and its forms work again', async () => {
+ const rotated = await api('POST', `/api/v1/watches/${stripeWatch.id}/webhook-secret`, { token: ME });
+ assert.match(rotated.body.webhook.secret, /^whk_/);
+ assert.strictEqual(await svc.webhookSources.providerOf(stripeWatch.id), null);
+ const body = JSON.stringify({ type: 'payment_intent.succeeded' });
+ assert.strictEqual((await deliver(stripeWatch.id, body, { 'X-Telegram-Bot-Api-Secret-Token': rotated.body.webhook.secret })).status, 200, 'Telegram\'s header');
+});
+
+t('stop', async () => { await svc.stop(); });
+
+t.run();