Skip to content

Commit

Permalink
add extensive test and documentation (closes #69)
Browse files Browse the repository at this point in the history
  • Loading branch information
JaneJeon committed May 22, 2023
1 parent e26701b commit 1566344
Show file tree
Hide file tree
Showing 4 changed files with 103 additions and 10 deletions.
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ npm i got-ssrf

## Usage

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()`).
```js
import { gotSsrf } from 'got-ssrf'
Expand All @@ -41,6 +41,14 @@ import { gotInstance } from 'some-other-got-plugin'
const merged = got.extend(gotSsrf, gotInstance)
```

### 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

```sh
Expand Down
15 changes: 11 additions & 4 deletions __mocks__/dns.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
// A fucking workaround because jest can't fucking mock builtin modules in ESM mode mother fuck

// We use the hostname lookups to define how got should behave when visiting a URL.
// For URLs that are supposed to be public, we return a public IP address.
// For URLs that are supposed to be private, we return a private IP address.
// This, *in conjunction with* the HTTP request mocking (the `nock` module),
// defines the entire behaviour of what a request looks like.
export function lookup(hostname, options, callback) {
if (callback === undefined) {
callback = options
Expand All @@ -11,7 +14,11 @@ export function lookup(hostname, options, callback) {
callback(undefined, { address: '1.1.1.1', family: 4 }) // something I know is public
}

if (hostname === 'private-url.com') {
if (
hostname === 'private-url.com' ||
hostname === 'public-url.com.' ||
hostname === 'private'
) {
callback(undefined, { address: '192.168.0.1', family: 4 }) // should be caught by SSRF protection
}

Expand All @@ -24,5 +31,5 @@ export function lookup(hostname, options, callback) {

// 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'))
callback(new Error('DNS lookup was not mocked'))
}
6 changes: 2 additions & 4 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import debugGen from 'debug'
const debug = debugGen('got-ssrf')
const nativeLookup = promisify(nativeCallbackLookup) // importing straight from dns/promises limits node.js version to 15 or higher

const ALLOWED_PROTOCOLS = ['http:', 'https:']

// Assume all URLs are properly formed by the time it hits the hooks
const protect = async options => {
let lookup
Expand All @@ -26,8 +24,8 @@ const protect = async options => {
// To prevent Server Side Request Forgery, we need to check the protocol.
// Otherwise, you could end up making requests to internal services (e.g. the database)
// 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

// Check if the hostname is an IP address - we don't need to "lookup" IP addresses!
let IP
Expand Down
82 changes: 81 additions & 1 deletion index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,23 @@ const { gotSsrf } = await import('./index.js')
nock.disableNetConnect()

describe('got-ssrf', () => {
it('rejects non-http(s) protocols', async () => {
await expect(gotSsrf('ftp://example.com')).rejects.toThrow(
'Unsupported protocol: ftp'
)

await expect(gotSsrf('http2://example.com')).rejects.toThrow(
'Unsupported protocol: http2'
)

await expect(gotSsrf('file:///etc/passwd')).rejects.toThrow(
'Unsupported protocol: file'
)

// You *need* to specify the protocol
await expect(gotSsrf('example.com')).rejects.toThrow('Invalid URL')
})

it('works for public address', async () => {
nock('http://public-url.com').get('/').reply(200)
await gotSsrf('http://public-url.com/')
Expand All @@ -25,6 +42,11 @@ describe('got-ssrf', () => {
})

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'
)
Expand All @@ -41,6 +63,59 @@ describe('got-ssrf', () => {
).rejects.toThrow('The IP of the domain is reserved!')
})

it('handles weird URLs/edge cases', async () => {
await expect(gotSsrf('http://public-url.com.')).rejects.toThrow(
'The IP of the domain is reserved!'
)

await expect(gotSsrf('http://example.com:foo')).rejects.toThrow(
'Invalid URL'
)

// Below are trick cases from https://azeemba.com/posts/what-is-a-url.html#query-or-username

// Based on the http://http://http://@http://http://?http://#http:// example.
await expect(
gotSsrf('http://private://part2://@part3://part4://?part5://#part6://')
).rejects.toThrow('The IP of the domain is reserved!')

// Query or Username?
await expect(
gotSsrf('http://1.1.1.1 &@ 2.2.2.2# @3.3.3.3/')
).rejects.toThrow('Invalid URL')

await expect(
gotSsrf('http://1.1.1.1&@127.0.0.1#@3.3.3.3/')
).rejects.toThrow('The IP of the domain is reserved!')

// Port or Path?
await expect(gotSsrf('http://127.0.0.1:5000:80/')).rejects.toThrow(
'Invalid URL'
)

// Host confusion (see: https://daniel.haxx.se/blog/2021/04/19/curl-those-funny-ipv4-addresses/)
await expect(gotSsrf('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(gotSsrf('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(gotSsrf('http://2130706433')).rejects.toThrow(
'The IP of the domain is reserved!' // 32-bit number converted as IPv4 addresses
)
await expect(gotSsrf('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(gotSsrf('http://0xc0.0xa8.0x00.0x01')).rejects.toThrow(
'The IP of the domain is reserved!' // same deal, but octal
)

// Other weird hostnames
await expect(gotSsrf('http://example.com%2F10.0.0.1/')).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.
Expand All @@ -51,6 +126,11 @@ describe('got-ssrf', () => {
'The IP of the domain is reserved!'
)

// Commonly used for metadata services in cloud environments
await expect(gotSsrf('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 gotSsrf('http://1.1.1.1')
Expand All @@ -71,7 +151,7 @@ describe('got-ssrf', () => {
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(
await expect(gotSsrf('http://[::1]')).rejects.toThrow(
'The IP of the domain is reserved!'
)
})
Expand Down

0 comments on commit 1566344

Please sign in to comment.