diff --git a/src/p3k/HTTP.php b/src/p3k/HTTP.php index 487c5f9..42bd29f 100644 --- a/src/p3k/HTTP.php +++ b/src/p3k/HTTP.php @@ -8,8 +8,9 @@ class HTTP { private $_transport; private $_user_agent; + private $_guard = null; - public function __construct($user_agent=null, HTTP\Transport $transport=null) { + public function __construct($user_agent=null, ?HTTP\Transport $transport=null) { if($user_agent) { $this->_user_agent = $user_agent; } @@ -36,48 +37,116 @@ class HTTP { $this->_transport = $transport; } + /** + * Safe mode: only fetch http and https URLs whose host resolves to public + * addresses, follow redirects one at a time so every hop is checked, and + * drop credentials when a redirect leaves the original origin. + * + * @param bool $enabled + * @param array $allow Hostnames, IP addresses or CIDR ranges that + * may be reached although they are private, + * e.g. a development server on the LAN. + * @param callable|null $resolver fn(string $host): string[]; the system + * resolver when null. For tests. + */ + public function set_safe_mode($enabled, array $allow=[], $resolver=null) { + $this->_guard = $enabled ? new HTTP\Guard($allow, $resolver) : null; + } + + public function safe_mode() { + return $this->_guard !== null; + } + public function get($url, $headers=[]) { - $this->_transport->set_timeout($this->_timeout); - $this->_transport->set_max_redirects($this->_max_redirects); - if($this->_user_agent) { - $headers[] = 'User-Agent: ' . $this->_user_agent; - } - $response = $this->_transport->get($url, $headers); - $response = $this->_build_response($response); - return $response; + return $this->_request('get', $url, false, $headers); } public function post($url, $body, $headers=[]) { - $this->_transport->set_timeout($this->_timeout); - $this->_transport->set_max_redirects($this->_max_redirects); - if($this->_user_agent) { - $headers[] = 'User-Agent: ' . $this->_user_agent; - } - $response = $this->_transport->post($url, $body, $headers); - $response = $this->_build_response($response); - return $response; + return $this->_request('post', $url, $body, $headers); } public function head($url, $headers=[]) { - $this->_transport->set_timeout($this->_timeout); - $this->_transport->set_max_redirects($this->_max_redirects); - if($this->_user_agent) { - $headers[] = 'User-Agent: ' . $this->_user_agent; - } - $response = $this->_transport->head($url, $headers); - $response = $this->_build_response($response); - return $response; + return $this->_request('head', $url, false, $headers); } public function put($url, $body, $headers=[]) { + return $this->_request('put', $url, $body, $headers); + } + + private function _request($method, $url, $body, $headers) { $this->_transport->set_timeout($this->_timeout); - $this->_transport->set_max_redirects($this->_max_redirects); if($this->_user_agent) { $headers[] = 'User-Agent: ' . $this->_user_agent; } - $response = $this->_transport->put($url, $body, $headers); - $response = $this->_build_response($response); - return $response; + + if($this->_guard === null) { + $this->_transport->set_max_redirects($this->_max_redirects); + return $this->_build_response($this->_send($method, $url, $body, $headers)); + } + + $this->_transport->set_max_redirects(0); + $redirects = $this->_max_redirects; + + while(true) { + $check = $this->_guard->check($url); + if(isset($check['error'])) { + return $this->_build_response([ + 'code' => 0, + 'header' => '', + 'body' => false, + 'error' => $check['error'], + 'error_description' => $check['error_description'], + 'url' => $url, + 'debug' => '', + ]); + } + + $pinnable = $this->_transport instanceof HTTP\Pinnable; + if($pinnable) { + $this->_transport->pin_addresses(array_map(function($address) use($check) { + return $check['host'] . ':' . $check['port'] . ':' . (strpos($address, ':') !== false ? '[' . $address . ']' : $address); + }, $check['addresses'])); + } + try { + $response = $this->_build_response($this->_send($method, $url, $body, $headers)); + } finally { + if($pinnable) + $this->_transport->pin_addresses(null); + } + + $location = in_array((int)$response['code'], [301, 302, 303, 307, 308], true) ? ($response['headers']['Location'] ?? null) : null; + if(is_array($location)) + $location = end($location); + if(!is_string($location) || $location === '') + return $response; + + if($redirects-- <= 0) { + $response['error'] = 'too_many_redirects'; + $response['error_description'] = 'Stopped after ' . $this->_max_redirects . ' redirects'; + return $response; + } + + $next = \Mf2\resolveUrl($url, $location); + if(self::_origin($next) !== self::_origin($url)) { + $headers = array_values(array_filter($headers, function($header) { + return !preg_match('/^\s*(authorization|proxy-authorization|cookie)\s*:/i', $header); + })); + } + $url = $next; + } + } + + private function _send($method, $url, $body, $headers) { + if($method === 'post' || $method === 'put') + return $this->_transport->{$method}($url, $body, $headers); + return $this->_transport->{$method}($url, $headers); + } + + private static function _origin($url) { + $parts = parse_url($url); + $scheme = strtolower($parts['scheme'] ?? ''); + $port = $parts['port'] ?? ($scheme === 'https' ? 443 : 80); + return $scheme . '://' . strtolower($parts['host'] ?? '') . ':' . $port; } private function _build_response($response) {