Tie a webhook's eSign identity to its team as well as its URL, and follow no redirect with it

HANZO_WEBHOOKS entries are <team url>=<url>: another team pointing at a listed URL is posted as a stranger. A call made as eSign does not follow a redirect, so the token reaches the listed URL and nowhere else.
This commit is contained in:
2026-10-20 18:21:45 -07:00
parent 31a45e2826
commit 3578254c60
5 changed files with 49 additions and 21 deletions
+4 -3
View File
@@ -188,9 +188,10 @@ nothing alone; the list does. Empty is nobody.
The other way round, this app calls the Hanzo API as itself: `Machine` mints its
client_credentials token for the Hanzo API (`resource` https://api.hanzo.ai), never for
itself, and refuses any bearer its own client minted. notify takes it, and so does a
webhook whose URL `HANZO_WEBHOOKS` lists exactly (`webhooks/send.ts`, the one sender for
sends and resends), so the receiver verifies eSign against hanzo.id and holds no secret;
what it answers is not kept for the team to read. A webhook anywhere else is sent no
webhook whose team and exact URL `HANZO_WEBHOOKS` lists together (`<team url>=<url>`;
`webhooks/send.ts`, the one sender for sends and resends, following no redirect), so the
receiver verifies eSign against hanzo.id and holds no secret; what it answers is not kept
for the team to read. A webhook anywhere else is sent no
identity, only its own secret: a team chooses where its webhook points. Zapier, which could only carry an API token, is gone.
An API token is still read in exactly one place: embed authoring signs its presign
token with one (`embedding-presign/api-token.ts`).
+6 -5
View File
@@ -128,14 +128,15 @@ describe('the teams applications act for', () => {
});
describe('the webhooks eSign posts as itself', () => {
test('are exact URLs on the Hanzo API, and nothing else', () => {
test('are a team and an exact URL on the Hanzo API, together', () => {
const urls = receivers(
'https://api.hanzo.ai/v1/marketing/leads/esign, https://evil.test/x, not a url, https://api.hanzo.ai.evil.test/x',
'hanzo=https://api.hanzo.ai/v1/marketing/leads/esign, hanzo=https://evil.test/x, =https://api.hanzo.ai/v1/x, ' +
'hanzo=not a url, hanzo=https://api.hanzo.ai.evil.test/x, https://api.hanzo.ai/v1/y, a=b=c',
);
assert.deepEqual([...urls], ['https://api.hanzo.ai/v1/marketing/leads/esign']);
assert.equal(urls.has('https://api.hanzo.ai/v1/marketing/leads/esign?x=1'), false);
assert.equal(urls.has('https://api.hanzo.ai/v1/billing/usage'), false);
assert.deepEqual([...urls], ['hanzo https://api.hanzo.ai/v1/marketing/leads/esign']);
assert.equal(urls.has('acme https://api.hanzo.ai/v1/marketing/leads/esign'), false);
assert.equal(urls.has('hanzo https://api.hanzo.ai/v1/marketing/leads/esign?x=1'), false);
assert.equal(receivers('').size, 0);
});
});
@@ -31,7 +31,7 @@ export const run = async ({
webhookEndpoint: url,
};
const sent = await sendWebhook({ url, body: payloadData, secret });
const sent = await sendWebhook({ url, body: payloadData, secret, teamId: webhook.teamId });
await prisma.webhookCall.create({
data: {
+37 -12
View File
@@ -1,40 +1,60 @@
import type { Prisma } from '@prisma/client';
import { prisma } from '@hanzo/esign-prisma';
import { env } from '../../utils/env';
import { HANZO_API, machine } from '../iam/token';
/**
* The webhook endpoints this application posts to as itself, from HANZO_WEBHOOKS:
* full URLs on the Hanzo API, comma-separated. Empty is none.
* The webhooks this application posts as itself, from HANZO_WEBHOOKS: `<team
* url>=<URL on the Hanzo API>`, comma-separated, read as `<team url> <URL>` keys.
* A webhook is eSign's own only when its team AND its URL are listed together:
* any other team pointing at the same URL is a stranger to it. Empty is none.
*/
export const receivers = (raw = env('HANZO_WEBHOOKS') ?? ''): Set<string> =>
new Set(
raw
.split(',')
.map((url) => url.trim())
.filter((url) => url !== '' && URL.canParse(url) && new URL(url).origin === HANZO_API),
.map((entry) => entry.trim().split('='))
.filter(
([team, url, ...rest]) =>
rest.length === 0 &&
Boolean(team) &&
Boolean(url) &&
URL.canParse(url) &&
new URL(url).origin === HANZO_API,
)
.map(([team, url]) => `${team} ${url}`),
);
/**
* Posts one webhook, the one way every send and resend goes out.
*
* A URL HANZO_WEBHOOKS lists, exactly, is posted as this application: an IAM
* token minted for the Hanzo API, which verifies it like any other caller, so the
* receiver holds no secret of ours. Every other URL is sent no identity at all,
* only the webhook's own secret: a team chooses where its webhook points, and must
* never be able to aim eSign's credential at an endpoint of its choosing. What a
* receiver answered a call made as eSign is not kept for the team to read.
* A webhook HANZO_WEBHOOKS lists, its team and its exact URL together, is posted
* as this application: an IAM token minted for the Hanzo API, which verifies it
* like any other caller, so the receiver holds no secret of ours. It follows no
* redirect, so the token goes nowhere but the URL listed, and what the receiver
* answered is not kept for the team to read. Every other webhook is sent no
* identity at all, only its own secret: a team chooses where its webhook points,
* and must never be able to aim eSign's credential at an endpoint of its choosing.
*/
export const sendWebhook = async ({
url,
body,
secret,
teamId,
}: {
url: string;
body: unknown;
secret: string | null;
teamId: number;
}) => {
const identity = receivers().has(url);
const listed = receivers();
const team =
listed.size > 0
? await prisma.team.findUnique({ where: { id: teamId }, select: { url: true } })
: null;
const identity = Boolean(team) && listed.has(`${team?.url} ${url}`);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Hanzo-Secret': secret ?? '',
@@ -44,7 +64,12 @@ export const sendWebhook = async ({
headers.Authorization = `Bearer ${await machine.token(HANZO_API)}`;
}
const response = await fetch(url, { method: 'POST', body: JSON.stringify(body), headers });
const response = await fetch(url, {
method: 'POST',
body: JSON.stringify(body),
headers,
redirect: identity ? 'manual' : 'follow',
});
const text = await response.text();
let responseBody: Prisma.InputJsonValue = text;
@@ -91,6 +91,7 @@ export const webhookRoutes: ZapRouteMap = {
url: webhookCall.url,
body: webhookCall.requestBody,
secret: webhook.secret,
teamId: webhook.teamId,
});
return await prisma.webhookCall.update({