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.
128 lines
3.8 KiB
TypeScript
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);
|
|
});
|
|
});
|