Skip to content

Commit

Permalink
Began implementing NSM (Native Security Mechanism).
Browse files Browse the repository at this point in the history
  • Loading branch information
knivre committed Oct 14, 2013
1 parent dae377a commit 1617e83
Show file tree
Hide file tree
Showing 3 changed files with 308 additions and 0 deletions.
66 changes: 66 additions & 0 deletions SECURITY
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
PHPJobs provides a simple API to run jobs on a remote PHP-enabled host. As such,
most use cases require to think about security.
PHPJobs does not enforce any security policy at all. Instead, it invites users
to write their own security controls in its configuration file.
A simple, typical way to implement security consists in checking the value of a
HTTP header, e.g. checking an expected token was provided, or dealing with
complete sessions.
PHPJobs provides no mechanism of its own to prevent potential attackers from
reading data exchanged between clients and servers. Therefore, a first step to
achieve security consists in relying on HTTPS instead of HTTP.

In case you have no idea of how to implement your security, PHPJobs still
provides a Native Security Mechanism, also known as "NSM".


This mechanism relies on a secret (usually referred as "password") known by both
server and client. This password is never transmitted "as is" on the network.
Instead, it is used to sign client HTTP requests so the server can ensure
neither the query string nor the POST data were modified on their way from the
client to the server. The signature is provided to the server through the
X-PHPJobs-Security HTTP header. Upon reception of each client request, the
server computes the signature on its own and denies access to the client if it
does not match the provided one.

However, this kind of signed requests is subject to replay attacks, i.e. an
attacker having eavesdropped network traffic could send again the same job
request (without being able to modify it though) along with the same signature
to the same server, leading to potential intrusions.

That is why client requests also provide the following HTTP headers:

X-PHPJobs-Host, which is supposed to reflect the target machine the request is
intended to. It is checked server-side upon reception of each client request. By
default, PHPJobs attempts to match the provided value against the hostname of
the machine. One can provide either the FQDN (as known to the host) or the basic
host name (typically the first part of the FQDN). PHPJobs may also be configured
to accept only a given whitelist of hosts. This whitelist may even include
regular expressions. This should prevent attackers from replaying a request
against another server accepting the same secret.

X-PHPJobs-Timestamp, which must be a standard Unix timestamp followed by a dot
followed by the number of the request within that second, formatted with leading
zeros so it takes exactly four digits (e.g. 1381769851.0003). Request numbers
must start from 0001. It is assumed the induced ratio of 9999 requests per
second "ought to be enough for everyone". Upon reception of each client request,
the server compares the timestamp against the current date and time. If the
provided timestamp is more than 10 seconds old (this threshold can be
configured), the server denies access to the client, assuming it tried to replay
an old request. This should prevent attackers from replaying a client request
against the same server. However, there remains a 10-seconds interval where the
replay could work.

X-PHPJobs-Session is a session id. Unlike most web-based applications, it is
crafted client-side. It is expected to begin with the host name of the client
(though this is actually neither checked nor enforced), followed by a dash,
followed by a 24 characters long identifier made of letters (both uppercase and
lowercase are accepted) and digits (e.g. darkstar-R1FsooOFjQSNeJFrAQUQFIZx). The
server uses this session identifier to store a single piece of information: the
value of the latest timestamp header received within the session. Upon reception
of each client request, the server checks whether the session already exists. If
so, it retrieves the latest known timestamp for this session. If the freshly
provided one is not strictly greater than the known one, the server denies
access to the client, assuming it tried to replay an old request. This should
prevent attackers from replaying a client request against the same server, even
if they manage to strike less than 10 seconds after the initial request was
sent.
32 changes: 32 additions & 0 deletions jobs.default.init.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,43 @@
define('JOBS_IDENTIFIER', constant('JOBS_HOSTNAME'));

if (constant('JOBS_CONTEXT') == 'manager') {
// Here is a good place to enforce some PHP directives...
error_reporting(0);
ini_set('display_errors', '0');

// This is where you will want to take care of security by yourself:
// just exit() if you do not find what you expect.

// Ok, since you do not look quite enthusiastic about it, we still provide a
// native security mechanism (NSM). Please refer to the "SECURITY" file for
// explanations about how it works.
$use_nsm = TRUE;

$nsm_conf = array(
// The default value is actually a random string, making the service
// unusable so you *have* to change it...
'secret' => substr(md5(rand()), 0, 12),
// example:
// 'secret' => 'UyoYf7rZVGI',
// Control validation of the X-PHPJobs-Host header.
'accept_real_hostname' => TRUE,
'accepted_hosts' => array(),
'accepted_hosts_regexps' => array(),
// Control validation of the X-PHPJobs-Timestamp header.
// Requests having a timestamp older than max_age are denied.
'max_age' => 10,
// Control validation of the X-PHPJobs-Session header.
'nsm_session_dir' => dirname(__FILE__) . DIRECTORY_SEPARATOR . 'sessions',
);

if (isset($use_nsm) && $use_nsm) nsm_apply($nsm_conf);
}

if (constant('JOBS_CONTEXT') == 'worker') {
// Here is another good place to enforce some PHP directives...
error_reporting(E_ALL);
ini_set('display_errors', 1);

// You may also want to ensure your worker script is executed with
// php-cli only... just a hint.
}
Expand Down
210 changes: 210 additions & 0 deletions jobs.functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -424,3 +424,213 @@ public function setFilter($f, $t, $o = 'm') {
public $token;
public $op;
};

/**
Apply NSM (native security mechanism).
@param $conf Array holding NSM configuration directives.
TODO cleanup of old sessions?
*/
function nsm_apply($conf) {
// Arbitrarily considered the request is received when this function is
// called.
$conf['request_time'] = time();

// The native security mechanism relies on four HTTP headers.
// They must be present...
nsm_require_headers(array('HOST', 'SECURITY', 'SESSION', 'TIMESTAMP'));

// ... and valid.
nsm_validate_host_header($conf);
$session = nsm_validate_session_header($conf);
nsm_validate_timestamp_header($session, $conf);
nsm_validate_security_header();

// Once headers are verified, compute the security hash.
$hash = nsm_hash($conf);

// Compare this hash against the provided one.
if ($hash != $_SERVER['HTTP_X_PHPJOBS_SECURITY']) {
header('HTTP/1.1 403 Forbidden');
exit();
}
else {
header('X-PHPJobs-Security: allowed');
}
}

/**
Ensure all headers required by the NSM are present.
*/
function nsm_require_headers($headers) {
if (!is_array($headers)) $headers = array($headers);
foreach ($headers as $header_name) {
$full_header_name = 'HTTP_X_PHPJOBS_' . strtoupper($header_name);
if (!isset($_SERVER[$full_header_name]) || !strlen($_SERVER[$full_header_name])) {
exit_with_error('Missing security header');
}
}
}

/**
Validate the X-PHPJobs-Host header.
*/
function nsm_validate_host_header($conf) {
// if enabled, try to validate the provided host against the system hostname
if ($conf['accept_real_hostname']) {
$real_hostname = constant('JOBS_HOSTNAME');
if ($_SERVER['HTTP_X_PHPJOBS_HOST'] == $real_hostname) return TRUE;

$matches = array();
if (preg_match('/^[^.]$\./', $real_hostname, $matches)) {
if ($_SERVER['HTTP_X_PHPJOBS_HOST'] == $matches[1]) return TRUE;
}
}

// try to validate the provided host against a list of regular strings
foreach ($conf['accepted_hosts'] as $accepted_host) {
if ($_SERVER['HTTP_X_PHPJOBS_HOST'] == $accepted_host) return TRUE;
}

// try to validate the provided host against a list of regular expressions
foreach ($conf['accepted_hosts_regexps'] as $accepted_hosts_regexp) {
if (preg_match($accepted_hosts_regexp, $_SERVER['HTTP_X_PHPJOBS_HOST'])) {
return TRUE;
}
}

exit_with_error('Malformed security header');
}

/**
Validate the X-PHPJobs-Host header.
*/
function nsm_validate_session_header($conf) {
$matches = array();
if (preg_match('/^([A-Za-z0-9][A-Za-z0-9-]{0,254})-([A-Za-z0-9]{24})$/', $_SERVER['HTTP_X_PHPJOBS_SESSION'], $matches)) {
$dirpath = $conf['nsm_session_dir'] . DIRECTORY_SEPARATOR . $matches[1];
return array(
'session' => $_SERVER['HTTP_X_PHPJOBS_SESSION'],
'hostname' => $matches[1],
'id' => $matches[2],
'dirpath' => $dirpath,
'filepath' => $dirpath . DIRECTORY_SEPARATOR . $matches[2] . '.session'
);
}

exit_with_error('Malformed security header');
}

/**
@return the last known timestamp for the given \a $session.
TODO file locking
*/
function nsm_get_session_timestamp($session) {
mkpath($session['dirpath']);
if (!is_dir($session['dirpath'])) {
exit_with_error('unable to create session directory ' . $session['dirpath'] );
}

if (file_exists($session['filepath'])) {
$session_contents = file_get_contents($session['filepath']);
if (!nsm_validate_timestamp($session_contents)) {
exit_with_error('invalid session');
}
return $session_contents;
}
return 0;
}

/**
Set \a $timestamp as last known timestamp for \a $session.
TODO file locking
*/
function nsm_set_session_timestamp($session, $timestamp) {
mkpath($session['dirpath']);
if (!is_dir($session['dirpath'])) {
exit_with_error('unable to create session directory');
}

$tmp_filepath = $session['filepath'] . pseudo_random_string();
if (file_put_contents($tmp_filepath, $timestamp) === FALSE) {
exit_with_error('unable to write session');
}
if (!rename($tmp_filepath, $session['filepath'])) {
exit_with_error('unable to write session');
}
}

/**
Validate the X-PHPJobs-Timestamp header.
*/
function nsm_validate_timestamp_header($session, $conf) {
// the provided timestamp header must look like a timestamp
if (!nsm_validate_timestamp($_SERVER['HTTP_X_PHPJOBS_TIMESTAMP'])) {
exit_with_error('Malformed security header');
}

// the provided timestamp must not be older than max_age
$timestamp = array_shift(explode('.', $_SERVER['HTTP_X_PHPJOBS_TIMESTAMP']));
if ($conf['request_time'] - $timestamp > $conf['max_age']) {
exit_with_error('provided timestamp is too old (trying to reuse former request?)');
}

// retrieve the timestamp of the last request for the provided session
$session_timestamp = nsm_get_session_timestamp($session);

if ($session_timestamp !== 0) {
// get rid of the dot in both timestamps
$session_timestamp = str_replace('.', '', $session_timestamp);
$user_timestamp = str_replace('.', '', $_SERVER['HTTP_X_PHPJOBS_TIMESTAMP']);

// the provided timestamp must be strictly greater than the current one for this session
if ($user_timestamp <= $session_timestamp) {
exit_with_error('provided timestamp is too old for this session (trying to reuse former request?)');
}
}

// update last known timestamp for this session
nsm_set_session_timestamp($session, $_SERVER['HTTP_X_PHPJOBS_TIMESTAMP']);

return TRUE;
}

/**
@return TRUE if \a $timestamp matches the expected format, false otherwise.
*/
function nsm_validate_timestamp($timestamp) {
return preg_match('/^[0-9]{10}\.[0-9]{4}$/', $timestamp);
}

/**
Validate the X-PHPJobs-Security header.
*/
function nsm_validate_security_header() {
if (preg_match('/^[0-9a-f]{64}$/', $_SERVER['HTTP_X_PHPJOBS_SECURITY'])) {
return TRUE;
}

exit_with_error('Malformed security header');
}

/**
@return the SHA256 hash of all sent data.
This hash is meant to be compared with the X-PHPJobs-Security header.
*/
function nsm_hash($conf) {
$hash_ctx = hash_init('sha256');

// Data are concatenated following a particular syntax before being hashed
$hash_string = sprintf(
'%s:%s@%s?%s&%s&',
$_SERVER['HTTP_X_PHPJOBS_SESSION'],
$_SERVER['HTTP_X_PHPJOBS_TIMESTAMP'],
$_SERVER['HTTP_X_PHPJOBS_HOST'],
$_SERVER['QUERY_STRING'],
$conf['secret']
);

hash_update($hash_ctx, $hash_string);
// hash POST data without allocating a potentially huge string for them
hash_update_file($hash_ctx, 'php://input');
return hash_final($hash_ctx);
}

0 comments on commit 1617e83

Please sign in to comment.