mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-09-13 05:38:05 -04:00
ZM_AUTH_HASH_IPS binds the auth hash to the client address. When that address changes mid-session - a phone moving between wifi and cellular is the common case - the hash the browser is still holding no longer matches the address we now see, and the user is bounced to the login page. The usual workaround is to turn ZM_AUTH_HASH_IPS off entirely. Accept the address the request arrives from plus the one it arrived from immediately before, so an in-flight hash validates once and generateAuthHash() then reissues against the new address. Addresses are matched exactly. A netmask was considered and rejected: accepting a whole subnet would let any other host on the client's network replay a stolen hash, which on a home LAN includes the cameras themselves. The previous address is only accepted for as long as a hash issued to it would itself still be valid (ZM_AUTH_HASH_TTL), so this widens which address is accepted without extending how long any hash lives. A login clears it, since nothing from before a privilege boundary should stay acceptable, and only one previous address is ever retained. userFromSession() needed the same treatment: it looks the cached hash up by the live address, so after a change the slot does not exist yet and the user was reported as not logged in regardless of what getAuthUser() would have accepted. Also centralises the X-Forwarded-For/REMOTE_ADDR handling in getRemoteAddr(), replacing four duplicated copies across session.php and auth.php. Those copies sat on both the generation and validation sides, so any drift between them broke authentication outright behind a reverse proxy. Network.php holds only that address parsing; which addresses an auth hash is accepted from is auth policy and lives in auth.php. This is web-side only; zms has no session, so a stream request still fails once on an address change and recovers through the existing auth-refresh path in MonitorStream.js. Tests: tests/php/test_remote_addr.php covers getRemoteAddr() parsing and the session address rotation, and needs no config or database - 18 assertions, all pass. tests/php/test_auth_hash_candidate_addrs.php covers the acceptance window including both sides of the TTL boundary; it bootstraps config.php as the other tests in that directory do and so needs an installed tree to run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01477mR97vfnK6zczbHgzq6T
78 lines
3.1 KiB
PHP
78 lines
3.1 KiB
PHP
<?php
|
|
// Tests ZM\authHashCandidateAddrs() in web/includes/auth.php: which client
|
|
// addresses an IP-bound auth hash (ZM_AUTH_HASH_IPS) may be validated against,
|
|
// and how long the address a hash was issued to stays acceptable after the
|
|
// client moves. The function is pure, but auth.php pulls in ZM's config, so
|
|
// bootstrap config.php first as the other tests in this directory do.
|
|
//
|
|
// Run: sudo -u www-data php tests/php/test_auth_hash_candidate_addrs.php
|
|
// (from the repo root, on a tree with a readable zm.conf)
|
|
//
|
|
// How the retained address gets into the session in the first place is covered
|
|
// by test_remote_addr.php, which needs no config or database.
|
|
set_include_path(__DIR__.'/../../web/includes'.PATH_SEPARATOR.get_include_path());
|
|
require_once('config.php'); // connects to DB + loads config; must precede auth.php
|
|
require_once('auth.php');
|
|
|
|
$failures = 0;
|
|
$passes = 0;
|
|
|
|
function check($name, $got, $expected) {
|
|
global $failures, $passes;
|
|
if ($got === $expected) {
|
|
$passes++;
|
|
echo "ok - $name\n";
|
|
} else {
|
|
$failures++;
|
|
echo "not ok - $name (got ".var_export($got, true)." expected ".var_export($expected, true).")\n";
|
|
}
|
|
}
|
|
|
|
// Signature: authHashCandidateAddrs($liveAddr, $prevAddr, $prevAt, $now, $ttlHours)
|
|
$now = 1000000;
|
|
$ttl = 2; // hours, matching the ZM_AUTH_HASH_TTL default
|
|
|
|
check('no previous address yields just the live one',
|
|
authHashCandidateAddrs('192.168.1.55', '', 0, $now, $ttl),
|
|
array('192.168.1.55'));
|
|
|
|
check('unchanged address is not duplicated',
|
|
authHashCandidateAddrs('192.168.1.55', '192.168.1.55', $now - 60, $now, $ttl),
|
|
array('192.168.1.55'));
|
|
|
|
check('recently changed address is accepted alongside the live one',
|
|
authHashCandidateAddrs('10.0.0.7', '192.168.1.55', $now - 60, $now, $ttl),
|
|
array('10.0.0.7', '192.168.1.55'));
|
|
|
|
// The point of the window: an address stops being accepted once a hash issued
|
|
// to it would have expired anyway, so this never extends a hash's lifetime.
|
|
check('previous address expires after the hash TTL',
|
|
authHashCandidateAddrs('10.0.0.7', '192.168.1.55', $now - (2 * 3600) - 1, $now, $ttl),
|
|
array('10.0.0.7'));
|
|
|
|
check('previous address still accepted just inside the TTL',
|
|
authHashCandidateAddrs('10.0.0.7', '192.168.1.55', $now - (2 * 3600) + 1, $now, $ttl),
|
|
array('10.0.0.7', '192.168.1.55'));
|
|
|
|
check('previous address with no timestamp is rejected',
|
|
authHashCandidateAddrs('10.0.0.7', '192.168.1.55', 0, $now, $ttl),
|
|
array('10.0.0.7'));
|
|
|
|
check('null previous address is rejected',
|
|
authHashCandidateAddrs('10.0.0.7', null, $now - 60, $now, $ttl),
|
|
array('10.0.0.7'));
|
|
|
|
// Addresses are matched exactly. Accepting a neighbouring host would let anyone
|
|
// on the client's network replay a stolen hash, so no netmask is applied.
|
|
check('a same-subnet neighbour is never added',
|
|
authHashCandidateAddrs('192.168.1.99', '', 0, $now, $ttl),
|
|
array('192.168.1.99'));
|
|
|
|
check('the exact previous address is added, not its subnet',
|
|
authHashCandidateAddrs('192.168.1.99', '192.168.1.55', $now - 60, $now, $ttl),
|
|
array('192.168.1.99', '192.168.1.55'));
|
|
|
|
echo "\n$passes passed, $failures failed\n";
|
|
exit($failures ? 1 : 0);
|
|
?>
|