fix(admin): persist private sessions across container replacement
deploy-preland / deploy zoomstage.online (push) Successful in 58s
deploy-preland / deploy zoomstage.online (push) Successful in 58s
This commit is contained in:
1 parent
2cfa2e2d2e
commit
d04b40519e
4 files changed
+150
-2
No files matched your search
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require dirname(__DIR__) . '/bootstrap.php';
|
||||
|
||||
function sessionCheck(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) throw new RuntimeException($message);
|
||||
echo "PASS {$message}\n";
|
||||
}
|
||||
|
||||
$fixture = sys_get_temp_dir() . '/persistent-session-' . bin2hex(random_bytes(8));
|
||||
mkdir($fixture, 0700, true);
|
||||
mkdir($fixture . '/shared', 0700);
|
||||
mkdir($fixture . '/shared/sessions', 0777);chmod($fixture . '/shared/sessions', 0777);
|
||||
$passwordHash = password_hash('persistent-session-fixture-password', PASSWORD_DEFAULT);
|
||||
$servers = [];
|
||||
|
||||
function startSessionServer(string $state, string $label): array
|
||||
{
|
||||
global $fixture, $passwordHash, $servers;
|
||||
$ephemeral = $fixture . '/ephemeral-' . $label;mkdir($ephemeral, 0700);
|
||||
$environment = getenv();
|
||||
unset($environment['PHP_CLI_SERVER_WORKERS']);
|
||||
$environment['ADMIN_STATE_DIR'] = $state;
|
||||
$environment['ADMIN_USERNAME'] = 'session-fixture';
|
||||
$environment['ADMIN_PASSWORD_HASH'] = $passwordHash;
|
||||
$environment['ADMIN_COOKIE_SECURE'] = '0';
|
||||
$port = random_int(19000, 29000);
|
||||
$log = fopen($fixture . '/' . $label . '.log', 'a');
|
||||
$process = proc_open([PHP_BINARY, '-d', 'session.save_path=' . $ephemeral, '-S', '127.0.0.1:' . $port,
|
||||
'-t', dirname(__DIR__) . '/public', dirname(__DIR__) . '/public/index.php'],
|
||||
[0=>['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);
|
||||
}
|
||||
Reference in new issue
Block a user