From a0f7573af51fe083fb0339a78fedd7e27d61170c Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sun, 23 Aug 2026 07:05:53 +0200 Subject: [PATCH] fix(backend): block SSRF via webhook subscription URLs WebhooksService.create() rejected obviously private, loopback, or link-local URLs (incl. cloud metadata 169.254.169.254 and the decimal/octal/hex IPv4 shorthand encodings the WHATWG URL parser normalizes) so an admin-configured webhook can no longer be used to proxy requests into internal infrastructure. Registration-time only, no DNS resolution (DNS-rebinding is out of scope for this check). --- .../src/webhooks/webhook-url-guard.spec.ts | 73 +++++++++++++++++++ .../src/webhooks/webhook-url-guard.ts | 66 +++++++++++++++++ .../src/webhooks/webhooks.service.ts | 2 + ApiCore-Backend/test/webhooks.e2e-spec.ts | 34 ++++++++- 4 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 ApiCore-Backend/src/webhooks/webhook-url-guard.spec.ts create mode 100644 ApiCore-Backend/src/webhooks/webhook-url-guard.ts diff --git a/ApiCore-Backend/src/webhooks/webhook-url-guard.spec.ts b/ApiCore-Backend/src/webhooks/webhook-url-guard.spec.ts new file mode 100644 index 0000000..b318865 --- /dev/null +++ b/ApiCore-Backend/src/webhooks/webhook-url-guard.spec.ts @@ -0,0 +1,73 @@ +import { BadRequestException } from '@nestjs/common'; +import { assertPublicWebhookUrl } from './webhook-url-guard'; + +describe('assertPublicWebhookUrl', () => { + it('accepts a normal public https URL', () => { + expect(() => + assertPublicWebhookUrl('https://example.com/hook'), + ).not.toThrow(); + }); + + it('rejects an invalid URL', () => { + expect(() => assertPublicWebhookUrl('not-a-url')).toThrow( + BadRequestException, + ); + }); + + it('rejects non-http(s) schemes', () => { + expect(() => assertPublicWebhookUrl('ftp://example.com/hook')).toThrow( + BadRequestException, + ); + expect(() => assertPublicWebhookUrl('file:///etc/passwd')).toThrow( + BadRequestException, + ); + }); + + it('rejects localhost by hostname', () => { + expect(() => assertPublicWebhookUrl('http://localhost/hook')).toThrow( + BadRequestException, + ); + }); + + it('rejects IPv4 loopback and its shorthand/decimal/hex/octal encodings', () => { + for (const url of [ + 'http://127.0.0.1/hook', + 'http://127.1/hook', + 'http://0x7f000001/hook', + 'http://2130706433/hook', + 'http://017700000001/hook', + ]) { + expect(() => assertPublicWebhookUrl(url)).toThrow(BadRequestException); + } + }); + + it('rejects RFC 1918 private ranges', () => { + for (const url of [ + 'http://10.0.0.5/hook', + 'http://172.16.0.5/hook', + 'http://172.31.255.255/hook', + 'http://192.168.1.1/hook', + ]) { + expect(() => assertPublicWebhookUrl(url)).toThrow(BadRequestException); + } + }); + + it('rejects link-local addresses including the cloud metadata endpoint', () => { + expect(() => + assertPublicWebhookUrl('http://169.254.169.254/latest/meta-data/'), + ).toThrow(BadRequestException); + }); + + it('does not reject a public IPv4 literal outside reserved ranges', () => { + expect(() => assertPublicWebhookUrl('http://8.8.8.8/hook')).not.toThrow(); + }); + + it('rejects IPv6 loopback and link-local addresses', () => { + expect(() => assertPublicWebhookUrl('http://[::1]/hook')).toThrow( + BadRequestException, + ); + expect(() => assertPublicWebhookUrl('http://[fe80::1]/hook')).toThrow( + BadRequestException, + ); + }); +}); diff --git a/ApiCore-Backend/src/webhooks/webhook-url-guard.ts b/ApiCore-Backend/src/webhooks/webhook-url-guard.ts new file mode 100644 index 0000000..f35c868 --- /dev/null +++ b/ApiCore-Backend/src/webhooks/webhook-url-guard.ts @@ -0,0 +1,66 @@ +import { BadRequestException } from '@nestjs/common'; +import * as net from 'node:net'; + +const BLOCKED_HOSTNAMES = new Set(['localhost', '0.0.0.0']); + +function isPrivateOrReservedIpv4(octets: number[]): boolean { + const [a, b] = octets; + if (a === 127) return true; // loopback + if (a === 10) return true; // private (RFC 1918) + if (a === 172 && b >= 16 && b <= 31) return true; // private (RFC 1918) + if (a === 192 && b === 168) return true; // private (RFC 1918) + if (a === 169 && b === 254) return true; // link-local, incl. cloud metadata (169.254.169.254) + if (a === 0) return true; // "this network" + return false; +} + +function isPrivateOrReservedIp(ip: string): boolean { + if (net.isIPv4(ip)) { + return isPrivateOrReservedIpv4(ip.split('.').map(Number)); + } + if (net.isIPv6(ip)) { + const normalized = ip.toLowerCase(); + if (normalized === '::1' || normalized === '::') return true; // loopback / unspecified + if (normalized.startsWith('fe80:')) return true; // link-local + if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true; // unique local + const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(normalized); + if (mapped && net.isIPv4(mapped[1])) { + return isPrivateOrReservedIpv4(mapped[1].split('.').map(Number)); + } + return false; + } + return false; +} + +/** + * Blocks webhook targets that would let an admin turn the backend into an + * SSRF proxy against internal services or the cloud metadata endpoint. + * Checks are syntactic only (no DNS lookups), relying on the WHATWG URL + * parser to normalize decimal/octal/hex IPv4 shorthand into dotted form + * before the range check runs; DNS-rebinding (a hostname resolving to a + * private address only at delivery time) is not covered. + */ +export function assertPublicWebhookUrl(rawUrl: string): void { + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + throw new BadRequestException('Invalid webhook URL'); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new BadRequestException('Webhook URL must use http or https'); + } + + const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, ''); + if (BLOCKED_HOSTNAMES.has(hostname)) { + throw new BadRequestException( + 'Webhook URL must not target localhost or a reserved address', + ); + } + if (net.isIP(hostname) && isPrivateOrReservedIp(hostname)) { + throw new BadRequestException( + 'Webhook URL must not target a private, loopback, or link-local address', + ); + } +} diff --git a/ApiCore-Backend/src/webhooks/webhooks.service.ts b/ApiCore-Backend/src/webhooks/webhooks.service.ts index f2f62a6..07e4288 100644 --- a/ApiCore-Backend/src/webhooks/webhooks.service.ts +++ b/ApiCore-Backend/src/webhooks/webhooks.service.ts @@ -5,6 +5,7 @@ import { AuditLogService } from '../audit/audit-log.service'; import { WebhookSubscription } from '../../generated/prisma'; import { CreateWebhookDto } from './dto/create-webhook.dto'; import { WebhookEvent } from './webhook-events'; +import { assertPublicWebhookUrl } from './webhook-url-guard'; const SECRET_BYTES = 32; @@ -36,6 +37,7 @@ export class WebhooksService { dto: CreateWebhookDto, actingUserId: string, ): Promise { + assertPublicWebhookUrl(dto.url); const secret = crypto.randomBytes(SECRET_BYTES).toString('hex'); const subscription = await this.prisma.webhookSubscription.create({ data: { diff --git a/ApiCore-Backend/test/webhooks.e2e-spec.ts b/ApiCore-Backend/test/webhooks.e2e-spec.ts index 6785abe..bf4fe8f 100644 --- a/ApiCore-Backend/test/webhooks.e2e-spec.ts +++ b/ApiCore-Backend/test/webhooks.e2e-spec.ts @@ -32,6 +32,7 @@ describe('Webhooks (e2e)', () => { let adminAccessToken: string; let staffAccessToken: string; let subscriptionId: string; + let deliverySubscriptionId: string; let receiverServer: http.Server; let receiverUrl: string; @@ -91,7 +92,7 @@ describe('Webhooks (e2e)', () => { receiverServer.close((error) => (error ? reject(error) : resolve())), ); await prisma.webhookSubscription.deleteMany({ - where: { id: subscriptionId }, + where: { id: { in: [subscriptionId, deliverySubscriptionId] } }, }); await prisma.task.deleteMany({ where: { createdByUserId: { in: [adminId, staffId] } }, @@ -113,11 +114,27 @@ describe('Webhooks (e2e)', () => { .expect(403); }); + it('rejects a webhook URL targeting a private or loopback address (SSRF guard)', async () => { + await request(app.getHttpServer()) + .post('/api/v1/webhooks') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ url: receiverUrl, events: ['task.created'] }) + .expect(400); + await request(app.getHttpServer()) + .post('/api/v1/webhooks') + .set('Authorization', `Bearer ${adminAccessToken}`) + .send({ + url: 'http://169.254.169.254/latest/meta-data/', + events: ['task.created'], + }) + .expect(400); + }); + it('lets an admin create a webhook subscription with a generated secret', async () => { const response = await request(app.getHttpServer()) .post('/api/v1/webhooks') .set('Authorization', `Bearer ${adminAccessToken}`) - .send({ url: receiverUrl, events: ['task.created'] }) + .send({ url: 'https://example.com/hook', events: ['task.created'] }) .expect(201); const body = response.body as WebhookSubscriptionBody; subscriptionId = body.id; @@ -136,9 +153,18 @@ describe('Webhooks (e2e)', () => { }); it('delivers an HMAC-signed POST to the subscribed URL when task.created fires', async () => { - const subscription = await prisma.webhookSubscription.findUniqueOrThrow({ - where: { id: subscriptionId }, + // 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). + const subscription = await prisma.webhookSubscription.create({ + data: { + url: receiverUrl, + events: ['task.created'], + secret: crypto.randomBytes(32).toString('hex'), + createdByUserId: adminId, + }, }); + deliverySubscriptionId = subscription.id; const taskResponse = await request(app.getHttpServer()) .post('/api/v1/tasks')