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).
This commit is contained in:
1 parent
2458927d38
commit
a0f7573af5
4 files changed
+171
-4
No files matched your search
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { AuditLogService } from '../audit/audit-log.service';
|
|||||||
import { WebhookSubscription } from '../../generated/prisma';
|
import { WebhookSubscription } from '../../generated/prisma';
|
||||||
import { CreateWebhookDto } from './dto/create-webhook.dto';
|
import { CreateWebhookDto } from './dto/create-webhook.dto';
|
||||||
import { WebhookEvent } from './webhook-events';
|
import { WebhookEvent } from './webhook-events';
|
||||||
|
import { assertPublicWebhookUrl } from './webhook-url-guard';
|
||||||
|
|
||||||
const SECRET_BYTES = 32;
|
const SECRET_BYTES = 32;
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ export class WebhooksService {
|
|||||||
dto: CreateWebhookDto,
|
dto: CreateWebhookDto,
|
||||||
actingUserId: string,
|
actingUserId: string,
|
||||||
): Promise<WebhookSubscription> {
|
): Promise<WebhookSubscription> {
|
||||||
|
assertPublicWebhookUrl(dto.url);
|
||||||
const secret = crypto.randomBytes(SECRET_BYTES).toString('hex');
|
const secret = crypto.randomBytes(SECRET_BYTES).toString('hex');
|
||||||
const subscription = await this.prisma.webhookSubscription.create({
|
const subscription = await this.prisma.webhookSubscription.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ describe('Webhooks (e2e)', () => {
|
|||||||
let adminAccessToken: string;
|
let adminAccessToken: string;
|
||||||
let staffAccessToken: string;
|
let staffAccessToken: string;
|
||||||
let subscriptionId: string;
|
let subscriptionId: string;
|
||||||
|
let deliverySubscriptionId: string;
|
||||||
|
|
||||||
let receiverServer: http.Server;
|
let receiverServer: http.Server;
|
||||||
let receiverUrl: string;
|
let receiverUrl: string;
|
||||||
@@ -91,7 +92,7 @@ describe('Webhooks (e2e)', () => {
|
|||||||
receiverServer.close((error) => (error ? reject(error) : resolve())),
|
receiverServer.close((error) => (error ? reject(error) : resolve())),
|
||||||
);
|
);
|
||||||
await prisma.webhookSubscription.deleteMany({
|
await prisma.webhookSubscription.deleteMany({
|
||||||
where: { id: subscriptionId },
|
where: { id: { in: [subscriptionId, deliverySubscriptionId] } },
|
||||||
});
|
});
|
||||||
await prisma.task.deleteMany({
|
await prisma.task.deleteMany({
|
||||||
where: { createdByUserId: { in: [adminId, staffId] } },
|
where: { createdByUserId: { in: [adminId, staffId] } },
|
||||||
@@ -113,11 +114,27 @@ describe('Webhooks (e2e)', () => {
|
|||||||
.expect(403);
|
.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 () => {
|
it('lets an admin create a webhook subscription with a generated secret', async () => {
|
||||||
const response = await request(app.getHttpServer())
|
const response = await request(app.getHttpServer())
|
||||||
.post('/api/v1/webhooks')
|
.post('/api/v1/webhooks')
|
||||||
.set('Authorization', `Bearer ${adminAccessToken}`)
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
.send({ url: receiverUrl, events: ['task.created'] })
|
.send({ url: 'https://example.com/hook', events: ['task.created'] })
|
||||||
.expect(201);
|
.expect(201);
|
||||||
const body = response.body as WebhookSubscriptionBody;
|
const body = response.body as WebhookSubscriptionBody;
|
||||||
subscriptionId = body.id;
|
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 () => {
|
it('delivers an HMAC-signed POST to the subscribed URL when task.created fires', async () => {
|
||||||
const subscription = await prisma.webhookSubscription.findUniqueOrThrow({
|
// Delivery is tested against a real local receiver, so the subscription
|
||||||
where: { id: subscriptionId },
|
// 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())
|
const taskResponse = await request(app.getHttpServer())
|
||||||
.post('/api/v1/tasks')
|
.post('/api/v1/tasks')
|
||||||
|
|||||||
Reference in new issue
Block a user