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
This commit is contained in:
mark_story 2012-11-11 14:23:17 -05:00
parent 7a5dfdf4ba
commit 240c8718eb
3 changed files with 124 additions and 9 deletions

View file

@ -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);
}

View file

@ -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
*

View file

@ -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);
}
}
}