Merge pull request #6450 from JakubOnderka/client-certificate-info

new: [sync] Show client certificate info in connection test
pull/6471/head
Jakub Onderka 2020-10-20 10:15:20 +02:00 committed by GitHub
commit ff4c98446a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 272 additions and 128 deletions

View File

@ -36,10 +36,15 @@ class ServerShell extends AppShell
}
$serverId = intval($this->args[0]);
$res = @$this->Server->runConnectionTest($serverId);
if (!empty($res['message']))
$res['message'] = json_decode($res['message']);
$server = $this->Server->find('first', [
'conditions' => ['Server.id' => $serverId],
'recursive' => -1,
]);
if (!$server) {
die("Server with ID $serverId doesn't exists.");
}
$res = @$this->Server->runConnectionTest($server);
echo json_encode($res) . PHP_EOL;
}

View File

@ -1659,34 +1659,33 @@ class ServersController extends AppController
if (!$this->Auth->user('Role')['perm_sync'] && !$this->Auth->user('Role')['perm_site_admin']) {
throw new MethodNotAllowedException('You don\'t have permission to do that.');
}
$this->Server->id = $id;
if (!$this->Server->exists()) {
$server = $this->Server->find('first', ['Server.id' => $id]);
if (!$server) {
throw new NotFoundException(__('Invalid server'));
}
$result = $this->Server->runConnectionTest($id);
$result = $this->Server->runConnectionTest($server);
if ($result['status'] == 1) {
$version = json_decode($result['message'], true);
if (isset($version['version']) && preg_match('/^[0-9]+\.+[0-9]+\.[0-9]+$/', $version['version'])) {
if (isset($result['info']['version']) && preg_match('/^[0-9]+\.+[0-9]+\.[0-9]+$/', $result['info']['version'])) {
$perm_sync = false;
if (isset($version['perm_sync'])) {
$perm_sync = $version['perm_sync'];
if (isset($result['info']['perm_sync'])) {
$perm_sync = $result['info']['perm_sync'];
}
$perm_sighting = false;
if (isset($version['perm_sighting'])) {
$perm_sighting = $version['perm_sighting'];
if (isset($result['info']['perm_sighting'])) {
$perm_sighting = $result['info']['perm_sighting'];
}
App::uses('Folder', 'Utility');
$file = new File(ROOT . DS . 'VERSION.json', true);
$local_version = json_decode($file->read(), true);
$file->close();
$version = explode('.', $version['version']);
$version = explode('.', $result['info']['version']);
$mismatch = false;
$newer = false;
$parts = array('major', 'minor', 'hotfix');
if ($version[0] == 2 && $version[1] == 4 && $version[2] > 68) {
$post = $this->Server->runPOSTTest($id);
$post = $this->Server->runPOSTTest($server);
}
$testPost = false;
foreach ($parts as $k => $v) {
if (!$mismatch) {
if ($version[$k] > $local_version[$v]) {
@ -1718,7 +1717,8 @@ class ServersController extends AppController
'version' => implode('.', $version),
'mismatch' => $mismatch,
'newer' => $newer,
'post' => isset($post) ? $post : 'too old'
'post' => isset($post) ? $post : 'too old',
'client_certificate' => $result['client_certificate'],
)
),
'type' => 'json'

View File

@ -15,7 +15,7 @@ class MispAdminSyncTestWidget
{
$this->Server = ClassRegistry::init('Server');
$servers = $this->Server->find('all', array(
'fields' => array('id', 'url', 'name', 'pull', 'push', 'caching_enabled'),
'fields' => array('id', 'url', 'name', 'pull', 'push', 'caching_enabled', 'authkey', 'cert_file', 'client_cert_file', 'self_signed'),
'conditions' => array('OR' => array('pull' => 1, 'push' => 1, 'caching_enabled' => 1)),
'recursive' => -1
));
@ -25,16 +25,15 @@ class MispAdminSyncTestWidget
}
$syncTestErrorCodes = $this->Server->syncTestErrorCodes;
foreach ($servers as $server) {
$result = $this->Server->runConnectionTest($server['Server']['id']);
$result = $this->Server->runConnectionTest($server);
if ($result['status'] === 1) {
$message = __('Connected.');
$colour = 'green';
$flags = json_decode($result['message'], true);
if (empty($flags['perm_sync'])) {
if (empty($result['info']['perm_sync'])) {
$colour = 'orange';
$message .= ' ' . __('No sync access.');
}
if (empty($flags['perm_sighting'])) {
if (empty($result['info']['perm_sighting'])) {
$colour = 'orange';
$message .= ' ' . __('No sighting access.');
}

View File

@ -60,4 +60,115 @@ class SyncTool
}
return $HttpSocket;
}
/**
* @param array $server
* @return array|void
* @throws Exception
*/
public static function getServerClientCertificateInfo(array $server)
{
if (!$server['Server']['client_cert_file']) {
return;
}
$clientCertificate = new File(APP . "files" . DS . "certs" . DS . $server['Server']['id'] . '_client.pem');
if (!$clientCertificate->exists()) {
throw new Exception("Certificate file '{$clientCertificate->pwd()}' doesn't exists.");
}
$certificateContent = $clientCertificate->read();
if ($certificateContent === false) {
throw new Exception("Could not read '{$clientCertificate->pwd()}' file with client certificate.");
}
return self::getClientCertificateInfo($certificateContent);
}
/**
* @param string $certificateContent PEM encoded certificate and private key.
* @return array
* @throws Exception
*/
private static function getClientCertificateInfo($certificateContent)
{
$certificate = openssl_x509_read($certificateContent);
if (!$certificate) {
throw new Exception("Could't parse certificate: " . openssl_error_string());
}
$privateKey = openssl_pkey_get_private($certificateContent);
if (!$privateKey) {
throw new Exception("Could't get private key from certificate: " . openssl_error_string());
}
$verify = openssl_x509_check_private_key($certificate, $privateKey);
if (!$verify) {
throw new Exception('Public and private key do not match.');
}
return self::parseCertificate($certificate);
}
/**
* @param mixed $certificate
* @return array
* @throws Exception
*/
private static function parseCertificate($certificate)
{
$parsed = openssl_x509_parse($certificate);
if (!$parsed) {
throw new Exception("Could't get parse X.509 certificate: " . openssl_error_string());
}
$currentTime = new DateTime();
$output = [
'serial_number' => $parsed['serialNumberHex'],
'signature_type' => $parsed['signatureTypeSN'],
'valid_from' => isset($parsed['validFrom_time_t']) ? new DateTime("@{$parsed['validFrom_time_t']}") : null,
'valid_to' => isset($parsed['validTo_time_t']) ? new DateTime("@{$parsed['validTo_time_t']}") : null,
'public_key_size' => null,
'public_key_type' => null,
'public_key_size_ok' => null,
];
$output['valid_from_ok'] = $output['valid_from'] ? ($output['valid_from'] <= $currentTime) : null;
$output['valid_to_ok'] = $output['valid_to'] ? ($output['valid_to'] >= $currentTime) : null;
$subject = [];
foreach ($parsed['subject'] as $type => $value) {
$subject[] = "$type=$value";
}
$output['subject'] = implode(', ', $subject);
$issuer = [];
foreach ($parsed['issuer'] as $type => $value) {
$issuer[] = "$type=$value";
}
$output['issuer'] = implode(', ', $issuer);
$publicKey = openssl_pkey_get_public($certificate);
if ($publicKey) {
$publicKeyDetails = openssl_pkey_get_details($publicKey);
if ($publicKeyDetails) {
$output['public_key_size'] = $publicKeyDetails['bits'];
switch ($publicKeyDetails['type']) {
case OPENSSL_KEYTYPE_RSA:
$output['public_key_type'] = 'RSA';
$output['public_key_size_ok'] = $output['public_key_size'] >= 2048;
break;
case OPENSSL_KEYTYPE_DSA:
$output['public_key_type'] = 'DSA';
$output['public_key_size_ok'] = $output['public_key_size'] >= 2048;
break;
case OPENSSL_KEYTYPE_DH:
$output['public_key_type'] = 'DH';
break;
case OPENSSL_KEYTYPE_EC:
$output['public_key_type'] = "EC ({$publicKeyDetails['ec']['curve_name']})";
$output['public_key_size_ok'] = $output['public_key_size'] >= 224;
break;
}
}
}
return $output;
}
}

View File

@ -2979,4 +2979,15 @@ class AppModel extends Model
return $this->attachmentTool;
}
/**
* @return Log
*/
protected function loadLog()
{
if (!isset($this->Log)) {
$this->Log = ClassRegistry::init('Log');
}
return $this->Log;
}
}

View File

@ -4277,96 +4277,99 @@ class Server extends AppModel
return $validItems;
}
public function runConnectionTest($id)
/**
* @param array $server
* @return array
* @throws JsonException
*/
public function runConnectionTest(array $server)
{
$server = $this->find('first', array('conditions' => array('Server.id' => $id)));
App::uses('SyncTool', 'Tools');
try {
$clientCertificate = SyncTool::getServerClientCertificateInfo($server);
if ($clientCertificate) {
$clientCertificate['valid_from'] = $clientCertificate['valid_from'] ? $clientCertificate['valid_from']->format('c') : __('Not defined');
$clientCertificate['valid_to'] = $clientCertificate['valid_to'] ? $clientCertificate['valid_to']->format('c') : __('Not defined');
$clientCertificate['public_key_size'] = $clientCertificate['public_key_size'] ?: __('Unknwon');
$clientCertificate['public_key_type'] = $clientCertificate['public_key_type'] ?: __('Unknwon');
}
} catch (Exception $e) {
$clientCertificate = ['error' => $e->getMessage()];
}
$HttpSocket = $this->setupHttpSocket($server, null, 5);
$request = $this->setupSyncRequest($server);
$uri = $server['Server']['url'] . '/servers/getVersion';
try {
$response = $HttpSocket->get($uri, false, $request);
if ($response === false) {
throw new Exception("Connection failed for unknown reason.");
}
} catch (Exception $e) {
$this->Log = ClassRegistry::init('Log');
$this->Log->create();
$this->Log->save(array(
'org' => 'SYSTEM',
'model' => 'Server',
'model_id' => $id,
'email' => 'SYSTEM',
'action' => 'error',
'user_id' => 0,
'title' => 'Error: Connection test failed. Reason: ' . json_encode($e->getMessage()),
));
return array('status' => 2);
$logTitle = 'Error: Connection test failed. Reason: ' . $e->getMessage();
$this->loadLog()->createLogEntry('SYSTEM', 'error', 'Server', $server['Server']['id'], $logTitle);
return array('status' => 2, 'client_certificate' => $clientCertificate);
}
if ($response->isOk()) {
return array('status' => 1, 'message' => $response->body());
} else {
if ($response->code == '403') {
return array('status' => 4);
}
if ($response->code == '405') {
try {
$responseText = $this->jsonDecode($response->body)['message'];
} catch (Exception $e) {
return array('status' => 3);
}
if ($response->code == '403') {
return array('status' => 4, 'client_certificate' => $clientCertificate);
} else if ($response->code == '405') {
try {
$responseText = $this->jsonDecode($response->body)['message'];
if ($responseText === 'Your user account is expecting a password change, please log in via the web interface and change it before proceeding.') {
return array('status' => 5);
return array('status' => 5, 'client_certificate' => $clientCertificate);
} elseif ($responseText === 'You have not accepted the terms of use yet, please log in via the web interface and accept them.') {
return array('status' => 6);
return array('status' => 6, 'client_certificate' => $clientCertificate);
}
} catch (Exception $e) {
// pass
}
} else if ($response->isOk()) {
try {
$info = $this->jsonDecode($response->body());
if (!isset($info['version'])) {
throw new Exception("Server returns JSON response, but doesn't contain required 'version' field.");
}
return array('status' => 1, 'info' => $info, 'client_certificate' => $clientCertificate);
} catch (Exception $e) {
// Even if server returns OK status, that doesn't mean that connection to another MISP instance works
}
$this->Log = ClassRegistry::init('Log');
$this->Log->create();
$this->Log->save(array(
'org' => 'SYSTEM',
'model' => 'Server',
'model_id' => $id,
'email' => 'SYSTEM',
'action' => 'error',
'user_id' => 0,
'title' => 'Error: Connection test failed. Returned data is in the change field.',
'change' => sprintf(
'response () => (%s), response-code () => (%s)',
$response->body,
$response->code
)
));
return array('status' => 3);
}
$logTitle = 'Error: Connection test failed. Returned data is in the change field.';
$this->loadLog()->createLogEntry('SYSTEM', 'error', 'Server', $server['Server']['id'], $logTitle, [
'response' => ['', $response->body],
'response-code' => ['', $response->code],
]);
return array('status' => 3, 'client_certificate' => $clientCertificate);
}
public function runPOSTtest($id)
/**
* @param array $server
* @return int
* @throws JsonException
*/
public function runPOSTtest(array $server)
{
$server = $this->find('first', array('conditions' => array('Server.id' => $id)));
if (empty($server)) {
throw new InvalidArgumentException(__('Invalid server.'));
$testFile = file_get_contents(APP . 'files/scripts/test_payload.txt');
if (!$testFile) {
throw new Exception("Could not load payload for POST test.");
}
$HttpSocket = $this->setupHttpSocket($server);
$request = $this->setupSyncRequest($server);
$testFile = file_get_contents(APP . 'files/scripts/test_payload.txt');
$uri = $server['Server']['url'] . '/servers/postTest';
$this->Log = ClassRegistry::init('Log');
try {
$response = $HttpSocket->post($uri, json_encode(array('testString' => $testFile)), $request);
$rawBody = $response->body;
$response = json_decode($response, true);
$response = $this->jsonDecode($rawBody);
} catch (Exception $e) {
$this->Log->create();
$this->Log->save(array(
'org' => 'SYSTEM',
'model' => 'Server',
'model_id' => $id,
'email' => 'SYSTEM',
'action' => 'error',
'user_id' => 0,
'title' => 'Error: POST connection test failed. Reason: ' . json_encode($e->getMessage()),
));
$title = 'Error: POST connection test failed. Reason: ' . $e->getMessage();
$this->loadLog()->createLogEntry('SYSTEM', 'error', 'Server', $server['Server']['id'], $title);
return 8;
}
if (!isset($response['body']['testString']) || $response['body']['testString'] !== $testFile) {
$responseString = '';
if (!empty($repsonse['body']['testString'])) {
$responseString = $response['body']['testString'];
} else if (!empty($rawBody)){
@ -4374,32 +4377,17 @@ class Server extends AppModel
} else {
$responseString = __('Response was empty.');
}
$this->Log->create();
$this->Log->save(array(
'org' => 'SYSTEM',
'model' => 'Server',
'model_id' => $id,
'email' => 'SYSTEM',
'action' => 'error',
'user_id' => 0,
'title' => 'Error: POST connection test failed due to the message body not containing the expected data. Response: ' . PHP_EOL . PHP_EOL . $responseString,
));
$title = 'Error: POST connection test failed due to the message body not containing the expected data. Response: ' . PHP_EOL . PHP_EOL . $responseString;
$this->loadLog()->createLogEntry('SYSTEM', 'error', 'Server', $server['Server']['id'], $title);
return 9;
}
$headers = array('Accept', 'Content-type');
foreach ($headers as $header) {
if (!isset($response['headers'][$header]) || $response['headers'][$header] != 'application/json') {
$responseHeader = isset($response['headers'][$header]) ? $response['headers'][$header] : 'Header was not set.';
$this->Log->create();
$this->Log->save(array(
'org' => 'SYSTEM',
'model' => 'Server',
'model_id' => $id,
'email' => 'SYSTEM',
'action' => 'error',
'user_id' => 0,
'title' => 'Error: POST connection test failed due to a header not matching the expected value. Expected: "application/json", received "' . $responseHeader,
));
$title = 'Error: POST connection test failed due to a header not matching the expected value. Expected: "application/json", received "' . $responseHeader . '"';
$this->loadLog()->createLogEntry('SYSTEM', 'error', 'Server', $server['Server']['id'], $title);
return 10;
}
}

View File

@ -3297,22 +3297,51 @@ function getRemoteSyncUser(id) {
function testConnection(id) {
$.ajax({
url: baseurl + '/servers/testConnection/' + id,
type:'GET',
beforeSend: function (XMLHttpRequest) {
type: 'GET',
beforeSend: function () {
$("#connection_test_" + id).html('Running test...');
},
error: function(){
$("#connection_test_" + id).html('Internal error.');
$("#connection_test_" + id).html('<span class="red bold">Internal error</span>');
},
success: function(response){
var result = response;
success: function(result) {
function line(name, value, valid) {
var $value = $('<span></span>').text(value);
if (valid === true) {
$value.addClass('green');
} else if (valid === false) {
$value.addClass('red');
} else if (valid) {
$value.addClass(valid);
}
return $('<div></div>').text(name + ': ').append($value).html() + '<br>';
}
var html = '';
if (result.client_certificate) {
var cert = result.client_certificate;
html += '<span class="bold">Client certificate:</span><br>';
if (cert.error) {
html += '<span class="red bold">Error: ' + cert.error + '</span><br>';
} else {
html += line("Subject", cert.subject);
html += line("Issuer", cert.issuer);
html += line("Serial number", cert.serial_number);
html += line("Valid from", cert.valid_from, cert.valid_from_ok);
html += line("Valid to", cert.valid_to, cert.valid_to_ok);
html += line("Public key", cert.public_key_type + ' (' + cert.public_key_size + ' bits)', cert.public_key_size_ok);
}
html += "<br>";
}
switch (result.status) {
case 1:
status_message = "OK";
compatibility = "Compatible";
compatibility_colour = "green";
colours = {'local': 'class="green"', 'remote': 'class="green"', 'status': 'class="green"'};
issue_colour = "red";
var status_message = "OK";
var compatibility = "Compatible";
var compatibility_colour = "green";
var colours = {'local': 'class="green"', 'remote': 'class="green"', 'status': 'class="green"'};
var issue_colour = "red";
if (result.mismatch == "hotfix") issue_colour = "orange";
if (result.newer == "local") {
colours.remote = 'class="' + issue_colour + '"';
@ -3338,6 +3367,7 @@ function testConnection(id) {
else status_message = "Remote outdated, notify admin!"
colours.status = 'class="' + issue_colour + '"';
}
var post_result;
if (result.post != false) {
var post_colour = "red";
if (result.post == 1) {
@ -3354,36 +3384,36 @@ function testConnection(id) {
post_result = "Remote too old for this test";
}
}
resultDiv = '<div>Local version: <span ' + colours.local + '>' + result.local_version + '</span><br />';
resultDiv += '<div>Remote version: <span ' + colours.remote + '>' + result.version + '</span><br />';
resultDiv += '<div>Status: <span ' + colours.status + '>' + status_message + '</span><br />';
resultDiv += '<div>Compatiblity: <span class="' + compatibility_colour + '">' + compatibility + '</span><br />';
resultDiv += '<div>POST test: <span class="' + post_colour + '">' + post_result + '</span><br />';
$("#connection_test_" + id).html(resultDiv);
//$("#connection_test_" + id).html('<span class="green bold" title="Connection established, correct response received.">OK</span>');
html += line('Local version', result.local_version, colours.local);
html += line('Remote version', result.version, colours.remote);
html += line('Status', status_message, colours.status);
html += line('Compatibility', compatibility, compatibility_colour);
html += line('POST test', post_result, post_colour);
break;
case 2:
$("#connection_test_" + id).html('<span class="red bold" title="There seems to be a connection issue. Make sure that the entered URL is correct and that the certificates are in order.">Server unreachable</span>');
html += '<span class="red bold" title="There seems to be a connection issue. Make sure that the entered URL is correct and that the certificates are in order.">Server unreachable</span>';
break;
case 3:
$("#connection_test_" + id).html('<span class="red bold" title="The server returned an unexpected result. Make sure that the provided URL (or certificate if it applies) are correct.">Unexpected error</span>');
html += '<span class="red bold" title="The server returned an unexpected result. Make sure that the provided URL (or certificate if it applies) are correct.">Unexpected error</span>';
break;
case 4:
$("#connection_test_" + id).html('<span class="red bold" title="Authentication failed due to incorrect authentication key or insufficient privileges on the remote instance.">Authentication failed</span>');
html += '<span class="red bold" title="Authentication failed due to incorrect authentication key or insufficient privileges on the remote instance.">Authentication failed</span>';
break;
case 5:
$("#connection_test_" + id).html('<span class="red bold" title="Authentication failed because the sync user is expected to change passwords. Log into the remote MISP to rectify this.">Password change required</span>');
html += '<span class="red bold" title="Authentication failed because the sync user is expected to change passwords. Log into the remote MISP to rectify this.">Password change required</span>';
break;
case 6:
$("#connection_test_" + id).html('<span class="red bold" title="Authentication failed because the sync user on the remote has not accepted the terms of use. Log into the remote MISP to rectify this.">Terms not accepted</span>');
html += '<span class="red bold" title="Authentication failed because the sync user on the remote has not accepted the terms of use. Log into the remote MISP to rectify this.">Terms not accepted</span>';
break;
case 7:
$("#connection_test_" + id).html('<span class="red bold" title="The user account on the remote instance is not a sync user.">Remote user not a sync user</span>');
html += '<span class="red bold" title="The user account on the remote instance is not a sync user.">Remote user not a sync user</span>';
break;
case 8:
$("#connection_test_" + id).html('<span class="orange bold" title="The user account on the remote instance is only a sightings user.">Remote user not a sync user, syncing sightings only</span>');
html += '<span class="orange bold" title="The user account on the remote instance is only a sightings user.">Remote user not a sync user, syncing sightings only</span>';
break;
}
$("#connection_test_" + id).html(html);
}
})
}