From d04b40519e4bf7ad8b678be8a4509e60e0472461 Mon Sep 17 00:00:00 2001 From: Panfilov Konstantin Date: Sun, 25 Oct 2026 18:26:41 +0300 Subject: [PATCH] fix(admin): persist private sessions across container replacement --- admin/README.md | 6 ++ admin/composer.json | 2 +- admin/src/Auth.php | 25 +++++- admin/tests/session-persistence.php | 119 ++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 admin/tests/session-persistence.php diff --git a/admin/README.md b/admin/README.md index afe48c5..471b37a 100644 --- a/admin/README.md +++ b/admin/README.md @@ -42,6 +42,12 @@ PHP8.3, ZIP, Imagick с AVIF/WebP/JPEG и phpseclib3 из зафиксирова В HTTP Secure-cookie включена по умолчанию; `ADMIN_COOKIE_SECURE=0` разрешена только на изолированном HTTP-стенде. +PHP-сессии хранятся в `state/sessions` с режимом0700, файлы имеют режим0600. +Пересоздание web-контейнера сохраняет вход; срок бездействия остаётся3600секунд. +Хранилище сессий не допускает относительный путь, symlink или публичный каталог. +При первом переходе со старого ephemeral backing существующие session-файлы +нужно перенести приватно до пересоздания; сам код не мигрирует старый `/tmp`. + UID/GID приложения — 997:987. Приложение получает приватный state и ровно два local mount: `/targets/zoompixel.ru`, `/targets/zoomstage.online`. Весь `/srv/preland`, Docker socket, root SSH key и база основного сайта в приложение не передаются. Права публикации diff --git a/admin/composer.json b/admin/composer.json index d5377f8..20210a9 100644 --- a/admin/composer.json +++ b/admin/composer.json @@ -4,5 +4,5 @@ "license": "proprietary", "require": {"php": "^8.3", "ext-json": "*", "ext-zip": "*", "ext-imagick": "*", "phpseclib/phpseclib": "^3.0"}, "config": {"sort-packages": true}, - "scripts": {"test": ["php tests/backend-review.php", "php tests/link-builder.php", "php tests/backend-http.php"], "test:worker": "php tests/backend-worker.php"} + "scripts": {"test": ["php tests/backend-review.php", "php tests/link-builder.php", "php tests/backend-http.php", "php tests/session-persistence.php"], "test:worker": "php tests/backend-worker.php"} } diff --git a/admin/src/Auth.php b/admin/src/Auth.php index 56f34d9..62f8f10 100644 --- a/admin/src/Auth.php +++ b/admin/src/Auth.php @@ -6,10 +6,33 @@ final class Auth public function __construct(private Store $store) {} public static function session(): void { if(session_status()===PHP_SESSION_ACTIVE)return; + $directory=self::sessionDirectory(); + if(ini_get('session.save_handler')!=='files'||session_save_path($directory)===false)throw new Problem('Файловое хранилище сессий недоступно.',503); ini_set('session.use_strict_mode','1');ini_set('session.use_only_cookies','1'); + ini_set('session.gc_maxlifetime','3600');ini_set('session.gc_probability','1');ini_set('session.gc_divisor','100'); session_name('preland_admin'); session_set_cookie_params(['lifetime'=>0,'path'=>'/','secure'=>getenv('ADMIN_COOKIE_SECURE')!=='0','httponly'=>true,'samesite'=>'Strict']); - session_start(); + if(!@session_start())throw new Problem('Не удалось открыть приватное хранилище сессий.',503); + } + /** Готовит приватный backing до Store: не создаёт данные в небезопасном state. */ + private static function sessionDirectory(): string { + $state=rtrim(getenv('ADMIN_STATE_DIR')?:'/var/lib/preland-admin','/'); + if($state===''||!str_starts_with($state,'/')||str_contains($state,'\\')||preg_match('/[\x00-\x1f]/',$state))throw new Problem('Для сессий требуется абсолютный приватный путь состояния.',503); + $parts=explode('/',substr($state,1)); + foreach($parts as $part)if($part===''||$part==='.'||$part==='..')throw new Problem('Некорректный путь приватного состояния сессий.',503); + $publicRoots=[dirname(__DIR__).'/public',$_SERVER['DOCUMENT_ROOT']??'']; + foreach(explode(',',getenv('ADMIN_LOCAL_ROOTS')?:'') as $root)$publicRoots[]=trim($root); + foreach($publicRoots as $root){$root=rtrim($root,'/');if($root!==''&&($state===$root||str_starts_with($state,$root.'/')))throw new Problem('Сессии нельзя хранить в публичном каталоге.',503);} + $current=''; + foreach([...$parts,'sessions'] as $part){ + $current.='/'.$part;clearstatcache(true,$current); + if(is_link($current))throw new Problem('Символические ссылки в пути сессий запрещены.',503); + if(!is_dir($current)&&!@mkdir($current,0700)&&!is_dir($current))throw new Problem('Не удалось подготовить приватное хранилище сессий.',503); + clearstatcache(true,$current); + if(is_link($current)||realpath($current)!==$current)throw new Problem('Небезопасный путь хранилища сессий.',503); + } + if(!@chmod($current,0700)||!is_readable($current)||!is_writable($current))throw new Problem('Приватное хранилище сессий недоступно.',503); + return $current; } public function login(string $username,string $password,string $ip): array { $key=hash('sha256',$ip);$this->store->locked('login',function() use($username,$password,$key){ diff --git a/admin/tests/session-persistence.php b/admin/tests/session-persistence.php new file mode 100644 index 0000000..cc6aee0 --- /dev/null +++ b/admin/tests/session-persistence.php @@ -0,0 +1,119 @@ +['pipe','r'], 1=>$log, 2=>$log], $pipes, null, $environment); + if (!is_resource($process)) throw new RuntimeException('Не удалось запустить PHP server fixture.'); + fclose($pipes[0]); + $servers[$label] = [$process, $log]; + $deadline = microtime(true) + 5; + do { + $socket = @fsockopen('127.0.0.1', $port); + if ($socket) { fclose($socket); return ['port'=>$port, 'label'=>$label]; } + usleep(20000); + } while (microtime(true) < $deadline); + throw new RuntimeException('PHP server fixture не отвечает.'); +} + +function stopSessionServer(string $label): void +{ + global $servers; + if (!isset($servers[$label])) return; + [$process, $log] = $servers[$label]; + proc_terminate($process);proc_close($process);fclose($log);unset($servers[$label]); +} + +function sessionRequest(array $server, string $path, string $cookie = '', ?array $body = null, ?string $csrf = null): array +{ + $headers = ['Content-Type: application/json']; + if ($cookie !== '') $headers[] = 'Cookie: ' . $cookie; + if ($csrf !== null) $headers[] = 'X-CSRF-Token: ' . $csrf; + $context = stream_context_create(['http'=>['method'=>$body===null?'GET':'POST', + 'header'=>implode("\r\n",$headers),'content'=>$body===null?'':json_encode((object)$body, JSON_THROW_ON_ERROR), + 'ignore_errors'=>true,'timeout'=>10]]); + $text = file_get_contents('http://127.0.0.1:' . $server['port'] . $path, false, $context); + $responseHeaders = $http_response_header ?? []; + preg_match('~HTTP/\S+ (\d+)~', $responseHeaders[0] ?? '', $match); + foreach ($responseHeaders as $header) if (str_starts_with(strtolower($header), 'set-cookie: ')) $cookie=explode(';',substr($header,12))[0]; + return [(int)($match[1]??0), json_decode((string)$text,true,64,JSON_THROW_ON_ERROR), $cookie]; +} + +try { + $a = startSessionServer($fixture . '/shared', 'a'); + [$status, $login, $cookie] = sessionRequest($a, '/api/login', '', ['username'=>'session-fixture','password'=>'persistent-session-fixture-password']); + sessionCheck($status===200 && isset($login['csrf']), 'server A logs in and returns session cookie'); + stopSessionServer('a'); + $b = startSessionServer($fixture . '/shared', 'b'); + [$status, $body] = sessionRequest($b, '/api/state', $cookie); + sessionCheck($status===200 && $body['user']==='session-fixture' && $body['csrf']===$login['csrf'], 'fresh server B with separate ephemeral default path restores same cookie from shared state'); + sessionCheck((fileperms($fixture.'/shared/sessions')&0777)===0700, 'persistent session directory has private 0700 permissions'); + parse_str(str_replace(';','&',$cookie), $cookieValues);$sessionId=$cookieValues['preland_admin']; + $sessionFile=$fixture.'/shared/sessions/sess_'.$sessionId; + sessionCheck(is_file($sessionFile), 'session data exists inside ADMIN_STATE_DIR sessions'); + sessionCheck((fileperms($sessionFile)&0777)===0600, 'native persistent session file has private 0600 permissions'); + $c = startSessionServer($fixture . '/isolated', 'c'); + [$status] = sessionRequest($c, '/api/state', $cookie); + sessionCheck($status===401, 'same cookie cannot authenticate fresh server with different state'); + stopSessionServer('c'); + [$status] = sessionRequest($b, '/api/logout', $cookie, [], 'wrong-token'); + sessionCheck($status===403, 'persisted session still rejects invalid CSRF'); + [$status] = sessionRequest($b, '/api/logout', $cookie, [], $login['csrf']); + sessionCheck($status===200, 'persisted session logs out with its original CSRF'); + [$status] = sessionRequest($b, '/api/state', $cookie); + sessionCheck($status===401, 'logout invalidates original persistent cookie'); + [$status, $newLogin, $newCookie] = sessionRequest($b, '/api/login', '', ['username'=>'session-fixture','password'=>'persistent-session-fixture-password']); + sessionCheck($status===200 && $newCookie!==$cookie && $newLogin['csrf']!==$login['csrf'], 'new login regenerates session ID and CSRF after persistent logout'); + parse_str($newCookie, $values);$expiredFile=$fixture.'/shared/sessions/sess_'.$values['preland_admin']; + $expired=preg_replace('/last\|i:\d+;/', 'last|i:'.(time()-3601).';', file_get_contents($expiredFile));file_put_contents($expiredFile,$expired); + [$status] = sessionRequest($b, '/api/state', $newCookie); + sessionCheck($status===401, 'persistent file does not extend the existing 3600-second idle expiry'); + mkdir($fixture.'/external',0700);mkdir($fixture.'/symlink-session',0700);symlink($fixture.'/external',$fixture.'/symlink-session/sessions'); + $bad = startSessionServer($fixture.'/symlink-session','bad-session');[$status]=sessionRequest($bad,'/api/state'); + sessionCheck($status===503 && glob($fixture.'/external/*')===[], 'symlink sessions directory fails closed without writing outside state');stopSessionServer('bad-session'); + symlink($fixture.'/shared',$fixture.'/state-link'); + $bad = startSessionServer($fixture.'/state-link','bad-state');[$status]=sessionRequest($bad,'/api/state'); + sessionCheck($status===503, 'symlink state root fails closed');stopSessionServer('bad-state'); + $bad = startSessionServer($fixture.'/state-link/nested','bad-ancestor');[$status]=sessionRequest($bad,'/api/state'); + sessionCheck($status===503 && !file_exists($fixture.'/shared/nested'), 'symlink state ancestor rejected before creating nested directory');stopSessionServer('bad-ancestor'); + $publicState=dirname(__DIR__).'/public/session-state-'.bin2hex(random_bytes(6)); + $bad = startSessionServer($publicState,'bad-public');[$status]=sessionRequest($bad,'/api/state'); + sessionCheck($status===503 && !file_exists($publicState), 'public webroot state path rejected before creating any directory');stopSessionServer('bad-public'); + $bad = startSessionServer('relative-session-state','bad-relative');[$status]=sessionRequest($bad,'/api/state'); + sessionCheck($status===503, 'relative state path fails closed');stopSessionServer('bad-relative'); + file_put_contents($fixture.'/file-state','not-a-directory'); + $bad = startSessionServer($fixture.'/file-state','bad-file');[$status]=sessionRequest($bad,'/api/state'); + sessionCheck($status===503, 'unusable state path returns safe HTTP 503');stopSessionServer('bad-file'); + mkdir($fixture.'/file-session',0700);file_put_contents($fixture.'/file-session/sessions','not-a-directory'); + $bad = startSessionServer($fixture.'/file-session','bad-session-file');[$status]=sessionRequest($bad,'/api/state'); + sessionCheck($status===503, 'regular file in place of sessions directory returns safe HTTP 503');stopSessionServer('bad-session-file'); + echo "Session persistence contract complete\n"; +} finally { + foreach (array_keys($servers) as $label) stopSessionServer($label); +}