diff --git a/proxy/.htaccess b/proxy/.htaccess new file mode 100644 index 0000000..d4f1006 --- /dev/null +++ b/proxy/.htaccess @@ -0,0 +1,6 @@ +# Serve + + RewriteEngine on + RewriteCond %{REQUEST_URI} ^/civicrm/ajax/api4 + RewriteRule ^civicrm/ajax/api4/([^/]*)/([^/]*) rest4.php?entity=$1&action=$2 [QSA,B] + diff --git a/proxy/checks.php b/proxy/checks.php new file mode 100644 index 0000000..0768563 --- /dev/null +++ b/proxy/checks.php @@ -0,0 +1,82 @@ + 1, + 'error_message' => $message); + // TODO: Implement header(); + print json_encode($error); + exit(); +} + +/** + * Updates $credentials['api_key'] in-place, or displays an error if api key + * is missing or does not correspond to an entry in $api_key_map (which should + * be set in config.php). + * @param array $credentials + * @param array $api_key_map + */ +function civiproxy_map_api_key(array &$credentials, array $api_key_map) { + if (empty($credentials['api_key'])) { + civiproxy_rest_error("No API key given"); + } + else { + if (isset($api_key_map[$credentials['api_key']])) { + $credentials['api_key'] = $api_key_map[$credentials['api_key']]; + } + else { + civiproxy_rest_error("Invalid api key"); + } + } +} + +/** + * Updates $credentials['key'] in-place, or displays an error if site key + * is missing or does not correspond to an entry in $sys_key_map (which should + * be set in config.php). + * @param array $credentials + * @param array $sys_key_map + */ +function civiproxy_map_site_key(array &$credentials, array $sys_key_map) { + if (empty($credentials['key'])) { + civiproxy_rest_error("No site key given"); + } + else { + if (isset($sys_key_map[$credentials['key']])) { + $credentials['key'] = $sys_key_map[$credentials['key']]; + } + else { + civiproxy_rest_error("Invalid site key"); + } + } +} + +/** + * @param array $action should have both 'entity' and 'action' keys set + * @param array $rest_allowed_actions from config.php + * @return array + */ +function civiproxy_get_valid_parameters(array $action, array $rest_allowed_actions) { + // in release 0.4, allowed entity/actions per IP were introduced. To introduce backward compatibility, + // the previous test is still used when no 'all' key is found in the array + if (isset($rest_allowed_actions['all'])) { + // get valid key for the rest_allowed_actions + $valid_allowed_key = civiproxy_get_valid_allowed_actions_key($action, $rest_allowed_actions); + $valid_parameters = civiproxy_retrieve_api_parameters($valid_allowed_key, $action['entity'], $action['action'], $rest_allowed_actions); + if (!$valid_parameters) { + civiproxy_rest_error("Invalid entity/action."); + } + } + else { + if (isset($rest_allowed_actions[$action['entity']]) && isset($rest_allowed_actions[$action['entity']][$action['action']])) { + $valid_parameters = $rest_allowed_actions[$action['entity']][$action['action']]; + } + else { + civiproxy_rest_error("Invalid entity/action."); + } + } + return $valid_parameters; +} diff --git a/proxy/config.dist.php b/proxy/config.dist.php index 7ddeef4..8816611 100644 --- a/proxy/config.dist.php +++ b/proxy/config.dist.php @@ -41,6 +41,8 @@ $target_civicrm = 'https://your.civicrm.installation.org'; // default paths, override if you want. Set to NULL to disable $target_rest = $target_civicrm . '/sites/all/modules/civicrm/extern/rest.php'; +// base URL for api4 calls. Will append entity and action path segments +$target_rest4 = $target_civicrm . '/civicrm/ajax/api4/'; $target_file = $target_civicrm . '/sites/default/files/civicrm/persist/'; $target_mosaico = NULL; // (disabled by default): $target_civicrm . '/civicrm/mosaico/img?src='; $target_mosaico_template_url = NULL; // (disabled by default): $target_civicrm . '/wp-content/uploads/civicrm/ext/uk.co.vedaconsulting.mosaico/packages/mosaico/templates/'; @@ -75,6 +77,10 @@ $debug = NULL; //'LUXFbiaoz4dVWuAHEcuBAe7YQ4YP96rN4MCDmKj89 // This is useful in some VPN configurations (see CURLOPT_INTERFACE) $target_interface = NULL; + +/*************************************************************** + ** Authentication Options ** + ***************************************************************/ // API and SITE keys (you may add keys here) $api_key_map = [ 'my_api_key' => 'my_api_key', // use this to allow API key @@ -91,6 +97,19 @@ if (file_exists(dirname(__FILE__)."/secrets.php")) { require "secrets.php"; } +// CiviCRM's API can authenticate with different flows +// https://docs.civicrm.org/dev/en/latest/framework/authx/#flows +// CiviProxy supports 'header', 'xheader', 'legacyrest', and 'param'. +// These flows are supported for API4 but could be extended to API3. +// $authx_internal_flow controls how CiviProxy sends credentials to CiviCRM, and +// $authx_external_flow where CiviProxy looks for credentials on incoming requests. +// The internal setting needs to have a single scalar value, but the +// external setting can be an array of accepted flows. +// There is no standard header for site key, so in both header and xheader +// flows it uses X-Civi-Key +$authx_internal_flow = 'header'; +$authx_external_flow = ['legacyrest']; + /**************************************************************** ** File Caching Options ** diff --git a/proxy/proxy.php b/proxy/proxy.php index 612688b..1212956 100644 --- a/proxy/proxy.php +++ b/proxy/proxy.php @@ -90,6 +90,148 @@ function civiproxy_redirect($url_requested, $parameters) { curl_close ($curlSession); } +/** + * this will redirect the request to an API4 URL, + * i.e. will pass the reply on to this request + * + * @see losely based on https://code.google.com/p/php-proxy/ + * + * @param $url_requested string the URL to which the request should be sent + * @param $parameters array + * @param $credentials array + */ +function civiproxy_redirect4($url_requested, $parameters, $credentials) { + global $target_interface, $authx_internal_flow; + $url = $url_requested; + $curlSession = curl_init(); + $credential_params = civiproxy_build_credential_params($credentials, $authx_internal_flow); + $credential_headers = civiproxy_build_credential_headers($credentials, $authx_internal_flow); + + if ($_SERVER['REQUEST_METHOD'] == 'POST'){ + // POST requests should be passed on as POST + curl_setopt($curlSession, CURLOPT_POST, 1); + $urlparams = 'params=' . urlencode(json_encode($parameters)) . $credential_params; + curl_setopt($curlSession, CURLOPT_POSTFIELDS, $urlparams); + } else { + // GET requests will get the parameters as url params + if (!empty($parameters)) { + $url .= '?params=' . urlencode(json_encode($parameters)) . $credential_params; + } + } + + curl_setopt($curlSession, CURLOPT_HTTPHEADER, array_merge([ + 'Content-Type: application/x-www-form-urlencoded' + ], $credential_headers)); + curl_setopt($curlSession, CURLOPT_URL, $url); + curl_setopt($curlSession, CURLOPT_HEADER, 1); + curl_setopt($curlSession, CURLOPT_RETURNTRANSFER,1); + curl_setopt($curlSession, CURLOPT_TIMEOUT, 30); + curl_setopt($curlSession, CURLOPT_SSL_VERIFYHOST, 2); + if (!empty($target_interface)) { + curl_setopt($curlSession, CURLOPT_INTERFACE, $target_interface); + } + if (file_exists(dirname(__FILE__).'/target.pem')) { + curl_setopt($curlSession, CURLOPT_CAINFO, dirname(__FILE__).'/target.pem'); + } + + //Send the request and store the result in an array + $response = curl_exec($curlSession); + + // Check that a connection was made + if (curl_error($curlSession)){ + civiproxy_http_error(curl_error($curlSession), curl_errno($curlSession)); + + } else { + //clean duplicate header that seems to appear on fastcgi with output buffer on some servers!! + $response = str_replace("HTTP/1.1 100 Continue\r\n\r\n","",$response); + + // split header / content + $content = explode("\r\n\r\n", $response, 2); + $header = $content[0]; + $body = $content[1]; + + // handle headers - simply re-outputing them + $header_ar = explode(chr(10), $header); + foreach ($header_ar as $header_line){ + if (!preg_match("/^Transfer-Encoding/", $header_line)){ + civiproxy_mend_URLs($header_line); + header(trim($header_line)); + } + } + + //rewrite all hard coded urls to ensure the links still work! + civiproxy_mend_URLs($body); + + print $body; + } + + curl_close($curlSession); +} + +/** + * Creates a string with the API credentials to be appended to an API4 GET or POST request. + * When $api4_internal_auth_flow is 'header' or 'xheader', returns a blank string + * + * @param array $credentials + * @param string $authx_internal_flow + * @return string credential string, including leading '&' + */ +function civiproxy_build_credential_params(array $credentials, string $authx_internal_flow): string { + switch($authx_internal_flow) { + case 'legacyrest': + $map = ['api_key' => 'api_key', 'key' => 'key']; + break; + case 'param': + $map = ['api_key' => '_authx', 'key' => '_authxSiteKey']; + break; + default: + return ''; + } + $params = []; + foreach($map as $credential_key => $param_name) { + if (isset($credentials[$credential_key])) { + $credential_value = $credentials[$credential_key]; + if ($param_name === '_authx') { + $credential_value = 'Bearer ' . $credential_value; + } + $params[$param_name] = $credential_value; + } + } + + $param_string = http_build_query($params); + if (!empty($param_string)) { + $param_string = '&' . $param_string; + } + return $param_string; +} + +/** + * Builds an array of headers to send on an API4 request. When $api4_internal_auth_flow + * is 'param' or 'legacyrest', will always return an empty array. + * + * @param array $credentials + * @param string $authx_internal_flow + * @return array + */ +function civiproxy_build_credential_headers(array $credentials, string $authx_internal_flow): array { + switch($authx_internal_flow) { + case 'header': + $map = ['api_key' => 'Authorization: Bearer', 'key' => 'X-Civi-Key:']; + break; + case 'xheader': + $map = ['api_key' => 'X-Civi-Auth: Bearer', 'key' => 'X-Civi-Key:']; + break; + default: + return []; + } + $headers = []; + foreach($map as $credential_key => $header_prefix) { + if (isset($credentials[$credential_key])) { + $headers[] = $header_prefix . ' ' . $credentials[$credential_key]; + } + } + return $headers; +} /** * Will mend all the URLs in the string that point to the target, @@ -131,11 +273,12 @@ function civiproxy_mend_URLs(&$string) { * unauthorized access quantities, etc. * * @param $target - * @param $quit if TRUE, quit immediately if access denied + * @param $quit bool if TRUE, quit immediately if access denied + * @param $log_headers array add these headers (sanitized) to log data * * @return TRUE if allowed, FALSE if not (or quits if $quit is set) */ -function civiproxy_security_check($target, $quit=TRUE) { +function civiproxy_security_check($target, $quit=TRUE, $log_headers = []) { // verify that we're SSL encrypted if ($_SERVER['HTTPS'] != "on") { civiproxy_http_error("This CiviProxy installation requires SSL encryption.", 400); @@ -145,11 +288,16 @@ function civiproxy_security_check($target, $quit=TRUE) { if (!empty($debug)) { // filter log data $log_data = $_REQUEST; - if (isset($log_data['api_key'])) { - $log_data['api_key'] = substr($log_data['api_key'], 0, 4) . '...'; + $sanitize_params = ['api_key', 'key', '_authxSiteKey', '_authx']; + foreach ($sanitize_params as $param) { + if (isset($log_data[$param])) { + $log_data[$param] = substr($log_data[$param], 0, 4) . '...'; + } } - if (isset($log_data['key'])) { - $log_data['key'] = substr($log_data['key'], 0, 4) . '...'; + + foreach($log_headers as $header) { + if (!empty($_SERVER[$header])) + $log_data[$header] = substr($_SERVER[$header], 0, 4) . '...'; } // log @@ -205,7 +353,7 @@ function civiproxy_get_parameters($valid_parameters, $request = NULL) { // process wildcard elements if ($default_sanitation !== NULL) { // i.e. we want the others too - $remove_parameters = array('key', 'api_key', 'version', 'entity', 'action'); + $remove_parameters = array('key', 'api_key', '_authx', '_authxSiteKey', 'version', 'entity', 'action'); foreach ($request as $name => $value) { if (!in_array($name, $remove_parameters) && !isset($valid_parameters[$name])) { $result[$name] = civiproxy_sanitise($value, $default_sanitation); @@ -216,6 +364,26 @@ function civiproxy_get_parameters($valid_parameters, $request = NULL) { return $result; } +/** + * Get the value of a header on the incoming request + * + * @param string $header name of the header, in all uppercase + * @param string $prefix to be stripped off the value of the header + * @return string|null value of the header, or null if not found. + */ +function civiproxy_get_header($header, $prefix = ''): ?string { + if (!empty($_SERVER['HTTP_' . $header])) { + $value = $_SERVER['HTTP_' . $header]; + if ($prefix === '') { + return $value; + } + if (strpos($value, $prefix) === 0) { + return trim(substr($value, strlen($prefix))); + } + } + return NULL; +} + /** * sanitise the given value with the given sanitiation type */ diff --git a/proxy/rest.php b/proxy/rest.php index d1efe38..b7ca07b 100644 --- a/proxy/rest.php +++ b/proxy/rest.php @@ -9,11 +9,11 @@ require_once "config.php"; require_once "proxy.php"; +require_once "checks.php"; // see if REST API is enabled if (!$target_rest) civiproxy_http_error("Feature disabled", 405); - // basic check if (!civiproxy_security_check('rest')) { civiproxy_rest_error("Access denied."); @@ -21,25 +21,9 @@ if (!civiproxy_security_check('rest')) { // check credentials $credentials = civiproxy_get_parameters(array('key' => 'string', 'api_key' => 'string')); -if (empty($credentials['key'])) { - civiproxy_rest_error("No site key given"); -} else { - if (isset($sys_key_map[$credentials['key']])) { - $credentials['key'] = $sys_key_map[$credentials['key']]; - } else { - civiproxy_rest_error("Invalid site key"); - } -} -if (empty($credentials['api_key'])) { - civiproxy_rest_error("No API key given"); -} else { - if (isset($api_key_map[$credentials['api_key']])) { - $credentials['api_key'] = $api_key_map[$credentials['api_key']]; - } else { - civiproxy_rest_error("Invalid api key"); - } -} +civiproxy_map_site_key($credentials, $sys_key_map); +civiproxy_map_api_key($credentials, $api_key_map); // check if the call itself is allowed $action = civiproxy_get_parameters(array('entity' => 'string', 'action' => 'string', 'version' => 'int', 'json' => 'int', 'sequential' => 'int')); @@ -47,22 +31,7 @@ if (!isset($action['version']) || $action['version'] != 3) { civiproxy_rest_error("API 'version' information missing."); } -// in release 0.4, allowed entity/actions per IP were introduced. To introduce backward compatibility, -// the previous test is still used when no 'all' key is found in the array -if (isset($rest_allowed_actions['all'])) { - // get valid key for the rest_allowed_actions - $valid_allowed_key = civiproxy_get_valid_allowed_actions_key($action, $rest_allowed_actions); - $valid_parameters = civiproxy_retrieve_api_parameters($valid_allowed_key, $action['entity'], $action['action'], $rest_allowed_actions); - if (!$valid_parameters) { - civiproxy_rest_error("Invalid entity/action."); - } -} else { - if (isset($rest_allowed_actions[$action['entity']]) && isset($rest_allowed_actions[$action['entity']][$action['action']])) { - $valid_parameters = $rest_allowed_actions[$action['entity']][$action['action']]; - } else { - civiproxy_rest_error("Invalid entity/action."); - } -} +$valid_parameters= civiproxy_get_valid_parameters($action, $rest_allowed_actions); // extract parameters and add credentials and action data $parameters = civiproxy_get_parameters($valid_parameters); @@ -88,17 +57,3 @@ if ($rest_evaluate_json_parameter) { // finally execute query civiproxy_log($target_rest); civiproxy_redirect($target_rest, $parameters); - - -/** - * generates a CiviCRM REST API compliant error - * and ends processing - */ -function civiproxy_rest_error($message) { - $error = array( 'is_error' => 1, - 'error_message' => $message); - // TODO: Implement - //header(); - print json_encode($error); - exit(); -} diff --git a/proxy/rest4.php b/proxy/rest4.php new file mode 100644 index 0000000..f09a6df --- /dev/null +++ b/proxy/rest4.php @@ -0,0 +1,89 @@ + ['HTTP_AUTHORIZATION', 'HTTP_X_CIVI_KEY'], + 'xheader' => ['HTTP_X_CIVI_AUTH', 'HTTP_X_CIVI_KEY'], + 'legacyrest' => [], + 'param' => [], +]; +if (!in_array($authx_internal_flow, $valid_flows)) { + civiproxy_http_error("Invalid internal auth flow '$authx_internal_flow'", 500); +} +$headers_to_log = []; +foreach ($authx_external_flow as $external_flow) { + if (!in_array($external_flow, $valid_flows)) { + civiproxy_http_error("Invalid external auth flow '$external_flow'", 500); + } + $headers_to_log = array_merge($headers_to_log, $headers_by_flow[$external_flow]); +} + +// basic check +if (!civiproxy_security_check('rest', TRUE, $headers_to_log)) { + civiproxy_rest_error("Access denied."); +} + +$credentials = []; +// Find credentials on the incoming request +foreach ($authx_external_flow as $external_flow) { + switch($external_flow) { + case 'header': + $credentials['api_key'] = civiproxy_get_header('AUTHORIZATION', 'Bearer '); + $credentials['key'] = civiproxy_get_header('HTTP_X_CIVI_KEY'); + break; + case 'xheader': + $credentials['api_key'] = civiproxy_get_header('X_CIVI_AUTH', 'Bearer '); + $credentials['key'] = civiproxy_get_header('HTTP_X_CIVI_KEY'); + break; + case 'legacyrest': + $credentials = civiproxy_get_parameters(array('api_key' => 'string', 'key' => 'string')); + break; + case 'param': + $authx_credentials = civiproxy_get_parameters(array('_authx' => 'string', '_authxSiteKey' => 'string')); + if (!empty($authx_credentials['_authx'])) { + // Snip off leading 'Bearer ' or 'Bearer+' + if (substr($authx_credentials['_authx'], 0, 6) === 'Bearer') { + $credentials['api_key'] = substr($authx_credentials['_authx'], 7); + } + } + if (!empty($authx_credentials['_authxSiteKey'])) { + $credentials['key'] = $authx_credentials['_authxSiteKey']; + } + break; + } + if (!empty($credentials['api_key'])) { + break; + } +} + +civiproxy_map_api_key($credentials, $api_key_map); +if (!empty($credentials['key'])) { + civiproxy_map_site_key( $credentials, $sys_key_map); +} + +// check if the call itself is allowed +$action = civiproxy_get_parameters(array('entity' => 'string', 'action' => 'string')); + +$valid_parameters = civiproxy_get_valid_parameters($action, $rest_allowed_actions); + +// extract parameters and add action data +$parameters = civiproxy_get_parameters($valid_parameters, json_decode($_REQUEST['params'], true)); + +// finally execute query +civiproxy_log($target_rest4); +civiproxy_redirect4($target_rest4 . $action['entity'] . '/' . $action['action'] , $parameters, $credentials);