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
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user