diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d152092 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +indent_style = space +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..37c288c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.travis.yml export-ignore +/README.md export-ignore +/phpunit.xml export-ignore +/tests export-ignore +/example export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aadd369 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +vendor +composer.lock +build diff --git a/.scrutinizer.yml b/.scrutinizer.yml new file mode 100644 index 0000000..e790b83 --- /dev/null +++ b/.scrutinizer.yml @@ -0,0 +1,9 @@ +tools: + external_code_coverage: + timeout: 600 + +filter: + excluded_paths: + - 'example/*' + - 'tests/*' + - '*Test.php' diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..a1d1b18 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,33 @@ +language: php + +php: + - 5.3.3 + - 5.3 + - 5.4 + - 5.5 + - 5.6 + - 7.0 + - hhvm + +# cache vendor dirs +cache: + directories: + - vendor + - $HOME/.composer/cache + +# faster builds on new travis setup not using sudo +sudo: false + +install: + - composer self-update + +before_script: + - composer install --prefer-dist --no-interaction + +script: + - ./vendor/bin/phpunit --coverage-clover=coverage.clover + +after_script: + - | + wget https://scrutinizer-ci.com/ocular.phar + php ocular.phar code-coverage:upload --format=php-clover coverage.clover diff --git a/README.md b/README.md index 631bb62..5305e8e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # SafeCurl +[![Build Status](https://travis-ci.org/j0k3r/safecurl.svg?branch=master)](https://travis-ci.org/j0k3r/safecurl) +[![Code Coverage](https://scrutinizer-ci.com/g/j0k3r/safecurl/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/j0k3r/safecurl/?branch=master) +[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/j0k3r/safecurl/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/j0k3r/safecurl/?branch=master) + SafeCurl intends to be a drop-in replacement for the [curl_exec](http://php.net/manual/en/function.curl-exec.php) function in PHP. SafeCurl validates each part of the URL against a white or black list, to help protect against Server-Side Request Forgery attacks. For more infomation about the project see the blog post ['SafeCurl: SSRF Protection, and a "Capture the Bitcoins"'](http://blog.fin1te.net/post/86235998757/safecurl-ssrf-protection-and-a-capture-the-bitcoins). @@ -15,9 +19,9 @@ If you chose to enable "FOLLOWLOCATION", then any redirects are caught, and re-v SafeCurl can be included in any PHP project using [Composer](https://getcomposer.org). Include the following in your `composer.json` file under `require`. ``` - "require": { - "fin1te\safecurl": "~1" - } +"require": { + "fin1te\safecurl": "~1" +} ``` Then update Composer. @@ -28,7 +32,7 @@ composer update ## Usage -It's as easy as replacing `curl_exec` with `SafeCurl::execute`, and wrapping it in a `try {} catch {}` block. +It's as easy as replacing `curl_exec` and wrapping it in a `try {} catch {}` block. ```php use fin1te\SafeCurl\SafeCurl; @@ -38,11 +42,13 @@ try { $url = 'http://www.google.com'; $curlHandle = curl_init(); + //Your usual cURL options - curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (SafeCurl)'); + curl_setopt($curlHandle, CURLOPT_USERAGENT, 'Mozilla/5.0 (SafeCurl)'); //Execute using SafeCurl - $response = SafeCurl::execute($url, $curlHandle); + $safeCurl = new SafeCurl($curlHandle); + $response = $safeCurl->execute($url); } catch (Exception $e) { //URL wasn't safe } @@ -56,17 +62,22 @@ If you wish to add your own options (such as to blacklist any requests to domain Domains are express using regex syntax, whilst IPs, scheme and ports are standard strings (IPs can be specified in [CIDR notation](https://en.wikipedia.org/wiki/Cidr)). ```php +use fin1te\SafeCurl\SafeCurl; use fin1te\SafeCurl\Options; $options = new Options(); $options->addToList('blacklist', 'domain', '(.*)\.fin1te\.net'); $options->addToList('whitelist', 'scheme', 'ftp'); +$curlHandle = curl_init(); + //This will now throw an InvalidDomainException -$response = SafeCurl::execute('http://safecurl.fin1te.net', $curlHandle, $options); +$safeCurl = new SafeCurl($curlHandle, $options); +$response = $safeCurl->execute('http://safecurl.fin1te.net'); //Whilst this will be allowed, and return the response -$response = SafeCurl::execute('ftp://fin1te.net', $curlHandle, $options); +$safeCurl = new SafeCurl($curlHandle, $options); +$response = $safeCurl->execute('ftp://fin1te.net'); ``` Since we can't get access to any already set cURL options (see Caveats section), to enable `CURL_FOLLOWREDIRECTS` you must call the `enableFollowRedirects()` method. If you wish to specify a redirect limit, you will need to call `setMaxRedirects()`. Passing in `0` will allow infinite redirects. @@ -74,6 +85,7 @@ Since we can't get access to any already set cURL options (see Caveats section), ```php $options = new Options(); $options->enableFollowLocation(); + //Abort after 10 redirects $options->setFollowLocationLimit(10); ``` @@ -84,6 +96,7 @@ The URL checking methods are also public, meaning that you can validate a URL be ```php use fin1te\SafeCurl\Url; +use fin1te\SafeCurl\Exception; try { $url = 'http://www.google.com'; @@ -95,7 +108,6 @@ try { } ``` - #### Optional Protections In addition to the standard checks, two more are available. @@ -110,24 +122,21 @@ $options->enablePinDns(); The second disables the use of credentials in a URL, since PHP's `parse_url` returns values which differ from ones cURL uses. This is a temporary fix. ```php +use fin1te\SafeCurl\SafeCurl; +use fin1te\SafeCurl\Exception; +use fin1te\SafeCurl\Options; + $options = new Options(); $options->disableSendCredentials(); +$curlHandle = curl_init(); + //This will throw an InvalidURLException -$response = SafeCurl::execute('http://user:pass@google.com', $curlHandle, $options); +$safeCurl = new SafeCurl($curlHandle, $options); +$response = $safeCurl->execute('http://user:pass@google.com'); ``` #### Cavets -Since SafeCurl uses `getaddrbyhostl` to resolve domain names, which isn't IPv6 compatible, the class will only work with IPv4 at the moment. See [Issue #1](https://github.com/fin1te/safecurl/issues/1). +Since SafeCurl uses `gethostbynamel` to resolve domain names, which isn't IPv6 compatible, the class will only work with IPv4 at the moment. See [Issue #1](https://github.com/fin1te/safecurl/issues/1). As mentioned above, we can't fetch the value of any cURL options set against the provided cURL handle. Because SafeCurl handles redirects itself, it will turn off `CURLOPT_FOLLOWLOCATION` and use the value from the `Options` object. This is also true of `CURLOPT_MAXREDIRECTS`. - -## Demo - -A live demo is available at [http://safecurl.fin1te.net/#demo](http://safecurl.fin1te.net/#demo). For the site source code (if you're curious), it's hosted at [fin1te/safecurl.fin1te.net](https://github.com/fin1te/safecurl.fin1te.net). - -## Bounty - -In order to help make SafeCurl secure and ready for production use, [a Bitcoin bounty](http://safecurl.fin1te.net/#bounty) has been setup. - -Inside the document root is a [Bitcoin wallet](http://safecurl.fin1te.net/btc.txt), which is only accessible by 127.0.0.1. If you can bypass the protections and grab the file, you're free to take the Bitcoins. diff --git a/composer.json b/composer.json index 1c3eced..0822bc6 100644 --- a/composer.json +++ b/composer.json @@ -15,11 +15,15 @@ } ], "require": { - "php": ">=5.3.0" + "php": ">=5.3.3", + "ext-curl": "*" + }, + "require-dev": { + "phpunit/phpunit": "^4.0.0" }, "autoload": { - "psr-0": { - "fin1te\\SafeCurl": "src/" + "psr-4": { + "fin1te\\SafeCurl\\": "src/" } } } diff --git a/example/default.php b/example/default.php index 1daf542..c32f344 100644 --- a/example/default.php +++ b/example/default.php @@ -9,8 +9,8 @@ use fin1te\SafeCurl\SafeCurl; try { - $curlHandle = curl_init(); - $result = SafeCurl::execute('https://fin1te.net', $curlHandle); + $safeCurl = new SafeCurl(curl_init()); + $result = $safeCurl->execute('https://fin1te.net'); } catch (Exception $e) { //Handle exception } diff --git a/example/options.php b/example/options.php index ba7cb35..a108f1d 100644 --- a/example/options.php +++ b/example/options.php @@ -10,8 +10,6 @@ use fin1te\SafeCurl\Options; try { - $curlHandle = curl_init(); - $options = new Options(); //Completely clear the whitelist $options->setList('whitelist', []); @@ -20,7 +18,8 @@ //Set the domain whitelist only $options->setList('whitelist', ['google.com', 'youtube.com'], 'domain'); - $result = SafeCurl::execute('http://www.youtube.com', $curlHandle); + $safeCurl = new SafeCurl(curl_init()); + $result = $safeCurl->execute('http://www.youtube.com'); } catch (Exception $e) { //Handle exception } diff --git a/example/redirects.php b/example/redirects.php index df025b3..5981a2c 100644 --- a/example/redirects.php +++ b/example/redirects.php @@ -10,13 +10,12 @@ use fin1te\SafeCurl\Options; try { - $curlHandle = curl_init(); - $options = new Options(); //Follow redirects, but limit to 10 $options->enableFollowLocation()->setFollowLocationLimit(10); - $result = SafeCurl::execute('http://fin1te.net', $curlHandle); + $safeCurl = new SafeCurl(curl_init()); + $result = $safeCurl->execute('http://fin1te.net'); } catch (Exception $e) { //Handle exception } diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..9afd887 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,28 @@ + + + + + ./tests/ + + + + + + ./src/ + + + + + + + diff --git a/src/Exception.php b/src/Exception.php new file mode 100644 index 0000000..4279f90 --- /dev/null +++ b/src/Exception.php @@ -0,0 +1,7 @@ + array(), + 'port' => array('80', '443', '8080'), + 'domain' => array(), + 'scheme' => array('http', 'https'), + ); + + /** + * @var array + */ + private $blacklist = array( + 'ip' => array( + '0.0.0.0/8', + '10.0.0.0/8', + '100.64.0.0/10', + '127.0.0.0/8', + '169.254.0.0/16', + '172.16.0.0/12', + '192.0.0.0/29', + '192.0.2.0/24', + '192.88.99.0/24', + '192.168.0.0/16', + '198.18.0.0/15', + '198.51.100.0/24', + '203.0.113.0/24', + '224.0.0.0/4', + '240.0.0.0/4', + ), + 'port' => array(), + 'domain' => array(), + 'scheme' => array(), + ); + + public function __construct() + { + } + + /** + * Get followLocation. + * + * @return bool + */ + public function getFollowLocation() + { + return $this->followLocation; + } + + /** + * Enables following redirects. + * + * @return Options + */ + public function enableFollowLocation() + { + $this->followLocation = true; + + return $this; + } + + /** + * Disables following redirects. + * + * @return Options + */ + public function disableFollowLocation() + { + $this->followLocation = false; + + return $this; + } + + /** + * Gets the follow location limit + * 0 is no limit (infinite). + * + * @return int + */ + public function getFollowLocationLimit() + { + return $this->followLocationLimit; + } + + /** + * Sets the follow location limit + * 0 is no limit (infinite). + * + * @param $limit int + * + * @return Options + */ + public function setFollowLocationLimit($limit) + { + if (!is_numeric($limit) || $limit < 0) { + throw new InvalidOptionException('Provided limit "'.$limit.'" must be an integer >= 0'); + } + + $this->followLocationLimit = (int) $limit; + + return $this; + } + + /** + * Get send credentials option. + * + * @return bool + */ + public function getSendCredentials() + { + return $this->sendCredentials; + } + + /** + * Enable sending of credenitals + * This is potentially a security risk. + * + * @return Options + */ + public function enableSendCredentials() + { + $this->sendCredentials = true; + + return $this; + } + + /** + * Disable sending of credentials. + * + * @return Options + */ + public function disableSendCredentials() + { + $this->sendCredentials = false; + + return $this; + } + + /** + * Get pin DNS option. + * + * @return bool + */ + public function getPinDns() + { + return $this->pinDns; + } + + /** + * Enable DNS pinning. + * + * @return Options + */ + public function enablePinDns() + { + $this->pinDns = true; + + return $this; + } + + /** + * Disable DNS pinning. + * + * @return Options + */ + public function disablePinDns() + { + $this->pinDns = false; + + return $this; + } + + /** + * Checks if a specific value is in a list. + * + * @param $list string + * @param $type string + * @param $values string + * + * @return bool + */ + public function isInList($list, $type, $value) + { + if (!in_array($list, array('whitelist', 'blacklist'))) { + throw new InvalidOptionException('Provided list "'.$list.'" must be "whitelist" or "blacklist"'); + } + + if (!array_key_exists($type, $this->$list)) { + throw new InvalidOptionException('Provided type "'.$type.'" must be "ip", "port", "domain" or "scheme"'); + } + + if (empty($this->{$list}[$type])) { + if ($list == 'whitelist') { + //Whitelist will return true + return true; + } + + //Blacklist returns false + return false; + } + + //For domains, a regex match is needed + if ($type == 'domain') { + foreach ($this->{$list}[$type] as $domain) { + if (preg_match('/^'.$domain.'$/i', $value)) { + return true; + } + } + + return false; + } + + return in_array($value, $this->{$list}[$type]); + } + + /** + * Returns a specific list. + * + * @param $list string + * @param $type string optional + * + * @return array + */ + public function getList($list, $type = null) + { + if (!in_array($list, array('whitelist', 'blacklist'))) { + throw new InvalidOptionException('Provided list "'.$list.'" must be "whitelist" or "blacklist"'); + } + + if ($type !== null) { + if (!array_key_exists($type, $this->$list)) { + throw new InvalidOptionException('Provided type "'.$type.'" must be "ip", "port", "domain" or "scheme"'); + } + + return $this->{$list}[$type]; + } + + return $this->{$list}; + } + + /** + * Sets a list to the passed in array. + * + * @param $list string + * @param $values array + * @param $type string optional + * + * @return Options + */ + public function setList($list, $values, $type = null) + { + if (!in_array($list, array('whitelist', 'blacklist'))) { + throw new InvalidOptionException('Provided list "'.$list.'" must be "whitelist" or "blacklist"'); + } + + if (!is_array($values)) { + throw new InvalidOptionException('Provided values must be an array, "'.gettype($values).'" given'); + } + + if ($type !== null) { + if (!array_key_exists($type, $this->$list)) { + throw new InvalidOptionException('Provided type "'.$type.'" must be "ip", "port", "domain" or "scheme"'); + } + + $this->{$list}[$type] = $values; + + return $this; + } + + foreach ($values as $type => $value) { + if (!in_array($type, array('ip', 'port', 'domain', 'scheme'))) { + throw new InvalidOptionException('Provided type "'.$type.'" must be "ip", "port", "domain" or "scheme"'); + } + + $this->{$list}[$type] = $value; + } + + return $this; + } + + /** + * Adds a value/values to a specific list. + * + * @param $list string + * @param $type string + * @param $values array|string + * + * @return Options + */ + public function addToList($list, $type, $values) + { + if (!in_array($list, array('whitelist', 'blacklist'))) { + throw new InvalidOptionException('Provided list "'.$list.'" must be "whitelist" or "blacklist"'); + } + + if (!array_key_exists($type, $this->$list)) { + throw new InvalidOptionException('Provided type "'.$type.'" must be "ip", "port", "domain" or "scheme"'); + } + + if (empty($values)) { + throw new InvalidOptionException('Provided values cannot be empty'); + } + + //Cast single values to an array + if (!is_array($values)) { + $values = array($values); + } + + foreach ($values as $value) { + if (!in_array($value, $this->{$list}[$type])) { + $this->{$list}[$type][] = $value; + } + } + + return $this; + } + + /** + * Removes a value/values from a specific list. + * + * @param $list string + * @param $type string + * @param $values array|string + * + * @return Options + */ + public function removeFromList($list, $type, $values) + { + if (!in_array($list, array('whitelist', 'blacklist'))) { + throw new InvalidOptionException('Provided list "'.$list.'" must be "whitelist" or "blacklist"'); + } + + if (!array_key_exists($type, $this->$list)) { + throw new InvalidOptionException('Provided type "'.$type.'" must be "ip", "port", "domain" or "scheme"'); + } + + if (empty($values)) { + throw new InvalidOptionException('Provided values cannot be empty'); + } + + //Cast single values to an array + if (!is_array($values)) { + $values = array($values); + } + + $this->{$list}[$type] = array_diff($this->{$list}[$type], $values); + + return $this; + } +} diff --git a/src/SafeCurl.php b/src/SafeCurl.php new file mode 100644 index 0000000..6c1c007 --- /dev/null +++ b/src/SafeCurl.php @@ -0,0 +1,177 @@ +setCurlHandle($curlHandle); + + if ($options === null) { + $options = new Options(); + } + + $this->setOptions($options); + $this->init(); + } + + /** + * Returns cURL handle. + * + * @return resource + */ + public function getCurlHandle() + { + return $this->curlHandle; + } + + /** + * Sets cURL handle. + * + * @param $curlHandle resource + */ + public function setCurlHandle($curlHandle) + { + if (!is_resource($curlHandle) || get_resource_type($curlHandle) != 'curl') { + //Need a valid cURL resource, throw exception + throw new Exception('SafeCurl expects a valid cURL resource - "'.gettype($curlHandle).'" provided.'); + } + + $this->curlHandle = $curlHandle; + } + + /** + * Gets Options. + * + * @return Options + */ + public function getOptions() + { + return $this->options; + } + + /** + * Sets Options. + * + * @param $options Options + */ + public function setOptions(Options $options) + { + $this->options = $options; + } + + /** + * Sets up cURL ready for executing. + */ + protected function init() + { + //To start with, disable FOLLOWLOCATION since we'll handle it + curl_setopt($this->curlHandle, CURLOPT_FOLLOWLOCATION, false); + + //Always return the transfer + curl_setopt($this->curlHandle, CURLOPT_RETURNTRANSFER, true); + + //Force IPv4, since this class isn't yet comptible with IPv6 + $curlVersion = curl_version(); + + if ($curlVersion['features'] & CURLOPT_IPRESOLVE) { + curl_setopt($this->curlHandle, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4); + } + } + + /** + * Exectutes a cURL request, whilst checking that the + * URL abides by our whitelists/blacklists. + * + * @param $url string + * + * @return bool + */ + public function execute($url) + { + $redirected = false; + $redirectCount = 0; + $redirectLimit = $this->getOptions()->getFollowLocationLimit(); + + do { + //Validate the URL + $url = Url::validateUrl($url, $this->getOptions()); + + if ($this->getOptions()->getPinDns()) { + //Send a Host header + curl_setopt($this->curlHandle, CURLOPT_HTTPHEADER, array('Host: '.$url['host'])); + //The "fake" URL + curl_setopt($this->curlHandle, CURLOPT_URL, $url['url']); + //We also have to disable SSL cert verfication, which is not great + //Might be possible to manually check the certificate ourselves? + curl_setopt($this->curlHandle, CURLOPT_SSL_VERIFYPEER, false); + } else { + curl_setopt($this->curlHandle, CURLOPT_URL, $url['url']); + } + + // in case of `CURLINFO_REDIRECT_URL` isn't defined + curl_setopt($this->curlHandle, CURLOPT_HEADER, true); + + //Execute the cURL request + $response = curl_exec($this->curlHandle); + + //Check for any errors + if (curl_errno($this->curlHandle)) { + throw new Exception('cURL Error: '.curl_error($this->curlHandle)); + } + + //Check for an HTTP redirect + if ($this->getOptions()->getFollowLocation()) { + $statusCode = curl_getinfo($this->curlHandle, CURLINFO_HTTP_CODE); + switch ($statusCode) { + case 301: + case 302: + case 303: + case 307: + case 308: + //Redirect received, so rinse and repeat + if ($redirectLimit == 0 || ++$redirectCount < $redirectLimit) { + // `CURLINFO_REDIRECT_URL` was introduced in 5.3.7 & it doesn't exist in HHVM + // use a custom solution is that both case + if (defined('CURLINFO_REDIRECT_URL')) { + $url = curl_getinfo($this->curlHandle, CURLINFO_REDIRECT_URL); + } else { + preg_match('/Location:(.*?)\n/i', $response, $matches); + $url = trim(array_pop($matches)); + } + + $redirected = true; + } else { + throw new Exception('Redirect limit "'.$redirectLimit.'" hit'); + } + break; + default: + $redirected = false; + } + } + } while ($redirected); + + return $response; + } +} diff --git a/src/fin1te/SafeCurl/Url.php b/src/Url.php similarity index 56% rename from src/fin1te/SafeCurl/Url.php rename to src/Url.php index 56d94d6..7ba4df2 100644 --- a/src/fin1te/SafeCurl/Url.php +++ b/src/Url.php @@ -1,4 +1,5 @@ getSendCredentials() && (array_key_exists('user', $parts) || array_key_exists('pass', $parts))) { - throw new InvalidURLException("Credentials passed in but 'sendCredentials' is set to false"); + throw new InvalidURLException('Credentials passed in but "sendCredentials" is set to false'); } - //First, validate the scheme + //First, validate the scheme if (array_key_exists('scheme', $parts)) { $parts['scheme'] = self::validateScheme($parts['scheme'], $options); } else { @@ -55,7 +58,7 @@ public static function validateUrl($url, Options $options) { if ($options->getPinDns()) { //Since we're pinning DNS, we replace the host in the URL //with an IP, then get cURL to send the Host header - $parts['host'] = $host['ips'][0]; + $parts['host'] = $host['ips'][0]; } else { //Not pinning DNS, so just use the host $parts['host'] = $host['host']; @@ -64,26 +67,32 @@ public static function validateUrl($url, Options $options) { //Rebuild the URL $url = self::buildUrl($parts); - return array('url' => $url, 'host' => $host['host'], 'ips' => $host['ips']); + return array( + 'url' => $url, + 'host' => $host['host'], + 'ips' => $host['ips'], + ); } /** - * Validates a URL scheme + * Validates a URL scheme. * * @param $scheme string - * @param $options fin1te\SafeCurl\Options + * @param $options Options * * @return string */ - public static function validateScheme($scheme, Options $options) { + public static function validateScheme($scheme, Options $options) + { + $scheme = strtolower($scheme); + //Whitelist always takes precedence over a blacklist if (!$options->isInList('whitelist', 'scheme', $scheme)) { - throw new InvalidSchemeException("Provided scheme '$scheme' doesn't match whitelisted values: " - . implode(', ', $options->getList('whitelist', 'scheme'))); + throw new InvalidSchemeException('Provided scheme "'.$scheme.'" doesn\'t match whitelisted values: '.implode(', ', $options->getList('whitelist', 'scheme'))); } if ($options->isInList('blacklist', 'scheme', $scheme)) { - throw new InvalidSchemeException("Provided scheme '$scheme' matches a blacklisted value"); + throw new InvalidSchemeException('Provided scheme "'.$scheme.'" matches a blacklisted value'); } //Existing value is fine @@ -91,21 +100,21 @@ public static function validateScheme($scheme, Options $options) { } /** - * Validates a port + * Validates a port. * * @param $port int - * @param $options fin1te\SafeCurl\Options + * @param $options Options * * @return int */ - public static function validatePort($port, Options $options) { + public static function validatePort($port, Options $options) + { if (!$options->isInList('whitelist', 'port', $port)) { - throw new InvalidPortException("Provided port '$port' doesn't match whitelisted values: " - . implode(', ', $options->getList('whitelist', 'port'))); + throw new InvalidPortException('Provided port "'.$port.'" doesn\'t match whitelisted values: '.implode(', ', $options->getList('whitelist', 'port'))); } if ($options->isInList('blacklist', 'port', $port)) { - throw new InvalidPortException("Provided port '$port' matches a blacklisted value"); + throw new InvalidPortException('Provided port "'.$port.'" matches a blacklisted value'); } //Existing value is fine @@ -113,28 +122,30 @@ public static function validatePort($port, Options $options) { } /** - * Validates a URL host + * Validates a URL host. * * @param $host string - * @param $options fin1te\SafeCurl\Options + * @param $options Options * * @returns string */ - public static function validateHost($host, Options $options) { + public static function validateHost($host, Options $options) + { + $host = strtolower($host); + //Check the host against the domain lists if (!$options->isInList('whitelist', 'domain', $host)) { - throw new InvalidDomainException("Provided host '$host' doesn't match whitelisted values: " - . implode(', ', $options->getList('whitelist', 'domain'))); + throw new InvalidDomainException('Provided host "'.$host.'" doesn\'t match whitelisted values: '.implode(', ', $options->getList('whitelist', 'domain'))); } if ($options->isInList('blacklist', 'domain', $host)) { - throw new InvalidDomainException("Provided host '$host' matches a blacklisted value"); + throw new InvalidDomainException('Provided host "'.$host.'" matches a blacklisted value'); } //Now resolve to an IP and check against the IP lists $ips = @gethostbynamel($host); if (empty($ips)) { - throw new InvalidDomainException("Provided host '$host' doesn't resolve to an IP address"); + throw new InvalidDomainException('Provided host "'.$host.'" doesn\'t resolve to an IP address'); } $whitelistedIps = $options->getList('whitelist', 'ip'); @@ -152,9 +163,7 @@ public static function validateHost($host, Options $options) { } if (!$valid) { - throw new InvalidIpException("Provided host '$host' resolves to '" . implode(', ', $ips) - . "', which doesn't match whitelisted values: " - . implode(', ', $whitelistedIps)); + throw new InvalidIpException('Provided host "'.$host.'" resolves to "'.implode(', ', $ips).'", which doesn\'t match whitelisted values: '.implode(', ', $whitelistedIps)); } } @@ -164,8 +173,7 @@ public static function validateHost($host, Options $options) { foreach ($blacklistedIps as $blacklistedIp) { foreach ($ips as $ip) { if (self::cidrMatch($ip, $blacklistedIp)) { - throw new InvalidIpException("Provided host '$host' resolves to '" . implode(', ', $ips) - . "', which matches a blacklisted value: " . $blacklistedIp); + throw new InvalidIpException('Provided host "'.$host.'" resolves to "'.implode(', ', $ips).'", which matches a blacklisted value: '.$blacklistedIp); } } } @@ -175,72 +183,48 @@ public static function validateHost($host, Options $options) { } /** - * Re-build a URL based on an array of parts + * Re-build a URL based on an array of parts. * * @param $parts array * * @return string */ - public static function buildUrl($parts) { - $url = ''; - - $url .= (!empty($parts['scheme'])) - ? $parts['scheme'] . '://' - : ''; - - $url .= (!empty($parts['user'])) - ? $parts['user'] - : ''; - - $url .= (!empty($parts['pass'])) - ? ':' . $parts['pass'] - : ''; + public static function buildUrl($parts) + { + $url = ''; + $url .= !empty($parts['scheme']) ? $parts['scheme'].'://' : ''; + $url .= !empty($parts['user']) ? $parts['user'] : ''; + $url .= !empty($parts['pass']) ? ':'.$parts['pass'] : ''; //If we have a user or pass, make sure to add an "@" - $url .= (!empty($parts['user']) || !empty($parts['pass'])) - ? '@' - : ''; - - $url .= (!empty($parts['host'])) - ? $parts['host'] - : ''; - - $url .= (!empty($parts['port'])) - ? ':' . $parts['port'] - : ''; - - $url .= (!empty($parts['path'])) - ? $parts['path'] - : ''; - - $url .= (!empty($parts['query'])) - ? '?' . $parts['query'] - : ''; - - $url .= (!empty($parts['fragment'])) - ? '#' . $parts['fragment'] - : ''; + $url .= !empty($parts['user']) || !empty($parts['pass']) ? '@' : ''; + $url .= !empty($parts['host']) ? $parts['host'] : ''; + $url .= !empty($parts['port']) ? ':'.$parts['port'] : ''; + $url .= !empty($parts['path']) ? $parts['path'] : ''; + $url .= !empty($parts['query']) ? '?'.$parts['query'] : ''; + $url .= !empty($parts['fragment']) ? '#'.$parts['fragment'] : ''; return $url; } /** * Checks a passed in IP against a CIDR. - * See http://stackoverflow.com/questions/594112/matching-an-ip-to-a-cidr-mask-in-php5 + * See http://stackoverflow.com/questions/594112/matching-an-ip-to-a-cidr-mask-in-php5. * * @param $ip string * @param $cidr string * * @return bool */ - public static function cidrMatch($ip, $cidr) { + public static function cidrMatch($ip, $cidr) + { if (strpos($cidr, '/') === false) { //It doesn't have a prefix, just a straight IP match return $ip == $cidr; } list($subnet, $mask) = explode('/', $cidr); - if ((ip2long($ip) & ~((1 << (32 - $mask)) - 1) ) == ip2long($subnet)) { + if ((ip2long($ip) & ~((1 << (32 - $mask)) - 1)) == ip2long($subnet)) { return true; } diff --git a/src/fin1te/SafeCurl/Exception.php b/src/fin1te/SafeCurl/Exception.php deleted file mode 100644 index 555e3a0..0000000 --- a/src/fin1te/SafeCurl/Exception.php +++ /dev/null @@ -1,4 +0,0 @@ - array(), - 'port' => array('80', '443', '8080'), - 'domain' => array(), - 'scheme' => array('http', 'https')); - - /** - * @var array - */ - private $blacklist = array('ip' => array('0.0.0.0/8', - '10.0.0.0/8', - '100.64.0.0/10', - '127.0.0.0/8', - '169.254.0.0/16', - '172.16.0.0/12', - '192.0.0.0/29', - '192.0.2.0/24', - '192.88.99.0/24', - '192.168.0.0/16', - '198.18.0.0/15', - '198.51.100.0/24', - '203.0.113.0/24', - '224.0.0.0/4', - '240.0.0.0/4'), - 'port' => array(), - 'domain' => array(), - 'scheme' => array()); - - /** - * @return fin1te\SafeCurl\Options - */ - public function __construct() { } - - /** - * Get followLocation - * - * @return bool - */ - public function getFollowLocation() { - return $this->followLocation; - } - - /** - * Enables following redirects - * - * @return fin1te\SafeCurl\Options - */ - public function enableFollowLocation() { - $this->followLocation = true; - - return $this; - } - - /** - * Disables following redirects - * - * @return fin1te\SafeCurl\Options - */ - public function disableFollowLocation() { - $this->followLocation = false; - - return $this; - } - - /** - * Gets the follow location limit - * 0 is no limit (infinite) - * - * @return int - */ - public function getFollowLocationLimit() { - return $this->followLocationLimit; - } - - /** - * Sets the follow location limit - * 0 is no limit (infinite) - * - * @param $limit int - * - * @return fin1te\SafeCurl\Options - */ - public function setFollowLocationLimit($limit) { - if (!is_numeric($limit) || $limit < 0) { - throw new InvalidOptionException("Provided limit '$limit' must be an integer >= 0"); - } - - $this->followLocationLimit = $limit; - - return $this; - } - - /** - * Get send credentials option - * - * @return bool - */ - public function getSendCredentials() { - return $this->sendCredentials; - } - - /** - * Enable sending of credenitals - * This is potentially a security risk - * - * @return fin1te\SafeCurl\Options - */ - public function enableSendCredentials() { - $this->sendCredentials = true; - - return $this; - } - - /** - * Disable sending of credentials - * - * @return fin1te\SafeCurl\Options - */ - public function disableSendCredentials() { - $this->sendCredentials = false; - - return $this; - } - - /** - * Get pin DNS option - * - * @return bool - */ - public function getPinDns() { - return $this->pinDns; - } - - /** - * Enable DNS pinning - * - * @return fin1te\SafeCurl\Options - */ - public function enablePinDns() { - $this->pinDns = true; - - return $this; - } - - /** - * Disable DNS pinning - * - * @return fin1te\SafeCurl\Options - */ - public function disablePinDns() { - $this->pinDns = false; - - return $this; - } - - /** - * Checks if a specific value is in a list - * - * @param $list string - * @param $type string - * @param $values string - * - * @return bool - */ - public function isInList($list, $type, $value) { - if (!in_array($list, array('whitelist', 'blacklist'))) { - throw new InvalidOptionException("Provided list '$list' must be 'whitelist' or 'blacklist'"); - } - - if (!array_key_exists($type, $this->$list)) { - throw new InvalidOptionException("Provided type '$type' must be 'ip', 'port', 'domain' or 'scheme'"); - } - - if (empty($this->{$list}[$type])) { - if ($list == 'whitelist') { - //Whitelist will return true - return true; - } - //Blacklist returns false - return false; - } - - //For domains, a regex match is needed - if ($type == 'domain') { - foreach ($this->{$list}[$type] as $domain) { - if (preg_match('/^' . $domain . '$/i', $value)) { - return true; - } - } - - return false; - } else { - return (in_array($value, $this->{$list}[$type])); - } - } - - /** - * Returns a specific list - * - * @param $list string - * @param $type string optional - * - * @return array - */ - public function getList($list, $type = null) { - if (!in_array($list, array('whitelist', 'blacklist'))) { - throw new InvalidOptionException("Provided list '$list' must be 'whitelist' or 'blacklist'"); - } - - if ($type !== null) { - if (!array_key_exists($type, $this->$list)) { - throw new InvalidOptionException("Provided type '$type' must be 'ip', 'port', 'domain' or 'scheme'"); - } - - return $this->{$list}[$type]; - } - - return $this->{$list}; - } - - /** - * Sets a list to the passed in array - * - * @param $list string - * @param $values array - * @param $type string optional - * - * @return fin1te\SafeCurl\Options - */ - public function setList($list, $values, $type = null) { - if (!in_array($list, array('whitelist', 'blacklist'))) { - throw new InvalidOptionException("Provided list '$list' must be 'whitelist' or 'blacklist'"); - } - - if (!is_array($values)) { - throw new InvalidOptionException("Provided values must be an array"); - } - - if ($type !== null) { - if (!array_key_exists($type, $this->$list)) { - throw new InvalidOptionException("Provided type '$type' must be 'ip', 'port', 'domain' or 'scheme'"); - } - - $this->{$list}[$type] = $values; - - return $this; - } - - foreach ($values as $type => $value) { - if (!in_array($type, array('ip', 'port', 'domain', 'scheme'))) { - throw new InvalidOptionException("Provided type '$type' must be 'ip', 'port', 'domain' or 'scheme'"); - } - - $this->{$list}[$type] = $value; - } - - return $this; - } - - /** - * Adds a value/values to a specific list - * - * @param $list string - * @param $type string - * @param $values array|string - * - * @return fin1te\SafeCurl\Options - */ - public function addToList($list, $type, $values) { - if (!in_array($list, array('whitelist', 'blacklist'))) { - throw new InvalidOptionException("Provided list '$list' must be 'whitelist' or 'blacklist'"); - } - - if (!array_key_exists($type, $this->$list)) { - throw new InvalidOptionException("Provided type '$type' must be 'ip', 'port', 'domain' or 'scheme'"); - } - - if (empty($values)) { - throw new InvalidOptionException("Provided values cannot be empty"); - } - - //Cast single values to an array - if (!is_array($values)) { - $values = array($values); - } - - foreach ($values as $value) { - if (!in_array($value, $this->{$list}[$type])) { - $this->{$list}[$type][] = $value; - } - } - - return $this; - } - - /** - * Removes a value/values from a specific list - * - * @param $list string - * @param $type string - * @param $values array|string - * - * @return fin1te\SafeCurl\Options - */ - public function removeFromList($list, $type, $values) { - if (!in_array($list, array('whitelist', 'blacklist'))) { - throw new InvalidOptionException("Provided list '$list' must be 'whitelist' or 'blacklist'"); - } - - if (!array_key_exists($type, $this->$list)) { - throw new InvalidOptionException("Provided type '$type' must be 'ip', 'port', 'domain' or 'scheme'"); - } - - if (empty($values)) { - throw new InvalidOptionException("Provided values cannot be empty"); - } - - //Cast single values to an array - if (!is_array($values)) { - $values = array($values); - } - - $this->{$list}[$type] = array_diff($this->{$list}[$type], $values); - - return $this; - } -} diff --git a/src/fin1te/SafeCurl/SafeCurl.php b/src/fin1te/SafeCurl/SafeCurl.php deleted file mode 100644 index 3b42ea1..0000000 --- a/src/fin1te/SafeCurl/SafeCurl.php +++ /dev/null @@ -1,179 +0,0 @@ -setCurlHandle($curlHandle); - - if ($options === null) { - $options = new Options(); - } - $this->setOptions($options); - $this->init(); - } - - /** - * Returns cURL handle - * - * @return resource - */ - public function getCurlHandle() { - return $this->curlHandle; - } - - /** - * Sets cURL handle - * - * @param $curlHandle resource - */ - public function setCurlHandle($curlHandle) { - if (!is_resource($curlHandle) || get_resource_type($curlHandle) != 'curl') { - //Need a valid cURL resource, throw exception - throw new Exception("SafeCurl expects a valid cURL resource - '" . gettype($curlHandle) . "' provided."); - } - $this->curlHandle = $curlHandle; - } - - /** - * Gets Options - * - * @return SafeCurl\Options - */ - public function getOptions() { - return $this->options; - } - - /** - * Sets Options - * - * @param $options SafeCurl\Options - */ - public function setOptions(Options $options) { - $this->options = $options; - } - - /** - * Sets up cURL ready for executing - */ - protected function init() { - //To start with, disable FOLLOWLOCATION since we'll handle it - curl_setopt($this->curlHandle, CURLOPT_FOLLOWLOCATION, false); - - //Always return the transfer - curl_setopt($this->curlHandle, CURLOPT_RETURNTRANSFER, true); - - //Force IPv4, since this class isn't yet comptible with IPv6 - $curlVersion = curl_version(); - if ($curlVersion['features'] & CURLOPT_IPRESOLVE) { - curl_setopt($this->curlHandle, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4); - } - } - - /** - * Exectutes a cURL request, whilst checking that the - * URL abides by our whitelists/blacklists - * - * @param $url string - * @param $curlHandle resource optional - Incase called on an object rather than statically - * @param $options SafeCurl\Options optional - * - * @return bool - */ - public static function execute($url, $curlHandle = null, Options $options = null) { - //Check if we've been called staticly or not - if (isset($this) && get_class($this) == __CLASS__) { - $safeCurl = $this; - //Get the cURL handle, if it wasn't passed in - if (!is_resource($curlHandle) || get_resource_type($curlHandle) != 'curl') { - $curlHandle = $this->getCurlHandle(); - } - } else { - $safeCurl = new SafeCurl($curlHandle, $options); - } - - - //Backup the existing URL - $originalUrl = $url; - - //Execute, catch redirects and validate the URL - $redirected = false; - $redirectCount = 0; - $redirectLimit = $safeCurl->getOptions()->getFollowLocationLimit(); - $followLocation = $safeCurl->getOptions()->getFollowLocation(); - do { - //Validate the URL - $url = Url::validateUrl($url, $safeCurl->getOptions()); - - if ($safeCurl->getOptions()->getPinDns()) { - //Send a Host header - curl_setopt($curlHandle, CURLOPT_HTTPHEADER, array('Host: ' . $url['host'])); - //The "fake" URL - curl_setopt($curlHandle, CURLOPT_URL, $url['url']); - //We also have to disable SSL cert verfication, which is not great - //Might be possible to manually check the certificate ourselves? - curl_setopt($curlHandle, CURLOPT_SSL_VERIFYPEER, false); - } else { - curl_setopt($curlHandle, CURLOPT_URL, $url['url']); - } - - //Execute the cURL request - $response = curl_exec($curlHandle); - - //Check for any errors - if (curl_errno($curlHandle)) { - throw new Exception("cURL Error: " . curl_error($curlHandle)); - } - - //Check for an HTTP redirect - if ($followLocation) { - $statusCode = curl_getinfo($curlHandle, CURLINFO_HTTP_CODE); - switch ($statusCode) { - case 301: - case 302: - case 303: - case 307: - case 308: - if ($redirectLimit == 0 || ++$redirectCount < $redirectLimit) { - //Redirect received, so rinse and repeat - $url = curl_getinfo($curlHandle, CURLINFO_REDIRECT_URL); - $redirected = true; - } else { - throw new Exception("Redirect limit '$redirectLimit' hit"); - } - break; - default: - $redirected = false; - } - } - } while ($redirected); - - return $response; - } -} \ No newline at end of file diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php new file mode 100644 index 0000000..cd2c124 --- /dev/null +++ b/tests/OptionsTest.php @@ -0,0 +1,341 @@ +options = new Options(); + } + + public function testFollowlocation() + { + $this->assertFalse($this->options->getFollowLocation()); + + $this->options->enableFollowLocation(); + + $this->assertTrue($this->options->getFollowLocation()); + + $this->options->disableFollowLocation(); + + $this->assertFalse($this->options->getFollowLocation()); + } + + public function testFollowlocationLimit() + { + $this->assertEquals(0, $this->options->getFollowLocationLimit()); + + $this->options->setFollowLocationLimit(10); + + $this->assertEquals(10, $this->options->getFollowLocationLimit()); + } + + public function dataForFollowlocationLimit() + { + return array( + array(-1), + array('"é"é"é'), + array(null), + ); + } + + /** + * @dataProvider dataForFollowlocationLimit + * @expectedException fin1te\SafeCurl\Exception\InvalidOptionException + * @expectedExceptionMessage Provided limit + */ + public function testFollowlocationLimitException($limit) + { + $this->options->setFollowLocationLimit($limit); + } + + public function testSendCredentials() + { + $this->assertFalse($this->options->getSendCredentials()); + + $this->options->enableSendCredentials(); + + $this->assertTrue($this->options->getSendCredentials()); + + $this->options->disableSendCredentials(); + + $this->assertFalse($this->options->getSendCredentials()); + } + + public function testPinDns() + { + $this->assertFalse($this->options->getPinDns()); + + $this->options->enablePinDns(); + + $this->assertTrue($this->options->getPinDns()); + + $this->options->disablePinDns(); + + $this->assertFalse($this->options->getPinDns()); + } + + public function testInListEmptyValue() + { + $this->assertTrue($this->options->isInList('whitelist', 'ip', '')); + $this->assertFalse($this->options->isInList('whitelist', 'port', '')); + $this->assertTrue($this->options->isInList('whitelist', 'domain', '')); + $this->assertFalse($this->options->isInList('whitelist', 'scheme', '')); + + $this->assertFalse($this->options->isInList('blacklist', 'ip', '')); + $this->assertFalse($this->options->isInList('blacklist', 'port', '')); + $this->assertFalse($this->options->isInList('blacklist', 'domain', '')); + $this->assertFalse($this->options->isInList('blacklist', 'scheme', '')); + } + + public function testInListDomainRegex() + { + $this->options->addToList('whitelist', 'domain', '(.*)\.fin1te\.net'); + + $this->assertFalse($this->options->isInList('whitelist', 'domain', '')); + $this->assertFalse($this->options->isInList('whitelist', 'domain', 'fin1te.net')); + $this->assertFalse($this->options->isInList('whitelist', 'domain', 'superfin1te.net')); + $this->assertTrue($this->options->isInList('whitelist', 'domain', 'www.fin1te.net')); + } + + /** + * @expectedException fin1te\SafeCurl\Exception\InvalidOptionException + * @expectedExceptionMessage Provided list "noo" must be "whitelist" or "blacklist" + */ + public function testInListBadList() + { + $this->options->isInList('noo', 'domain', ''); + } + + /** + * @expectedException fin1te\SafeCurl\Exception\InvalidOptionException + * @expectedExceptionMessage Provided type "noo" must be "ip", "port", "domain" or "scheme" + */ + public function testInListBadType() + { + $this->options->isInList('whitelist', 'noo', ''); + } + + public function testGetListWithoutType() + { + $list = $this->options->getList('whitelist'); + + $this->assertCount(4, $list); + $this->assertArrayHasKey('ip', $list); + $this->assertArrayHasKey('port', $list); + $this->assertArrayHasKey('domain', $list); + $this->assertArrayHasKey('scheme', $list); + + $list = $this->options->getList('blacklist'); + + $this->assertCount(4, $list); + $this->assertArrayHasKey('ip', $list); + $this->assertArrayHasKey('port', $list); + $this->assertArrayHasKey('domain', $list); + $this->assertArrayHasKey('scheme', $list); + } + + public function testGetListWhitelistWithType() + { + $this->options->addToList('whitelist', 'ip', '0.0.0.0'); + $list = $this->options->getList('whitelist', 'ip'); + + $this->assertCount(1, $list); + $this->assertArrayHasKey(0, $list); + $this->assertEquals('0.0.0.0', $list[0]); + + $list = $this->options->getList('whitelist', 'port'); + + $this->assertCount(3, $list); + $this->assertEquals('80', $list[0]); + $this->assertEquals('443', $list[1]); + $this->assertEquals('8080', $list[2]); + + $this->options->addToList('whitelist', 'domain', '(.*)\.fin1te\.net'); + $list = $this->options->getList('whitelist', 'domain'); + + $this->assertCount(1, $list); + $this->assertEquals('(.*)\.fin1te\.net', $list[0]); + + $list = $this->options->getList('whitelist', 'scheme'); + + $this->assertCount(2, $list); + $this->assertEquals('http', $list[0]); + $this->assertEquals('https', $list[1]); + } + + public function testGetListBlacklistWithType() + { + $list = $this->options->getList('blacklist', 'ip'); + + $this->assertCount(15, $list); + $this->assertEquals('0.0.0.0/8', $list[0]); + + $this->options->addToList('blacklist', 'port', '8080'); + $list = $this->options->getList('blacklist', 'port'); + + $this->assertCount(1, $list); + $this->assertEquals('8080', $list[0]); + + $this->options->addToList('blacklist', 'domain', '(.*)\.fin1te\.net'); + $list = $this->options->getList('blacklist', 'domain'); + + $this->assertCount(1, $list); + $this->assertEquals('(.*)\.fin1te\.net', $list[0]); + + $this->options->addToList('blacklist', 'scheme', 'ftp'); + $list = $this->options->getList('blacklist', 'scheme'); + + $this->assertCount(1, $list); + $this->assertEquals('ftp', $list[0]); + } + + /** + * @expectedException fin1te\SafeCurl\Exception\InvalidOptionException + * @expectedExceptionMessage Provided list "noo" must be "whitelist" or "blacklist" + */ + public function testGetListBadList() + { + $this->options->getList('noo'); + } + + /** + * @expectedException fin1te\SafeCurl\Exception\InvalidOptionException + * @expectedExceptionMessage Provided type "noo" must be "ip", "port", "domain" or "scheme" + */ + public function testGetListBadType() + { + $this->options->getList('whitelist', 'noo'); + } + + public function testSetList() + { + $this->options->setList('whitelist', array('ip' => array('0.0.0.0'))); + + $this->assertEquals(array('0.0.0.0'), $this->options->getList('whitelist', 'ip')); + + $this->options->setList('blacklist', array(22), 'port'); + + $this->assertEquals(array(22), $this->options->getList('blacklist', 'port')); + } + + /** + * @expectedException fin1te\SafeCurl\Exception\InvalidOptionException + * @expectedExceptionMessage Provided list "noo" must be "whitelist" or "blacklist" + */ + public function testSetListBadList() + { + $this->options->setList('noo', array()); + } + + /** + * @expectedException fin1te\SafeCurl\Exception\InvalidOptionException + * @expectedExceptionMessage Provided values must be an array, "integer" given + */ + public function testSetListBadValue() + { + $this->options->setList('whitelist', 12); + } + + /** + * @expectedException fin1te\SafeCurl\Exception\InvalidOptionException + * @expectedExceptionMessage Provided type "noo" must be "ip", "port", "domain" or "scheme" + */ + public function testSetListBadType() + { + $this->options->setList('whitelist', array(), 'noo'); + } + + /** + * @expectedException fin1te\SafeCurl\Exception\InvalidOptionException + * @expectedExceptionMessage Provided type "noo" must be "ip", "port", "domain" or "scheme" + */ + public function testSetListBadTypeValue() + { + $this->options->setList('whitelist', array('noo' => 'oops')); + } + + /** + * @expectedException fin1te\SafeCurl\Exception\InvalidOptionException + * @expectedExceptionMessage Provided list "noo" must be "whitelist" or "blacklist" + */ + public function testAddToListBadList() + { + $this->options->addToList('noo', 'noo', 'noo'); + } + + /** + * @expectedException fin1te\SafeCurl\Exception\InvalidOptionException + * @expectedExceptionMessage Provided type "noo" must be "ip", "port", "domain" or "scheme" + */ + public function testAddToListBadType() + { + $this->options->addToList('whitelist', 'noo', 'noo'); + } + + /** + * @expectedException fin1te\SafeCurl\Exception\InvalidOptionException + * @expectedExceptionMessage Provided values cannot be empty + */ + public function testAddToListBadValue() + { + $this->options->addToList('whitelist', 'ip', null); + } + + /** + * @expectedException fin1te\SafeCurl\Exception\InvalidOptionException + * @expectedExceptionMessage Provided list "noo" must be "whitelist" or "blacklist" + */ + public function testRemoveFromListBadList() + { + $this->options->removeFromList('noo', 'noo', 'noo'); + } + + /** + * @expectedException fin1te\SafeCurl\Exception\InvalidOptionException + * @expectedExceptionMessage Provided type "noo" must be "ip", "port", "domain" or "scheme" + */ + public function testRemoveFromListBadType() + { + $this->options->removeFromList('whitelist', 'noo', 'noo'); + } + + /** + * @expectedException fin1te\SafeCurl\Exception\InvalidOptionException + * @expectedExceptionMessage Provided values cannot be empty + */ + public function testRemoveFromListBadValue() + { + $this->options->removeFromList('whitelist', 'ip', null); + } + + public function testRemoveFromList() + { + // remove not an array + $this->options->addToList('blacklist', 'port', '8080'); + $list = $this->options->getList('blacklist', 'port'); + + $this->assertCount(1, $list); + $this->assertEquals('8080', $list[0]); + + $this->options->removeFromList('blacklist', 'port', '8080'); + $list = $this->options->getList('blacklist', 'port'); + + $this->assertCount(0, $list); + + // remove using an array + $this->options->addToList('blacklist', 'scheme', 'ftp'); + $list = $this->options->getList('blacklist', 'scheme'); + + $this->assertCount(1, $list); + $this->assertEquals('ftp', $list[0]); + + $this->options->removeFromList('blacklist', 'scheme', array('ftp')); + $list = $this->options->getList('blacklist', 'scheme'); + + $this->assertCount(0, $list); + } +} diff --git a/tests/SafeCurlTest.php b/tests/SafeCurlTest.php new file mode 100644 index 0000000..41b3cb3 --- /dev/null +++ b/tests/SafeCurlTest.php @@ -0,0 +1,140 @@ +execute('http://www.google.com'); + + $this->assertNotEmpty($response); + $this->assertEquals($handle, $safeCurl->getCurlHandle()); + } + + /** + * @expectedException fin1te\SafeCurl\Exception + * @expectedExceptionMessage SafeCurl expects a valid cURL resource - "NULL" provided. + */ + public function testBadCurlHandler() + { + new SafeCurl(null); + } + + public function dataForBlockedUrl() + { + return array( + array('http://0.0.0.0:123', 'fin1te\SafeCurl\Exception\InvalidURLException\InvalidPortException', 'Provided port "123" doesn\'t match whitelisted values: 80, 443, 8080'), + array('http://127.0.0.1/server-status', 'fin1te\SafeCurl\Exception\InvalidURLException\InvalidIPException', 'Provided host "127.0.0.1" resolves to "127.0.0.1", which matches a blacklisted value: 127.0.0.0/8'), + array('file:///etc/passwd', 'fin1te\SafeCurl\Exception\InvalidURLException', 'Provided URL "file:///etc/passwd" doesn\'t contain a hostname'), + array('ssh://localhost', 'fin1te\SafeCurl\Exception\InvalidURLException\InvalidSchemeException', 'Provided scheme "ssh" doesn\'t match whitelisted values: http, https'), + array('gopher://localhost', 'fin1te\SafeCurl\Exception\InvalidURLException\InvalidSchemeException', 'Provided scheme "gopher" doesn\'t match whitelisted values: http, https'), + array('telnet://localhost:25', 'fin1te\SafeCurl\Exception\InvalidURLException\InvalidSchemeException', 'Provided scheme "telnet" doesn\'t match whitelisted values: http, https'), + array('http://169.254.169.254/latest/meta-data/', 'fin1te\SafeCurl\Exception\InvalidURLException\InvalidIPException', 'Provided host "169.254.169.254" resolves to "169.254.169.254", which matches a blacklisted value: 169.254.0.0/16'), + array('ftp://myhost.com', 'fin1te\SafeCurl\Exception\InvalidURLException\InvalidSchemeException', 'Provided scheme "ftp" doesn\'t match whitelisted values: http, https'), + array('http://user:pass@safecurl.fin1te.net?@google.com/', 'fin1te\SafeCurl\Exception\InvalidURLException', 'Credentials passed in but "sendCredentials" is set to false'), + ); + } + + /** + * @dataProvider dataForBlockedUrl + */ + public function testBlockedUrl($url, $exception, $message) + { + $this->setExpectedException($exception, $message); + + $safeCurl = new SafeCurl(curl_init()); + $safeCurl->execute($url); + } + + public function dataForBlockedUrlByOptions() + { + return array( + array('http://login:password@google.fr', 'fin1te\SafeCurl\Exception\InvalidURLException', 'Credentials passed in but "sendCredentials" is set to false'), + array('http://safecurl.fin1te.net', 'fin1te\SafeCurl\Exception\InvalidURLException', 'Provided host "safecurl.fin1te.net" matches a blacklisted value'), + ); + } + + /** + * @dataProvider dataForBlockedUrlByOptions + */ + public function testBlockedUrlByOptions($url, $exception, $message) + { + $this->setExpectedException($exception, $message); + + $options = new Options(); + $options->addToList('blacklist', 'domain', '(.*)\.fin1te\.net'); + $options->addToList('whitelist', 'scheme', 'ftp'); + $options->disableSendCredentials(); + + $safeCurl = new SafeCurl(curl_init(), $options); + $safeCurl->execute($url); + } + + public function testWithPinDnsEnabled() + { + $options = new Options(); + $options->enablePinDns(); + + $safeCurl = new SafeCurl(curl_init(), $options); + $response = $safeCurl->execute('http://google.com'); + + $this->assertNotEmpty($response); + } + + /** + * @expectedException fin1te\SafeCurl\Exception + * @expectedExceptionMessage Redirect limit "1" hit + */ + public function testWithFollowLocationLimit() + { + $options = new Options(); + $options->enableFollowLocation(); + $options->setFollowLocationLimit(1); + + $safeCurl = new SafeCurl(curl_init(), $options); + $safeCurl->execute('http://t.co/5AMOLpSq3v'); + } + + public function testWithFollowLocation() + { + $options = new Options(); + $options->enableFollowLocation(); + + $safeCurl = new SafeCurl(curl_init(), $options); + $response = $safeCurl->execute('http://t.co/5AMOLpSq3v'); + + $this->assertNotEmpty($response); + } + + /** + * @expectedException fin1te\SafeCurl\Exception\InvalidURLException\InvalidPortException + * @expectedExceptionMessage Provided port "123" doesn't match whitelisted values: 80, 443, 8080 + */ + public function testWithFollowLocationLeadingToABlockedUrl() + { + $options = new Options(); + $options->enableFollowLocation(); + + $safeCurl = new SafeCurl(curl_init(), $options); + // this bit.ly redirect to `http://0.0.0.0:123` + $safeCurl->execute('http://bit.ly/1L9Ttv0'); + } + + /** + * @expectedException fin1te\SafeCurl\Exception + * @expectedExceptionMessage cURL Error: Operation timed out after + */ + public function testWithCurlTimeout() + { + $handle = curl_init(); + curl_setopt($handle, CURLOPT_TIMEOUT, 1); + + $safeCurl = new SafeCurl($handle); + $safeCurl->execute('http://hostname.fr'); + } +} diff --git a/tests/UrlTest.php b/tests/UrlTest.php new file mode 100644 index 0000000..25a3b6e --- /dev/null +++ b/tests/UrlTest.php @@ -0,0 +1,154 @@ +setExpectedException($exception, $message); + + Url::validateUrl($url, new Options()); + } + + /** + * @expectedException fin1te\SafeCurl\Exception\InvalidURLException\InvalidSchemeException + * @expectedExceptionMessage Provided scheme "http" matches a blacklisted value + */ + public function testValidateScheme() + { + $options = new Options(); + $options->addToList('blacklist', 'scheme', 'http'); + + Url::validateUrl('http://www.fin1te.net', $options); + } + + /** + * @expectedException fin1te\SafeCurl\Exception\InvalidURLException\InvalidPortException + * @expectedExceptionMessage Provided port "8080" matches a blacklisted value + */ + public function testValidatePort() + { + $options = new Options(); + $options->addToList('blacklist', 'port', '8080'); + + Url::validateUrl('http://www.fin1te.net:8080', $options); + } + + /** + * @expectedException fin1te\SafeCurl\Exception\InvalidURLException\InvalidDomainException + * @expectedExceptionMessage Provided host "www.fin1te.net" matches a blacklisted value + */ + public function testValidateHostBlacklist() + { + $options = new Options(); + $options->addToList('blacklist', 'domain', '(.*)\.fin1te\.net'); + + Url::validateUrl('http://www.fin1te.net', $options); + } + + /** + * @expectedException fin1te\SafeCurl\Exception\InvalidURLException\InvalidDomainException + * @expectedExceptionMessage Provided host "www.google.fr" doesn't match whitelisted values: (.*)\.fin1te\.net + */ + public function testValidateHostWhitelist() + { + $options = new Options(); + $options->addToList('whitelist', 'domain', '(.*)\.fin1te\.net'); + + Url::validateUrl('http://www.google.fr', $options); + } + + /** + * @expectedException fin1te\SafeCurl\Exception\InvalidURLException\InvalidDomainException + * @expectedExceptionMessage Provided host "www.youpi.boom" doesn't resolve to an IP address + */ + public function testValidateHostWithnoip() + { + $options = new Options(); + + Url::validateUrl('http://www.youpi.boom', $options); + } + + /** + * @expectedException fin1te\SafeCurl\Exception\InvalidURLException\InvalidIPException + * @expectedExceptionMessage Provided host "2.2.2.2" resolves to "2.2.2.2", which doesn't match whitelisted values: 1.1.1.1 + */ + public function testValidateHostWithWhitelistIp() + { + $options = new Options(); + $options->addToList('whitelist', 'ip', '1.1.1.1'); + + Url::validateUrl('http://2.2.2.2', $options); + } + + public function testValidateHostWithWhitelistIpOk() + { + $options = new Options(); + $options->addToList('whitelist', 'ip', '1.1.1.1'); + + $res = Url::validateUrl('http://1.1.1.1', $options); + + $this->assertCount(3, $res); + $this->assertArrayHasKey('url', $res); + $this->assertArrayHasKey('host', $res); + $this->assertArrayHasKey('ips', $res); + $this->assertArrayHasKey(0, $res['ips']); + } + + /** + * @expectedException fin1te\SafeCurl\Exception\InvalidURLException\InvalidIPException + * @expectedExceptionMessage Provided host "1.1.1.1" resolves to "1.1.1.1", which matches a blacklisted value: 1.1.1.1 + */ + public function testValidateHostWithBlacklistIp() + { + $options = new Options(); + $options->addToList('blacklist', 'ip', '1.1.1.1'); + + Url::validateUrl('http://1.1.1.1', $options); + } + + public function testValidateUrlOk() + { + $options = new Options(); + $options->enablePinDns(); + + $res = Url::validateUrl('http://www.fin1te.net:8080', $options); + + $this->assertCount(3, $res); + $this->assertArrayHasKey('url', $res); + $this->assertArrayHasKey('host', $res); + $this->assertArrayHasKey('ips', $res); + $this->assertArrayHasKey(0, $res['ips']); + $this->assertEquals('http://37.48.73.92:8080', $res['url']); + $this->assertEquals('www.fin1te.net', $res['host']); + + $res = Url::validateUrl('http://www.fin1te.net:8080', new Options()); + + $this->assertCount(3, $res); + $this->assertArrayHasKey('url', $res); + $this->assertArrayHasKey('host', $res); + $this->assertArrayHasKey('ips', $res); + $this->assertArrayHasKey(0, $res['ips']); + $this->assertEquals('http://www.fin1te.net:8080', $res['url']); + $this->assertEquals('www.fin1te.net', $res['host']); + } +}