mirror of
https://github.com/ZoneMinder/zoneminder.git
synced 2026-05-30 09:26:00 -04:00
The session garbage collector ran DELETE FROM Sessions WHERE access < ? against an unindexed column, forcing a full table scan and taking gap locks across the access range. With REPLACE INTO Sessions happening on every authenticated request, this is a deadlock hotspot. - Add Sessions_access_idx on Sessions(access) in both fresh-install schema (zm_create.sql.in) and a migration (zm_update-1.39.10.sql). - Rewrite ZMSessionHandler::gc to a two-phase delete: SELECT up to 100 expired ids via the new index (consistent read, no locks), then DELETE WHERE id IN (...) by primary key. InnoDB takes record locks only on the matched rows, not gap locks on the access range. - Bump version to 1.39.10 so zmupdate.pl picks up the new migration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
212 lines
7.4 KiB
PHP
212 lines
7.4 KiB
PHP
<?php
|
|
// Wrapper around setcookie that auto-sets samesite, and deals with older versions of php
|
|
function zm_setcookie($cookie, $value, $options=array()) {
|
|
if (!isset($options['path'])) {
|
|
$options['path'] = '/';
|
|
}
|
|
if (!isset($options['expires'])) {
|
|
$options['expires'] = time()+3600*24*30*12*10; // 10 years?!
|
|
}
|
|
if (!isset($options['samesite'])) {
|
|
$options['samesite'] = 'Strict';
|
|
}
|
|
|
|
if (version_compare(phpversion(), '7.3.0', '>=')) {
|
|
setcookie($cookie, $value, $options);
|
|
} else {
|
|
setcookie($cookie, $value, $options['expires'], '/; samesite=strict');
|
|
}
|
|
//ZM\Debug("Setting cookie for $cookie to $value");
|
|
}
|
|
|
|
// ZM session start function support timestamp management
|
|
function zm_session_start() {
|
|
if (ini_get('session.name') != 'ZMSESSID') {
|
|
// Make sure use_strict_mode is enabled.
|
|
// use_strict_mode is mandatory for security reasons.
|
|
ini_set('session.use_strict_mode', 1);
|
|
|
|
$currentCookieParams = session_get_cookie_params();
|
|
if (defined('ZM_OPT_USE_REMEMBER_ME') && ZM_OPT_USE_REMEMBER_ME != 'None' && ZM_OPT_USE_REMEMBER_ME != '' && ZM_OPT_USE_REMEMBER_ME != '0' && empty($_COOKIE['ZM_REMEMBER_ME'])) {
|
|
$currentCookieParams['lifetime'] = 0;
|
|
} else {
|
|
$currentCookieParams['lifetime'] = ZM_COOKIE_LIFETIME;
|
|
}
|
|
$currentCookieParams['httponly'] = true;
|
|
if ( version_compare(phpversion(), '7.3.0', '<') ) {
|
|
session_set_cookie_params(
|
|
$currentCookieParams['lifetime'],
|
|
$currentCookieParams['path'].'; samesite=strict',
|
|
$currentCookieParams['domain'],
|
|
$currentCookieParams['secure'],
|
|
$currentCookieParams['httponly']
|
|
);
|
|
} else {
|
|
# samesite was introduced in 7.3.0
|
|
$currentCookieParams['samesite'] = 'Strict';
|
|
session_set_cookie_params($currentCookieParams);
|
|
}
|
|
|
|
ini_set('session.name', 'ZMSESSID');
|
|
//ZM\Debug('Setting cookie parameters to '.print_r($currentCookieParams, true));
|
|
}
|
|
session_start();
|
|
// To help prevent session hijacking
|
|
// Use HTTP_X_FORWARDED_FOR if available (for reverse proxy setups), taking only the first IP
|
|
// to guard against spoofed multi-value headers. Falls back to REMOTE_ADDR for direct connections.
|
|
$_SESSION['remoteAddr'] = !empty($_SERVER['HTTP_X_FORWARDED_FOR'])
|
|
? trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0])
|
|
: $_SERVER['REMOTE_ADDR'];
|
|
$now = time();
|
|
// Do not allow to use expired session ID
|
|
if ( !empty($_SESSION['last_time']) && ($_SESSION['last_time'] < ($now - 180)) ) {
|
|
//ZM\Info('Destroying session due to timeout.');
|
|
session_destroy();
|
|
session_start();
|
|
} else if ( !empty($_SESSION['generated_at']) ) {
|
|
if ( $_SESSION['generated_at']<($now-(ZM_COOKIE_LIFETIME/2)) ) {
|
|
ZM\Debug('Regenerating session because generated_at ' . $_SESSION['generated_at'] . ' < ' . $now . '-'.ZM_COOKIE_LIFETIME.'/2 = '.($now-ZM_COOKIE_LIFETIME/2));
|
|
zm_session_regenerate_id();
|
|
}
|
|
}
|
|
} // function zm_session_start()
|
|
|
|
// session regenerate id function
|
|
// Assumes that zm_session_start has been called previously
|
|
function zm_session_regenerate_id() {
|
|
if (!is_session_started()) session_start();
|
|
|
|
// Set deleted timestamp. Session data must not be deleted immediately for reasons.
|
|
$_SESSION['last_time'] = time();
|
|
session_write_close();
|
|
|
|
session_start();
|
|
|
|
//ZM\Debug("Regenerating session. Old id was " . session_id());
|
|
session_regenerate_id();
|
|
//ZM\Debug("Regenerating session. New id was " . session_id());
|
|
unset($_SESSION['last_time']);
|
|
$_SESSION['generated_at'] = time();
|
|
$_SESSION['remoteAddr'] = !empty($_SERVER['HTTP_X_FORWARDED_FOR'])
|
|
? trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0])
|
|
: $_SERVER['REMOTE_ADDR'];
|
|
} // function zm_session_regenerate_id()
|
|
|
|
function is_session_started() {
|
|
if ( php_sapi_name() !== 'cli' ) {
|
|
if ( version_compare(phpversion(), '5.4.0', '>=') ) {
|
|
return session_status() === PHP_SESSION_ACTIVE ? TRUE : FALSE;
|
|
} else {
|
|
return session_id() === '' ? FALSE : TRUE;
|
|
}
|
|
} else {
|
|
Warning("php_sapi_name === 'cli'");
|
|
}
|
|
return FALSE;
|
|
} // function is_session_started()
|
|
|
|
function zm_session_clear() {
|
|
if (!is_session_started()) session_start();
|
|
$_SESSION = array();
|
|
if ( ini_get('session.use_cookies') ) {
|
|
$p = session_get_cookie_params();
|
|
# Update the cookie to expire in the past.
|
|
$p['expires'] = time() - 31536000;
|
|
unset($p['lifetime']); // Not valid for a cookie
|
|
zm_setcookie(session_name(), '', $p);
|
|
}
|
|
session_unset();
|
|
session_destroy();
|
|
session_write_close();
|
|
} // function zm_session_clear()
|
|
|
|
class ZMSessionHandler implements SessionHandlerInterface {
|
|
private $db;
|
|
public function __construct() {
|
|
global $dbConn;
|
|
$this->db = $dbConn;
|
|
|
|
// Set handler to overide SESSION
|
|
/*
|
|
session_set_save_handler(
|
|
array($this, '_open'),
|
|
array($this, '_close'),
|
|
array($this, '_read'),
|
|
array($this, '_write'),
|
|
array($this, '_destroy'),
|
|
array($this, '_gc'),
|
|
array($this, '_create_sid'),
|
|
array($this, '_validate_sid')
|
|
);
|
|
*/
|
|
}
|
|
public function open($path, $name): bool {
|
|
return $this->db ? true : false;
|
|
}
|
|
public function close() : bool {
|
|
// The example code closed the db connection.. I don't think we care to.
|
|
return true;
|
|
}
|
|
#[\ReturnTypeWillChange]
|
|
public function read($id){
|
|
$sth = $this->db->prepare('SELECT data FROM Sessions WHERE id = :id');
|
|
if (!$sth->bindParam(':id', $id, PDO::PARAM_STR, 32)) {
|
|
ZM\Error("Failed to bind param");
|
|
if (!$sth->bindParam(':id', $id, PDO::PARAM_STR)) {
|
|
ZM\Error("Failed to bind param");
|
|
}
|
|
}
|
|
|
|
if ( $sth->execute() ) {
|
|
if (( $row = $sth->fetch(PDO::FETCH_ASSOC) ) ) {
|
|
return $row['data'];
|
|
}
|
|
}
|
|
// Return an empty string
|
|
return '';
|
|
}
|
|
public function write($id, $data) : bool {
|
|
// Create time stamp
|
|
$access = time();
|
|
|
|
$sth = $this->db->prepare('REPLACE INTO Sessions VALUES (:id, :access, :data)');
|
|
|
|
$sth->bindParam(':id', $id, PDO::PARAM_STR, 32);
|
|
$sth->bindParam(':access', $access, PDO::PARAM_INT);
|
|
$sth->bindParam(':data', $data);
|
|
|
|
return $sth->execute() ? true : false;
|
|
}
|
|
public function destroy($id) : bool {
|
|
$sth = $this->db->prepare('DELETE FROM Sessions WHERE Id = :id');
|
|
$sth->bindParam(':id', $id, PDO::PARAM_STR, 32);
|
|
return $sth->execute() ? true : false;
|
|
}
|
|
#[\ReturnTypeWillChange]
|
|
public function gc($max) {
|
|
// Calculate what is to be deemed old
|
|
$now = time();
|
|
$old = $now - $max;
|
|
ZM\Debug('doing session gc ' . $now . '-' . $max. '='.$old);
|
|
|
|
// Two-phase delete: find expired ids via the access index (consistent read, no locks),
|
|
// then delete by primary key so InnoDB only takes record locks on the matched rows
|
|
// and not gap locks across the access range — avoids deadlocks with concurrent
|
|
// REPLACE INTO Sessions on every authenticated request.
|
|
$sel = $this->db->prepare('SELECT id FROM Sessions WHERE access < :old LIMIT 100');
|
|
$sel->bindParam(':old', $old, PDO::PARAM_INT);
|
|
if (!$sel->execute()) return false;
|
|
$ids = $sel->fetchAll(PDO::FETCH_COLUMN);
|
|
if (!$ids) return true;
|
|
|
|
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
|
$del = $this->db->prepare("DELETE FROM Sessions WHERE id IN ($placeholders)");
|
|
return $del->execute($ids) ? true : false;
|
|
}
|
|
public function validateId($key) : bool {return true;}
|
|
} # end class Session
|
|
|
|
$session = new ZMSessionHandler;
|
|
session_set_save_handler($session, true);
|
|
?>
|