mirror of
https://github.com/hanzoai/sign.git
synced 2026-10-21 01:21:51 +00:00
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:
@@ -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`).
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user