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