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

@@ -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,
);
});
});