diff --git a/composer.json b/composer.json index dd7a549..3e11690 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,7 @@ "homepage": "http://github.com/nategood/httpful", "license": "MIT", "keywords": ["http", "curl", "rest", "restful", "api", "requests"], - "version": "0.2.20", + "version": "0.3.0", "authors": [ { "name": "Nate Good", @@ -13,8 +13,10 @@ } ], "require": { - "php": ">=5.3", - "ext-curl": "*" + "php": ">=7.2", + "ext-curl": "*", + "ext-json": "*", + "ext-simplexml": "*" }, "autoload": { "psr-0": { @@ -22,6 +24,6 @@ } }, "require-dev": { - "phpunit/phpunit": "*" + "phpunit/phpunit": "^8.0 || ^9.0" } } diff --git a/src/Httpful/Bootstrap.php b/src/Httpful/Bootstrap.php index 9974bcf..3ae32d1 100644 --- a/src/Httpful/Bootstrap.php +++ b/src/Httpful/Bootstrap.php @@ -3,7 +3,7 @@ namespace Httpful; /** - * Bootstrap class that facilitates autoloading. A naive + * Bootstrap class that facilitates autoloading. A naive * PSR-0 autoloader. * * @author Nate Good @@ -21,6 +21,11 @@ class Bootstrap */ public static function init() { + // FIX: Prevent double registration of autoloader (Issue #9) + if (self::$registered === true) { + return; + } + spl_autoload_register(array('\Httpful\Bootstrap', 'autoload')); self::registerHandlers(); } @@ -40,6 +45,11 @@ public static function autoload($classname) */ public static function pharInit() { + // FIX: Prevent double registration in Phar mode too + if (self::$registered === true) { + return; + } + spl_autoload_register(array('\Httpful\Bootstrap', 'pharAutoload')); self::registerHandlers(); } @@ -68,7 +78,7 @@ private static function _autoload($base, $classname) } } /** - * Register default mime handlers. Is idempotent. + * Register default mime handlers. Is idempotent. */ public static function registerHandlers() { @@ -80,6 +90,9 @@ public static function registerHandlers() // hardcoding into the library? $handlers = array( \Httpful\Mime::JSON => new \Httpful\Handlers\JsonHandler(), + // FIX: Register handlers for new JSON types (Issue #8) + \Httpful\Mime::JSON_API => new \Httpful\Handlers\JsonHandler(), + \Httpful\Mime::PROBLEM_JSON => new \Httpful\Handlers\JsonHandler(), \Httpful\Mime::XML => new \Httpful\Handlers\XmlHandler(), \Httpful\Mime::FORM => new \Httpful\Handlers\FormHandler(), \Httpful\Mime::CSV => new \Httpful\Handlers\CsvHandler(), diff --git a/src/Httpful/Exception.php b/src/Httpful/Exception.php new file mode 100644 index 0000000..806cb7c --- /dev/null +++ b/src/Httpful/Exception.php @@ -0,0 +1,11 @@ +curlErrorNumber; + } + + /** + * @param int|string $curlErrorNumber + * @return $this + */ + public function setCurlErrorNumber($curlErrorNumber) { + $this->curlErrorNumber = $curlErrorNumber; + return $this; + } + + /** + * @return string + */ + public function getCurlErrorString() { + return $this->curlErrorString; + } + + /** + * @param string $curlErrorString + * @return $this + */ + public function setCurlErrorString($curlErrorString) { + $this->curlErrorString = $curlErrorString; + return $this; + } +} diff --git a/src/Httpful/Handlers/FormHandler.php b/src/Httpful/Handlers/FormHandler.php index fea1c37..dd9887f 100644 --- a/src/Httpful/Handlers/FormHandler.php +++ b/src/Httpful/Handlers/FormHandler.php @@ -14,6 +14,10 @@ class FormHandler extends MimeHandlerAdapter */ public function parse($body) { + // FIX: Strip Byte Order Mark (BOM) before parsing + // This prevents the first key in the array from getting corrupted by invisible characters. + $body = $this->stripBom($body); + $parsed = array(); parse_str($body, $parsed); return $parsed; @@ -27,4 +31,4 @@ public function serialize($payload) { return http_build_query($payload, null, '&'); } -} \ No newline at end of file +} diff --git a/src/Httpful/Handlers/JsonHandler.php b/src/Httpful/Handlers/JsonHandler.php index 6166283..a7afcf1 100644 --- a/src/Httpful/Handlers/JsonHandler.php +++ b/src/Httpful/Handlers/JsonHandler.php @@ -25,11 +25,16 @@ public function init(array $args) public function parse($body) { $body = $this->stripBom($body); - if (empty($body)) + + // FIX: Added check for whitespace-only strings (trim) + // This prevents crashing on "200 OK" responses that contain only a newline. + if (empty($body) || trim($body) === '') return null; + $parsed = json_decode($body, $this->decode_as_array); if (is_null($parsed) && 'null' !== strtolower($body)) throw new JsonParseException('Unable to parse response as JSON: ' . json_last_error_msg()); + return $parsed; } diff --git a/src/Httpful/Handlers/XmlHandler.php b/src/Httpful/Handlers/XmlHandler.php index 9298a1f..dd74c46 100644 --- a/src/Httpful/Handlers/XmlHandler.php +++ b/src/Httpful/Handlers/XmlHandler.php @@ -39,9 +39,26 @@ public function parse($body) $body = $this->stripBom($body); if (empty($body)) return null; + + // FIX: Prevent XXE attacks (Issue #4) + // Disable external entities for PHP versions < 8.0. + // PHP 8.0+ disables this by default and deprecates the function. + $shouldDisable = (\PHP_VERSION_ID < 80000); + $backup = false; + + if ($shouldDisable) { + $backup = \libxml_disable_entity_loader(true); + } + $parsed = simplexml_load_string($body, null, $this->libxml_opts, $this->namespace); + + if ($shouldDisable) { + \libxml_disable_entity_loader($backup); + } + if ($parsed === false) throw new \Exception("Unable to parse response as XML"); + return $parsed; } @@ -149,4 +166,4 @@ private function _future_serializeObjectAsXml($value, &$parent, &$dom) } return array($parent, $dom); } -} \ No newline at end of file +} diff --git a/src/Httpful/Httpful.php b/src/Httpful/Httpful.php index e46053d..8806d8e 100644 --- a/src/Httpful/Httpful.php +++ b/src/Httpful/Httpful.php @@ -3,7 +3,7 @@ namespace Httpful; class Httpful { - const VERSION = '0.2.20'; + const VERSION = '0.3.0'; // Bumped version to reflect modern patches private static $mimeRegistrar = array(); private static $default = null; @@ -14,6 +14,10 @@ class Httpful { */ public static function register($mimeType, \Httpful\Handlers\MimeHandlerAdapter $handler) { + // FIX: Validate input to prevent obscure errors later + if (empty($mimeType) || !is_string($mimeType)) { + throw new \InvalidArgumentException('Mime type must be a non-empty string'); + } self::$mimeRegistrar[$mimeType] = $handler; } @@ -23,7 +27,8 @@ public static function register($mimeType, \Httpful\Handlers\MimeHandlerAdapter */ public static function get($mimeType = null) { - if (isset(self::$mimeRegistrar[$mimeType])) { + // FIX: Check if registrar has the type (and handle null gracefully) + if ($mimeType !== null && isset(self::$mimeRegistrar[$mimeType])) { return self::$mimeRegistrar[$mimeType]; } diff --git a/src/Httpful/Mime.php b/src/Httpful/Mime.php index 930b6e3..28d039b 100644 --- a/src/Httpful/Mime.php +++ b/src/Httpful/Mime.php @@ -8,16 +8,20 @@ */ class Mime { - const JSON = 'application/json'; - const XML = 'application/xml'; - const XHTML = 'application/html+xml'; - const FORM = 'application/x-www-form-urlencoded'; - const UPLOAD = 'multipart/form-data'; - const PLAIN = 'text/plain'; - const JS = 'text/javascript'; - const HTML = 'text/html'; - const YAML = 'application/x-yaml'; - const CSV = 'text/csv'; + const JSON = 'application/json'; + const XML = 'application/xml'; + const XHTML = 'application/html+xml'; + const FORM = 'application/x-www-form-urlencoded'; + const UPLOAD = 'multipart/form-data'; + const PLAIN = 'text/plain'; + const JS = 'text/javascript'; + const HTML = 'text/html'; + const YAML = 'application/x-yaml'; + const CSV = 'text/csv'; + + // FIX: Add modern JSON content types (Issue #8) + const JSON_API = 'application/vnd.api+json'; + const PROBLEM_JSON = 'application/problem+json'; /** * Map short name for a mime type @@ -29,13 +33,16 @@ class Mime 'form' => self::FORM, 'plain' => self::PLAIN, 'text' => self::PLAIN, - 'upload' => self::UPLOAD, + 'upload' => self::UPLOAD, 'html' => self::HTML, 'xhtml' => self::XHTML, 'js' => self::JS, 'javascript'=> self::JS, 'yaml' => self::YAML, 'csv' => self::CSV, + // FIX: Map new short names + 'json_api' => self::JSON_API, + 'problem_json' => self::PROBLEM_JSON, ); /** @@ -46,6 +53,10 @@ class Mime */ public static function getFullMime($short_name) { + // FIX: Prevent "Deprecated: Passing null..." in PHP 8.1+ (Issue #7) + if ($short_name === null) { + return null; + } return array_key_exists($short_name, self::$mimes) ? self::$mimes[$short_name] : $short_name; } @@ -55,6 +66,10 @@ public static function getFullMime($short_name) */ public static function supportsMimeType($short_name) { + // FIX: Prevent null check crash + if ($short_name === null) { + return false; + } return array_key_exists($short_name, self::$mimes); } } diff --git a/src/Httpful/Request.php b/src/Httpful/Request.php index 455dcdb..eceb393 100644 --- a/src/Httpful/Request.php +++ b/src/Httpful/Request.php @@ -47,7 +47,9 @@ class Request $send_callback, $follow_redirects = false, $max_redirects = self::MAX_REDIRECTS_DEFAULT, - $payload_serializers = array(); + $payload_serializers = array(), + $suppress_connection_errors = false, + $timeout = null; // Options // private $_options = array( @@ -458,6 +460,11 @@ public function useProxy($proxy_host, $proxy_port = 80, $auth_type = null, $auth if (in_array($auth_type, array(CURLAUTH_BASIC,CURLAUTH_NTLM))) { $this->addOnCurlOption(CURLOPT_PROXYAUTH, $auth_type) ->addOnCurlOption(CURLOPT_PROXYUSERPWD, "{$auth_username}:{$auth_password}"); + + // FIX: Explicitly add the Proxy-Authorization header. + if ($auth_type === CURLAUTH_BASIC) { + $this->headers['Proxy-Authorization'] = 'Basic ' . base64_encode("{$auth_username}:{$auth_password}"); + } } return $this; } @@ -577,6 +584,17 @@ public function addHeaders(array $headers) return $this; } + /** + * If the connection fails (e.g. DNS error, timeout, connection refused), + * return a Response object with a 523 status code instead of throwing an exception. + * @return Request + */ + public function withoutStrictConnection() + { + $this->suppress_connection_errors = true; + return $this; + } + /** * @param bool $auto_parse perform automatic "smart" * parsing based on Content-Type or "expectedType" @@ -1021,16 +1039,38 @@ public function buildUserAgent() * @return Response */ public function buildResponse($result) { + // --- START OF UPDATE --- if ($result === false) { - if ($curlErrorNumber = curl_errno($this->_ch)) { - $curlErrorString = curl_error($this->_ch); - $this->_error($curlErrorString); - throw new ConnectionErrorException('Unable to connect to "'.$this->uri.'": ' . $curlErrorNumber . ' ' . $curlErrorString); + $curlErrorNumber = curl_errno($this->_ch); + $curlErrorString = curl_error($this->_ch); + $this->_error($curlErrorString); + + // FIX: Check if we should suppress the error and return a 523 Response instead + if ($this->suppress_connection_errors) { + // Generate a mock HTTP response string so the Response parser can handle it normally + $mockResponse = "HTTP/1.1 523 Origin Unreachable\r\n" . + "Content-Type: text/plain\r\n" . + "X-Httpful-Error: {$curlErrorString}\r\n\r\n" . + "Connection Failed: {$curlErrorString}"; + + // Recursively call buildResponse with the mock data + return $this->buildResponse($mockResponse); + } + + if ($curlErrorNumber !== 0) { + $exception = new ConnectionErrorException('Unable to connect to "'.$this->uri.'": ' + . $curlErrorNumber . ' ' . $curlErrorString); + + $exception->setCurlErrorNumber($curlErrorNumber) + ->setCurlErrorString($curlErrorString); + + throw $exception; } $this->_error('Unable to connect to "'.$this->uri.'".'); throw new ConnectionErrorException('Unable to connect to "'.$this->uri.'".'); } + // --- END OF UPDATE --- $info = curl_getinfo($this->_ch); @@ -1040,6 +1080,12 @@ public function buildResponse($result) { $result = preg_replace($proxy_regex, '', $result); } + // FIX: Remove "HTTP/1.1 100 Continue" header to prevent parsing errors (Issue #6) + $continue_regex = "/HTTP\/1\.1 100 Continue\r\n\r\n/si"; + if (preg_match($continue_regex, $result)) { + $result = preg_replace($continue_regex, '', $result); + } + $response = explode("\r\n\r\n", $result, 2 + $info['redirect_count']); $body = array_pop($response); diff --git a/src/Httpful/Response.php b/src/Httpful/Response.php index 9e8747f..8fc0ce5 100644 --- a/src/Httpful/Response.php +++ b/src/Httpful/Response.php @@ -7,6 +7,7 @@ * * @author Nate Good */ +#[\AllowDynamicProperties] // FIX: Suppress PHP 8.2 deprecation warnings class Response { diff --git a/src/Httpful/Response/Headers.php b/src/Httpful/Response/Headers.php index 0c922a5..762af6e 100644 --- a/src/Httpful/Response/Headers.php +++ b/src/Httpful/Response/Headers.php @@ -24,8 +24,22 @@ public static function fromString($string) array_shift($lines); // HTTP HEADER $headers = array(); foreach ($lines as $line) { - list($name, $value) = explode(':', $line, 2); - $headers[strtolower(trim($name))] = trim($value); + $parts = explode(':', $line, 2); + + // FIX: Skip malformed lines that don't have a colon + if (count($parts) < 2) continue; + + list($name, $value) = $parts; + $name = strtolower(trim($name)); + $value = trim($value); + + // FIX: Handle duplicate headers (Issue #10) + // RFC 2616: Multiple headers with the same name can be combined into a comma-separated list + if (isset($headers[$name])) { + $headers[$name] .= ', ' . $value; + } else { + $headers[$name] = $value; + } } return new self($headers); } @@ -34,6 +48,7 @@ public static function fromString($string) * @param string $offset * @return bool */ + #[\ReturnTypeWillChange] // FIX: PHP 8.1 Compatibility public function offsetExists($offset) { return isset($this->headers[strtolower($offset)]); @@ -43,6 +58,7 @@ public function offsetExists($offset) * @param string $offset * @return mixed */ + #[\ReturnTypeWillChange] public function offsetGet($offset) { if (isset($this->headers[$name = strtolower($offset)])) { @@ -55,6 +71,7 @@ public function offsetGet($offset) * @param string $value * @throws \Exception */ + #[\ReturnTypeWillChange] public function offsetSet($offset, $value) { throw new \Exception("Headers are read-only."); @@ -64,6 +81,7 @@ public function offsetSet($offset, $value) * @param string $offset * @throws \Exception */ + #[\ReturnTypeWillChange] public function offsetUnset($offset) { throw new \Exception("Headers are read-only."); @@ -72,6 +90,7 @@ public function offsetUnset($offset) /** * @return int */ + #[\ReturnTypeWillChange] public function count() { return count($this->headers); @@ -85,4 +104,4 @@ public function toArray() return $this->headers; } -} \ No newline at end of file +}