fix(webhooks): close DNS rebinding, time out deliveries, sign with timestamp
- Deliveries connect through a lookup hook that checks the address actually dialled, so a host that resolves to a private address after registration (DNS rebinding) or a tampered row cannot reach internal services. Redirects are still not followed. - More blocked ranges (CGNAT/100.64/10 incl. cloud metadata, multicast, reserved, benchmarking, NAT64, 6to4, site-local, IPv4-mapped IPv6); only ports 80/443, no credentials in the URL, https in production. - 5 s timeout, response body discarded; dispatch no longer waits for receivers, so a hanging endpoint cannot stall the user's request. - Signature covers "timestamp.deliveryId.body" with X-ApiCore-Timestamp and X-ApiCore-Delivery headers, so receivers can reject replays. - Signing secrets are stored AES-256-GCM-encrypted; older plain rows still work. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
ebeb499a18
commit
79d20ee64e
8 files changed
+281
-72
No files matched your search
@@ -3,6 +3,13 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { WebhooksService } from './webhooks.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { postWebhook } from './webhook-delivery';
|
||||
import { openWebhookSecret, sealWebhookSecret } from './webhook-secret';
|
||||
|
||||
jest.mock('./webhook-delivery', () => ({ postWebhook: jest.fn() }));
|
||||
|
||||
const postWebhookMock = postWebhook as jest.MockedFunction<typeof postWebhook>;
|
||||
const flushDeliveries = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
describe('WebhooksService', () => {
|
||||
let service: WebhooksService;
|
||||
@@ -16,9 +23,11 @@ describe('WebhooksService', () => {
|
||||
};
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let fetchMock: jest.SpiedFunction<typeof fetch>;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret-0123456789';
|
||||
postWebhookMock.mockReset();
|
||||
postWebhookMock.mockResolvedValue({ status: 200 });
|
||||
prisma = {
|
||||
webhookSubscription: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
@@ -33,9 +42,6 @@ describe('WebhooksService', () => {
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
);
|
||||
fetchMock = jest
|
||||
.spyOn(global, 'fetch')
|
||||
.mockResolvedValue(new Response(null, { status: 200 }));
|
||||
});
|
||||
|
||||
it('create generates a random secret and stores it', async () => {
|
||||
@@ -53,8 +59,20 @@ describe('WebhooksService', () => {
|
||||
|
||||
expect(subscription.secret).toHaveLength(64);
|
||||
const [[createArg]] = prisma.webhookSubscription.create.mock.calls as [
|
||||
[{ data: { url: string; events: string[]; createdByUserId: string } }],
|
||||
[
|
||||
{
|
||||
data: {
|
||||
url: string;
|
||||
events: string[];
|
||||
createdByUserId: string;
|
||||
secret: string;
|
||||
};
|
||||
},
|
||||
],
|
||||
];
|
||||
// Stored encrypted, shown in plain only in this one response.
|
||||
expect(createArg.data.secret).not.toContain(subscription.secret);
|
||||
expect(openWebhookSecret(createArg.data.secret)).toBe(subscription.secret);
|
||||
expect(createArg.data.url).toBe('https://203.0.113.10/hook');
|
||||
expect(createArg.data.events).toEqual(['colony.created']);
|
||||
expect(createArg.data.createdByUserId).toBe('user-1');
|
||||
@@ -75,33 +93,43 @@ describe('WebhooksService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatch signs the payload with HMAC-SHA256 and posts it with event headers', async () => {
|
||||
it('dispatch signs timestamp, delivery id and payload with HMAC-SHA256', async () => {
|
||||
const subscription = {
|
||||
id: 'wh-1',
|
||||
url: 'https://example.test/hook',
|
||||
secret: 'topsecret',
|
||||
secret: sealWebhookSecret('topsecret'),
|
||||
active: true,
|
||||
};
|
||||
prisma.webhookSubscription.findMany.mockResolvedValueOnce([subscription]);
|
||||
|
||||
await service.dispatch('colony.created', { id: 'colony-1' });
|
||||
await flushDeliveries();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, options] = fetchMock.mock.calls[0] as [
|
||||
string,
|
||||
{ method: string; headers: Record<string, string>; body: string },
|
||||
];
|
||||
expect(postWebhookMock).toHaveBeenCalledTimes(1);
|
||||
const [url, headers, body] = postWebhookMock.mock.calls[0];
|
||||
expect(url).toBe('https://example.test/hook');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(options.headers['X-ApiCore-Event']).toBe('colony.created');
|
||||
expect(headers['X-ApiCore-Event']).toBe('colony.created');
|
||||
expect(headers['X-ApiCore-Delivery']).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(Number(headers['X-ApiCore-Timestamp'])).toBeGreaterThan(0);
|
||||
|
||||
const expectedSignature = crypto
|
||||
.createHmac('sha256', subscription.secret)
|
||||
.update(options.body)
|
||||
.createHmac('sha256', 'topsecret')
|
||||
.update(
|
||||
`${headers['X-ApiCore-Timestamp']}.${headers['X-ApiCore-Delivery']}.${body}`,
|
||||
)
|
||||
.digest('hex');
|
||||
expect(options.headers['X-ApiCore-Signature']).toBe(
|
||||
`sha256=${expectedSignature}`,
|
||||
);
|
||||
expect(headers['X-ApiCore-Signature']).toBe(`sha256=${expectedSignature}`);
|
||||
});
|
||||
|
||||
it('dispatch returns before a slow receiver has answered', async () => {
|
||||
prisma.webhookSubscription.findMany.mockResolvedValueOnce([
|
||||
{ id: 'wh-1', url: 'https://example.test/hook', secret: 's' },
|
||||
]);
|
||||
postWebhookMock.mockReturnValueOnce(new Promise(() => undefined));
|
||||
|
||||
await expect(
|
||||
service.dispatch('colony.created', { id: 'colony-1' }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('dispatch does not throw when delivery fails', async () => {
|
||||
@@ -113,11 +141,12 @@ describe('WebhooksService', () => {
|
||||
active: true,
|
||||
},
|
||||
]);
|
||||
fetchMock.mockRejectedValueOnce(new Error('network down'));
|
||||
postWebhookMock.mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
await expect(
|
||||
service.dispatch('colony.created', { id: 'colony-1' }),
|
||||
).resolves.toBeUndefined();
|
||||
await flushDeliveries();
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
|
||||
Reference in new issue
Block a user