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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user