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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 02:17:40 +02:00
1 parent ebeb499a18
commit 79d20ee64e
8 files changed
+281 -72

No files matched your search

+8 -23
View File
@@ -152,10 +152,11 @@ describe('Webhooks (e2e)', () => {
expect(listed?.secret).toBeUndefined();
});
it('delivers an HMAC-signed POST to the subscribed URL when task.created fires', async () => {
// Delivery is tested against a real local receiver, so the subscription
// is inserted directly (the create() SSRF guard blocks loopback URLs by
// design; the guard itself is covered separately above).
it('never delivers to a private address, even from a row that bypassed the create guard', async () => {
// Stands in for DNS rebinding or a tampered row: the stored URL points
// at a loopback receiver. The connection-time check must refuse it, and
// the request that fired the event must not wait for the receiver.
// Signing is covered by webhooks.service.spec.ts.
const subscription = await prisma.webhookSubscription.create({
data: {
url: receiverUrl,
@@ -166,30 +167,14 @@ describe('Webhooks (e2e)', () => {
});
deliverySubscriptionId = subscription.id;
const taskResponse = await request(app.getHttpServer())
await request(app.getHttpServer())
.post('/api/v1/tasks')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({ title: `Webhook-Test ${Date.now()}` })
.expect(201);
await new Promise((resolve) => setTimeout(resolve, 300));
expect(receivedRequests).toHaveLength(1);
const delivery = receivedRequests[0];
expect(delivery.headers['x-apicore-event']).toBe('task.created');
const expectedSignature = crypto
.createHmac('sha256', subscription.secret)
.update(delivery.body)
.digest('hex');
expect(delivery.headers['x-apicore-signature']).toBe(
`sha256=${expectedSignature}`,
);
const payload = JSON.parse(delivery.body) as {
event: string;
data: { id: string };
};
expect(payload.event).toBe('task.created');
expect(payload.data.id).toBe((taskResponse.body as { id: string }).id);
expect(receivedRequests).toHaveLength(0);
});
it('lets an admin delete a webhook subscription', () => {