Files
ApiCore/ApiCore-Backend/src/webhooks/webhook-url-guard.spec.ts
T
Nick Szaunig 79010a926b fix(backend): close two SSRF bypasses in webhook URL validation
1. The URL guard only classified IP-literal hostnames; any plain domain
   name (the common case) skipped validation entirely. Hostnames are now
   resolved via DNS and every returned address is checked against the
   same private/loopback/link-local ranges (net.BlockList), so an
   attacker-controlled domain pointed at an internal IP is rejected too.
2. fetch() follows redirects by default, so a registered public URL could
   302 to an internal target at delivery time. Delivery now uses
   redirect: 'manual' so such a redirect is never followed.
2026-08-23 11:08:40 +02:00

128 lines
3.8 KiB
TypeScript

import { BadRequestException } from '@nestjs/common';
import { assertPublicWebhookUrl } from './webhook-url-guard';
function lookupResolving(...addresses: string[]) {
return () =>
Promise.resolve(addresses.map((address) => ({ address, family: 4 })));
}
function lookupFailing() {
return () => Promise.reject(new Error('ENOTFOUND'));
}
describe('assertPublicWebhookUrl', () => {
it('accepts a hostname that resolves only to public addresses', async () => {
await expect(
assertPublicWebhookUrl(
'https://example.com/hook',
lookupResolving('93.184.216.34'),
),
).resolves.toBeUndefined();
});
it('rejects an invalid URL', async () => {
await expect(assertPublicWebhookUrl('not-a-url')).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('rejects non-http(s) schemes', async () => {
await expect(
assertPublicWebhookUrl('ftp://example.com/hook'),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
assertPublicWebhookUrl('file:///etc/passwd'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects localhost by hostname', async () => {
await expect(
assertPublicWebhookUrl('http://localhost/hook'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects IPv4 loopback and its shorthand/decimal/hex/octal encodings', async () => {
for (const url of [
'http://127.0.0.1/hook',
'http://127.1/hook',
'http://0x7f000001/hook',
'http://2130706433/hook',
'http://017700000001/hook',
]) {
await expect(assertPublicWebhookUrl(url)).rejects.toBeInstanceOf(
BadRequestException,
);
}
});
it('rejects RFC 1918 private ranges', async () => {
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',
]) {
await expect(assertPublicWebhookUrl(url)).rejects.toBeInstanceOf(
BadRequestException,
);
}
});
it('rejects link-local addresses including the cloud metadata endpoint', async () => {
await expect(
assertPublicWebhookUrl('http://169.254.169.254/latest/meta-data/'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('does not reject a public IPv4 literal outside reserved ranges', async () => {
await expect(
assertPublicWebhookUrl('http://8.8.8.8/hook'),
).resolves.toBeUndefined();
});
it('rejects IPv6 loopback and link-local addresses', async () => {
await expect(
assertPublicWebhookUrl('http://[::1]/hook'),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
assertPublicWebhookUrl('http://[fe80::1]/hook'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects a hostname that resolves to a private address (DNS-based bypass)', async () => {
await expect(
assertPublicWebhookUrl(
'https://attacker.example/hook',
lookupResolving('10.0.0.5'),
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects a hostname that resolves to the cloud metadata address', async () => {
await expect(
assertPublicWebhookUrl(
'https://attacker.example/hook',
lookupResolving('169.254.169.254'),
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects a hostname where any resolved address is private, even if others are public', async () => {
await expect(
assertPublicWebhookUrl(
'https://attacker.example/hook',
lookupResolving('93.184.216.34', '10.0.0.5'),
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects a hostname that fails to resolve', async () => {
await expect(
assertPublicWebhookUrl(
'https://does-not-exist.invalid/hook',
lookupFailing(),
),
).rejects.toBeInstanceOf(BadRequestException);
});
});