From 240c8718ebb92389ed32fb303777e4d289eb7ebc Mon Sep 17 00:00:00 2001 From: mark_story Date: Sun, 11 Nov 2012 14:23:17 -0500 Subject: [PATCH] Implement SSL peer verification in HttpSocket Use the stream API and enable SSL certificate validation. This increases the security features of HttpSocket, and provides easy access to the stream context options. SSL related configuration should be prefixed with 'ssl_'. Refs #2270 --- lib/Cake/Network/CakeSocket.php | 42 +++++++++++++-- lib/Cake/Network/Http/HttpSocket.php | 38 ++++++++++++- .../Test/Case/Network/Http/HttpSocketTest.php | 53 ++++++++++++++++++- 3 files changed, 124 insertions(+), 9 deletions(-) diff --git a/lib/Cake/Network/CakeSocket.php b/lib/Cake/Network/CakeSocket.php index ce482174f..1fa0cd0c9 100644 --- a/lib/Cake/Network/CakeSocket.php +++ b/lib/Cake/Network/CakeSocket.php @@ -101,6 +101,14 @@ class CakeSocket { // @codingStandardsIgnoreEnd ); +/** + * Used to capture connection warnings which can happen when there are + * SSL errors for example. + * + * @var array + */ + protected $_connectionErrors = array(); + /** * Constructor. * @@ -130,17 +138,19 @@ class CakeSocket { $scheme = 'ssl://'; } - if (!empty($this->config['request']['context'])) { - $context = stream_context_create($this->config['request']['context']); + if (!empty($this->config['context'])) { + $context = stream_context_create($this->config['context']); } else { $context = stream_context_create(); } $connectAs = STREAM_CLIENT_CONNECT; if ($this->config['persistent']) { - $connectAs = STREAM_CLIENT_PERSISTENT; + $connectAs |= STREAM_CLIENT_PERSISTENT; } - $this->connection = @stream_socket_client( + + set_error_handler(array($this, '_connectionErrorHandler')); + $this->connection = stream_socket_client( $scheme . $this->config['host'] . ':' . $this->config['port'], $errNum, $errStr, @@ -148,12 +158,18 @@ class CakeSocket { $connectAs, $context ); + restore_error_handler(); if (!empty($errNum) || !empty($errStr)) { $this->setLastError($errNum, $errStr); throw new SocketException($errStr, $errNum); } + if (!$this->connection && $this->_connectionErrors) { + $message = implode("\n", $this->_connectionErrors); + throw new SocketException($message, E_WARNING); + } + $this->connected = is_resource($this->connection); if ($this->connected) { stream_set_timeout($this->connection, $this->config['timeout']); @@ -161,12 +177,28 @@ class CakeSocket { return $this->connected; } +/** + * socket_stream_client() does not populate errNum, or $errStr when there are + * connection errors, as in the case of SSL verification failure. + * + * Instead we need to handle those errors manually. + * + * @param int $code + * @param string $message + */ + protected function _connectionErrorHandler($code, $message) { + $this->_connectionErrors[] = $message; + } + /** * Get the connection context. * - * @return array + * @return null|array Null when there is no connnection, an array when there is. */ public function context() { + if (!$this->connection) { + return; + } return stream_context_get_options($this->connection); } diff --git a/lib/Cake/Network/Http/HttpSocket.php b/lib/Cake/Network/Http/HttpSocket.php index 600b3db85..a0dae10f6 100644 --- a/lib/Cake/Network/Http/HttpSocket.php +++ b/lib/Cake/Network/Http/HttpSocket.php @@ -18,6 +18,7 @@ */ App::uses('CakeSocket', 'Network'); App::uses('Router', 'Routing'); +App::uses('Hash', 'Utility'); /** * Cake network socket connection class. @@ -64,7 +65,7 @@ class HttpSocket extends CakeSocket { ), 'raw' => null, 'redirect' => false, - 'cookies' => array() + 'cookies' => array(), ); /** @@ -92,6 +93,9 @@ class HttpSocket extends CakeSocket { 'protocol' => 'tcp', 'port' => 80, 'timeout' => 30, + 'ssl_verify_peer' => true, + 'ssl_verify_depth' => 5, + 'ssl_verify_host' => true, 'request' => array( 'uri' => array( 'scheme' => array('http', 'https'), @@ -99,7 +103,7 @@ class HttpSocket extends CakeSocket { 'port' => array(80, 443) ), 'redirect' => false, - 'cookies' => array() + 'cookies' => array(), ) ); @@ -348,6 +352,8 @@ class HttpSocket extends CakeSocket { return false; } + $this->_configContext($this->request['uri']['host']); + $this->request['raw'] = ''; if ($this->request['line'] !== false) { $this->request['raw'] = $this->request['line']; @@ -395,6 +401,7 @@ class HttpSocket extends CakeSocket { throw new SocketException(__d('cake_dev', 'Class %s not found.', $this->responseClass)); } $this->response = new $responseClass($response); + if (!empty($this->response->cookies)) { if (!isset($this->config['request']['cookies'][$Host])) { $this->config['request']['cookies'][$Host] = array(); @@ -643,6 +650,33 @@ class HttpSocket extends CakeSocket { return true; } +/** + * Configure the socket's context. Adds in configuration + * that can not be declared in the class definition. + * + * @param string $host The host you're connecting to. + * @return void + */ + protected function _configContext($host) { + foreach ($this->config as $key => $value) { + if (substr($key, 0, 4) !== 'ssl_') { + continue; + } + $contextKey = substr($key, 4); + if (empty($this->config['context']['ssl'][$contextKey])) { + $this->config['context']['ssl'][$contextKey] = $value; + } + unset($this->config[$key]); + } + if (empty($this->_context['ssl']['cafile'])) { + $this->config['context']['ssl']['cafile'] = CAKE . 'Config' . DS . 'cacert.pem'; + } + if (!empty($this->config['context']['ssl']['verify_host'])) { + $this->config['context']['ssl']['CN_match'] = $host; + unset($this->config['context']['ssl']['verify_host']); + } + } + /** * Takes a $uri array and turns it into a fully qualified URL string * diff --git a/lib/Cake/Test/Case/Network/Http/HttpSocketTest.php b/lib/Cake/Test/Case/Network/Http/HttpSocketTest.php index 3fc70d742..8914b833f 100644 --- a/lib/Cake/Test/Case/Network/Http/HttpSocketTest.php +++ b/lib/Cake/Test/Case/Network/Http/HttpSocketTest.php @@ -253,6 +253,9 @@ class HttpSocketTest extends CakeTestCase { 'protocol' => 'tcp', 'port' => 23, 'timeout' => 30, + 'ssl_verify_peer' => true, + 'ssl_verify_depth' => 5, + 'ssl_verify_host' => true, 'request' => array( 'uri' => array( 'scheme' => 'https', @@ -260,7 +263,7 @@ class HttpSocketTest extends CakeTestCase { 'port' => 23 ), 'redirect' => false, - 'cookies' => array() + 'cookies' => array(), ) ); $this->assertEquals($expected, $this->Socket->config); @@ -278,6 +281,9 @@ class HttpSocketTest extends CakeTestCase { 'protocol' => 'tcp', 'port' => 80, 'timeout' => 30, + 'ssl_verify_peer' => true, + 'ssl_verify_depth' => 5, + 'ssl_verify_host' => true, 'request' => array( 'uri' => array( 'scheme' => 'http', @@ -285,7 +291,7 @@ class HttpSocketTest extends CakeTestCase { 'port' => 80 ), 'redirect' => false, - 'cookies' => array() + 'cookies' => array(), ) ); $this->assertEquals($expected, $this->Socket->config); @@ -311,6 +317,15 @@ class HttpSocketTest extends CakeTestCase { $response = $this->Socket->request(true); $this->assertFalse($response); + $context = array( + 'ssl' => array( + 'verify_peer' => true, + 'verify_depth' => 5, + 'CN_match' => 'www.cakephp.org', + 'cafile' => CAKE . 'Config' . DS . 'cacert.pem' + ) + ); + $tests = array( array( 'request' => 'http://www.cakephp.org/?foo=bar', @@ -321,6 +336,7 @@ class HttpSocketTest extends CakeTestCase { 'protocol' => 'tcp', 'port' => 80, 'timeout' => 30, + 'context' => $context, 'request' => array( 'uri' => array( 'scheme' => 'http', @@ -1668,4 +1684,37 @@ class HttpSocketTest extends CakeTestCase { } $this->assertEquals(true, $return); } + +/** + * test configuring the context from the flat keys. + * + * @return void + */ + public function testConfigContext() { + $this->Socket->reset(); + $this->Socket->request('http://example.com'); + $this->assertTrue($this->Socket->config['context']['ssl']['verify_peer']); + $this->assertEquals(5, $this->Socket->config['context']['ssl']['verify_depth']); + $this->assertEquals('example.com', $this->Socket->config['context']['ssl']['CN_match']); + $this->assertArrayNotHasKey('ssl_verify_peer', $this->Socket->config); + $this->assertArrayNotHasKey('ssl_verify_host', $this->Socket->config); + $this->assertArrayNotHasKey('ssl_verify_depth', $this->Socket->config); + } + +/** + * Test that requests fail when peer verification fails. + * + * @return void + */ + public function testVerifyPeer() { + $socket = new HttpSocket(); + try { + $result = $socket->get('https://typography.com'); + $this->markTestSkipped('Found valid certificate, was expecting invalid certificate.'); + } catch (SocketException $e) { + $message = $e->getMessage(); + $this->assertContains('Peer certificate CN', $message); + $this->assertContains('Failed to enable crypto', $message); + } + } }