From 018e7dbe43fe0d408c25ff1c76f3ee60183dbafc Mon Sep 17 00:00:00 2001 From: Jeremy Benoist Date: Fri, 2 Oct 2015 10:38:21 +0200 Subject: [PATCH 01/14] First tests step - use PSR-4 - add travis & scrutinizer - fix README --- .editorconfig | 9 +++++ .gitignore | 3 ++ .scrutinizer.yml | 3 ++ .travis.yml | 34 +++++++++++++++++++ README.md | 15 ++++++-- composer.json | 9 +++-- phpunit.xml | 28 +++++++++++++++ src/{fin1te/SafeCurl => }/Exception.php | 0 .../Exception/InvalidOptionException.php | 0 .../Exception/InvalidURLException.php | 0 .../InvalidDomainException.php | 0 .../InvalidIPException.php | 0 .../InvalidPortException.php | 0 .../InvalidSchemeException.php | 0 src/{fin1te/SafeCurl => }/Options.php | 0 src/{fin1te/SafeCurl => }/SafeCurl.php | 0 src/{fin1te/SafeCurl => }/Url.php | 0 tests/SafeCurlTest.php | 13 +++++++ 18 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .scrutinizer.yml create mode 100644 .travis.yml create mode 100644 phpunit.xml rename src/{fin1te/SafeCurl => }/Exception.php (100%) rename src/{fin1te/SafeCurl => }/Exception/InvalidOptionException.php (100%) rename src/{fin1te/SafeCurl => }/Exception/InvalidURLException.php (100%) rename src/{fin1te/SafeCurl => }/Exception/InvalidURLException/InvalidDomainException.php (100%) rename src/{fin1te/SafeCurl => }/Exception/InvalidURLException/InvalidIPException.php (100%) rename src/{fin1te/SafeCurl => }/Exception/InvalidURLException/InvalidPortException.php (100%) rename src/{fin1te/SafeCurl => }/Exception/InvalidURLException/InvalidSchemeException.php (100%) rename src/{fin1te/SafeCurl => }/Options.php (100%) rename src/{fin1te/SafeCurl => }/SafeCurl.php (100%) rename src/{fin1te/SafeCurl => }/Url.php (100%) create mode 100644 tests/SafeCurlTest.php 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/.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..4bb53b4 --- /dev/null +++ b/.scrutinizer.yml @@ -0,0 +1,3 @@ +tools: + external_code_coverage: + timeout: 600 diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..382d084 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,34 @@ +language: php + +php: + - 5.3.3 + - 5.3 + - 5.4 + - 5.5 + - 5.6 + - 7.0 + - hhvm + +# run build against 7.0 & hhvm but allow them to fail +matrix: + fast_finish: true + allow_failures: + - php: 7.0 + - php: hhvm + +# 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..4a674b7 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). @@ -38,8 +42,9 @@ 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); @@ -62,6 +67,8 @@ $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); @@ -74,6 +81,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); ``` @@ -95,7 +103,6 @@ try { } ``` - #### Optional Protections In addition to the standard checks, two more are available. @@ -113,6 +120,8 @@ The second disables the use of credentials in a URL, since PHP's `parse_url` ret $options = new Options(); $options->disableSendCredentials(); +$curlHandle = curl_init(); + //This will throw an InvalidURLException $response = SafeCurl::execute('http://user:pass@google.com', $curlHandle, $options); ``` @@ -128,6 +137,6 @@ A live demo is available at [http://safecurl.fin1te.net/#demo](http://safecurl.f ## Bounty -In order to help make SafeCurl secure and ready for production use, [a Bitcoin bounty](http://safecurl.fin1te.net/#bounty) has been setup. +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..82f2e35 100644 --- a/composer.json +++ b/composer.json @@ -15,11 +15,14 @@ } ], "require": { - "php": ">=5.3.0" + "php": ">=5.3.3" + }, + "require-dev": { + "phpunit/phpunit": "^4.0.0" }, "autoload": { - "psr-0": { - "fin1te\\SafeCurl": "src/" + "psr-4": { + "fin1te\\SafeCurl\\": "src/" } } } 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/fin1te/SafeCurl/Exception.php b/src/Exception.php similarity index 100% rename from src/fin1te/SafeCurl/Exception.php rename to src/Exception.php diff --git a/src/fin1te/SafeCurl/Exception/InvalidOptionException.php b/src/Exception/InvalidOptionException.php similarity index 100% rename from src/fin1te/SafeCurl/Exception/InvalidOptionException.php rename to src/Exception/InvalidOptionException.php diff --git a/src/fin1te/SafeCurl/Exception/InvalidURLException.php b/src/Exception/InvalidURLException.php similarity index 100% rename from src/fin1te/SafeCurl/Exception/InvalidURLException.php rename to src/Exception/InvalidURLException.php diff --git a/src/fin1te/SafeCurl/Exception/InvalidURLException/InvalidDomainException.php b/src/Exception/InvalidURLException/InvalidDomainException.php similarity index 100% rename from src/fin1te/SafeCurl/Exception/InvalidURLException/InvalidDomainException.php rename to src/Exception/InvalidURLException/InvalidDomainException.php diff --git a/src/fin1te/SafeCurl/Exception/InvalidURLException/InvalidIPException.php b/src/Exception/InvalidURLException/InvalidIPException.php similarity index 100% rename from src/fin1te/SafeCurl/Exception/InvalidURLException/InvalidIPException.php rename to src/Exception/InvalidURLException/InvalidIPException.php diff --git a/src/fin1te/SafeCurl/Exception/InvalidURLException/InvalidPortException.php b/src/Exception/InvalidURLException/InvalidPortException.php similarity index 100% rename from src/fin1te/SafeCurl/Exception/InvalidURLException/InvalidPortException.php rename to src/Exception/InvalidURLException/InvalidPortException.php diff --git a/src/fin1te/SafeCurl/Exception/InvalidURLException/InvalidSchemeException.php b/src/Exception/InvalidURLException/InvalidSchemeException.php similarity index 100% rename from src/fin1te/SafeCurl/Exception/InvalidURLException/InvalidSchemeException.php rename to src/Exception/InvalidURLException/InvalidSchemeException.php diff --git a/src/fin1te/SafeCurl/Options.php b/src/Options.php similarity index 100% rename from src/fin1te/SafeCurl/Options.php rename to src/Options.php diff --git a/src/fin1te/SafeCurl/SafeCurl.php b/src/SafeCurl.php similarity index 100% rename from src/fin1te/SafeCurl/SafeCurl.php rename to src/SafeCurl.php diff --git a/src/fin1te/SafeCurl/Url.php b/src/Url.php similarity index 100% rename from src/fin1te/SafeCurl/Url.php rename to src/Url.php diff --git a/tests/SafeCurlTest.php b/tests/SafeCurlTest.php new file mode 100644 index 0000000..ca81ff1 --- /dev/null +++ b/tests/SafeCurlTest.php @@ -0,0 +1,13 @@ +assertNotEmpty($response); + } +} From 5dcbbe003fd38872c4c660fa85c062d65511e249 Mon Sep 17 00:00:00 2001 From: Jeremy Benoist Date: Fri, 2 Oct 2015 11:27:17 +0200 Subject: [PATCH 02/14] CS --- .travis.yml | 6 + src/Exception.php | 5 +- src/Exception/InvalidOptionException.php | 5 +- src/Exception/InvalidURLException.php | 5 +- .../InvalidDomainException.php | 5 +- .../InvalidIPException.php | 5 +- .../InvalidPortException.php | 5 +- .../InvalidSchemeException.php | 5 +- src/Options.php | 175 ++++++++++-------- src/SafeCurl.php | 83 +++++---- src/Url.php | 116 +++++------- 11 files changed, 227 insertions(+), 188 deletions(-) diff --git a/.travis.yml b/.travis.yml index 382d084..68198f5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,6 +9,12 @@ php: - 7.0 - hhvm +# cache vendor dirs +cache: + directories: + - vendor + - $HOME/.composer/cache + # run build against 7.0 & hhvm but allow them to fail matrix: fast_finish: true diff --git a/src/Exception.php b/src/Exception.php index 555e3a0..4279f90 100644 --- a/src/Exception.php +++ b/src/Exception.php @@ -1,4 +1,7 @@ array(), - 'port' => array('80', '443', '8080'), - 'domain' => array(), - 'scheme' => array('http', 'https')); + private $whitelist = array( + 'ip' => 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()); + 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() { } + public function __construct() + { + } /** - * Get followLocation + * Get followLocation. * * @return bool */ - public function getFollowLocation() { + public function getFollowLocation() + { return $this->followLocation; } /** - * Enables following redirects + * Enables following redirects. * * @return fin1te\SafeCurl\Options */ - public function enableFollowLocation() { + public function enableFollowLocation() + { $this->followLocation = true; return $this; } /** - * Disables following redirects + * Disables following redirects. * * @return fin1te\SafeCurl\Options */ - public function disableFollowLocation() { + public function disableFollowLocation() + { $this->followLocation = false; return $this; @@ -92,25 +105,27 @@ public function disableFollowLocation() { /** * Gets the follow location limit - * 0 is no limit (infinite) + * 0 is no limit (infinite). * * @return int */ - public function getFollowLocationLimit() { + public function getFollowLocationLimit() + { return $this->followLocationLimit; } /** * Sets the follow location limit - * 0 is no limit (infinite) + * 0 is no limit (infinite). * * @param $limit int * * @return fin1te\SafeCurl\Options */ - public function setFollowLocationLimit($limit) { + public function setFollowLocationLimit($limit) + { if (!is_numeric($limit) || $limit < 0) { - throw new InvalidOptionException("Provided limit '$limit' must be an integer >= 0"); + throw new InvalidOptionException('Provided limit "'.$limit.'" must be an integer >= 0'); } $this->followLocationLimit = $limit; @@ -119,70 +134,76 @@ public function setFollowLocationLimit($limit) { } /** - * Get send credentials option + * Get send credentials option. * * @return bool */ - public function getSendCredentials() { + public function getSendCredentials() + { return $this->sendCredentials; } /** * Enable sending of credenitals - * This is potentially a security risk + * This is potentially a security risk. * * @return fin1te\SafeCurl\Options */ - public function enableSendCredentials() { + public function enableSendCredentials() + { $this->sendCredentials = true; return $this; } /** - * Disable sending of credentials + * Disable sending of credentials. * * @return fin1te\SafeCurl\Options */ - public function disableSendCredentials() { + public function disableSendCredentials() + { $this->sendCredentials = false; return $this; } /** - * Get pin DNS option + * Get pin DNS option. * * @return bool */ - public function getPinDns() { + public function getPinDns() + { return $this->pinDns; } /** - * Enable DNS pinning + * Enable DNS pinning. * * @return fin1te\SafeCurl\Options */ - public function enablePinDns() { + public function enablePinDns() + { $this->pinDns = true; return $this; } /** - * Disable DNS pinning + * Disable DNS pinning. * * @return fin1te\SafeCurl\Options */ - public function disablePinDns() { + public function disablePinDns() + { $this->pinDns = false; return $this; } /** - * Checks if a specific value is in a list + * Checks if a specific value is in a list. * * @param $list string * @param $type string @@ -190,13 +211,14 @@ public function disablePinDns() { * * @return bool */ - public function isInList($list, $type, $value) { + public function isInList($list, $type, $value) + { if (!in_array($list, array('whitelist', 'blacklist'))) { - throw new InvalidOptionException("Provided list '$list' must be 'whitelist' or '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'"); + throw new InvalidOptionException('Provided type "'.$type.'" must be "ip", "port", "domain" or "scheme"'); } if (empty($this->{$list}[$type])) { @@ -204,6 +226,7 @@ public function isInList($list, $type, $value) { //Whitelist will return true return true; } + //Blacklist returns false return false; } @@ -211,33 +234,34 @@ public function isInList($list, $type, $value) { //For domains, a regex match is needed if ($type == 'domain') { foreach ($this->{$list}[$type] as $domain) { - if (preg_match('/^' . $domain . '$/i', $value)) { + if (preg_match('/^'.$domain.'$/i', $value)) { return true; } } return false; - } else { - return (in_array($value, $this->{$list}[$type])); } + + return in_array($value, $this->{$list}[$type]); } /** - * Returns a specific list + * Returns a specific list. * * @param $list string * @param $type string optional * * @return array */ - public function getList($list, $type = null) { + public function getList($list, $type = null) + { if (!in_array($list, array('whitelist', 'blacklist'))) { - throw new InvalidOptionException("Provided list '$list' must be 'whitelist' or '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'"); + throw new InvalidOptionException('Provided type "'.$type.'" must be "ip", "port", "domain" or "scheme"'); } return $this->{$list}[$type]; @@ -247,7 +271,7 @@ public function getList($list, $type = null) { } /** - * Sets a list to the passed in array + * Sets a list to the passed in array. * * @param $list string * @param $values array @@ -255,18 +279,19 @@ public function getList($list, $type = null) { * * @return fin1te\SafeCurl\Options */ - public function setList($list, $values, $type = null) { + 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'"); + throw new InvalidOptionException('Provided list "'.$list.'" must be "whitelist" or "blacklist"'); } if (!is_array($values)) { - throw new InvalidOptionException("Provided values must be an array"); + 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'"); + throw new InvalidOptionException('Provided type "'.$type.'" must be "ip", "port", "domain" or "scheme"'); } $this->{$list}[$type] = $values; @@ -276,7 +301,7 @@ public function setList($list, $values, $type = null) { 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'"); + throw new InvalidOptionException('Provided type "'.$type.'" must be "ip", "port", "domain" or "scheme"'); } $this->{$list}[$type] = $value; @@ -286,7 +311,7 @@ public function setList($list, $values, $type = null) { } /** - * Adds a value/values to a specific list + * Adds a value/values to a specific list. * * @param $list string * @param $type string @@ -294,17 +319,18 @@ public function setList($list, $values, $type = null) { * * @return fin1te\SafeCurl\Options */ - public function addToList($list, $type, $values) { + public function addToList($list, $type, $values) + { if (!in_array($list, array('whitelist', 'blacklist'))) { - throw new InvalidOptionException("Provided list '$list' must be 'whitelist' or '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'"); + throw new InvalidOptionException('Provided type "'.$type.'" must be "ip", "port", "domain" or "scheme"'); } if (empty($values)) { - throw new InvalidOptionException("Provided values cannot be empty"); + throw new InvalidOptionException('Provided values cannot be empty'); } //Cast single values to an array @@ -322,7 +348,7 @@ public function addToList($list, $type, $values) { } /** - * Removes a value/values from a specific list + * Removes a value/values from a specific list. * * @param $list string * @param $type string @@ -330,17 +356,18 @@ public function addToList($list, $type, $values) { * * @return fin1te\SafeCurl\Options */ - public function removeFromList($list, $type, $values) { + public function removeFromList($list, $type, $values) + { if (!in_array($list, array('whitelist', 'blacklist'))) { - throw new InvalidOptionException("Provided list '$list' must be 'whitelist' or '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'"); + throw new InvalidOptionException('Provided type "'.$type.'" must be "ip", "port", "domain" or "scheme"'); } if (empty($values)) { - throw new InvalidOptionException("Provided values cannot be empty"); + throw new InvalidOptionException('Provided values cannot be empty'); } //Cast single values to an array diff --git a/src/SafeCurl.php b/src/SafeCurl.php index 3b42ea1..9670c6c 100644 --- a/src/SafeCurl.php +++ b/src/SafeCurl.php @@ -1,88 +1,91 @@ setCurlHandle($curlHandle); if ($options === null) { $options = new Options(); } + $this->setOptions($options); $this->init(); } /** - * Returns cURL handle + * Returns cURL handle. * * @return resource */ - public function getCurlHandle() { + public function getCurlHandle() + { return $this->curlHandle; } /** - * Sets cURL handle + * Sets cURL handle. * * @param $curlHandle resource */ - public function setCurlHandle($curlHandle) { - if (!is_resource($curlHandle) || get_resource_type($curlHandle) != 'curl') { + 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."); + throw new Exception('SafeCurl expects a valid cURL resource - "'.gettype($curlHandle).'" provided.'); } - $this->curlHandle = $curlHandle; + + $this->curlHandle = $curlHandle; } /** - * Gets Options + * Gets Options. * * @return SafeCurl\Options */ - public function getOptions() { + public function getOptions() + { return $this->options; } /** - * Sets Options + * Sets Options. * * @param $options SafeCurl\Options */ - public function setOptions(Options $options) { + public function setOptions(Options $options) + { $this->options = $options; } /** - * Sets up cURL ready for executing + * Sets up cURL ready for executing. */ - protected function init() { + protected function init() + { //To start with, disable FOLLOWLOCATION since we'll handle it curl_setopt($this->curlHandle, CURLOPT_FOLLOWLOCATION, false); @@ -91,14 +94,15 @@ protected function init() { //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 + * 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 @@ -106,34 +110,35 @@ protected function init() { * * @return bool */ - public static function execute($url, $curlHandle = null, Options $options = null) { + 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') { + if (!is_resource($curlHandle) || get_resource_type($curlHandle) != 'curl') { $curlHandle = $this->getCurlHandle(); - } + } } else { - $safeCurl = new SafeCurl($curlHandle, $options); + $safeCurl = new self($curlHandle, $options); } - //Backup the existing URL $originalUrl = $url; //Execute, catch redirects and validate the URL - $redirected = false; - $redirectCount = 0; - $redirectLimit = $safeCurl->getOptions()->getFollowLocationLimit(); + $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'])); + 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 @@ -148,7 +153,7 @@ public static function execute($url, $curlHandle = null, Options $options = null //Check for any errors if (curl_errno($curlHandle)) { - throw new Exception("cURL Error: " . curl_error($curlHandle)); + throw new Exception('cURL Error: '.curl_error($curlHandle)); } //Check for an HTTP redirect @@ -175,5 +180,5 @@ public static function execute($url, $curlHandle = null, Options $options = null } while ($redirected); return $response; - } -} \ No newline at end of file + } +} diff --git a/src/Url.php b/src/Url.php index 56d94d6..b36af86 100644 --- a/src/Url.php +++ b/src/Url.php @@ -1,4 +1,5 @@ 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,30 @@ 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 * * @return string */ - public static function validateScheme($scheme, Options $options) { + public static function validateScheme($scheme, Options $options) + { //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 +98,21 @@ public static function validateScheme($scheme, Options $options) { } /** - * Validates a port + * Validates a port. * * @param $port int * @param $options fin1te\SafeCurl\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 +120,28 @@ public static function validatePort($port, Options $options) { } /** - * Validates a URL host + * Validates a URL host. * * @param $host string * @param $options fin1te\SafeCurl\Options * * @returns string */ - public static function validateHost($host, Options $options) { + public static function validateHost($host, Options $options) + { //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 +159,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 +169,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 +179,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; } From 52b1c415af9d0c09a1c73005a0bf69eaad93ecaa Mon Sep 17 00:00:00 2001 From: Jeremy Benoist Date: Fri, 2 Oct 2015 11:32:37 +0200 Subject: [PATCH 03/14] Be sure that curl is installed Also, this project has to run on hhvm & php 7.0 --- .travis.yml | 7 ------- composer.json | 3 ++- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/.travis.yml b/.travis.yml index 68198f5..a1d1b18 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,13 +15,6 @@ cache: - vendor - $HOME/.composer/cache -# run build against 7.0 & hhvm but allow them to fail -matrix: - fast_finish: true - allow_failures: - - php: 7.0 - - php: hhvm - # faster builds on new travis setup not using sudo sudo: false diff --git a/composer.json b/composer.json index 82f2e35..0822bc6 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,8 @@ } ], "require": { - "php": ">=5.3.3" + "php": ">=5.3.3", + "ext-curl": "*" }, "require-dev": { "phpunit/phpunit": "^4.0.0" From 893988b5ed5ff1fd5745b669a837158ce62981c2 Mon Sep 17 00:00:00 2001 From: Jeremy Benoist Date: Fri, 2 Oct 2015 17:39:31 +0200 Subject: [PATCH 04/14] SafeCurl can not be called statically --- README.md | 20 ++++-- src/SafeCurl.php | 45 +++++------- src/Url.php | 8 +-- tests/SafeCurlTest.php | 116 ++++++++++++++++++++++++++++++- tests/UrlTest.php | 154 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 303 insertions(+), 40 deletions(-) create mode 100644 tests/UrlTest.php diff --git a/README.md b/README.md index 4a674b7..44f0400 100644 --- a/README.md +++ b/README.md @@ -32,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; @@ -47,7 +47,8 @@ try { 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 } @@ -61,6 +62,7 @@ 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(); @@ -70,10 +72,12 @@ $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. @@ -92,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'; @@ -117,13 +122,18 @@ $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 diff --git a/src/SafeCurl.php b/src/SafeCurl.php index 9670c6c..4b88fa2 100644 --- a/src/SafeCurl.php +++ b/src/SafeCurl.php @@ -105,60 +105,47 @@ protected function init() * 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) + public function execute($url) { - //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 self($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(); + $redirectLimit = $this->getOptions()->getFollowLocationLimit(); do { //Validate the URL - $url = Url::validateUrl($url, $safeCurl->getOptions()); + $url = Url::validateUrl($url, $this->getOptions()); - if ($safeCurl->getOptions()->getPinDns()) { + if ($this->getOptions()->getPinDns()) { //Send a Host header - curl_setopt($curlHandle, CURLOPT_HTTPHEADER, array('Host: '.$url['host'])); + curl_setopt($this->curlHandle, CURLOPT_HTTPHEADER, array('Host: '.$url['host'])); //The "fake" URL - curl_setopt($curlHandle, CURLOPT_URL, $url['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($curlHandle, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($this->curlHandle, CURLOPT_SSL_VERIFYPEER, false); } else { - curl_setopt($curlHandle, CURLOPT_URL, $url['url']); + curl_setopt($this->curlHandle, CURLOPT_URL, $url['url']); } //Execute the cURL request - $response = curl_exec($curlHandle); + $response = curl_exec($this->curlHandle); //Check for any errors - if (curl_errno($curlHandle)) { - throw new Exception('cURL Error: '.curl_error($curlHandle)); + if (curl_errno($this->curlHandle)) { + throw new Exception('cURL Error: '.curl_error($this->curlHandle)); } //Check for an HTTP redirect - if ($followLocation) { - $statusCode = curl_getinfo($curlHandle, CURLINFO_HTTP_CODE); + if ($this->getOptions()->getFollowLocation()) { + $statusCode = curl_getinfo($this->curlHandle, CURLINFO_HTTP_CODE); + switch ($statusCode) { case 301: case 302: @@ -167,10 +154,10 @@ public static function execute($url, $curlHandle = null, Options $options = null case 308: if ($redirectLimit == 0 || ++$redirectCount < $redirectLimit) { //Redirect received, so rinse and repeat - $url = curl_getinfo($curlHandle, CURLINFO_REDIRECT_URL); + $url = curl_getinfo($this->curlHandle, CURLINFO_REDIRECT_URL); $redirected = true; } else { - throw new Exception("Redirect limit '$redirectLimit' hit"); + throw new Exception('Redirect limit "'.$redirectLimit.'" hit'); } break; default: diff --git a/src/Url.php b/src/Url.php index b36af86..e8266f0 100644 --- a/src/Url.php +++ b/src/Url.php @@ -21,23 +21,23 @@ class Url public static function validateUrl($url, Options $options) { if (trim($url) == '') { - throw new InvalidURLException("Provided URL '$url' cannot be empty"); + throw new InvalidURLException('Provided URL "'.$url.'" cannot be empty'); } //Split URL into parts first $parts = parse_url($url); if (empty($parts)) { - throw new InvalidURLException("Error parsing URL '$url'"); + throw new InvalidURLException('Error parsing URL "'.$url.'"'); } if (!array_key_exists('host', $parts)) { - throw new InvalidURLException("Provided URL '$url' doesn't contain a hostname"); + throw new InvalidURLException('Provided URL "'.$url.'" doesn\'t contain a hostname'); } //If credentials are passed in, but we don't want them, raise an exception if (!$options->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 diff --git a/tests/SafeCurlTest.php b/tests/SafeCurlTest.php index ca81ff1..dde3d27 100644 --- a/tests/SafeCurlTest.php +++ b/tests/SafeCurlTest.php @@ -1,13 +1,125 @@ 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'), + ); + } + + /** + * @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 + * @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']); + } +} From 231c8ba62276d7d3a040229f6e5eda0e7f1e5dc8 Mon Sep 17 00:00:00 2001 From: Jeremy Benoist Date: Fri, 2 Oct 2015 17:56:50 +0200 Subject: [PATCH 05/14] Rewrite follow location count A previous solution was to use `CURLINFO_REDIRECT_URL` without `CURLOPT_FOLLOWLOCATION` and a do/while but `CURLINFO_REDIRECT_URL` was introduced in 5.3.7 & it doesn't exist in HHVM --- src/SafeCurl.php | 80 ++++++++++++++++++++---------------------------- src/Url.php | 2 +- 2 files changed, 34 insertions(+), 48 deletions(-) diff --git a/src/SafeCurl.php b/src/SafeCurl.php index 4b88fa2..28bf56c 100644 --- a/src/SafeCurl.php +++ b/src/SafeCurl.php @@ -118,53 +118,39 @@ public function execute($url) $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']); - } - - //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: - if ($redirectLimit == 0 || ++$redirectCount < $redirectLimit) { - //Redirect received, so rinse and repeat - $url = curl_getinfo($this->curlHandle, CURLINFO_REDIRECT_URL); - $redirected = true; - } else { - throw new Exception('Redirect limit "'.$redirectLimit.'" hit'); - } - break; - default: - $redirected = false; - } - } - } while ($redirected); + //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']); + } + + if ($this->getOptions()->getFollowLocation()) { + curl_setopt($this->curlHandle, CURLOPT_FOLLOWLOCATION, 1); + } + + //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)); + } + + // validate number of redirect + // a previous solution was to use `CURLINFO_REDIRECT_URL` without `CURLOPT_FOLLOWLOCATION` and a do/while + // but `CURLINFO_REDIRECT_URL` was introduced in 5.3.7 & it doesn't exist in HHVM + if ($this->getOptions()->getFollowLocation() && $redirectLimit !== 0 && (curl_getinfo($this->curlHandle, CURLINFO_REDIRECT_COUNT)) >= $redirectLimit) { + throw new Exception('Redirect limit "'.$redirectLimit.'" hit'); + } return $response; } diff --git a/src/Url.php b/src/Url.php index e8266f0..e370ce6 100644 --- a/src/Url.php +++ b/src/Url.php @@ -70,7 +70,7 @@ public static function validateUrl($url, Options $options) return array( 'url' => $url, 'host' => $host['host'], - 'ips' => $host['ips'] + 'ips' => $host['ips'], ); } From ad091d21ad05401bcaac3791c66c266d9405b436 Mon Sep 17 00:00:00 2001 From: Jeremy Benoist Date: Sat, 3 Oct 2015 09:32:14 +0200 Subject: [PATCH 06/14] Add test on Options --- src/Options.php | 2 +- tests/OptionsTest.php | 341 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 tests/OptionsTest.php diff --git a/src/Options.php b/src/Options.php index ade9b2a..f26feaa 100644 --- a/src/Options.php +++ b/src/Options.php @@ -286,7 +286,7 @@ public function setList($list, $values, $type = null) } if (!is_array($values)) { - throw new InvalidOptionException('Provided values must be an array'); + throw new InvalidOptionException('Provided values must be an array, "'.gettype($values).'" given'); } if ($type !== null) { 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); + } +} From b3a59e4fe521f3f4110a876f08c9a758222fe4cc Mon Sep 17 00:00:00 2001 From: Jeremy Benoist Date: Sat, 3 Oct 2015 09:36:21 +0200 Subject: [PATCH 07/14] Remove test files from Scrutinizer --- .scrutinizer.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.scrutinizer.yml b/.scrutinizer.yml index 4bb53b4..a7fc27d 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -1,3 +1,8 @@ tools: external_code_coverage: timeout: 600 + +filter: + excluded_paths: + - 'tests/*' + - '*Test.php' From 79b77a8ab4898077449e17fe5ecb847c792fcbee Mon Sep 17 00:00:00 2001 From: Jeremy Benoist Date: Sat, 3 Oct 2015 09:44:32 +0200 Subject: [PATCH 08/14] Fix examples --- .scrutinizer.yml | 1 + example/default.php | 4 ++-- example/options.php | 5 ++--- example/redirects.php | 5 ++--- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/.scrutinizer.yml b/.scrutinizer.yml index a7fc27d..e790b83 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -4,5 +4,6 @@ tools: filter: excluded_paths: + - 'example/*' - 'tests/*' - '*Test.php' 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 } From 206fb012182e5507f7f2d8b5cd5a5b9d4b1b6bc5 Mon Sep 17 00:00:00 2001 From: Jeremy Benoist Date: Sat, 3 Oct 2015 11:12:41 +0200 Subject: [PATCH 09/14] Re-introduce custom redirect To be able to validate url on each step --- src/SafeCurl.php | 91 ++++++++++++++++++++++++++---------------- tests/SafeCurlTest.php | 14 +++++++ 2 files changed, 71 insertions(+), 34 deletions(-) diff --git a/src/SafeCurl.php b/src/SafeCurl.php index 28bf56c..242cb4c 100644 --- a/src/SafeCurl.php +++ b/src/SafeCurl.php @@ -113,44 +113,67 @@ public function execute($url) //Backup the existing URL $originalUrl = $url; - //Execute, catch redirects and validate the URL $redirected = false; $redirectCount = 0; $redirectLimit = $this->getOptions()->getFollowLocationLimit(); - //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']); - } - - if ($this->getOptions()->getFollowLocation()) { - curl_setopt($this->curlHandle, CURLOPT_FOLLOWLOCATION, 1); - } - - //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)); - } - - // validate number of redirect - // a previous solution was to use `CURLINFO_REDIRECT_URL` without `CURLOPT_FOLLOWLOCATION` and a do/while - // but `CURLINFO_REDIRECT_URL` was introduced in 5.3.7 & it doesn't exist in HHVM - if ($this->getOptions()->getFollowLocation() && $redirectLimit !== 0 && (curl_getinfo($this->curlHandle, CURLINFO_REDIRECT_COUNT)) >= $redirectLimit) { - throw new Exception('Redirect limit "'.$redirectLimit.'" hit'); - } + 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/tests/SafeCurlTest.php b/tests/SafeCurlTest.php index dde3d27..ae0123b 100644 --- a/tests/SafeCurlTest.php +++ b/tests/SafeCurlTest.php @@ -110,6 +110,20 @@ public function testWithFollowLocation() $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 From 85dc5583109eb09680e2269fc58e7bd2ede9e15d Mon Sep 17 00:00:00 2001 From: Jeremy Benoist Date: Sat, 3 Oct 2015 11:20:47 +0200 Subject: [PATCH 10/14] Fix scrutinizer issues --- src/Options.php | 25 +++++++++++-------------- src/SafeCurl.php | 11 ++++------- src/Url.php | 8 ++++---- 3 files changed, 19 insertions(+), 25 deletions(-) diff --git a/src/Options.php b/src/Options.php index f26feaa..71cbf38 100644 --- a/src/Options.php +++ b/src/Options.php @@ -62,9 +62,6 @@ class Options 'scheme' => array(), ); - /** - * @return fin1te\SafeCurl\Options - */ public function __construct() { } @@ -82,7 +79,7 @@ public function getFollowLocation() /** * Enables following redirects. * - * @return fin1te\SafeCurl\Options + * @return Options */ public function enableFollowLocation() { @@ -94,7 +91,7 @@ public function enableFollowLocation() /** * Disables following redirects. * - * @return fin1te\SafeCurl\Options + * @return Options */ public function disableFollowLocation() { @@ -120,7 +117,7 @@ public function getFollowLocationLimit() * * @param $limit int * - * @return fin1te\SafeCurl\Options + * @return Options */ public function setFollowLocationLimit($limit) { @@ -128,7 +125,7 @@ public function setFollowLocationLimit($limit) throw new InvalidOptionException('Provided limit "'.$limit.'" must be an integer >= 0'); } - $this->followLocationLimit = $limit; + $this->followLocationLimit = (int) $limit; return $this; } @@ -147,7 +144,7 @@ public function getSendCredentials() * Enable sending of credenitals * This is potentially a security risk. * - * @return fin1te\SafeCurl\Options + * @return Options */ public function enableSendCredentials() { @@ -159,7 +156,7 @@ public function enableSendCredentials() /** * Disable sending of credentials. * - * @return fin1te\SafeCurl\Options + * @return Options */ public function disableSendCredentials() { @@ -181,7 +178,7 @@ public function getPinDns() /** * Enable DNS pinning. * - * @return fin1te\SafeCurl\Options + * @return Options */ public function enablePinDns() { @@ -193,7 +190,7 @@ public function enablePinDns() /** * Disable DNS pinning. * - * @return fin1te\SafeCurl\Options + * @return Options */ public function disablePinDns() { @@ -277,7 +274,7 @@ public function getList($list, $type = null) * @param $values array * @param $type string optional * - * @return fin1te\SafeCurl\Options + * @return Options */ public function setList($list, $values, $type = null) { @@ -317,7 +314,7 @@ public function setList($list, $values, $type = null) * @param $type string * @param $values array|string * - * @return fin1te\SafeCurl\Options + * @return Options */ public function addToList($list, $type, $values) { @@ -354,7 +351,7 @@ public function addToList($list, $type, $values) * @param $type string * @param $values array|string * - * @return fin1te\SafeCurl\Options + * @return Options */ public function removeFromList($list, $type, $values) { diff --git a/src/SafeCurl.php b/src/SafeCurl.php index 242cb4c..6c1c007 100644 --- a/src/SafeCurl.php +++ b/src/SafeCurl.php @@ -14,7 +14,7 @@ class SafeCurl /** * SafeCurl Options. * - * @var SafeCurl\Options + * @var Options */ private $options; @@ -22,7 +22,7 @@ class SafeCurl * Returns new instance of SafeCurl\SafeCurl. * * @param $curlHandle resource A valid cURL handle - * @param $options SafeCurl\Options optional + * @param $options Options optional */ public function __construct($curlHandle, Options $options = null) { @@ -64,7 +64,7 @@ public function setCurlHandle($curlHandle) /** * Gets Options. * - * @return SafeCurl\Options + * @return Options */ public function getOptions() { @@ -74,7 +74,7 @@ public function getOptions() /** * Sets Options. * - * @param $options SafeCurl\Options + * @param $options Options */ public function setOptions(Options $options) { @@ -110,9 +110,6 @@ protected function init() */ public function execute($url) { - //Backup the existing URL - $originalUrl = $url; - $redirected = false; $redirectCount = 0; $redirectLimit = $this->getOptions()->getFollowLocationLimit(); diff --git a/src/Url.php b/src/Url.php index e370ce6..24c156a 100644 --- a/src/Url.php +++ b/src/Url.php @@ -14,7 +14,7 @@ class Url * Validates the whole URL. * * @param $url string - * @param $options fin1te\SafeCurl\Options + * @param $options Options * * @return string */ @@ -78,7 +78,7 @@ public static function validateUrl($url, Options $options) * Validates a URL scheme. * * @param $scheme string - * @param $options fin1te\SafeCurl\Options + * @param $options Options * * @return string */ @@ -101,7 +101,7 @@ public static function validateScheme($scheme, Options $options) * Validates a port. * * @param $port int - * @param $options fin1te\SafeCurl\Options + * @param $options Options * * @return int */ @@ -123,7 +123,7 @@ public static function validatePort($port, Options $options) * Validates a URL host. * * @param $host string - * @param $options fin1te\SafeCurl\Options + * @param $options Options * * @returns string */ From dc707505308f77ab0fdb30f6e1f46502ffb49992 Mon Sep 17 00:00:00 2001 From: Jeremy Benoist Date: Sat, 3 Oct 2015 11:25:59 +0200 Subject: [PATCH 11/14] Spaces --- src/Options.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Options.php b/src/Options.php index 71cbf38..f200f39 100644 --- a/src/Options.php +++ b/src/Options.php @@ -34,7 +34,7 @@ class Options 'port' => array('80', '443', '8080'), 'domain' => array(), 'scheme' => array('http', 'https'), - ); + ); /** * @var array @@ -57,9 +57,9 @@ class Options '224.0.0.0/4', '240.0.0.0/4', ), - 'port' => array(), - 'domain' => array(), - 'scheme' => array(), + 'port' => array(), + 'domain' => array(), + 'scheme' => array(), ); public function __construct() From 7c4b89d20c1e676789e42ceeb87042b458fa71ef Mon Sep 17 00:00:00 2001 From: Jeremy Benoist Date: Sat, 3 Oct 2015 11:38:41 +0200 Subject: [PATCH 12/14] Lowercase for scheme & host --- src/Url.php | 4 ++++ tests/SafeCurlTest.php | 1 + 2 files changed, 5 insertions(+) diff --git a/src/Url.php b/src/Url.php index 24c156a..7ba4df2 100644 --- a/src/Url.php +++ b/src/Url.php @@ -84,6 +84,8 @@ public static function validateUrl($url, 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'))); @@ -129,6 +131,8 @@ public static function validatePort($port, 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'))); diff --git a/tests/SafeCurlTest.php b/tests/SafeCurlTest.php index ae0123b..41b3cb3 100644 --- a/tests/SafeCurlTest.php +++ b/tests/SafeCurlTest.php @@ -36,6 +36,7 @@ public function dataForBlockedUrl() 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'), ); } From 984cd2e00d78ddcbe4992d626412fd80329bb7ea Mon Sep 17 00:00:00 2001 From: Jeremy Benoist Date: Tue, 27 Oct 2015 13:44:42 +0100 Subject: [PATCH 13/14] Update readme * remove bounty (since there is no demo) * remove demo (since the demo use old project) --- README.md | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 44f0400..5305e8e 100644 --- a/README.md +++ b/README.md @@ -19,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. @@ -137,16 +137,6 @@ $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. From 13316769b0e53bad0f1701bca995f4e077a6d70f Mon Sep 17 00:00:00 2001 From: Jeremy Benoist Date: Thu, 29 Oct 2015 14:01:24 +0100 Subject: [PATCH 14/14] Add .gitattributes to slim down composer packages When composer is used as a dependency manager for large web apps, transferring lots of extra files (such as tests and documentation) can be very time and bandwidth consuming. By using .gitattributes we can tell composer what files to ignore when distributing through composer. --- .gitattributes | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .gitattributes 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