fix(security): resolve DNS before vetting external hostnames (SSRF cluster) (#941)
* 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 <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
8a87c9274b
commit
b7005692b3
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user