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

@@ -0,0 +1,19 @@
import {
decryptMfaSecret,
encryptMfaSecret,
} from '../auth/mfa/mfa-crypto.util';
// Webhook signing secrets must stay reversible (the server signs with them),
// so they are stored AES-256-GCM-encrypted like TOTP secrets. Rows written
// before this carry the plain secret and no prefix - still readable.
const PREFIX = 'enc:v1:';
export function sealWebhookSecret(secret: string): string {
return `${PREFIX}${encryptMfaSecret(secret)}`;
}
export function openWebhookSecret(stored: string): string {
return stored.startsWith(PREFIX)
? decryptMfaSecret(stored.slice(PREFIX.length))
: stored;
}