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.
This commit is contained in:
Nick Szaunig committed 2026-08-23 11:08:40 +02:00
1 parent a0f7573af5
commit 79010a926b
4 files changed
+157 -71

No files matched your search

@@ -1,35 +1,47 @@
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 normal public https URL', () => {
expect(() =>
assertPublicWebhookUrl('https://example.com/hook'),
).not.toThrow();
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', () => {
expect(() => assertPublicWebhookUrl('not-a-url')).toThrow(
it('rejects an invalid URL', async () => {
await expect(assertPublicWebhookUrl('not-a-url')).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('rejects non-http(s) schemes', () => {
expect(() => assertPublicWebhookUrl('ftp://example.com/hook')).toThrow(
BadRequestException,
);
expect(() => assertPublicWebhookUrl('file:///etc/passwd')).toThrow(
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', () => {
expect(() => assertPublicWebhookUrl('http://localhost/hook')).toThrow(
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', () => {
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',
@@ -37,37 +49,79 @@ describe('assertPublicWebhookUrl', () => {
'http://2130706433/hook',
'http://017700000001/hook',
]) {
expect(() => assertPublicWebhookUrl(url)).toThrow(BadRequestException);
await expect(assertPublicWebhookUrl(url)).rejects.toBeInstanceOf(
BadRequestException,
);
}
});
it('rejects RFC 1918 private ranges', () => {
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',
]) {
expect(() => assertPublicWebhookUrl(url)).toThrow(BadRequestException);
await expect(assertPublicWebhookUrl(url)).rejects.toBeInstanceOf(
BadRequestException,
);
}
});
it('rejects link-local addresses including the cloud metadata endpoint', () => {
expect(() =>
it('rejects link-local addresses including the cloud metadata endpoint', async () => {
await expect(
assertPublicWebhookUrl('http://169.254.169.254/latest/meta-data/'),
).toThrow(BadRequestException);
).rejects.toBeInstanceOf(BadRequestException);
});
it('does not reject a public IPv4 literal outside reserved ranges', () => {
expect(() => assertPublicWebhookUrl('http://8.8.8.8/hook')).not.toThrow();
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', () => {
expect(() => assertPublicWebhookUrl('http://[::1]/hook')).toThrow(
BadRequestException,
);
expect(() => assertPublicWebhookUrl('http://[fe80::1]/hook')).toThrow(
BadRequestException,
);
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);
});
});