fix(admin): route multi-megabyte sets to SFTP before shell escaping
deploy-preland / deploy zoomstage.online (push) Successful in 1m3s

This commit is contained in:
Panfilov Konstantin committed 2026-10-25 22:14:15 +03:00
1 parent 6ec813bc99
commit 4fc4fd8162
2 files changed
+27 -3

No files matched your search

+7 -3
View File
@@ -29,11 +29,15 @@ final class Transport
$ssh=$this->connect();$code=file_get_contents(__DIR__.'/Remote.php');$code=preg_replace('/^<\?php\s*/','',$code); $ssh=$this->connect();$code=file_get_contents(__DIR__.'/Remote.php');$code=preg_replace('/^<\?php\s*/','',$code);
// Сжат только доверенный helper из поставки; payload остаётся JSON-данными. // Сжат только доверенный helper из поставки; payload остаётся JSON-данными.
$entry="\necho json_encode(\\PrelandAdmin\\Remote::execute(json_decode(base64_decode(\$argv[1]),true,64,JSON_THROW_ON_ERROR)),JSON_THROW_ON_ERROR);"; $entry="\necho json_encode(\\PrelandAdmin\\Remote::execute(json_decode(base64_decode(\$argv[1]),true,64,JSON_THROW_ON_ERROR)),JSON_THROW_ON_ERROR);";
$command=self::inlineCommand($code,$entry,base64_encode(json_encode($payload,JSON_THROW_ON_ERROR))); $jsonPayload=json_encode($payload,JSON_THROW_ON_ERROR);
// Размер base64 проверяется ДО inlineCommand/escapeshellarg: для ZIP PHP
// завершает процесс при аргументе >2 МиБ раньше переключения на inbox.
$argumentLength=4*intdiv(strlen($jsonPayload)+2,3);
$command=$argumentLength>self::COMMAND_LIMIT?null:self::inlineCommand($code,$entry,base64_encode($jsonPayload));
$inbox=null;$sftp=null; $inbox=null;$sftp=null;
try { try {
// Большие наборы доставляются SFTP в приватный inbox: argv/ARG_MAX не используется. // Большие наборы доставляются SFTP в приватный inbox: argv/ARG_MAX не используется.
if(strlen($command)>self::COMMAND_LIMIT) { if($command===null||strlen($command)>self::COMMAND_LIMIT) {
if(($payload['op']??null)==='test')throw new Problem('SSH bootstrap превышает безопасный предел команды; сократите helper или путь подключения.'); if(($payload['op']??null)==='test')throw new Problem('SSH bootstrap превышает безопасный предел команды; сократите helper или путь подключения.');
$parent=dirname($this->target['root']);$private=$parent.'/.preland-admin-'.substr(hash('sha256',$this->target['root']),0,16); $parent=dirname($this->target['root']);$private=$parent.'/.preland-admin-'.substr(hash('sha256',$this->target['root']),0,16);
$candidate=$private.'/inbox-'.bin2hex(random_bytes(16)); $candidate=$private.'/inbox-'.bin2hex(random_bytes(16));
@@ -41,7 +45,7 @@ final class Transport
// не должен бесконечно пытаться доставить сам себя через inbox. // не должен бесконечно пытаться доставить сам себя через inbox.
$command=self::inboxCommand($code,$candidate); $command=self::inboxCommand($code,$candidate);
$this->call(['op'=>'test']);$sftp=$this->sftp();$inbox=$candidate; $this->call(['op'=>'test']);$sftp=$this->sftp();$inbox=$candidate;
if(!$sftp->put($inbox,json_encode($payload,JSON_THROW_ON_ERROR))||!$sftp->chmod(0600,$inbox))throw new Problem('Не удалось доставить подготовленный набор.'); if(!$sftp->put($inbox,$jsonPayload)||!$sftp->chmod(0600,$inbox))throw new Problem('Не удалось доставить подготовленный набор.');
} }
$timeout=max(1,min(120,(int)(getenv('ADMIN_TRANSPORT_TIMEOUT')?:60)));$ssh->setTimeout($timeout); $timeout=max(1,min(120,(int)(getenv('ADMIN_TRANSPORT_TIMEOUT')?:60)));$ssh->setTimeout($timeout);
$output=$ssh->exec($command);$result=is_string($output)?json_decode($output,true):null; $output=$ssh->exec($command);$result=is_string($output)?json_decode($output,true):null;
+20
View File
@@ -70,6 +70,26 @@ namespace {
SSH2::$withoutZlib=true; SSH2::$withoutZlib=true;
try{$transport->test();throw new RuntimeException('missing zlib accepted');}catch(Problem $error){ensure(str_contains($error->getMessage(),'zlib'),'missing gzinflate returns clear structured error');} try{$transport->test();throw new RuntimeException('missing zlib accepted');}catch(Problem $error){ensure(str_contains($error->getMessage(),'zlib'),'missing gzinflate returns clear structured error');}
SSH2::$withoutZlib=false; SSH2::$withoutZlib=false;
// Настоящие 90 изображений: два слоя base64 дают аргумент заметно больше PHP shell-limit 2 MiB.
$sourceSet=dirname(__DIR__,2).'/1';$files=[];
foreach(array_keys(\PrelandAdmin\IndpicSetStorage::expectedDimensions()) as $filename){$bytes=file_get_contents($sourceSet.'/'.$filename);$files[$filename]=['data'=>base64_encode($bytes),'sha256'=>hash('sha256',$bytes)];}
$hugePayload=['op'=>'set-write','number'=>3,'files'=>$files,'expected_manifest'=>null];
ensure(count($files)===90&&strlen(base64_encode(json_encode($hugePayload,JSON_THROW_ON_ERROR)))>2097152,'real 90-image set-write payload exceeds native 2 MiB shell argument limit');
$uploadsBeforeHuge=count(SFTP::$uploads);$configBeforeHuge=$transport->readConfig();
$transport->call($hugePayload);
ensure(\PrelandAdmin\Remote::manifest($site.'/3')===\PrelandAdmin\Remote::manifest($sourceSet),'multi-MiB set-write activates all 90 real files with exact hashes');
ensure(count(SFTP::$uploads)===$uploadsBeforeHuge+1,'multi-MiB set-write uses exactly one SFTP inbox');
$hugeInbox=SFTP::$uploads[array_key_last(SFTP::$uploads)];
ensure(!file_exists($hugeInbox),'multi-MiB set-write inbox removed after actual RPC');
ensure(max(array_map('strlen',SSH2::$commands))<=7000,'multi-MiB set-write final exec stays below bootstrap limit');
ensure($transport->readConfig()['version']===$configBeforeHuge['version'],'image upload preserves config and the 2 MiB config contract');
// Обрыв до исполнения большой команды не должен трогать опубликованный набор; inbox удаляется.
$hugePayload['expected_manifest']=\PrelandAdmin\Remote::manifest($site.'/3');$keptManifest=$hugePayload['expected_manifest'];SSH2::$dropInbox=true;
try{$transport->call($hugePayload);throw new RuntimeException('dropped multi-MiB set-write accepted');}
catch(Problem $error){ensure($error->status===504,'interrupted multi-MiB set-write remains unknown, not false success');}
finally{SSH2::$dropInbox=false;}
ensure(\PrelandAdmin\Remote::manifest($site.'/3')===$keptManifest,'interrupted multi-MiB exec preserves previously active set');
ensure(!file_exists(SFTP::$uploads[array_key_last(SFTP::$uploads)]),'interrupted multi-MiB set-write cleans private inbox');
} finally { } finally {
$iterator=new RecursiveIteratorIterator(new RecursiveDirectoryIterator($fixture,FilesystemIterator::SKIP_DOTS),RecursiveIteratorIterator::CHILD_FIRST); $iterator=new RecursiveIteratorIterator(new RecursiveDirectoryIterator($fixture,FilesystemIterator::SKIP_DOTS),RecursiveIteratorIterator::CHILD_FIRST);
foreach($iterator as $file)$file->isDir()?rmdir($file->getPathname()):unlink($file->getPathname());rmdir($fixture); foreach($iterator as $file)$file->isDir()?rmdir($file->getPathname()):unlink($file->getPathname());rmdir($fixture);