Files
zoneminder/web/includes/session.php
Isaac Connor 8fd17a4b91 perf: index Sessions.access and rework session gc to two-phase delete
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>
2026-05-14 07:38:18 -04:00

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);
?>