90275f88e9
* fix(security): resolve DNS before vetting external hostnames (SSRF cluster) * fix(security): harden SSRF fix per review (rsync backup path, S3 config-save, webhook transient-DNS retry) * fix(security): S3 endpoint validation on any endpoint update + no-connect on unresolved webhook host (codex r2) --------- Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
129 lines
4.8 KiB
JavaScript
129 lines
4.8 KiB
JavaScript
/**
|
|
* DNS-resolving SSRF guard (GHSA SSRF cluster: webhook / S3 / rsync / SMTP /
|
|
* IMAP). The literal isPrivateIP check can't see that a public-looking
|
|
* hostname resolves to an internal/metadata IP; isHostAllowed resolves the
|
|
* name and vets every A/AAAA record.
|
|
*/
|
|
jest.mock('dns', () => {
|
|
const actual = jest.requireActual('dns');
|
|
return { ...actual, promises: { ...actual.promises, lookup: jest.fn() } };
|
|
});
|
|
const dns = require('dns');
|
|
const {
|
|
isHostAllowed,
|
|
validateExternalUrlAsync,
|
|
classifyHost,
|
|
} = require('../../src/utils/networkValidation');
|
|
|
|
const lookup = dns.promises.lookup;
|
|
|
|
describe('classifyHost', () => {
|
|
beforeEach(() => lookup.mockReset());
|
|
|
|
it('distinguishes private, unresolved, ok, and invalid', async () => {
|
|
lookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }]);
|
|
expect(await classifyHost('evil.example')).toBe('private');
|
|
|
|
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
|
expect(await classifyHost('example.com')).toBe('ok');
|
|
|
|
lookup.mockRejectedValue(new Error('EAI_AGAIN'));
|
|
expect(await classifyHost('blip.example')).toBe('unresolved');
|
|
|
|
lookup.mockResolvedValue([]);
|
|
expect(await classifyHost('empty.example')).toBe('unresolved');
|
|
|
|
expect(await classifyHost('')).toBe('invalid');
|
|
expect(await classifyHost('10.0.0.1')).toBe('private'); // literal, no lookup
|
|
});
|
|
});
|
|
|
|
describe('isHostAllowed', () => {
|
|
beforeEach(() => lookup.mockReset());
|
|
|
|
it('rejects a public hostname that resolves to a private IP', async () => {
|
|
lookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }]);
|
|
expect(await isHostAllowed('evil.example.com')).toBe(false);
|
|
});
|
|
|
|
it('rejects when the hostname resolves to the cloud metadata IP', async () => {
|
|
lookup.mockResolvedValue([{ address: '169.254.169.254', family: 4 }]);
|
|
expect(await isHostAllowed('metadata-rebind.example')).toBe(false);
|
|
});
|
|
|
|
it('rejects when ANY resolved address is private (rebinding / mixed records)', async () => {
|
|
lookup.mockResolvedValue([
|
|
{ address: '93.184.216.34', family: 4 },
|
|
{ address: '169.254.169.254', family: 4 },
|
|
]);
|
|
expect(await isHostAllowed('rebind.example')).toBe(false);
|
|
});
|
|
|
|
it('allows a hostname that resolves only to public IPs', async () => {
|
|
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
|
expect(await isHostAllowed('example.com')).toBe(true);
|
|
});
|
|
|
|
it('fails closed when resolution errors', async () => {
|
|
lookup.mockRejectedValue(new Error('ENOTFOUND'));
|
|
expect(await isHostAllowed('nxdomain.invalid')).toBe(false);
|
|
});
|
|
|
|
it('fails closed on an empty resolution', async () => {
|
|
lookup.mockResolvedValue([]);
|
|
expect(await isHostAllowed('empty.example')).toBe(false);
|
|
});
|
|
|
|
it('rejects literal private IPs and blocked names without resolving', async () => {
|
|
expect(await isHostAllowed('127.0.0.1')).toBe(false);
|
|
expect(await isHostAllowed('10.0.0.1')).toBe(false);
|
|
expect(await isHostAllowed('localhost')).toBe(false);
|
|
expect(await isHostAllowed('metadata.google.internal')).toBe(false);
|
|
expect(await isHostAllowed('foo.internal')).toBe(false);
|
|
expect(lookup).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('allows a public IP literal without resolving', async () => {
|
|
expect(await isHostAllowed('93.184.216.34')).toBe(true);
|
|
expect(lookup).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects empty / non-string input', async () => {
|
|
expect(await isHostAllowed('')).toBe(false);
|
|
expect(await isHostAllowed(null)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('validateExternalUrlAsync', () => {
|
|
beforeEach(() => lookup.mockReset());
|
|
|
|
it('rejects a URL whose host resolves to a private address', async () => {
|
|
lookup.mockResolvedValue([{ address: '10.1.2.3', family: 4 }]);
|
|
const r = await validateExternalUrlAsync('https://evil.example/hook');
|
|
expect(r.valid).toBe(false);
|
|
});
|
|
|
|
it('accepts a URL whose host resolves public', async () => {
|
|
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
|
expect((await validateExternalUrlAsync('https://example.com/hook')).valid).toBe(true);
|
|
});
|
|
|
|
it('rejects a malformed URL', async () => {
|
|
expect((await validateExternalUrlAsync('not a url')).valid).toBe(false);
|
|
});
|
|
|
|
it('reports reason=unresolved for a transient lookup failure (retryable)', async () => {
|
|
lookup.mockRejectedValue(new Error('EAI_AGAIN'));
|
|
const r = await validateExternalUrlAsync('https://blip.example/hook');
|
|
expect(r.valid).toBe(false);
|
|
expect(r.reason).toBe('unresolved');
|
|
});
|
|
|
|
it('reports reason=private for a resolved-private host (permanent)', async () => {
|
|
lookup.mockResolvedValue([{ address: '169.254.169.254', family: 4 }]);
|
|
const r = await validateExternalUrlAsync('https://rebind.example/hook');
|
|
expect(r.valid).toBe(false);
|
|
expect(r.reason).toBe('private');
|
|
});
|
|
});
|