Comparing version 1.3.5 to 2.0.0
@@ -10,4 +10,2 @@ import { lookup as nativeCallbackLookup } from 'dns' | ||
const ALLOWED_PROTOCOLS = ['http:', 'https:'] | ||
// Assume all URLs are properly formed by the time it hits the hooks | ||
@@ -30,4 +28,4 @@ const protect = async options => { | ||
// that are within the same network but is not intended to be reached by the user. | ||
if (!ALLOWED_PROTOCOLS.includes(options.url.protocol)) | ||
throw new Error('Invalid protocol!') | ||
// This is done automatically by got, so we don't need to do anything here: | ||
// https://github.com/sindresorhus/got/blob/8f77e8d07d8684cde95d351feafaa308b466dff4/source/core/options.ts#L1411 | ||
@@ -34,0 +32,0 @@ // Check if the hostname is an IP address - we don't need to "lookup" IP addresses! |
import { expect, describe, it, jest } from '@jest/globals' | ||
import { promisify } from 'util' | ||
import nock from 'nock' | ||
import CacheableLookup from 'cacheable-lookup' | ||
@@ -14,80 +16,190 @@ // We can directly mock the "import { lookup } from 'dns'" call in index.js with jest. | ||
// Whether you pass in dnsCache: true, or an *instance* of CacheableLookup, | ||
// the dnsCache.lookupAsync being used is from an instance of the CacheableLookup class, | ||
// so we don't have to separately test the { dnsCache: true } case. | ||
// We just need to ensure that this library works correctly with a dnsCache.lookupAsync. | ||
const dnsCache = new CacheableLookup() | ||
// Also, for some reason trying to pass a mocked resolver with a resolve4() and a resolve6() | ||
// that always throws ENOTFOUND doesn't work (i.e. it doesn't use the `lookup` we pass to the options). | ||
// So we just directly mock lookupAsync. | ||
jest | ||
.spyOn(dnsCache, 'lookupAsync') | ||
.mockImplementation(promisify(mockDnsModule.lookup)) | ||
const setups = [ | ||
{ | ||
title: 'dnsCache', | ||
options: { | ||
dnsCache | ||
} | ||
}, | ||
{ | ||
title: 'dnsLookup', | ||
options: { | ||
dnsLookup: mockDnsModule.lookup | ||
} | ||
}, | ||
{ | ||
title: 'native dns', | ||
options: {} | ||
} | ||
] | ||
describe('got-ssrf', () => { | ||
it('works for public address', async () => { | ||
nock('http://public-url.com').get('/').reply(200) | ||
await gotSsrf('http://public-url.com/') | ||
}) | ||
describe.each(setups)('w/ $title', ({ options }) => { | ||
const got = gotSsrf.extend(options) | ||
it('throws for reserved addresses', async () => { | ||
nock('http://private-url.com').get('/').reply(200) | ||
await expect(gotSsrf('http://private-url.com/')).rejects.toThrow( | ||
'The IP of the domain is reserved!' | ||
) | ||
}) | ||
it('rejects non-http(s) protocols', async () => { | ||
await expect(got('ftp://example.com')).rejects.toThrow( | ||
'Unsupported protocol: ftp' | ||
) | ||
it('checks every redirect', async () => { | ||
nock( | ||
'http://public-url-that-redirects-to-private-url-that-redirects-to-public-url.com' | ||
) | ||
.get('/') | ||
.reply(301, 'Moved', { Location: 'http://private-url.com/' }) | ||
nock('http://private-url.com') | ||
.get('/') | ||
.reply(301, 'Moved', { Location: 'http://public-url.com/' }) | ||
nock('http://public-url.com').get('/').reply(200) | ||
await expect( | ||
gotSsrf( | ||
await expect(got('http2://example.com')).rejects.toThrow( | ||
'Unsupported protocol: http2' | ||
) | ||
await expect(got('file:///etc/passwd')).rejects.toThrow( | ||
'Unsupported protocol: file' | ||
) | ||
// You *need* to specify the protocol | ||
await expect(got('example.com')).rejects.toThrow('Invalid URL') | ||
}) | ||
it('works for public address', async () => { | ||
nock('http://public-url.com').get('/').reply(200) | ||
await got('http://public-url.com/') | ||
}) | ||
it('throws for reserved addresses', async () => { | ||
nock('http://private-url.com').get('/').reply(200) | ||
await expect(got('http://private-url.com/')).rejects.toThrow( | ||
'The IP of the domain is reserved!' | ||
) | ||
}) | ||
it('checks every redirect', async () => { | ||
// Basically, we prevent "smuggling" internal endpoints from a public hostname | ||
// by checking the URL before every redirect. | ||
// In this example, the seemingly public URL redirects to private-url.com, | ||
// so even though the private-url.com ultimately redirects the URL to a public one, | ||
// we must still reject this request! | ||
nock( | ||
'http://public-url-that-redirects-to-private-url-that-redirects-to-public-url.com' | ||
) | ||
).rejects.toThrow('The IP of the domain is reserved!') | ||
}) | ||
.get('/') | ||
.reply(301, 'Moved', { Location: 'http://private-url.com/' }) | ||
nock('http://private-url.com') | ||
.get('/') | ||
.reply(301, 'Moved', { Location: 'http://public-url.com/' }) | ||
nock('http://public-url.com').get('/').reply(200) | ||
await expect( | ||
got( | ||
'http://public-url-that-redirects-to-private-url-that-redirects-to-public-url.com' | ||
) | ||
).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 weird URLs/edge cases', async () => { | ||
await expect(got('http://public-url.com.')).rejects.toThrow( | ||
'The IP of the domain is reserved!' | ||
) | ||
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!' | ||
) | ||
await expect(got('http://example.com:foo')).rejects.toThrow('Invalid URL') | ||
// A public IPv4 address | ||
nock('http://1.1.1.1').get('/').reply(200) | ||
await gotSsrf('http://1.1.1.1') | ||
}) | ||
// Below are trick cases from https://azeemba.com/posts/what-is-a-url.html#query-or-username | ||
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!' | ||
) | ||
// Based on the http://http://http://@http://http://?http://#http:// example. | ||
await expect( | ||
got('http://private://part2://@part3://part4://?part5://#part6://') | ||
).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]') | ||
// Query or Username? | ||
await expect(got('http://1.1.1.1 &@ 2.2.2.2# @3.3.3.3/')).rejects.toThrow( | ||
'Invalid URL' | ||
) | ||
// 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]') | ||
await expect(got('http://1.1.1.1&@127.0.0.1#@3.3.3.3/')).rejects.toThrow( | ||
'The IP of the domain is reserved!' | ||
) | ||
// A private IPv6 address | ||
await expect(gotSsrf('http://[fe80::ffff:ffff:ffff:ffff]')).rejects.toThrow( | ||
'The IP of the domain is reserved!' | ||
) | ||
}) | ||
// Port or Path? | ||
await expect(got('http://127.0.0.1:5000:80/')).rejects.toThrow( | ||
'Invalid URL' | ||
) | ||
it('handles hostnames with brackets in it', async () => { | ||
await expect(gotSsrf('http://[hostname1.com')).rejects.toThrow( | ||
'Invalid URL' | ||
) | ||
// Host confusion (see: https://daniel.haxx.se/blog/2021/04/19/curl-those-funny-ipv4-addresses/) | ||
await expect(got('http://127.0.1')).rejects.toThrow( | ||
'The IP of the domain is reserved!' // the first number assumed to be 8 bits, the next 8, then 16 | ||
) | ||
await expect(got('http://127.1')).rejects.toThrow( | ||
'The IP of the domain is reserved!' // the first number assumed to be 8 bits, the next one 24 | ||
) | ||
await expect(got('http://2130706433')).rejects.toThrow( | ||
'The IP of the domain is reserved!' // 32-bit number converted as IPv4 addresses | ||
) | ||
await expect(got('http://0300.0250.0.01')).rejects.toThrow( | ||
'The IP of the domain is reserved!' // zero-prefix = octal number -> converted to 192.168.0.1 | ||
) | ||
await expect(got('http://0xc0.0xa8.0x00.0x01')).rejects.toThrow( | ||
'The IP of the domain is reserved!' // same deal, but octal | ||
) | ||
await expect(gotSsrf('http://[hostnam]e2.com')).rejects.toThrow( | ||
'Invalid URL' | ||
) | ||
// Other weird hostnames | ||
await expect(got('http://example.com%2F10.0.0.1/')).rejects.toThrow( | ||
'Invalid URL' | ||
) | ||
}) | ||
await expect( | ||
gotSsrf('http://[2606:2800:220:1:248:1893:25c8:g]') | ||
).rejects.toThrow('Invalid URL') | ||
// 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(got('http://192.168.0.1')).rejects.toThrow( | ||
'The IP of the domain is reserved!' | ||
) | ||
// Commonly used for metadata services in cloud environments | ||
await expect(got('http://169.254.169.254')).rejects.toThrow( | ||
'The IP of the domain is reserved!' | ||
) | ||
// A public IPv4 address | ||
nock('http://1.1.1.1').get('/').reply(200) | ||
await got('http://1.1.1.1') | ||
}) | ||
it('handles IPv6 addresses', async () => { | ||
// This is 127.0.0.1 mapped to IPv6 | ||
await expect(got('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 got('http://[::ffff:101:101]') | ||
// A public IPv6 address | ||
nock('http://[2606:2800:220:1:248:1893:25c8:1946]').get('/').reply(200) | ||
await got('http://[2606:2800:220:1:248:1893:25c8:1946]') | ||
// A private IPv6 address | ||
await expect(got('http://[::1]')).rejects.toThrow( | ||
'The IP of the domain is reserved!' | ||
) | ||
}) | ||
it('handles hostnames with brackets in it', async () => { | ||
await expect(got('http://[hostname1.com')).rejects.toThrow('Invalid URL') | ||
await expect(got('http://[hostnam]e2.com')).rejects.toThrow('Invalid URL') | ||
await expect( | ||
got('http://[2606:2800:220:1:248:1893:25c8:g]') | ||
).rejects.toThrow('Invalid URL') | ||
}) | ||
}) | ||
}) |
{ | ||
"name": "got-ssrf", | ||
"version": "1.3.5", | ||
"version": "2.0.0", | ||
"description": "Protect Got requests from SSRF", | ||
@@ -11,3 +11,3 @@ "type": "module", | ||
"engines": { | ||
"node": ">=14" | ||
"node": ">=16" | ||
}, | ||
@@ -23,3 +23,3 @@ "scripts": { | ||
"debug": "^4.3.2", | ||
"got": "^12.0.0", | ||
"got": "^13.0.0", | ||
"ipaddr.js": "^2.0.1" | ||
@@ -30,2 +30,3 @@ }, | ||
"@janejeon/prettier-config": "^1.1.0", | ||
"cacheable-lookup": "^7.0.0", | ||
"husky": "^8.0.1", | ||
@@ -32,0 +33,0 @@ "jest": "^29.5.0", |
<h1 align="center">Welcome to got-ssrf 👋</h1> | ||
[![CircleCI](https://circleci.com/gh/hanover-computing/got-ssrf/tree/master.svg?style=shield)](https://circleci.com/gh/hanover-computing/got-ssrf/tree/master) | ||
[![GitHub Actions](https://github.com/hanover-computing/got-ssrf/actions/workflows/ci.yml/badge.svg)](https://github.com/hanover-computing/got-ssrf/actions/workflows/ci.yml) | ||
[![codecov](https://codecov.io/gh/hanover-computing/got-ssrf/branch/master/graph/badge.svg)](https://codecov.io/gh/hanover-computing/got-ssrf) | ||
@@ -26,3 +27,3 @@ [![Version](https://img.shields.io/npm/v/got-ssrf)](https://www.npmjs.com/package/got-ssrf) | ||
Note that this package is ESM-only; see https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c for what to do if you're using CJS (i.e. `require()`). | ||
> Note that this package is ESM-only; see https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c for what to do if you're using CJS (i.e. `require()`). | ||
@@ -45,2 +46,10 @@ ```js | ||
### Security | ||
This library is tested against a whole host of weird edge cases (a URL is not as straightforward as it seems). To see what behaviours are expected, please see the test suite. | ||
As this library doesn't parse the URLs itself (but rather relies on got, which relies on the node `URL` module), a good rule of thumb is that whatever you'd expect from the node `URL` module, you can expect of this library as well. | ||
If you want to disallow "weird" URLs (and trust me, there are _many_), as people may try to 'smuggle' hostnames in them (and cause SSRF that may not be caught by the `URL` module), you'll need to do an input validation of the URL (and reject the "weird" ones) _before_ passing it into got/got-ssrf. | ||
## Run tests | ||
@@ -54,3 +63,3 @@ | ||
👤 **Jane Jeon <me@janejeon.dev>** | ||
👤 **Jane Jeon <git@janejeon.com>** | ||
@@ -70,5 +79,5 @@ - Website: janejeon.dev | ||
Copyright © 2022 [Jane Jeon <me@janejeon.dev>](https://github.com/JaneJeon).<br /> | ||
Copyright © 2023 [Jane Jeon <git@janejeon.com>](https://github.com/JaneJeon).<br /> | ||
This project is [LGPL-3.0](https://github.com/JaneJeon/got-csrf/blob/master/LICENSE) licensed. | ||
TL;DR: you are free to import and use this library "as-is" in your code, without needing to make your code source-available or to license it under the same license as this library; however, if you do change this library and you distribute it (directly or as part of your code consuming this library), please do contribute back any improvements for this library and this library alone. |
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
82483
20
793
80
11
2
+ Addedgot@13.0.0(transitive)
- Removedgot@12.6.1(transitive)
Updatedgot@^13.0.0