Files
zoneminder/tests/php/test_auth_hash_compare.php
Nic BoetandClaude Opus 5.5 16ffaa5384 fix: compare the auth hash with hash_equals() in getAuthUser()
getAuthUser() compared the auth= hash a client presents with the one it
builds using ==. When both strings look like numbers PHP compares them as
numbers, so a computed md5 of the form 0e<digits> equalled any other
0e<digits> string (and "00"), and == also returns at the first byte that
differs. hash_equals() compares the bytes in constant time.

A hash of that form comes up about once in 3*10^8 user-hours, so this is
hardening; ordinary hashes compare exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:57:14 -05:00

115 lines
4.3 KiB
PHP

<?php
// Tests the auth hash comparison in getAuthUser() (web/includes/auth.php).
//
// The hash a client presents (auth=) was compared to the one the server builds
// with ==. Two strings that both look like numbers compare as numbers under ==,
// so a computed md5 of the form 0e<digits> would have equalled any other
// 0e<digits> string, and == also stops at the first differing byte.
// hash_equals() compares the bytes, in constant time.
//
// Run: php tests/php/test_auth_hash_compare.php
//
// Including auth.php needs a database (User.php pulls in database.php), so
// getAuthUser() is lifted out of the file, as test_auth_request_string.php
// does, and evaluated in a namespace where its collaborators are stubs. md5()
// is stubbed there too, so a test can make the server's hash a 0e<digits>
// string without searching for one.
namespace AuthHashCompareTest\ZM {
class User {
public $row;
public function __construct($row) { $this->row = $row; }
}
function Debug($s) {}
function Info($s) {}
function Error($s) {}
}
namespace AuthHashCompareTest {
const ZM_OPT_USE_AUTH = true;
const ZM_AUTH_RELAY = 'hashed';
const ZM_AUTH_HASH_IPS = false;
const ZM_AUTH_HASH_TTL = 2;
const ZM_AUTH_HASH_SECRET = 'test-secret';
const ZM_CASE_INSENSITIVE_USERNAMES = false;
$GLOBALS['md5_override'] = null;
$GLOBALS['rows'] = array(); // rows for the query filtered by username
$GLOBALS['alt_rows'] = array(); // rows for the multi-server fallback query
function md5($s) {
return $GLOBALS['md5_override'] !== null ? $GLOBALS['md5_override'] : \md5($s);
}
function getRemoteAddr() { return ''; }
function authHashSessionAddrs() { return array(''); }
function requestString($key) {
return (isset($_REQUEST[$key]) && is_string($_REQUEST[$key])) ? $_REQUEST[$key] : null;
}
function dbFetchAll($sql, $col = null, $values = array()) {
return (strpos($sql, '!=') !== false) ? $GLOBALS['alt_rows'] : $GLOBALS['rows'];
}
}
namespace {
$auth_src = file_get_contents(__DIR__.'/../../web/includes/auth.php');
if ($auth_src === false) {
echo "FAIL could not read auth.php\n";
exit(1);
}
if (!preg_match('/^function getAuthUser\(.*?^}/ms', $auth_src, $matches)) {
echo "FAIL getAuthUser() not found in auth.php\n";
exit(1);
}
eval('namespace AuthHashCompareTest; '.$matches[0]);
$failures = 0;
$passes = 0;
function check($name, $ok) {
global $failures, $passes;
if ($ok) {
$passes++;
echo "ok $name\n";
} else {
$failures++;
echo "FAIL $name\n";
}
}
// The hash the server builds for $row in the current hour, as getAuthUser() does.
function current_hash($row) {
$time = localtime(time());
return md5('test-secret'.$row['Username'].$row['Password'].''.$time[2].$time[3].$time[4].$time[5]);
}
$alice = array('Id' => 2, 'Username' => 'alice', 'Password' => '$2y$10$abcdefghijklmnopqrstuv');
$auth = 'AuthHashCompareTest\getAuthUser';
// Primary loop: the request names the user.
$_REQUEST = array('user' => 'alice');
$GLOBALS['rows'] = array($alice);
$GLOBALS['alt_rows'] = array();
$got = $auth(current_hash($alice));
check('the right hash logs the user in', $got !== null && $got->row['Username'] === 'alice');
check('a wrong hash is refused', $auth(str_repeat('a', 32)) === null);
check('the right hash in upper case is refused, as before', $auth(strtoupper(current_hash($alice))) === null);
// Two different 0e<digits> strings are equal under ==.
$GLOBALS['md5_override'] = '0e462097431906509019562988736854';
check('a different 0e<digits> string is refused', $auth('0e830400451993494058024219903391') === null);
check('"00" is refused against a 0e<digits> hash', $auth('00') === null);
check('the exact 0e<digits> hash still matches', $auth('0e462097431906509019562988736854') !== null);
// Fallback loop: the hash belongs to another user than the one named.
$GLOBALS['rows'] = array();
$GLOBALS['alt_rows'] = array($alice);
check('fallback: a different 0e<digits> string is refused', $auth('0e830400451993494058024219903391') === null);
check('fallback: the exact hash still matches', $auth('0e462097431906509019562988736854') !== null);
$GLOBALS['md5_override'] = null;
check('fallback: the right hash logs the user in', $auth(current_hash($alice)) !== null);
echo "\n$passes passed, $failures failed\n";
exit($failures ? 1 : 0);
}