chg: [HttpTool] initial improvements for http client

pull/9460/head
Christophe Vandeplas 2023-12-19 18:39:31 +00:00
parent 6aaab1d77f
commit 7f57b186f8
3 changed files with 376 additions and 20 deletions

View File

@ -0,0 +1,56 @@
<?php
namespace App\Lib\Tools;
use Cake\Http\Client\Adapter\Curl;
use Cake\Http\Client\Exception\ClientException;
use Cake\Http\Client\Exception\NetworkException;
use Cake\Http\Client\Exception\RequestException;
use Psr\Http\Message\RequestInterface;
class CurlAdvanced extends Curl
{
/**
* @inheritDoc
*/
public function getCertificateChain(RequestInterface $request, array $options): array
{
if (!extension_loaded('curl')) {
throw new ClientException('curl extension is not loaded.');
}
$ch = curl_init();
$options['curl'] = [
CURLOPT_CERTINFO => true,
// CURLOPT_VERBOSE => true,
CURLOPT_NOBODY => true,
];
$options = $this->buildOptions($request, $options);
curl_setopt_array($ch, $options);
/** @var string|false $body */
$body = $this->exec($ch);
if ($body === false) {
$errorCode = curl_errno($ch);
$error = curl_error($ch);
curl_close($ch);
$message = "cURL Error ({$errorCode}) {$error}";
$errorNumbers = [
CURLE_FAILED_INIT,
CURLE_URL_MALFORMAT,
CURLE_URL_MALFORMAT_USER,
];
if (in_array($errorCode, $errorNumbers, true)) {
throw new RequestException($message, $request);
}
throw new NetworkException($message, $request);
}
$certinfo = curl_getinfo($ch, CURLINFO_CERTINFO);
curl_close($ch);
return $certinfo;
}
}

View File

@ -2,25 +2,101 @@
namespace App\Lib\Tools;
use Cake\Core\Exception\CakeException;
use App\Lib\Tools\CurlAdvanced;
use Cake\Core\Configure;
use Cake\Http\Client;
use Cake\Core\Exception\CakeException;
use Cake\Http\Client as CakeClient;
use Cake\Http\Client\Request;
use Cake\Http\Client\Response;
use Cake\Http\Exception\NotImplementedException;
use Cake\I18n\FrozenTime;
class HttpTool
class HttpTool extends CakeClient
{
public function createRequest(array $params = []): Client
public function __construct(array $config = [])
{
// Use own CA PEM file
$this->buildDefaultConfigFromSettings();
// Custom Curl Adapter to give extra features, including SSL Cert dumping
// $config['adapter'] = CurlAdvanced::class;
parent::__construct($config);
}
/**
* buildDefaultConfigFromSettings
*
* @return array
*/
public function buildDefaultConfigFromSettings()
{
/*
## CakeClient settings
headers - Array of additional headers
cookie - Array of cookies to use.
proxy - Array of proxy information.
auth - Array of authentication data, the type key is used to delegate to an authentication strategy. By default Basic auth is used.
ssl_verify_peer - defaults to true. Set to false to disable SSL certification verification (not recommended).
ssl_verify_peer_name - defaults to true. Set to false to disable host name verification when verifying SSL certificates (not recommended).
ssl_verify_depth - defaults to 5. Depth to traverse in the CA chain.
ssl_verify_host - defaults to true. Validate the SSL certificate against the host name.
ssl_cafile - defaults to built in cafile. Overwrite to use custom CA bundles.
timeout - Duration to wait before timing out in seconds.
type - Send a request body in a custom content type. Requires $data to either be a string, or the _content option to be set when doing GET requests.
redirect - Number of redirects to follow. Defaults to false.
curl - An array of additional curl options (if the curl adapter is used), for example, [CURLOPT_SSLKEY => 'key.pem'].
## MISP global settings
MISP.ca_path - certificate store
Proxy.host, port, user, pass, method
Security.min_tls_version
## MISP server/cerebrate setting
These settings are loaded in the _doRequest() function
- cert_file - translates to 'ssl_cafile'
- client_cert_file - translates to 'ssl_local_cert' - SSL client side authentication - see CURLOPT_SSLKEY
- self_signed - translates to 'ssl_allow_self_signed', 'ssl_verify_peer_name', 'ssl_verify_peer'
- skip_proxy -
*/
// proxy settings
$proxy = Configure::read('Proxy');
// proxy array as CakeClient likes it
// ['username' => 'mark',
// 'password' => 'testing',
// 'proxy' => '127.0.0.1:8080']
if (isset($proxy['host'])) {
$this->_defaultConfig['proxy'] = ['proxy' => $proxy['host'] . ":" . (empty($proxy['port']) ? 3128 : $proxy['port'])];
if (isset($proxy['user']) && isset($proxy['password']) && !isset($proxy['method'])) {
$proxy['method'] = 'basic';
}
if (isset($proxy['method'])) {
if (strtolower($proxy['method']) == 'basic' && isset($proxy['user']) && isset($proxy['password'])) {
$this->_defaultConfig['proxy']['username'] = $proxy['user'];
$this->_defaultConfig['proxy']['password'] = $proxy['password'];
}
if (strtolower($proxy['method']) == 'digest') {
throw new NotImplementedException('Digest proxy auth is not implemented'); // FIXME chri support Digest proxy auth
}
}
}
// global Certificate Authority
$caPath = Configure::read('MISP.ca_path');
if (!isset($params['ssl_cafile']) && $caPath) {
if ($caPath) {
if (!file_exists($caPath)) {
throw new CakeException("CA file '$caPath' doesn't exists.");
}
$params['ssl_cafile'] = $caPath;
$this->_defaultConfig['ssl_cafile'] = $caPath;
}
// min TLS version
if ($minTlsVersion = Configure::read('Security.min_tls_version')) {
$version = 0;
switch ($minTlsVersion) {
@ -40,20 +116,77 @@ class HttpTool
default:
throw new CakeException("Invalid `Security.min_tls_version` option $minTlsVersion");
}
$params['ssl_crypto_method'] = $version;
$this->_defaultConfig['ssl_crypto_method'] = $version;
}
//require_once(ROOT . '/src/Lib/Tools/HttpSocketExtended.php');
//$HttpSocket = new HttpSocketExtended($params);
// $client = new Client();
$proxy = Configure::read('Proxy');
if (empty($params['skip_proxy']) && isset($proxy['host']) && !empty($proxy['host'])) {
$params['proxy'] = [
'username' => $proxy['user'],
'password' => $proxy['password'],
'proxy' => $proxy['host'] . (empty($proxy['port']) ? '' : ':' . $proxy['port'])
];
// Add user-agent
// FIXME chri - add user-agent
}
/**
* Helper method for doing non-GET requests. This method is there to provide us a wrapper implementing our custom options.
*
* @param string $method HTTP method.
* @param string $url URL to request.
* @param mixed $data The request body.
* @param array<string, mixed> $options The options to use. Contains auth, proxy, etc.
* @return \Cake\Http\Client\Response
*/
protected function _doRequest(string $method, string $url, $data, $options): Response
{
if (isset($options['self_signed']) && $options['self_signed'] === true) {
$options = array_merge($options, [
'ssl_verify_peer' => false,
'ssl_verify_host' => false]);
}
return new Client($params);
if (isset($options['skip_proxy']) && $options['skip_proxy'] === true) {
unset($options['proxy']);
}
return parent::_doRequest($method, $url, $data, $options);
}
/**
* @deprecated createRequest - return an instance of HttpTool with automatic configuration
* @deprecated do not use this function, but use the HttpTool directly instead
* @param mixed $config
* @return HttpTool
*/
public function createRequest(array $config = []): HttpTool
{
return new HttpTool($config);
}
/**
* fetchCertificate - download the SSL certificate from the remote server
*
* @return string the certificate in pem format
*/
public function fetchCertificates(string $url, array $options = []) : array
{
$options = $this->_mergeOptions($options);
$options['ssl_verify_peer'] = false;
$options['ssl_verify_host'] = false;
$options['ssl_verify_peer_name'] = false;
// set CURL options, this is the place where magic happens.
$data = [];
$url = $this->buildUrl($url, $data, $options);
$request = $this->_createRequest(
Request::METHOD_GET,
$url,
$data,
$options
);
$curl = new CurlAdvanced();
$certificates = $curl->getCertificateChain($request, $options);
debug($certificates);
return $certificates;
// FIXME chri - now we need to find the right certificate
// $certificate = openssl_x509_read($caCertificate);
// if (!$certificate) {
// throw new CakeException("Couldn't read certificate: " . openssl_error_string());
// }
// return $caCertificate;
}
/**
@ -122,7 +255,7 @@ class HttpTool
* @return array
* @throws Exception
*/
private static function parseCertificate(mixed $certificate): array
public static function parseCertificate(mixed $certificate): array
{
$parsed = openssl_x509_parse($certificate);
if (!$parsed) {

View File

@ -0,0 +1,167 @@
<?php
namespace App\Test\TestCase\Tool;
use App\Lib\Tools\HttpTool;
use Cake\Core\Configure;
use Cake\Http\Client\Exception\NetworkException;
use Cake\TestSuite\TestCase;
class HttpToolTest extends TestCase
{
protected const PROXY_SERVER = '127.0.0.1';
protected const PROXY_USER = 'proxyuser';
protected const PROXY_PASSWORD = 'proxypassword';
protected const PROXY_PORT = 8888;
protected const HTTPS_SELF_SIGNED_URI = 'https://172.16.40.133';
protected const HTTPS_SELF_SIGNED_CA = "-----BEGIN CERTIFICATE-----
MIIFQTCCAykCFGsHklUem74YmI+bEVRlqD2KS0ReMA0GCSqGSIb3DQEBCwUAMF0x
CzAJBgNVBAYTAkxVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRl
cm5ldCBXaWRnaXRzIFB0eSBMdGQxFjAUBgNVBAMMDTE3Mi4xNi40MC4xMzMwHhcN
MjMxMjE4MTg0NTMxWhcNMjYwOTEyMTg0NTMxWjBdMQswCQYDVQQGEwJMVTETMBEG
A1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkg
THRkMRYwFAYDVQQDDA0xNzIuMTYuNDAuMTMzMIICIjANBgkqhkiG9w0BAQEFAAOC
Ag8AMIICCgKCAgEA1RaPUfi/O2nyf4CSoMJAmj9C8++heh7s2OjlAWeavCUc5bqC
bBjp02UC+7bS43SaRp0XMRNvnv9Zp33JRPblSVVYDjCCn7M9IS2T6CrpIy69EB9h
SlNlEXc2XQrmxQyExV6FLPXEZaADmX2DN2CM9+MntFhTupZUqzO+SwszscN9NUVY
uYsEuvFxyqrb0P5GqfqT6C/w6UnhBiIWZJIQDGQ+200qGl+eUY3rnM4sM1TwMGpk
mdXPOEvo+qQItivIGmUIDJtwrEH/rsBVgyEfgd53ESfr1J43eEG7DzWGjTQN73DQ
rPeT4Bja6VHKAYj7e+GZexZ1okeDtHejAeiPLa9lgJ8YuUnsCl8bGgMOF+K90mYW
rwQpNjkZgL8TAGQpop5s9oF3UkkGV7ftONnZ9MMphjmfvNWDOs2gLx4imzEi89Gv
xBFtZIHwUAgeFYZOC+nuXrRJX4V/apwtCzQaVx+VXWLHWvBdAYFWdru7HNchrlMm
GgbfFxnFL3qt4rm/EOTsnqG3gWZWuqUz2tYWIF79mFrKxUOmjpTp/N1KmlQh2Pj3
iRovZDu4SFB8Hv1fvfnefdxCCy+/w0sQ4ZPJ2rq2BxrT1bhdOfFtIbagtyvNXSgX
jrTfwAwvxyaftrQNYz849k7uKBYXLboZLxQ6SJ2QYS481keQBQw0KtbE680CAwEA
ATANBgkqhkiG9w0BAQsFAAOCAgEAEYeqFlIie6U6Qm21lhUfIT29JKTv9Q+3xC3W
d/X7hSxZ1qJ0BImAwLiA0DlBkgQBE6FSSSs0XIVxNG6TvXcCXFSJl219L+SD8GuS
jV+m7Xee0YXFxulzJsFQ3oSn2AuMx+7EnfKiQXtnga3lGlXpGuPS9HDHtoUP7C6x
0JkORYtK1y411UKCY0COGh03P8ETc/LnbH0jCIGUUIiD2K3pR0R/ieX8AAE5995g
QbEdz0MpLQz5xSRcFq7sMzoELn0jj5l6wJcDihoshqeIfb0vdPbKy/CoMTAeM81f
txq2dvNjPYY1dyK9fY8BsSt98dxtrrbB9WHfxzkqbZ82KbonRoatk/TuPTTr/8TW
atRvZsvW2hXTIQnZFxZImdpxnvkl5go7d/s3Iy6nliufzMJmMNke3iIkF6HrXFIs
Hh8Ph9g2NRrfuOQJuycG7JruDz29ri3miY/o+qGSA5fS7z8gfDwnUv8yCqJ2eCun
dh2QsJfDxjG3qIFc7+CMvbghWWOZyiR6KEIWMiXUVyTuSTZiu7J7fKSzY2WgVZOs
DCOxcbMAf9SzhYlcJBfjJyN5tosRd48yyKOCeiRDsBVD2z9v9DzjBokhEKRmkgmo
ofGFNygudATRXLEQwEZmQzl2NzeYDdg6EWvOnkjnmW6+gJ++Y8FEvqQKFzD/Jvwn
xWV4oBk=
-----END CERTIFICATE-----
";
public function testGoogle($options=[])
{
$client = new HttpTool($options);
$response = $client->get('https://www.google.com');
$this->assertTrue($response->isOk());
}
public function testSelfSigned()
{
$config = [
'self_signed' => true
];
$client = new HttpTool($config);
$response = $client->get(self::HTTPS_SELF_SIGNED_URI);
$this->assertTrue($response->isOk());
}
public function testSelfSignedFail()
{
$config = [
'ssl_verify_peer' => true,
'ssl_verify_host' => false];
$client = new HttpTool($config);
try {
$response = $client->get(self::HTTPS_SELF_SIGNED_URI);
$this->assertTrue(false); // always die as above should raise a self-signed cert error
} catch (NetworkException $e) {
$this->assertStringContainsString('SSL certificate problem: self-signed certificate', $e->getMessage(), 'Should have gotten error for self-signed certificiate.');
}
}
public function testSelfSignedCustomCa()
{
// write CA file to disk, load it from there
$fname = '/tmp/ca.pem';
$certfile = new \SplFileObject($fname, "w+");
$certfile->fwrite(self::HTTPS_SELF_SIGNED_CA);
$config = ['ssl_cafile' => $fname];
$client = new HttpTool($config);
$response = $client->get(self::HTTPS_SELF_SIGNED_URI);
$this->assertTrue($response->isOk());
unlink($fname);
}
public function testSelfSignedCustomSystemCa()
{
// write CA file to disk, load it from there
$fname = '/tmp/ca.pem';
$certfile = new \SplFileObject($fname, "w+");
$certfile->fwrite(self::HTTPS_SELF_SIGNED_CA);
Configure::write('MISP.ca_path', $fname);
$client = new HttpTool();
$response = $client->get(self::HTTPS_SELF_SIGNED_URI);
$this->assertTrue($response->isOk());
unlink($fname);
}
public function testProxy()
{
Configure::write('Proxy.host', self::PROXY_SERVER);
Configure::write('Proxy.port', self::PROXY_PORT);
// Configure::write('Proxy.method', 'basic'); // auth: basic / digest
// Configure::write('Proxy.user', self::PROXY_USER);
// Configure::write('Proxy.password', self::PROXY_PASSWORD);
$this->testGoogle();
$this->testSelfSigned();
$this->testSelfSignedFail();
$this->testSelfSignedCustomCa();
}
public function testSkipProxy()
{
Configure::write('Proxy.host', self::PROXY_SERVER);
Configure::write('Proxy.port', 1234); // bad port
$this->testGoogle(['skip_proxy' => true]);
}
public function testParseCertificate()
{
$certificate = self::HTTPS_SELF_SIGNED_CA;
$result = HttpTool::parseCertificate($certificate);
$this->assertArrayHasKey('serial_number', $result);
// $what_it_should_be = [
// 'serial_number' => '6B0792551E9BBE18988F9B115465A83D8A4B445E'
// 'signature_type' => 'RSA-SHA256'
// 'valid_from' => Cake\I18n\FrozenTime Object &000000000000015f0000000000000000 (
// 'date' => '2023-12-18 18:45:31.000000'
// 'timezone_type' => 1
// 'timezone' => '+00:00'
// )
// 'valid_to' => Cake\I18n\FrozenTime Object &00000000000001600000000000000000 (
// 'date' => '2026-09-12 18:45:31.000000'
// 'timezone_type' => 1
// 'timezone' => '+00:00'
// )
// 'public_key_size' => 4096
// 'public_key_type' => 'RSA'
// 'public_key_size_ok' => true
// 'valid_from_ok' => true
// 'valid_to_ok' => true
// 'subject' => 'C=LU, ST=Some-State, O=Internet Widgits Pty Ltd, CN=172.16.40.133'
// 'issuer' => 'C=LU, ST=Some-State, O=Internet Widgits Pty Ltd, CN=172.16.40.133'
// ];
// debug($result);
// $this->assertTrue(array_diff($result, $what_it_should_be));
}
public function testFetchCertificate()
{
$client = new HttpTool();
$certificates = $client->fetchCertificates('https://www.google.com');
// $certificates = $client->fetchCertificates(self::HTTPS_SELF_SIGNED_URI);
// $certificates = $client->fetchCertificates('http://www.google.com');
}
}