diff --git a/__mocks__/dns.js b/__mocks__/dns.js index 96cf4e8..ad7a5a6 100644 --- a/__mocks__/dns.js +++ b/__mocks__/dns.js @@ -21,4 +21,8 @@ export function lookup(hostname, options, callback) { ) { callback(undefined, { address: '1.1.1.1', family: 4 }) } + + // Really make sure that all test cases - at least, the ones that resolve an actual hostname - + // have a corresponding DNS mock as well (on top of the HTTP mock). + callback(new Error('Could not resolve host')) } diff --git a/index.js b/index.js index 2512245..2375ab7 100644 --- a/index.js +++ b/index.js @@ -29,13 +29,34 @@ const protect = async options => { if (!ALLOWED_PROTOCOLS.includes(options.url.protocol)) throw new Error('Invalid protocol!') - // Another layer of protection against SSRF - ensure we're not hitting internal services - const { address } = await lookup(options.url.hostname) + // Check if the hostname is an IP address - we don't need to "lookup" IP addresses! + let IP + const { hostname } = options.url + + if (ip.IPv4.isIPv4(hostname)) { + IP = hostname + } else if ( + // Per https://url.spec.whatwg.org/#host-parsing, + // if the hostname starts with a [, we need to check if it ends with a ], and is an IPv6. + hostname.startsWith('[') && + hostname.endsWith(']') && + ip.IPv6.isIPv6(hostname.slice(1, -1)) // strip the first and last characters - the brackets + ) { + IP = hostname.slice(1, -1) + } else { + // A regular hostname - we need to do a DNS lookup to get the IP address + const { address } = await lookup(hostname) + IP = address + } + + // Another layer of protection against SSRF - ensure we're not hitting internal services. // Try to match "reserved" IP ranges: https://en.wikipedia.org/wiki/Reserved_IP_addresses // https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html#case-2-application-can-send-requests-to-any-external-ip-address-or-domain-name // The function returns 'unicast' or the name of the reserved IP range, should it match any. // This in effect blocks all private IP Range: https://git.io/JWy3u, https://git.io/JWy3b - if (ip.parse(address).range() !== 'unicast') + // We use ip.process() here to deal with potentially IPv4-mapped IPv6 addresses (which will show up as "ipv4mapped" + // and not the whatever range the actual IPv4 address actually belongs to). + if (ip.process(IP).range() !== 'unicast') throw new Error('The IP of the domain is reserved!') } diff --git a/index.test.js b/index.test.js index a7239da..440b1e2 100644 --- a/index.test.js +++ b/index.test.js @@ -33,4 +33,53 @@ describe('got-ssrf', () => { ) ).rejects.toThrow('The IP of the domain is reserved!') }) + + // NOTE: for IP address tests, any valid IP address will be processed directly in the code, + // without the need for a DNS lookup (after all, you do a DNS lookup to get the IP address). + // Therefore, we do not need DNS mocks (__mocks__/dns.js) for the tests below. + + it('handles IPv4 addresses', async () => { + // A private IPv4 address + await expect(gotSsrf('http://192.168.0.1')).rejects.toThrow( + 'The IP of the domain is reserved!' + ) + + // A public IPv4 address + nock('http://1.1.1.1').get('/').reply(200) + await gotSsrf('http://1.1.1.1') + }) + + it('handles IPv6 addresses', async () => { + // This is 127.0.0.1 mapped to IPv6 + await expect(gotSsrf('http://[::ffff:7f00:1]:1338/hello')).rejects.toThrow( + 'The IP of the domain is reserved!' + ) + + // A public IPv4 address (1.1.1.1) mapped to IPv6 + nock('http://[::ffff:101:101]').get('/').reply(200) + await gotSsrf('http://[::ffff:101:101]') + + // A public IPv6 address + nock('http://[2606:2800:220:1:248:1893:25c8:1946]').get('/').reply(200) + await gotSsrf('http://[2606:2800:220:1:248:1893:25c8:1946]') + + // A private IPv6 address + await expect(gotSsrf('http://[fe80::ffff:ffff:ffff:ffff]')).rejects.toThrow( + 'The IP of the domain is reserved!' + ) + }) + + it('handles hostnames with brackets in it', async () => { + await expect(gotSsrf('http://[hostname1.com')).rejects.toThrow( + 'Invalid URL' + ) + + await expect(gotSsrf('http://[hostnam]e2.com')).rejects.toThrow( + 'Invalid URL' + ) + + await expect( + gotSsrf('http://[2606:2800:220:1:248:1893:25c8:g]') + ).rejects.toThrow('Invalid URL') + }) })