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:
Nick Szaunig committed 2026-08-23 07:05:53 +02:00
1 parent 2458927d38
commit a0f7573af5
4 files changed
+171 -4

No files matched your search

+30 -4
View File
@@ -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')